Raxx · internal docs

internal · gated

RCA — Stale WP org secret froze prod deploys for 5 weeks; console billing dark

Incident ID: 2026-08-29-deploy-prod-stale-wp-secret Date: 2026-08-29 Severity: SEV-2 (production deploy pipeline completely broken for 5 weeks; no data loss, no direct customer-facing exposure, but the entire billing sprint and 143 commits were undeployable) Duration: Root cause first manifested 2026-07-21 (rotation gap created); first observable failure 2026-08-22 (pipeline 7000); two operator-authorized recovery attempts (reseed HEROKU_PROD_API_KEY + restart, 2026-08-22 and 2026-08-27 — see Timeline) did not resolve it before pipeline 7800 also failed on 2026-08-27; confirmed root-caused and fixed 2026-08-29 — approximately 5 weeks from gap creation to fix, 7 days from first observed failure to fix. There is no comment evidence in https://github.com/raxx-app/TradeMasterAPI/issues/4502 confirming either authorized reseed was actually executed before its corresponding pipeline ran; see Timeline and Contributing factors. Blast radius: Production Heroku deploys (raxx-api-prod, raxx-console-prod, and raxx-queue-prod) via .woodpecker/deploy-prod.yaml and .woodpecker/deploy-queue.yaml's deploy-prod step — both consumed the same stale HEROKU_PROD_API_KEY static copy on the same event: tag gate before this PR. No customer-facing outage — prod stayed on its last-known-good release throughout. Operator-visible: console billing (console.raxx.app/billing) unavailable since 2026-07-25 because the fix for that (https://github.com/raxx-app/TradeMasterAPI/issues/4387) never reached prod. Author: raxx-dev-bot

Summary

