ADR-0136 — GHA deliberate-hold workflows: cutover decisions
Status: Accepted
Date: 2026-07-09 UTC
Deciders: Kristerpher (operator); software-architect (design)
Scope: Eight deliberate GHA hold workflows in the Wave D migration (epic #4066, cutover epic #4106)
Refs: ADR-0135 (Woodpecker full migration plan);
docs/architecture/gha-deliberate-holds-cutover.md (full design)
Implementation: Executed 2026-07-09 to 2026-09-28. As-built status, deviations and
resolved open questions are in section 10 of the design doc; the "As built" notes below
summarise the ones that touch a decision.
Numbering note. Several infrastructure files cite "ADR-0136 Option D" or "ADR-0136 (WP HA)" (for example
infra/ci/server.tf,infra/ci/cloudwatch.tf,infra/ci/ssm.tf,infra/ci/iam.tf,infra/ci/outputs.tf,terraform/ci-monitoring/*and ADR-0143). That is the Woodpecker high-availability decision, which is [ADR-0142](https://internal-docs.raxx.app/architecture/adr/0142-woodpecker-ha-option-d.html). This ADR (0136) covers only the deliberate-hold cutover;infra/ci/ecr-cache.tf,infra/ci/ci-artifacts.tfand the W3 to W8 pipelines cite it correctly.
Context
ADR-0135 classified eight GitHub Actions workflows as "deliberate GHA hold" after Wave D retired 51 other workflows. Each of the eight has a specific technical blocker preventing a straightforward Wave A–C port. This ADR records the key decisions made during the design of the cutover for those eight.
Decision 1 — woodpecker-healthcheck.yml stays on GHA permanently
Decision: woodpecker-healthcheck.yml is classified as GENUINELY-STAYS-ON-GHA.
No Woodpecker port will be created.
Rationale: A monitoring pipeline for Woodpecker must run on infrastructure independent from Woodpecker. If Woodpecker is down, a WP-hosted watchdog cannot fire. This is the precise failure mode the monitor detects. The external vantage requirement cannot be satisfied by any WP pipeline, a same-region Prometheus stack, or a WP cron.
Alternatives rejected:
- WP cron: Fails when WP is down — exactly the wrong behaviour.
- Prometheus blackbox exporter (#1022): Same AWS region and AZ as ci.moosequest.net;
insufficient independence.
- SaaS uptime monitor: Valid secondary layer but does not replace the GH anchor-issue
dedup or Postmark edge-triggered alerting without custom wiring.
Consequence: GHA is retained for one workflow indefinitely. GHA spending limit remains constrained by this one cron (approximately 15 runs/hour × ~15 seconds each = negligible minutes).
Decision 2 — OIDC short-lived tokens replaced by vault-loaded long-lived IAM keys
Decision: terraform-email-delivery-stack.yml and freescout-apply.yml both use
AWS credentials. The GHA OIDC federation mechanism (short-lived tokens, repo/branch
bound) cannot be replicated in Woodpecker. The WP replacement uses long-lived IAM
access keys stored in Infisical vault.
This is a security posture downgrade. Recorded here explicitly.
OIDC model (current GHA):
- Tokens valid for 15-30 minutes per workflow run.
- Cryptographically bound to sub: repo:raxx-app/TradeMasterAPI:ref:refs/heads/main.
- No key rotation required — tokens are ephemeral.
- A compromised token has a maximum 30-minute blast radius.
Long-lived key model (WP replacement): - Keys valid until rotated (90-day rotation schedule via velvet). - Not bound to a specific repo or branch — any process with the key can call AWS. - If a key leaks, blast radius is bounded only by the IAM policy until rotation.
Mitigations accepted as adequate for current team size (single operator):
1. Separate plan (read-only) and apply (read-write) IAM users. A leaked plan key
cannot modify or destroy infrastructure.
2. Automated 90-day rotation via velvet handler. No manual rotation dependency.
3. CloudTrail alerts on unexpected API calls from unexpected IPs or time windows.
4. Least-privilege per-resource policies (not AdministratorAccess).
5. Keys stored in Infisical vault at /MooseQuest/aws/terraform/ and
/MooseQuest/aws/freescout/ — never in repo files or WP YAML.
As built (2026-10-30): mitigations 1, 4 and 5 are in place (infra/ci/ci-artifacts.tf,
scripts/ops/secrets/mint_ci_aws_keys.sh). Mitigations 2 and 3 are not delivered as
written:
- No velvet handler exists. Rotation is the operator-run
mint_ci_aws_keys.sh --rotate (see docs/ops/runbooks/ci-aws-credentials.md), so the
90-day cadence depends on the operator.
- infra/ci/ defines no CloudTrail alarm on these users. Only the runbook's manual
aws cloudtrail lookup-events check exists.
Until both are built, treat the "90-day automated rotation" and "CloudTrail alerting"
claims in .woodpecker/terraform-email-delivery-stack.yaml as aspirational.
Revisit trigger: If the team grows beyond 2 people OR if a public OIDC provider for self-hosted Woodpecker is available and audited, revisit this decision and migrate back to short-lived tokens.
Alternatives rejected: - Vault as OIDC proxy: Vault can issue OIDC tokens, but Woodpecker is not configured to call Vault's OIDC endpoint at pipeline start. Requires Vault Enterprise features or significant custom scripting — too complex for current team size. - AWS IAM Roles Anywhere (certificate-based): Requires a private CA and certificate management. Adds operational complexity that exceeds the benefit at current scale.
Decision 3 — workflow_run: trigger replaced by polling cron
Decision: deploy-failure-streak-alert.yml uses workflow_run: (no WP
equivalent). The replacement is a 30-minute WP cron that polls the WP build API
for consecutive failures, replacing the GH Actions API calls.
Rationale: workflow_run: is a GHA-native push event (fires when a named
workflow run completes). ADR-0135 §4 already established that workflow_run and
workflow_call workflows are RETIRE-GHA-NATIVE. The polling cron approach is the
established pattern for this class of problem in WP (the existing
deploy-queue-failure-monitor.yml used an independent cron for the same reason when
the 30-minute window of the general monitor proved inadequate).
Consequence: deploy-queue-failure-monitor.yml is absorbed into the WP streak
pipeline (its queue-specific unbounded-window check is added as an additional check
block) and retired as a standalone GHA workflow.
Latency increase: Reactive (0-30 s lag after workflow completion) → polling (0-30 min lag). This is acceptable for a streak-detection alert — a 30-minute polling window matches the existing streak time window.
As built: .woodpecker/deploy-failure-monitor.yaml polls every 6 hours
(0 */6 * * *), not every 30 minutes, so worst-case detection lag is up to 6 hours. It
alerts by Postmark email to ops@raxx.app, not Slack. The Queue profile in that file
keys on tag prefix refs/tags/queue-, which deploy-queue.yaml never produces, so it
cannot fire until it is repointed at v* tags or release pushes.
Decision 4 — ZAP runs as a WP step image, not Docker-in-Docker
Decision: security-zap.yml uses zaproxy/action-baseline@v0.12.0, a GHA
composite action that wraps Docker internally. The WP replacement uses
image: zaproxy/zap-stable as the step image — no Docker-in-Docker or
WOODPECKER_ESCALATE required.
Rationale: GHA actions that wrap Docker internally do so because GHA steps cannot natively specify a container image. Woodpecker steps natively specify a container image per step. Running ZAP as the step image is the natural WP translation of a Docker-wrapping GHA action. The ZAP container is executed by the WP agent's Docker runtime, not by a nested Docker daemon inside the step.
Consequence: ZAP port is classified as effort S (low), not L. No privileged agent modes are required. ZAP port is Wave B (can proceed before the Docker-socket infrastructure for deploy-queue is in place).
As built: .woodpecker/security-zap.yaml pins zaproxy/zap-stable to 2.17.0 (bumped
from 2.14.0 on 2026-08-19) and a pin-check step fails the pipeline if the tag becomes
:latest.
Decision 5 — Docker socket mount preferred over DinD for queue builds
Decision: deploy-queue.yml and queue-docker-smoke.yml require Docker for
building the C++ container image and running smoke containers. WP will use Docker
socket mount (/var/run/docker.sock:/var/run/docker.sock in WOODPECKER_VOLUMES)
rather than Docker-in-Docker (WOODPECKER_PRIVILEGED=true).
Rationale: DinD starts a nested Docker daemon inside each pipeline step. On spot EC2 instances, the daemon startup adds 5-15 seconds per step and increases OOM risk. Socket mount reuses the host daemon — faster and lower overhead for a workload (25-30 min C++ builds) where step startup time matters.
Security trade-off: Socket mount gives pipeline code access to the host Docker
daemon. A malicious pipeline step could read/write any container on the agent.
Mitigated by: (1) only raxx-app/TradeMasterAPI pipelines run on this WP instance
(single-repo, operator-controlled); (2) pipeline YAML is in the operator's own repo
(not a public fork); (3) secret event restrictions prevent public PR pipelines from
accessing prod secrets.
Open question (OQ-1): Operator must confirm socket mount is acceptable over DinD before the P1 card is claimed.
As built: OQ-1 resolved in favour of the socket mount. The host socket is
bind-mounted into the agent container (infra/ci/templates/user_data_agent.sh.tpl) and
trusted.volumes=true on the WP repo lets steps inherit it (infra/ci/ecr-cache.tf).
The layer cache is ECR repo raxx-queue-vcpkg-cache in us-east-2.
Decision 6 — Approval gates use WP confirm-variable pattern (not GH Environments)
Decision: freescout-apply.yml and the destroy-action gate in
terraform-email-delivery-stack.yml used GH Environments for manual approval. WP
has no equivalent. The replacement pattern is: a CONFIRM_VAR=magic-string pipeline
variable checked at step 1.
Rationale: The GH Environment reviewer gate in these workflows currently has no
required reviewers configured (ADR-0020 reviewer-gate waiver). The Environment gate
is effectively a "confirm by running the workflow yourself" check — which the
WP confirm-variable pattern replicates. The pattern is already in use in the migrated
deploy-prod.yaml pipeline and is the established WP authorization mechanism per
ADR-0135 §3.
Consequence: The GH deployment audit trail (visible in the Environments UI) is replaced by the WP build log as the audit trail. Acceptable for a single-operator team. Revisit if team grows or if a regulator requires environment-level audit trails.
As built: the confirm variables are APPLY_CONFIRM=APPLY-FREESCOUT-NOW
(freescout-apply.yaml), APPLY_CONFIRM=yes plus DESTROY_CONFIRM=ALLOW-DESTROY-EMAIL-STACK
(terraform-email-delivery-stack.yaml, one pipeline, resolving OQ-2) and
DEPLOY_PROD_CONFIRM=deploy-prod-now (deploy-queue.yaml, break-glass from main).
Language choice rationale (no new services)
This ADR does not introduce any new services or processes. The 8 workflows are CI/CD pipelines that run on existing WP infrastructure. No Tier 1 / Tier 2 language classification is required.
Consequences
| Area | Before | After |
|---|---|---|
| GHA workflows remaining | 8 deliberate holds + ios-ci.yml |
1 (woodpecker-healthcheck.yml); as built, ios-ci.yml was retired in Wave D (#4064) and the paused billing-collector-cron.yml is a fallback, not a hold |
| AWS auth model | OIDC short-lived | IAM long-lived keys (90-day rotation, operator-run as built) |
| ZAP DAST | GHA composite action (Docker wrapper) | WP step image (zaproxy/zap-stable) |
| Failure-streak alerting | Reactive (workflow_run:) |
Polling cron (designed 30 min; as built every 6 hours, Postmark email) |
| Approval gates | GH Environments (no reviewers) | WP confirm-variable pattern |
| vcpkg cache | GHA actions/cache@v4 |
ECR layer cache (type=registry) |
Open Questions (all resolved)
Raised at design time; each is now closed. Detail is in section 10.3 of the design doc.
- OQ-1: Docker socket vs DinD. Resolved: socket mount, via
trusted.volumes=true. - OQ-2: Single vs two-pipeline Terraform. Resolved: one pipeline with confirm variables.
- OQ-3: ZAP weekly, WP cron vs monitoring stack (#1022). Resolved: WP cron (
security-zap-weekly). - OQ-4: S3 bucket
raxx-ci-artifacts, exists or create? Resolved: created byinfra/ci/ci-artifacts.tf. - OQ-5: Antlers cutover status. Resolved: already executed on 2026-06-05, so the GHA one-shot was retired unported (#4113).