Raxx · internal docs

internal · gated

ADR-0135 — Woodpecker CI: full GHA migration execution plan (Phases 3–5)

Status: Accepted Date: 2026-07-04 UTC Deciders: Kristerpher (operator); software-architect (design) Scope: All 86 .github/workflows/*.yml files; 5 composite actions; branch protection; deploy/release pipeline; agent capacity; decommission Refs: ADR-0134 (infra decision, Candidate B accepted); docs/architecture/ci-self-hosted-ec2-decision-2026-07-03.md; docs/architecture/woodpecker-migration-table-2026-07-04.md (per-workflow table)


Context

ADR-0134 accepted Woodpecker CI (Candidate B) as the full CI control plane replacement for GitHub Actions. Phases 1 (Terraform baseline) and 2 (3 cron pilot pipelines: security-scan, alembic-version-check, maxmind-asn-refresh) are complete and green. This ADR governs the execution design for Phases 3–5: the concrete decisions that every sub-card runs off.

The 86-workflow inventory breaks into four buckets (full per-workflow table in the linked migration table doc):

Bucket Count Description
MIGRATE 79 Port to .woodpecker/*.yaml in Waves A–C
RETIRE-GHA-NATIVE 6 GHA-specific triggers/actions with no WP equivalent
DISABLE 1 ios-ci.yml — deferred per ADR-0134 operator constraint
PORT-COMPOSITE 5 actions .github/actions/*scripts/ci/wpc_* init steps

Three of the 79 MIGRATE workflows have partial Phase 2 pilot pipelines already running; they complete their port in Wave A/B.


Invariants


Decision

1. Vault access — reusable WP init step, not agent-image bake

Decision: Five WP org-level secrets carry the vault bootstrap credentials. Every pipeline that needs application secrets includes a vault init step that runs scripts/ci/wpc_load_vault_secrets.py — a direct port of the .github/actions/load-vault-secrets shell logic, adapted for Woodpecker's WOODPECKER_ENV_FILE output mechanism.

Rejected — bake into agent image: a custom agent AMI with the Infisical CLI couples the image build to vault credential rotation; a compromised AMI would carry vault access. The reusable step pattern fetches fresh credentials at runtime and keeps the AMI vendor-neutral.

WP org-level secrets to create (values ported from current GHA repo secrets; set via POST /api/orgsecrets or POST /api/repos/1/secrets):

WP secret name What it holds
INFISICAL_CLIENT_ID Infisical Universal Auth machine identity client ID
INFISICAL_CLIENT_SECRET Infisical Universal Auth machine identity client secret
INFISICAL_PROJECT_ID Infisical project ID (MooseQuest workspace)
CF_ACCESS_CLIENT_ID CF Access service token ID for vault.raxx.app
CF_ACCESS_CLIENT_SECRET CF Access service token secret for vault.raxx.app

Canonical vault init step (copy into every pipeline that needs vault secrets):

- name: load-vault-secrets
  image: python:3.12-slim
  environment:
    INFISICAL_CLIENT_ID:
      from_secret: INFISICAL_CLIENT_ID
    INFISICAL_CLIENT_SECRET:
      from_secret: INFISICAL_CLIENT_SECRET
    INFISICAL_PROJECT_ID:
      from_secret: INFISICAL_PROJECT_ID
    CF_ACCESS_CLIENT_ID:
      from_secret: CF_ACCESS_CLIENT_ID
    CF_ACCESS_CLIENT_SECRET:
      from_secret: CF_ACCESS_CLIENT_SECRET
    VAULT_SECRETS: "SECRET_A,EXPORT_NAME=VAULT_KEY"  # per-pipeline
    VAULT_PATH: "/MooseQuest/"                        # per-pipeline
    VAULT_ENV: "prod"                                 # or "staging"
  commands:
    - pip install --quiet requests
    - python3 scripts/ci/wpc_load_vault_secrets.py

wpc_load_vault_secrets.py performs the same Infisical Universal Auth REST flow as the existing bash action. Key differences from the GHA version: - Writes KEY=VALUE\n to $WOODPECKER_ENV_FILE (WP v3 inter-step env passing) instead of $GITHUB_ENV. - Multi-line values (PEM keys) use WP's heredoc env-file syntax: KEY<<WDPK_DELIM\n<value>\nWDPK_DELIM. - Omits ::add-mask:: — WP secrets injected via from_secret are auto-redacted by the WP server in build logs.

2. Composite action ports

The five GHA composite actions become inline scripts. Woodpecker has no uses: ./.github/actions/... equivalent; each calling pipeline includes the step by value. Wave A sub-card A1 ports all five:

GHA composite action WP equivalent file Language
load-vault-secrets scripts/ci/wpc_load_vault_secrets.py Python
notify-deploy-status scripts/ci/wpc_notify_deploy_status.py Python
emit-audit-event scripts/ci/wpc_emit_audit_event.py Python
cloudflare-retry scripts/ci/wpc_cloudflare_retry.sh Shell
check-deploy-freeze scripts/ci/wpc_check_deploy_freeze.sh Shell

3. Deploy/release pipeline — tag-gated, Woodpecker-native

The ADR-0115 tag-gated model is preserved entirely in Woodpecker:

release-* tag pushed → develop
  .woodpecker/gatekeeper-develop-to-release.yaml  [event: tag, tag: release-*]
    1. Assert tag commit is on develop
    2. Poll Woodpecker API for CI pipeline on tagged SHA → must show status=success
       GET /api/repos/1/builds?event=push&branch=develop&limit=10, filter by commit SHA
    3. Idempotency check (already in release?)
    4. git merge --no-ff develop→release, push (raxx-ops-bot token from vault)
  → push to release branch auto-fires staging deploy pipelines

vX.Y.Z semver tag pushed → release
  .woodpecker/promote-release-to-main.yaml  [event: tag, tag: v*.*.*]
    1. Assert tag commit is on release
    2. Heavy scan suite (gitleaks, PII, SAST, dep audits) — same tools as GHA
    3. Idempotency check
    4. git merge --no-ff release→main, push (raxx-ops-bot token from vault)
    5. gh release create vX.Y.Z --generate-notes  [replaces release-please]
  → push to main auto-fires production deploy pipelines

How gatekeeper verifies CI: replaces the GHA API poll (GET /actions/workflows/ci.yml/runs?head_sha=...) with the Woodpecker API: GET /api/repos/1/builds?event=push&branch=develop&limit=20, filtered by commit == tagged_sha, checking status == "success". Same bounded retry loop (10 × 30 s = 5 min max) as the GHA version.

Prod deploy authorization: Production secrets (HEROKU_API_KEY prod, CLOUDFLARE_PAGES_TOKEN prod, etc.) are stored in WP with the event restriction event: ["tag", "manual"]. They are unavailable to push- or PR-triggered pipelines at the WP server level — not just by YAML convention. This enforces that production deploys can only occur via: - A vX.Y.Z semver tag push (triggering promote-release-to-main.yaml), or - A Console Ops Dispatch call to the WP API with event=manual (existing Console RBAC gate; changed from GHA workflow_dispatch URL to WP API endpoint POST /api/repos/1/pipelines).

GHA Environments currently have no required reviewers configured per the ADR-0020 reviewer-gate waiver. The WP tag + secret event restriction model provides equivalent authorization strength.

Console Ops Dispatch cutover (Wave C dependency): The Console deploy API changes from POST /repos/{repo}/actions/workflows/{file}/dispatches to POST /api/repos/1/pipelines with body {"event":"manual","branch":"<ref>","variables":{"environment":"production"}}. A Console sub-card (C-console) is a Wave C gate dependency.

4. workflow_call and workflow_run retirement

workflow_call (GHA reusable workflows) and workflow_run (GHA cross-workflow trigger) have no WP equivalents. Two workflows use them:

5. release-please retirement

release.yml uses google-github-actions/release-please-action@v4 — a GHA action with no WP equivalent. RETIRED. Replacement: a final step in .woodpecker/promote-release-to-main.yaml runs gh release create ${TAG_NAME} --generate-notes --notes-start-tag <prev-semver-tag> --target ${MAIN_SHA} using the raxx-ops-bot token from vault. Automated release PRs and a structured CHANGELOG are dropped; GitHub auto-generated release notes from PR titles replace the rich CHANGELOG. Acceptable trade-off — the operator confirmed the ADR-0115 explicit tag model supersedes the release-please flow.

6. Branch protection swap — add-alongside, then remove

Woodpecker posts commit status checks via the GitHub Checks API. Status context format in WP v3: ci/woodpecker/<pipeline-filename>/<step-name>.

Required check name mapping (develop branch):

Current GHA check name Woodpecker replacement
PR Gates / Backend tests ci/woodpecker/ci-pr/backend-tests-postgres
PR Gates / Frontend tests ci/woodpecker/ci-pr/frontend-tests
PR Gates / Sprint readiness gate ci/woodpecker/ci-pr/sprint-readiness-gate
PR Gates / base_branch_lint ci/woodpecker/ci-pr/base-branch-lint
PR Gates / Migration collision check ci/woodpecker/migration-collision-check/check
CI — develop / Backend tests ci/woodpecker/ci-develop/backend-tests-postgres

Wave A sub-card A7 audits the full required check list against the actual WP job/step names before any changes are made (exact names depend on the WP pipeline YAML written in A3–A6; audit AC is required before the swap).

Swap sequence: 1. Wave A — add WP check names as additional required checks alongside GHA. PRs must pass both during the parity window. Branch protection API: PATCH /repos/raxx-app/TradeMasterAPI/branches/develop/protection. 2. Wave B gate — after 7-day soak and 20+ successful WP backend-postgres runs: remove GHA check names; WP checks become the sole gate. 3. release/main branches — same pattern; swap happens in Wave C (deploy pipeline parity confirmed on staging before touching main protection).

7. Agent capacity — scheduled scale + heavy-fleet scale arbiter

Standard fleet (c6a.xlarge spot, size:standard label):

Action ASG scheduled action UTC cron
Scale up (biz hours) desired=2, min=1 0 13 * * 1-5 (Mon–Fri 13:00 = 6am PDT)
Scale down (off-hours) desired=0, min=0 0 4 * * 2-6 (Tue–Sat 04:00 = 11pm PDT prev day)

Weekend and off-hours PRs accept the ~90-second EC2 cold start + WP agent registration latency.

Heavy fleet (c6a.2xlarge on-demand, size:heavy label — backend-tests-postgres):

Default: min=0, desired=0 (scale to zero). A scale arbiter Lambda (infra/ci/scale_heavy_fleet.py, ~50 lines) runs every 5 minutes via EventBridge. It polls GET /api/repos/1/builds?status=pending for builds with environment labels containing size:heavy, then calls autoscaling:SetDesiredCapacity on the heavy fleet ASG to 1 (builds pending) or 0 (queue clear). IAM: the existing ci-runner-fleet-role gains autoscaling:SetDesiredCapacity scoped to the heavy ASG ARN. Cold start is ~90 seconds — acceptable for a 78-minute job.

Cost projection (full migration):

Line item $/month
WP server t4g.small (existing Phase 1) $12
RDS db.t4g.micro (existing Phase 1) $13
ALB (existing Phase 1) $18
NAT Gateway (existing Phase 1) $8
Standard agents: ~250 agent-hours/mo at $0.07/hr spot $17.50
Heavy agent: ~40 on-demand hours/mo at $0.33/hr $13
EBS ephemeral (agent root volumes) $4
CloudWatch + EventBridge + Lambda $3
GitHub Pro (iOS macOS buffer, per ADR-0134) $5
Total ~$94/mo

Within the operator-accepted ~$107/mo envelope. Savings vs $186/mo GHA bill: ~$92/month after full migration.

8. Decommission sequence (Wave D)

After Wave D 14-day prod soak:

  1. gh workflow disable for all remaining active GHA workflows (bulk script in Wave D sub-card D4; disables via API, does not delete files).
  2. .github/workflows/ stays in place for 30 days as an incident reference.
  3. After 30-day soak: git mv .github/workflows .github/workflows-retired in a single cleanup PR.
  4. Set GHA spending limit to $20/month (iOS macOS buffer).
  5. Close GH issue #728 (Ubicloud migration superseded); update #726 (billing).
  6. ios-ci.yml remains in .github/workflows-retired/ with a header comment documenting the defer decision; do not delete.

Language choice rationale

No new services are introduced by this ADR. All ported scripts are Tier 2.

scripts/ci/wpc_*.py / wpc_*.sh — Tier 2 (Python/shell). These are ephemeral CI helper scripts: no latency budget, no auth credential hot path, no memory-safety requirement. No Tier 1 criteria met. Portability risk to Rust: none (CI scripts, not runtime services).

Scale arbiter Lambda — Tier 2 (Python). Runs every 5 minutes; latency irrelevant; no customer PII; no auth credential handling. No Tier 1 criteria met.


Consequences

Positive

Negative / risks

Neutral


Alternatives considered

Bake vault credentials into agent AMI

Rejected: couples AMI build to vault credential rotation; a compromised AMI carries vault access. The inline init step has identical security properties with zero image-build overhead.

Keep release-please running in GHA (hybrid forever)

Rejected: leaves a permanently non-zero GHA workflow count. The gh release create replacement covers the functional need with no GHA dependency.

Lambda webhook autoscaling for standard fleet

A Lambda reacting to WP webhook events to scale the standard fleet was considered. Rejected in favor of simpler scheduled scale for the standard fleet (predictable business-hours pattern; fewer moving parts). Scale arbiter Lambda is scoped to the heavy fleet only, where on-demand scaling is economically necessary.


Security / GDPR checklist


Revisit when


Wave D execution record — 2026-07-07 (#4019)

Wave D decommission executed via PR chore(ci): Wave D — retire GitHub Actions (#4019). GHA workflows retired: 45. GHA workflows kept active (WP gap): 41.

Retirement method

Each retired workflow was: 1. Modified in place: on: trigger block replaced with on:\n workflow_dispatch: (manual-only, never auto-fires). 2. A top-of-file # RETIRED 2026-07-07 comment added with the WP replacement reference and issue number. 3. Moved to .github/workflows-retired/ via git mv (rename tracked; content preserved for reference). 4. Every modified file validated with yaml.safe_load before commit.

Script: scripts/ci/retire_gha_workflows.py.

What was retired (45 workflows)

Bucket GHA workflow WP equivalent
Phase 2 alembic-version-cron.yml .woodpecker/alembic-version-check.yaml
Phase 2 maxmind-asn-refresh.yml .woodpecker/maxmind-asn-refresh.yaml
Phase 2 nightly-security-scan.yml .woodpecker/security-scan-nightly.yaml
Wave A antlers-next-ci.yml .woodpecker/antlers-next-ci.yaml
Wave A bcp-vault-snapshot-daily.yml .woodpecker/bcp-vault-snapshot-daily.yaml
Wave A ci-boundary.yml .woodpecker/ci-boundary.yaml
Wave A ci-console.yml .woodpecker/ci-console.yaml
Wave A ci-digest-cron.yml .woodpecker/ci-digest-cron.yaml
Wave A ci-pr.yml .woodpecker/ci-pr.yaml
Wave A ci.yml .woodpecker/ci-develop.yaml
Wave A console-degraded-auto-file.yml .woodpecker/console-degraded-auto-file.yaml
Wave A cron-heartbeat-monitor.yml .woodpecker/cron-heartbeat-monitor.yaml
Wave A daily-bot-token-smoke.yml .woodpecker/daily-bot-token-smoke.yaml
Wave A daily-card-groomer.yml .woodpecker/daily-card-groomer.yaml
Wave A drift-orchestrator-cron.yml .woodpecker/drift-orchestrator-cron.yaml
Wave A flag-drift-check.yml .woodpecker/flag-drift-check.yaml
Wave A heroku-admin.yml .woodpecker/heroku-admin.yaml
Wave A infra-ci-validate.yml .woodpecker/infra-ci-validate.yaml
Wave A launch-readiness-check.yml .woodpecker/launch-readiness-check.yaml
Wave A lint-cf-access-headers.yml .woodpecker/lint-cf-access-headers.yaml
Wave A lint-cf-pages-deploy-uniqueness.yml .woodpecker/lint-cf-pages-deploy-uniqueness.yaml
Wave A lint-cf-tokens.yml .woodpecker/lint-cf-tokens.yaml
Wave A lint-workflow-secret-names.yml .woodpecker/lint-workflow-secret-names.yaml
Wave A migration-collision-check.yml .woodpecker/migration-collision-check.yaml
Wave A ops-mint-preview-invite.yml .woodpecker/ops-mint-preview-invite.yaml
Wave A pii-scan.yml .woodpecker/pii-scan.yaml
Wave A postmark-delivery-monitor.yml .woodpecker/postmark-delivery-monitor.yaml
Wave A pr-preview.yml .woodpecker/pr-preview.yaml
Wave A queue-zero-dyno-monitor.yml .woodpecker/queue-zero-dyno-monitor.yaml
Wave A reminders-second-factor-nightly.yml .woodpecker/reminders-second-factor-nightly.yaml
Wave A review-app-console.yml .woodpecker/review-app-console.yaml
Wave A sc-ident-6-smoke.yml .woodpecker/sc-ident-6-smoke.yaml
Wave A terraform-validate.yml .woodpecker/terraform-validate.yaml
Wave A vcpkg-manifest-check.yml .woodpecker/vcpkg-manifest-check.yaml
RETIRE-GHA-NATIVE release.yml deploy-prod.yaml create-github-release step
RETIRE-GHA-NATIVE slack-notify.yml Inline on_failure steps in each WP pipeline
RETIRE-GHA-NATIVE synth-probe-waitlist.yml None (disabled 2026-06-25; no replacement yet)
RETIRE-GHA-NATIVE test-cloudflare-retry-action.yml wpc_cloudflare_retry.sh (inline)
RETIRE-GHA-NATIVE test-emit-audit-event-action.yml wpc_emit_audit_event.py (inline)
RETIRE-GHA-NATIVE test-notify-deploy-status.yml wpc_notify_deploy_status.py (inline)
DISABLE ios-ci.yml None — iOS CI deferred per ADR-0134
Wave C deploy-heroku.yml .woodpecker/deploy-staging.yaml + deploy-prod.yaml
Wave C deploy-console.yml .woodpecker/deploy-staging.yaml + deploy-prod.yaml
Wave C gatekeeper-develop-to-release.yml .woodpecker/deploy-staging.yaml (gatekeeper step)
Wave C promote-release-to-main.yml .woodpecker/deploy-prod.yaml (gatekeeper-prod step)

What is still active in .github/workflows/ (41 WP gaps)

These workflows have no Woodpecker equivalent confirmed in .woodpecker/ on develop as of 2026-07-07. They remain active to avoid dropping coverage.

Wave B cron gap (19 workflows — WP pipelines not yet in .woodpecker/): account-merge-tombstone-cron.yml, audit-integrity-cron.yml, audit-reconciler-cron.yml, bcp-smoke-monthly.yml, billing-collector-cron.yml, billing-retention-cron.yml, e2e-smoke.yml, freescout-backup.yml, heroku-config-health-nightly.yml, historical-bars-1min-warm-nightly.yml, mbt-assignment-at-expiry-cron.yml, mbt-dividend-split-cron.yml, mbt-drift-daily.yml, mbt-drift-per-symbol-weekly.yml, mbt-resting-orders-cron.yml, queue-docker-smoke.yml, security-zap.yml, tickets-e2e-smoke.yml, trace-integrity-cron.yml

Wave C deploy gap (22 workflows — services not yet deployed via WP): cut-release-candidate.yml, deploy-antlers-cutover.yml, deploy-antlers-next-prod.yml, deploy-antlers-next-staging.yml, deploy-console-shim.yml, deploy-customer-docs.yml, deploy-failure-streak-alert.yml, deploy-flag-docs.yml, deploy-getraxx.yml, deploy-internal-docs.yml, deploy-mockups.yml, deploy-queue-failure-monitor.yml, deploy-queue.yml, deploy-status-page.yml, deploy-status-worker.yml, deploy-support.yml, deploy-velvet.yml, deploy-worker-waf-log-shipper.yml, freescout-apply.yml, synthetic-gate.yml, terraform-email-delivery-stack.yml, waf-synthetic-probe.yml

Known side-effect: deploy-failure-streak-alert.yml and deploy-queue-failure-monitor.yml use workflow_run triggers that listen for now-retired GHA workflow names ("Deploy to Heroku", "Deploy console", etc.). Those workflow_run events will never fire now that the source workflows are retired. Both files retain workflow_dispatch for manual invocation. Port both to WP as part of the Wave B/C backlog.

Reversibility

Any retired workflow can be re-enabled in under 5 minutes:

# Restore original triggers from git history
git show <pre-wave-d-sha>:.github/workflows/<file>.yml > .github/workflows/<file>.yml
git add .github/workflows/<file>.yml
git commit -m "fix: re-enable <file>"

After 30 days (2026-08-06), if no rollback was required, the .github/workflows-retired/ directory can be removed and this ADR amended to reflect stable decommission status.

Operator actions required (not automated)

  1. Drop GHA spending limit — set to $20/month (iOS macOS buffer per ADR-0134). GitHub Settings → Billing → Actions → Spending limit.
  2. Close GH issue #728 — Ubicloud migration superseded by WP.
  3. Update GH issue #726 — billing posture: WP is primary; GHA $20/mo buffer only.
  4. Port Wave B + Wave C remaining 41 workflows to WP — file sub-cards as follow-on to #4019.