Production had received no code deploy since 2026-07-21 (raxx-api-prod release #184, SHA 558c9eda). Two tag-triggered deploy-prod pipelines failed at the same step — deploy-raptor-prod, on the RELEASE_VERSION config-var PATCH — without ever reaching git push: pipeline 7000 (tag v1.13.0, cut 2026-08-22) and pipeline 7800 (tag v1.13.1, cut 2026-08-27T13:09:34Z, 99 seconds after that day's operator reseed+tag authorization — see Timeline; failure observed same day). Ground truth: https://api.raxx.app/api/system/version returned 1.10.0, a hardcoded literal deleted from the codebase on 2026-07-31 (ebc2e5f4a) — post-07-31 code cannot emit that string, confirming prod was frozen well before either failed pipeline. Staging (0.0.132-7073, correct shape) was unaffected — only the prod lane was dead. deploy-queue.yaml's tag-gated deploy-prod step reads the same secret family and was exposed to the same gap (see Blast radius); no confirmed pipeline record of a Queue-prod tag deploy in this window was found while writing this RCA.

Root cause: the Woodpecker org secret HEROKU_PROD_API_KEY (orgs/2, event-restricted to tag + manual) was a static copy of vault /MooseQuest/heroku/HEROKU_API_KEY, created 2026-07-06 by the Wave C port. The 2026-07-21 full credential rotation (docs/incidents/2026-07-21-full-credential-rotation.md:39) updated the vault value and the four Heroku app config vars, and its own log entry recorded "no static WP secret to update for this credential" — true for the vault-sourced HEROKU_API_KEY, false for the separately-named prod alias. Rotation coverage was name-matched, not consumer-matched, and no drift check covers WP org secrets, so the gap went undetected until the next tag-triggered prod deploy tried to use the now-revoked key. A secondary defect compounded diagnosis time: deploy-prod.yaml's RELEASE_VERSION PATCH ran as curl -fsS ... >/dev/null 2>&1, so the HTTP 401 response body was swallowed and both failed pipelines showed only curl exit 22 with no diagnostic.

Fixed by https://github.com/raxx-app/TradeMasterAPI/pull/4550 (closing https://github.com/raxx-app/TradeMasterAPI/issues/4544): HEROKU_API_KEY is now loaded from vault /MooseQuest/heroku/ at run time in both deploy-prod.yaml and deploy-queue.yaml's deploy-prod step — the same idiom already used by deploy-staging.yaml, deploy-velvet.yaml, and deploy-queue.yaml's staging step. The tag/manual when: gate and the in-step CI_COMMIT_TAG / DEPLOY_PROD_CONFIRM guards are unchanged and remain the prod authorization boundary. The RELEASE_VERSION PATCH now captures the HTTP status code and a truncated response body on failure and exits non-zero instead of swallowing the error.

Impact

Timeline (all times UTC)

Root cause

Rotation coverage was name-matched, not consumer-matched. The 2026-07-21 rotation correctly identified and updated every store literally named HEROKU_API_KEY (vault + 4 Heroku app config vars) and correctly noted that Woodpecker reads that name live from vault with no static copy. It had no mechanism to discover that a differently-named secret (HEROKU_PROD_API_KEY) existed as an out-of-band static copy of the same underlying credential, created by a separate migration effort (Wave C, 2026-07-06) six weeks earlier. There is no drift check or inventory that covers WP org-level secrets the way heroku-api-key-drift-recovery.md covers the vault ↔ Heroku-config-var relationship, so this class of gap has no automated detection.

Contributing factors

What went well

Remediation

This PR (https://github.com/raxx-app/TradeMasterAPI/pull/4550, closing https://github.com/raxx-app/TradeMasterAPI/issues/4544):

  1. Retires the static HEROKU_PROD_API_KEY WP org secret. deploy-prod.yaml (deploy-raptor-prod, deploy-console-prod) and deploy-queue.yaml (deploy-prod) now load HEROKU_API_KEY from vault /MooseQuest/heroku/ at run time, identical to deploy-staging.yaml, deploy-velvet.yaml, and deploy-queue.yaml's own staging step. The tag/manual when: gate and in-step CI_COMMIT_TAG / DEPLOY_PROD_CONFIRM guards are preserved verbatim as the prod authorization boundary — only the credential-loading mechanism changed.
  2. Makes the RELEASE_VERSION PATCH fail loud: captures the HTTP status code and a truncated response body on any non-2xx response and exits non-zero, instead of silently swallowing the error. Never prints a 2xx response body (which would echo all of the app's config vars, including secrets).
  3. create-github-release falls back to CI_FORGE_TOKEN when the ops-bot App token cannot be minted (corrupt PEM, https://github.com/raxx-app/TradeMasterAPI/issues/4530), so a release-notes step failure can never mask a deploy that already succeeded. Note this fallback only guarantees the step stays green — CI_FORGE_TOKEN is documented elsewhere as read-only, so the fallback very likely still cannot create the actual GitHub Release (expect a logged non-fatal 403/404) until the PEM itself is repaired.
  4. Docs swept in the same PR: docs/ops/runbooks/heroku-api-key-rotation.md (consumers table, static-copy sweep checklist item, dated rotation-log row), docs/ops/runbooks/heroku-api-key-drift-recovery.md (three-copies list, new symptom row), docs/architecture/adr/0135-woodpecker-ci-full-migration-phases3-5.md (dated amendment), docs/ops/runbooks/ci-woodpecker.md (dated amendment), and this RCA.

Action items

# Action Owner Due Issue
1 Retire HEROKU_PROD_API_KEY WP org secret usage in .woodpecker/*.yaml; load HEROKU_API_KEY from vault at run time in prod deploy pipelines raxx-dev-bot 2026-08-29 https://github.com/raxx-app/TradeMasterAPI/issues/4544
2 Toil reduction: add a "static-copy sweep" (grep -rn "from_secret: HEROKU" .woodpecker/ must return zero hits) to the rotation runbook checklist so this class of gap is checked on every future rotation, not just discovered after it breaks something raxx-dev-bot (done, same PR) 2026-08-29 https://github.com/raxx-app/TradeMasterAPI/issues/4544
3 Operator: delete the now-unused Woodpecker org secret HEROKU_PROD_API_KEY (DELETE /api/orgs/2/secrets/HEROKU_PROD_API_KEY) — harmless but dead after this PR merges operator next maintenance window https://github.com/raxx-app/TradeMasterAPI/issues/4544
4 Verify post-merge: cut a vX.Y.Z tag on main, confirm deploy-prod green through health-console-prod, confirm /api/system/version on prod returns a live 0.0.NNN-<pipeline> string, confirm console billing renders operator on next prod tag https://github.com/raxx-app/TradeMasterAPI/issues/4544
5 Consider a periodic synthetic exercise of the prod deploy credential path (e.g. a scheduled dry-run deploy-prod with DRY_RUN=1 that still exercises the vault load + key validity, without pushing) so a future credential gap surfaces before the next real tag deploy operator unscheduled — backlog not yet filed

References