Woodpecker CI runbook
System: ci-woodpecker (Woodpecker CI on EC2 — ci.moosequest.net)
Owner: operator / sre-agent
ADR: ADR-0134 (Candidate B, operator-authorized 2026-07-03)
Card: #4012 (Phase 1 baseline IaC)
Last incident: 2026-08-12 (three sibling WP crons re-verified via scoped trigger found real, previously-unvalidated drift — npm ci lockfile gap and a missing init_engine() bootstrap — plus a cron-trigger workflow-scoping anomaly; see docs/incidents/2026-08-12-cron-reverify-batch-e2e-warm-waf.md)
Last reviewed: 2026-08-12
Architecture summary
Internet
└── Oracle Dyn CNAME: ci.moosequest.net → ALB
└── ALB (public, us-east-2a/b)
├── AWS WAF WebACL (managed rules + rate limit)
├── :80 → redirect :443
└── :443 → listener rules
├── /hook* → Woodpecker server :8000 (no OIDC — priority 10)
├── /api/badge* → Woodpecker server :8000 (no OIDC — priority 20)
├── /api/* /authorize /login /logout → Woodpecker :8000 (no OIDC — priority 25)
└── (default) → Google Workspace OIDC → Woodpecker :8000
└── Woodpecker server (t4g.small, private subnet us-east-2a)
├── docker-compose: woodpecker-server container
├── gRPC :9000 → build agents (spot ASG, c7a/c6a.xlarge)
└── PostgreSQL → RDS db.t4g.micro (private subnet)
All instances: IMDSv2 required, no SSH keys, SSM Session Manager for break-glass access
Secrets: SSM Parameter Store SecureString (/ci/* path)
Build cache (2026-08-05, card #4393): the agent fleet's Docker daemon now
points at a pull-through registry mirror + PyPI caching proxy on the
ci-monitoring box, collapsing NAT data-processing cost from ~1.49 TB/mo of
repeat Docker Hub/PyPI traffic. See docs/ops/runbooks/ci-build-cache.md for
the full system runbook — do not debug agent build-cache issues here.
VPC endpoints single-AZ (2026-08-05, card #4394): the four interface
endpoints (ssm/ssmmessages/ec2messages/logs, infra/ci/vpc.tf) are now
scoped to us-east-2a only (previously both AZs) — halves their hourly cost
from ~$53.76/mo to ~$26.88/mo. The agent fleet's AZ placement is NOT
pinned — an agent that lands in us-east-2b (still in the ASG's failover
subnet set) falls back to routing SSM/CloudWatch-Logs traffic via NAT
instead of the local endpoint. This is expected and accepted (see vpc.tf
header comment for the full risk writeup) — it does not break SSM access,
it just routes it differently. An AZ-A outage would take VPC-endpoint-based
SSM access down fleet-wide until endpoints are restored or re-added to AZ-B
(rollback: re-add aws_subnet.private[1].id to each endpoint's
subnet_ids and re-apply).
How to tell it's broken
- ALB health check fails (
/healthzreturns non-200): Woodpecker container is down - Webhook delivery failures in GitHub repository settings:
/hook*ALB routing broken, WAF blocking, or Woodpecker server crashed ci.moosequest.netunreachable: ACM cert expired, Dyn CNAME missing/wrong, ALB stopped- No agents pick up jobs: ASG desired=0 (scale up manually), agent-secret mismatch, gRPC port blocked
- Builds queue forever: agents running but disconnected from server (check gRPC connectivity, server logs)
- Login buttons dead / "Login to CI" does nothing: ALB OIDC is intercepting Woodpecker's own auth endpoints — see Failure mode F
Apply order (Phase 1 first-time deploy)
Prerequisites
Before running terraform apply:
- AWS credentials configured for the Terraform executor role (see §IAM executor role)
- State bootstrap completed (see §State bootstrap below)
- SSM parameter placeholders created by bootstrap apply
- Google OAuth client created (see §Google OAuth client setup)
- GitHub OAuth App registered (see §GitHub OAuth App registration)
- Real SSM parameter values filled (see §Secrets fill-in checklist)
State bootstrap (one-time)
cd infra/ci/bootstrap
terraform init
terraform plan
terraform apply
# Note the state_bucket_name and lock_table_name outputs.
# These match the values hardcoded in infra/ci/main.tf backend block.
Bootstrap state lives at infra/ci/bootstrap/terraform.tfstate — do NOT commit this
file (it is gitignored). Keep a copy in a secure location (Google Drive ops folder).
Main apply
cd infra/ci
terraform init # connects to S3 backend created by bootstrap
terraform plan -out=ci-phase1.plan -var-file=terraform.tfvars
terraform apply ci-phase1.plan
After first apply
-
Read outputs:
bash terraform output -json -
Add two CNAME records in Oracle Dyn (see §DNS setup below).
-
Confirm ACM certificate validates (5-30 min after DNS propagation):
bash aws acm describe-certificate \ --certificate-arn "$(terraform output -raw acm_certificate_arn)" \ --region us-east-2 \ --query "Certificate.Status" # Expected: "ISSUED" -
Fill SSM parameter values (see §Secrets fill-in checklist).
-
Restart Woodpecker server to pick up real secrets:
bash INSTANCE_ID="$(terraform output -raw woodpecker_server_instance_id)" aws ssm start-session --target "$INSTANCE_ID" --region us-east-2 # In the session: sudo systemctl restart woodpecker sudo docker compose -f /opt/woodpecker/docker-compose.yml logs -f -
Confirm SNS subscription for ops@raxx.app (check email, click confirmation link).
-
Verify Woodpecker UI at
https://ci.moosequest.net(requires Dyn CNAME and ACM ISSUED).
DNS setup (Oracle Dyn)
Add two CNAME records in Oracle Dyn after first terraform apply:
Record 1: ACM validation CNAME
Type: CNAME
Name: <terraform output acm_validation_cname_name>
(looks like: _acm-challenge.ci.moosequest.net. or _abc123.ci.moosequest.net.)
Value: <terraform output acm_validation_cname_value>
(looks like: _xyz789.acm-validations.aws.)
TTL: 300
Purpose: Allows AWS Certificate Manager to validate ownership of ci.moosequest.net and issue the TLS certificate. This record can remain indefinitely (ACM uses it for renewals too).
Record 2: ci.moosequest.net service CNAME
Type: CNAME
Name: ci.moosequest.net
Value: <terraform output alb_dns_name>
(looks like: ci-woodpecker-alb-1234567890.us-east-2.elb.amazonaws.com)
TTL: 300 (lower during initial setup for faster propagation)
Purpose: Points ci.moosequest.net to the ALB. Required for the Woodpecker UI,
webhook delivery, and the OIDC redirect from Google.
After adding both records: Wait for DNS propagation (typically 2-5 minutes for Dyn's authoritative nameservers; up to 5 minutes for resolvers to pick up TTL=300 records).
Google OAuth client setup (operator browser steps)
The ALB OIDC action uses Google Workspace OAuth 2.0. Create a client once:
- Open Google Cloud Console:
https://console.cloud.google.com/ - Select the MooseQuest project (or create one named "moosequest-ops").
- Navigate to APIs & Services → Credentials.
- Click Create Credentials → OAuth client ID.
- Application type: Web application.
- Name:
Woodpecker CI ALB OIDC - Authorized redirect URIs — add:
https://ci.moosequest.net/oauth2/idpresponse(This is the AWS ALB OIDC callback endpoint — do NOT change the path.) - Click Create.
- Copy the Client ID and Client Secret.
-
Fill SSM parameters (see §Secrets fill-in checklist): ```bash aws ssm put-parameter \ --name "/ci/oidc/google-client-id" \ --value "PASTE_CLIENT_ID_HERE" \ --type SecureString \ --overwrite \ --region us-east-2
aws ssm put-parameter \ --name "/ci/oidc/google-client-secret" \ --value "PASTE_CLIENT_SECRET_HERE" \ --type SecureString \ --overwrite \ --region us-east-2 ```
Important: The redirect URI must be https://ci.moosequest.net/oauth2/idpresponse
exactly. The ALB uses this path for the OIDC code exchange. If ci.moosequest.net is
not yet resolving, the Google OAuth client can be created first (the URI is validated
by Google only at runtime, not at creation time).
Status (2026-07-03): Google OIDC credentials are already staged in Infisical vault
at raxx-secrets /Raxx/CI (prod) as CI_GOOGLE_OIDC_CLIENT_ID and
CI_GOOGLE_OIDC_CLIENT_SECRET. Skip steps 1–9 above. Proceed directly to the SSM
fill step, reading from vault (requires a session with CF Access service token):
GOOGLE_CLIENT_ID="$(infisical secrets get CI_GOOGLE_OIDC_CLIENT_ID \
--projectId raxx-secrets --path /Raxx/CI --env prod --plain)"
GOOGLE_CLIENT_SECRET="$(infisical secrets get CI_GOOGLE_OIDC_CLIENT_SECRET \
--projectId raxx-secrets --path /Raxx/CI --env prod --plain)"
aws ssm put-parameter --name "/ci/oidc/google-client-id" \
--value "$GOOGLE_CLIENT_ID" --type SecureString --overwrite --region us-east-2
aws ssm put-parameter --name "/ci/oidc/google-client-secret" \
--value "$GOOGLE_CLIENT_SECRET" --type SecureString --overwrite --region us-east-2
unset GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET
GitHub OAuth App registration (operator browser steps)
Woodpecker uses a GitHub OAuth App for SCM integration (pipeline triggers, status checks, user authentication).
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:
- Application name:
Woodpecker CI — moosequest.net- Homepage URL:https://ci.moosequest.net- Authorization callback URL:https://ci.moosequest.net/authorize - Click Register application.
- On the app page, click Generate a new client secret.
- Copy Client ID and Client Secret.
- Fill SSM parameters: ```bash aws ssm put-parameter \ --name "/ci/woodpecker/github-client-id" \ --value "PASTE_CLIENT_ID_HERE" \ --type SecureString \ --overwrite \ --region us-east-2
aws ssm put-parameter \ --name "/ci/woodpecker/github-client-secret" \ --value "PASTE_CLIENT_SECRET_HERE" \ --type SecureString \ --overwrite \ --region us-east-2 ```
Secrets fill-in checklist
After terraform apply, all SSM parameters exist as PLACEHOLDER values. Fill them
in this order (prerequisite steps noted):
# 1. Woodpecker admin GitHub username (your GitHub login)
aws ssm put-parameter \
--name "/ci/woodpecker/admin-user" \
--value "GITHUB_USERNAME" \
--type SecureString \
--overwrite \
--region us-east-2
# 2. Woodpecker agent↔server RPC secret (must be identical on server and agents)
AGENT_SECRET="$(openssl rand -hex 32)"
aws ssm put-parameter \
--name "/ci/woodpecker/agent-secret" \
--value "$AGENT_SECRET" \
--type SecureString \
--overwrite \
--region us-east-2
# Save $AGENT_SECRET in 1Password / secure notes — needed if agents are
# rebuilt without Terraform re-reading SSM.
# 3. PostgreSQL password
DB_PASSWORD="$(openssl rand -base64 32 | tr -d /=+ | head -c 32)"
aws ssm put-parameter \
--name "/ci/woodpecker/db-password" \
--value "$DB_PASSWORD" \
--type SecureString \
--overwrite \
--region us-east-2
# ALSO update the RDS master password to match:
aws rds modify-db-instance \
--db-instance-identifier ci-woodpecker \
--master-user-password "$DB_PASSWORD" \
--apply-immediately \
--region us-east-2
# 4. GitHub OAuth App credentials (from §GitHub OAuth App registration above)
aws ssm put-parameter \
--name "/ci/woodpecker/github-client-id" \
--value "GITHUB_CLIENT_ID" \
--type SecureString \
--overwrite \
--region us-east-2
aws ssm put-parameter \
--name "/ci/woodpecker/github-client-secret" \
--value "GITHUB_CLIENT_SECRET" \
--type SecureString \
--overwrite \
--region us-east-2
# 5. Google Workspace OIDC credentials (from §Google OAuth client setup above)
aws ssm put-parameter \
--name "/ci/oidc/google-client-id" \
--value "GOOGLE_CLIENT_ID" \
--type SecureString \
--overwrite \
--region us-east-2
aws ssm put-parameter \
--name "/ci/oidc/google-client-secret" \
--value "GOOGLE_CLIENT_SECRET" \
--type SecureString \
--overwrite \
--region us-east-2
After filling all 7 parameters, restart the Woodpecker server (see §Apply order step 5).
IAM executor role
The identity running terraform apply needs the ci-terraform-executor-policy
attached (provisioned by the IaC itself on first apply — bootstrapping caveat below).
Bootstrapping caveat: On first apply, the executor policy does not yet exist (Terraform creates it). The operator's identity must have sufficient permissions to create it. Simplest path for first apply: use an existing role with broad permissions, then attach the scoped policy and switch to it for subsequent applies.
Do NOT use claude-infisical-bootstrap — that user must not be widened per ADR-0134.
Recommended first-apply path:
1. Create a new IAM role ci-terraform-executor with a trust policy for the operator's IAM user or SSO identity.
2. Attach AdministratorAccess temporarily for the first apply only.
3. After first apply, detach AdministratorAccess and attach ci-terraform-executor-policy (ARN from terraform output terraform_executor_policy_arn).
4. All subsequent applies use the scoped policy only.
Smoke checklist (after first apply + secrets filled)
Run in order; each step should pass before proceeding:
- [ ]
curl -I https://ci.moosequest.net/healthz— expect HTTP 200 (may redirect through OIDC for browser; use-Lto follow) - [ ] Visit
https://ci.moosequest.netin browser — Google OIDC login prompt appears - [ ] After login: Woodpecker dashboard loads, shows 0 pipelines
- [ ] GitHub webhook delivery test: Repository Settings → Webhooks → test delivery → Woodpecker logs show receipt
- [ ] Scale agents to 1:
aws autoscaling set-desired-capacity --auto-scaling-group-name ci-woodpecker-agents-asg --desired-capacity 1 --region us-east-2 - [ ] Wait 3-5 minutes; agent appears in Woodpecker → Admin → Agents
- [ ] Activate a test repository in Woodpecker, push a commit, confirm pipeline runs
- [ ] CloudWatch dashboard
ci-woodpeckershows ALB requests, server CPU, agent count - [ ] ops@raxx.app received SNS subscription confirmation email (click confirm link)
- [ ] Scale agents back to 0:
aws autoscaling set-desired-capacity --auto-scaling-group-name ci-woodpecker-agents-asg --desired-capacity 0 --region us-east-2
Current versions
- Server:
woodpeckerci/woodpecker-server:v3.16.0(upgraded from v2.8.3 on 2026-07-04) - Agent:
woodpeckerci/woodpecker-agent:v3.16.0(must match server major version) - IaC pin:
infra/ci/variables.tf—woodpecker_server_version/woodpecker_agent_version - Server AMI:
ami-044f1973a68a5577f(AL2023 ARM64, pinned 2026-07-08 viavar.woodpecker_ami_id) - Pinned to prevent AWS AMI rotation from proposing DESTROY+recreate on next
terraform apply. - Latest AL2023 arm64 available at pin time:
ami-0166a0721b9400800(the drift source). - To bump: schedule a maintenance window (~5 min CI downtime), look up the latest AMI,
update
var.woodpecker_ami_iddefault ininfra/ci/variables.tf, open a PR, confirm plan showsreplace(not destroy) ofaws_instance.woodpecker_server, then apply. Seevar.woodpecker_ami_iddescription ininfra/ci/variables.tffor the full command.
Bumping server + agent versions
Server and agent versions are coupled: agent LT user-data embeds the agent image tag, so
a version bump requires terraform apply to publish a new LT version. Agents then relaunch
from the new LT on next scale-up.
Normal upgrade path (immutable re-provision):
1. Update woodpecker_server_version and woodpecker_agent_version in infra/ci/variables.tf.
2. Remove the lifecycle { ignore_changes = [user_data] } block from server.tf
(if present — see below).
3. terraform plan — confirm server is marked for replace (not destroy).
4. terraform apply — server is replaced, new LT version published.
5. Re-add the lifecycle block if needed (see below).
In-place upgrade path (SSM patch, no terraform replace):
If a version was applied via SSM (e.g., systemctl restart with a new image tag in
docker-compose), the TF state user_data hash will drift from the computed hash, causing
user_data_replace_on_change = true to schedule a server replacement even though the
server is already healthy at the new version.
Fix: add lifecycle { ignore_changes = [user_data] } to aws_instance.woodpecker_server
in infra/ci/server.tf BEFORE running terraform plan. This makes the plan read
0 destroy instead of scheduling a replacement. The lifecycle block is already present
on develop as of 2026-07-04 (incident: docs/incidents/2026-07-04-woodpecker-v3-upgrade.md).
When you next want to intentionally re-provision the server (e.g., major version upgrade
via immutable path), remove the lifecycle { ignore_changes = [user_data] } block from
server.tf, confirm the plan proposes a replace (not a destroy), then apply.
Woodpecker clone plugin allow list
Woodpecker v3 maintains a trusted-clone plugin list. The default list (per --plugins-trusted-clone)
is:
docker.io/woodpeckerci/plugin-git:2.9.2
docker.io/woodpeckerci/plugin-git
quay.io/woodpeckerci/plugin-git
Pipeline YAML must use woodpeckerci/plugin-git (Docker Hub org, no hyphen). The common
mistake is woodpecker-ci/plugin-git (GHCR-style, with hyphen) which is NOT in the list —
Woodpecker will refuse to inject netrc credentials, and private repo clones fail.
v3.x API changes (vs v2.x)
- Cron endpoint:
/api/repos/{id}/cron(singular — was/cronsin v2.x docs, but v3.x router uses/cron) - Cron
enabledfield: New crons default toenabled: false. MustPATCH /api/repos/{id}/cron/{cron_id}with{"enabled":true}after creation. - Version header:
GET /api/versionreturns HTML body (SPA) but version is inX-Woodpecker-Versionresponse header. - Repo activation:
POST /api/repos?forge_remote_id=ID— same as v2.x. - Unrecognized paths: Woodpecker v3.x returns HTTP 200 with SPA HTML for all unrecognized paths. Check
Content-Type: application/jsonto distinguish valid API responses from SPA fallback.
v3.x env vars — confirmed unchanged from v2.x
WOODPECKER_GITHUB_CLIENT, WOODPECKER_GITHUB_SECRET, WOODPECKER_ADMIN, WOODPECKER_AGENT_SECRET, WOODPECKER_DATABASE_DATASOURCE, WOODPECKER_DATABASE_DRIVER, WOODPECKER_HOST, WOODPECKER_OPEN, WOODPECKER_GRPC_ADDR, WOODPECKER_GRPC_SECURE, WOODPECKER_SERVER_ADDR, WOODPECKER_LOG_LEVEL. No renames required for current config.
v3.x pipeline YAML breaking changes
pipeline:top-level key → removed; usesteps:branches:top-level key → removed; usewhen: [{event: push, branch: ...}]steps.[name].group→ removed; usesteps.[name].depends_onsecrets:keyword → removed; useenvironment: {VAR: {from_secret: NAME}}- Cron schedule: 5-field syntax (no seconds). Old:
0 0 8 * * *→ New:0 8 * * * - Webhook signatures use RFC 9421; repos activated before upgrade may need repair.
Known failure modes
Failure mode A: ACM certificate stuck in PENDING_VALIDATION
Symptom: aws acm describe-certificate returns Status: PENDING_VALIDATION for more than 30 minutes.
Cause: ACM validation CNAME not present in Dyn, or DNS propagation not yet complete.
Fix:
# Verify the validation record is expected:
terraform output acm_validation_cname_name
terraform output acm_validation_cname_value
# Check Dyn DNS console — confirm CNAME exists with the exact name and value.
# Test DNS propagation:
dig CNAME "$(terraform output -raw acm_validation_cname_name)" @8.8.8.8
# Expected: non-empty CNAME answer matching the validation value.
Verification: aws acm describe-certificate --certificate-arn ... --query "Certificate.Status" returns "ISSUED".
Failure mode B: Woodpecker server not starting
Symptom: ALB health checks fail; /healthz returns 502/503.
Cause: Docker container failed to start (bad SSM secrets, RDS unreachable, image pull failure).
Fix:
# Get instance ID
INSTANCE_ID="$(terraform -chdir=infra/ci output -raw woodpecker_server_instance_id)"
# Open SSM session
aws ssm start-session --target "$INSTANCE_ID" --region us-east-2
# In session:
sudo systemctl status woodpecker
sudo docker compose -f /opt/woodpecker/docker-compose.yml ps
sudo docker compose -f /opt/woodpecker/docker-compose.yml logs woodpecker-server
# Common root causes:
# - "invalid database" / connection refused: DB password wrong or RDS SG blocks
# - "cannot reach github.com": NAT Gateway issue; check route tables
# - "placeholder" in env: SSM parameters not filled; fill and restart
sudo systemctl restart woodpecker
Verification: curl -sf http://localhost:8000/healthz from SSM session returns 200.
Failure mode B-2: Forge connectivity timeout — container-level (host network healthy)
Symptom: No new pipelines are created after webhook events. Woodpecker server logs show repeated context deadline exceeded errors:
"could not get folder from forge: context deadline exceeded"
"error while fetching config '' in 'refs/tags/v1.10.3' with user: 'MooseQuest', and did not get any config"
ALB /healthz returns 200. EC2 host can reach api.github.com (200 in <100ms). Container network appears normal.
Cause: Go HTTP client connection pool in the server process accumulates stale/half-open TCP connections after extended uptime (typically 3–7 days with high webhook volume). The pool reuses dead sockets; requests time out before falling back to fresh connections. Distinct from Failure mode B (container down) and WAF blocks (GitHub sees 403/200 but pipeline not created).
Diagnose:
# 1. Confirm host network is healthy (should be <100ms, 200):
aws ssm send-command --region us-east-2 --instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["curl -sf --max-time 5 https://api.github.com/zen; echo exit:$?"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
# retrieve with: aws ssm get-command-invocation --command-id <ID> --instance-id i-082ee835595d90ae0
# 2. Confirm container error pattern (look for 'context deadline exceeded' in last 5 min):
aws ssm send-command --region us-east-2 --instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["docker logs woodpecker-woodpecker-server-1 --since 5m 2>&1 | grep deadline"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
# 3. Confirm conntrack is not saturated (should be << /proc/sys/net/netfilter/nf_conntrack_max):
aws ssm send-command --region us-east-2 --instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["wc -l /proc/net/nf_conntrack && cat /proc/sys/net/netfilter/nf_conntrack_max"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
# 4. Confirm queue is empty before restarting:
WP_TOKEN=$(aws ssm get-parameter --name /ci/woodpecker/admin-api-token --with-decryption \
--region us-east-2 --query "Parameter.Value" --output text)
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
https://ci.moosequest.net/api/queue/info | python3 -c "
import sys,json; d=json.load(sys.stdin)
print('pending:', len(d.get('pending',[])), 'running:', len(d.get('running',[])))
"
Fix (requires operator authorization for service restart):
# Option A — SSM send-command (no interactive session needed, ~15s):
CMD_ID=$(aws ssm send-command \
--region us-east-2 \
--instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["sudo systemctl restart woodpecker && echo RESTART_OK"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])")
sleep 8
aws ssm get-command-invocation \
--command-id "$CMD_ID" --instance-id i-082ee835595d90ae0 \
--query "StandardOutputContent" --output text
# Option B — SSM interactive session:
aws ssm start-session --target i-082ee835595d90ae0 --region us-east-2
# In session:
# sudo systemctl restart woodpecker
# sudo docker compose -f /opt/woodpecker/docker-compose.yml logs -f --tail=20
After restart: Wait 30s, then redeliver the blocked webhook OR manually trigger the pipeline:
# Redeliver the specific delivery (get ID from GitHub repo Settings → Webhooks → Recent Deliveries):
gh api "repos/raxx-app/TradeMasterAPI/hooks/649427232/deliveries/<DELIVERY_ID>/attempts" --method POST
# OR manual pipeline trigger for deploy-prod (break-glass):
WP_TOKEN=$(aws ssm get-parameter --name /ci/woodpecker/admin-api-token --with-decryption \
--region us-east-2 --query "Parameter.Value" --output text)
curl -sf -X POST \
-H "Authorization: Bearer $WP_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: sre-agent/1.0" \
-d '{"branch":"main","variables":{"DEPLOY_PROD_CONFIRM":"yes","CI_COMMIT_BRANCH":"main"}}' \
https://ci.moosequest.net/api/repos/raxx-app/TradeMasterAPI/pipelines
Note on create-github-release: The deploy-prod pipeline's create-github-release step swallows errors as non-fatal when CI_COMMIT_TAG is empty (manual trigger). If the pipeline was triggered manually (not via a tag event), the GitHub Release will NOT be created automatically. Create it manually after the pipeline succeeds:
gh api repos/raxx-app/TradeMasterAPI/releases -X POST \
--field tag_name="vX.Y.Z" \
--field target_commitish="<main HEAD SHA>" \
--field name="Release vX.Y.Z" \
--field generate_release_notes=true
Verification:
# Forge connectivity restored: new pipeline created after webhook redeliver
WP_TOKEN=$(aws ssm get-parameter --name /ci/woodpecker/admin-api-token --with-decryption \
--region us-east-2 --query "Parameter.Value" --output text)
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/repos/1/pipelines?page=1&perPage=3" | \
python3 -c "import sys,json; [print(f\"#{p['number']} {p['status']}\") for p in json.load(sys.stdin)]"
# Expected: most recent pipeline is running or success, not error/pending indefinitely
Prevention (action items): #1 (forge connectivity probe in healthcheck) and #2 (weekly restart schedule) in docs/incidents/2026-07-09-wp-forge-context-deadline.md.
RCA: docs/incidents/2026-07-09-wp-forge-context-deadline.md
Failure mode B-3: ForgeConnectivityDown alert — false positive from monitoring-stack restart
Symptom: ForgeConnectivityDown Prometheus alert fires (email to ops@raxx.app). WP server logs show NO auth errors. GitHub webhooks are being received and processed normally.
Cause: The alert fires on probe_success{job="blackbox-forge"} == 0 for: 10m. This probe runs inside the blackbox-exporter container on the ci-monitoring EC2 (i-0a656c6631ddc45b3). If the container is restarted by an SSM command (e.g., injecting config changes for an unrelated probe), consecutive Prometheus scrapes at the 5m interval can find the probe failing long enough to cross the for: 10m threshold. The Woodpecker server's actual forge connectivity to GitHub is unaffected.
Distinguish from genuine forge failure (Failure mode B-2):
# 1. Check WP server logs — genuine failure shows 'context deadline exceeded' or auth errors:
aws ssm send-command --region us-east-2 --instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["docker logs woodpecker-woodpecker-server-1 --since=15m 2>&1 | grep -E \"deadline|401|403|bad credential|forge\" | tail -20"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
# 2. Check if blackbox-exporter recently restarted:
aws ssm send-command --region us-east-2 --instance-ids i-0a656c6631ddc45b3 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["docker inspect blackbox-exporter --format \"Started: {{.State.StartedAt}} Status: {{.State.Status}}\""]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
# 3. Directly probe the forge target from the monitoring box (should return probe_success 1):
aws ssm send-command --region us-east-2 --instance-ids i-0a656c6631ddc45b3 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["curl -s \"http://localhost:9115/probe?target=https://api.github.com/zen&module=github_api_2xx\" | grep probe_success"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
If false positive (blackbox restarted, probe now succeeds): No fix needed. Alert clears automatically on next Prometheus scrape (max 5m wait). Check Alertmanager current state:
aws ssm send-command --region us-east-2 --instance-ids i-0a656c6631ddc45b3 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["curl -s http://localhost:9093/api/v2/alerts | python3 -m json.tool | head -10"]}' \
--output json | python3 -c "import sys,json; print(json.load(sys.stdin)['Command']['CommandId'])"
Expected: [] (empty).
If genuine forge connectivity failure: See Failure mode B-2 (stale TCP pool → restart woodpecker-server container) or check GitHub status at https://githubstatus.com + review security group outbound rules on i-082ee835595d90ae0.
Prevention: The hardened alert expression guards against blackbox-exporter restarts:
probe_success{job="blackbox-forge"} == 0 and on() (up{job="blackbox-http"} == 1)
Fires only when the blackbox-exporter is reachable but the forge probe fails. This is the expression baked into the IaC template (terraform/ci-monitoring/templates/cloud-init.sh.tpl) as of the 2026-07-14 drift consolidation.
RCA: docs/incidents/2026-07-14-forge-connectivity-down.md
Failure mode C: Agents not connecting to server
Symptom: Pipelines queue forever; Woodpecker Admin → Agents shows 0 connected. Cause: Agent-secret mismatch, SG blocking port 9000, or agents not running. Fix:
# Check ASG desired capacity:
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names ci-woodpecker-agents-asg \
--region us-east-2 \
--query "AutoScalingGroups[0].{desired:DesiredCapacity,inService:Instances[?LifecycleState=='InService'].InstanceId}" \
--output json
# If desired=0, scale up:
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 1 \
--region us-east-2
# If agents are running but not connected, SSH into an agent via SSM:
# (find instance ID from ASG describe output above)
aws ssm start-session --target i-XXXXXXXXXX --region us-east-2
# Check agent logs:
sudo docker compose -f /opt/woodpecker-agent/docker-compose.yml logs woodpecker-agent
# Common error: "rpc error: code = Unauthenticated" → agent-secret mismatch
# Verify server and agent SSM parameter have same value:
aws ssm get-parameter --name /ci/woodpecker/agent-secret --with-decryption --region us-east-2
Verification: Woodpecker Admin → Agents shows agent with status "connected".
Failure mode D: WAF blocking legitimate webhook traffic
Symptom: GitHub webhook delivery shows 403; WAF metrics show blocked requests on /hook*.
Cause: GitHub IP range hit the WAF rate limit or a managed rule matched the payload.
Fix:
# Check WAF logs in CloudWatch:
aws logs filter-log-events \
--log-group-name aws-waf-logs-ci-woodpecker \
--region us-east-2 \
--filter-pattern '{ $.action = "BLOCK" }' \
--start-time "$(date -d '-1 hour' +%s000)" \
| jq '.events[].message | fromjson | {action,httpRequest,terminatingRuleId}'
# Identify which rule blocked the request.
# If rate limit: increase var.waf_rate_limit_webhook_per_5min in terraform.tfvars + apply.
# If managed rule: add an override in waf.tf for that specific rule (count instead of block).
Verification: GitHub webhook delivery test returns 200 in repository settings.
Failure mode D-2: WAF SizeRestrictions_BODY blocks all GitHub webhook deliveries
Symptom: Every GitHub webhook delivery to ci.moosequest.net/api/hook returns HTTP 403.
No Woodpecker pipelines trigger on PR or push events. WAF CloudWatch logs show
terminatingRuleId: SizeRestrictions_BODY from AWSManagedRulesCommonRuleSet.
Cause: The AWSManagedRulesCommonRuleSet SizeRestrictions_BODY rule enforces an
8192-byte body inspection cap. Real GitHub PR/push webhook payloads are 12–36 KB and
exceed this cap. The managed rule blocks the request before Woodpecker ever sees it.
This is distinct from rate-limit blocks (Failure mode D) — it fires on the very first
delivery regardless of volume.
Fix:
# Confirm the root cause: look for SizeRestrictions_BODY in the terminatingRuleId field
aws logs filter-log-events \
--log-group-name aws-waf-logs-ci-woodpecker \
--region us-east-2 \
--filter-pattern '{ $.action = "BLOCK" }' \
--start-time "$(date -d '-1 hour' +%s000)" \
| python3 -c "
import sys, json
for line in sys.stdin:
try:
obj = json.loads(line)
for ev in obj.get('events', []):
msg = json.loads(ev['message'])
print(msg.get('terminatingRuleId'), msg.get('httpRequest', {}).get('uri'))
except Exception:
pass
"
# If you see SizeRestrictions_BODY, verify the AllowGitHubWebhook rule exists in waf.tf:
grep -A 20 'AllowGitHubWebhook' infra/ci/waf.tf
# If the rule is missing, apply via Terraform to restore it:
cd infra/ci
terraform plan -out ci-waf-fix.plan
# Verify plan: 0 to destroy, no server/RDS/ALB/ASG replacement
terraform apply ci-waf-fix.plan
Verification: Push a commit to any branch. In GitHub → repository Settings →
Webhooks → Recent Deliveries, the new delivery returns HTTP 204. A Woodpecker pipeline
appears in GET /api/repos/1/pipelines.
RCA: docs/incidents/2026-07-04-waf-webhook-block-batch1-parse-error.md
Failure mode E-2: Nil-pointer panic at repo activation (server/api/repo.go)
Symptom: POST /api/repos?forge_remote_id=ID returns a 500 panic trace referencing server/api/repo.go:149. Repo activation fails.
Cause: Pre-v3.0 bug in v2.8.x — nil forge OAuth token on user record. Fixed in v3.x.
Fix: This should not recur on v3.16.0. If it does, the admin user's forge OAuth token may be missing (user never completed OAuth flow). Operator must log out of Woodpecker UI and log back in via the GitHub OAuth flow to mint a fresh token.
Verification: POST /api/repos?forge_remote_id=ID returns HTTP 200 JSON with "active":true.
Failure mode E-3: Pipeline compile errors after v3.x migration
Symptom: Manual pipeline trigger returns {"status":"error","errors":[{"type":"compiler","message":"go-yaml load error..."}]}. No GitHub commit status posted.
Cause: .woodpecker/*.yaml pipeline definitions use v2.x syntax. v3.x YAML parser is stricter. Common causes: group: field (must be depends_on:), multi-line command continuation syntax, pipeline: keyword.
Fix: Update .woodpecker/*.yaml files for v3.x syntax. See §v3.x pipeline YAML breaking changes above.
Common v3 compile errors to check:
- secret "NAME" not found — the secret named in from_secret: is not registered in
Woodpecker. Either register the secret via POST /api/repos/{owner}/{name}/secrets or
remove event: manual from the pipeline's when: block until the secret is available
(cron triggers are unaffected).
- Specified clone image does not match allow list, netrc is not injected — clone block
uses woodpecker-ci/plugin-git (with hyphen) instead of woodpeckerci/plugin-git
(without hyphen). See §Woodpecker clone plugin allow list.
Verification: Manual pipeline trigger completes; status field is pending or running, not error. GitHub commit status appears on the HEAD commit.
Failure mode E-4: Shell-defined variables silently empty in pipeline commands
Symptom: Pipeline runs but produces wrong output or fails with empty-argument errors:
- git checkout "" — git fails with "bad revision"
- --base-sha "" — script fails with invalid SHA
- MaxMind URL missing license_key parameter (download returns 401)
- echo prints "Expected head revision: " with nothing after the colon
All are caused by the same root issue.
Cause: WP v3 performs ${VAR} variable substitution at YAML-parse time on ALL text
in the pipeline definition. Only WP built-in variables (CI_*, WOODPECKER_*) and
secrets registered via from_secret: are available at parse time. Shell-defined variables
(assigned by earlier shell commands: BASELINE=$(...), BASE_SHA=$(...), FILESIZE=$(...))
do NOT exist at parse time. WP substitutes them to empty string silently — no parse error,
no warning, commands just receive empty arguments.
Known broken patterns (all fixed 2026-07-05, except the 2026-08-19 recurrence below):
- git checkout "${BASELINE}" → git checkout "" — empty baseline SHA in vcpkg-manifest-check.yaml
- --base-sha "${BASE_SHA}" → --base-sha "" — empty diff base in migration-collision-check.yaml
- URL ...&license_key=${MAXMIND_LICENSE_KEY}&... → URL with missing key param in maxmind-asn-refresh.yaml
- echo "${HEAD}" where HEAD is set by a prior shell command in alembic-version-check.yaml
- Recurrence, 2026-08-19: git push --force heroku "${SUBTREE_SHA}:refs/heads/main" →
git push --force heroku ":refs/heads/main" (empty-source refspec) in
deploy-velvet.yaml's deploy step. Introduced 2026-07-08 (6bf74fefab), three
days after this class was first fixed elsewhere — the fix was never generalized
into an enforced check, so a new file reintroduced the identical anti-pattern.
Latent/undetected for ~6 weeks because the step's path filter
(velvet/** or the workflow file itself) never matched a release/main push
until PR #4474 incidentally touched deploy-velvet.yaml and tripped it. Heroku's
pre-receive hook refused the empty-refspec push ("cannot delete main branch") —
loud failure, but the 6-week latency before that first trigger is the real gap.
Discovered during the release-2026.08.19 staging shepherd run. See
docs/incidents/2026-08-19-ci-boundary-velvet-deploy-false-positive-refspec.md
and issue #4513 (fix filed, not yet applied).
Fix: Use $VAR (no braces) for shell-defined variables. WP only substitutes ${VAR}
(brace form). Bare $VAR is passed through to the shell unchanged.
# WRONG — WP substitutes ${BASELINE} to empty at parse time
git -C /opt/vcpkg checkout "${BASELINE}"
# CORRECT — $BASELINE is passed to the shell which resolves it at runtime
git -C /opt/vcpkg checkout "$BASELINE"
Exception: WP built-in variables (${CI_COMMIT_BRANCH}, ${CI_REPO}, etc.) MUST use
the ${VAR} brace form in YAML environment: blocks because they need WP substitution.
In shell command strings, both $CI_COMMIT_BRANCH and ${CI_COMMIT_BRANCH} work since WP
substitutes them either way; but prefer $VAR in commands for consistency.
Lint check: grep -rn '\${[A-Z][A-Z_0-9]*}' .woodpecker/ then manually verify
each match is a WP built-in variable (CI_, WOODPECKER_) or a step environment: key.
If it's a shell-defined variable, replace ${VAR} with $VAR.
Verification: Re-run the pipeline; commands receive the correct non-empty values.
Failure mode E-5: WP v3 YAML pre-processor rejects ${{ — ALL pipelines break
Symptom: Every pipeline for every event (PR, push, cron, manual) fails immediately at
compile time with "unable to parse variable name". No steps execute. GitHub commit status
checks are absent (Woodpecker never posts them). The error is global — it affects ALL
.woodpecker/*.yaml files in the repo, not just the file containing the offending pattern.
Cause: Woodpecker v3's substitution pre-processor scans ALL YAML text — including YAML
comments — before handing the file to the YAML parser. ${{ — WP parses ${...}
substitutions; the second { is not a valid identifier character and causes
"unable to parse variable name". This is the GitHub Actions expression syntax. It is
invalid in WP YAML even inside a comment line.
Fix:
# Scan for the pattern:
grep -rn '\${{' .woodpecker/
# BAD: # GH_TOKEN: ${{ steps.mint.outputs.token || github.token }}
# GOOD: # GH_TOKEN: (mint_bot_token.outputs.token || github.token) from GHA workflow
Verification: curl -s -X POST https://ci.moosequest.net/api/repos/1/pipelines \
-H "Authorization: Bearer $WP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"branch":"develop"}' | jq '{id:.id,status:.status,errors:.errors}'
status should be pending or running, not error.
RCA: docs/incidents/2026-07-04-waf-webhook-block-batch1-parse-error.md
Correction (2026-08-10, #4452): The original version of this entry also listed
${VAR:-default} (the :- default-expansion form) as a second global-breakage pattern,
recommending the $${VAR:-default} escape for all cases. That was an overbroad reading of
the 2026-07-04 incident — the confirmed root cause there was ${{ alone (see the RCA's own
"What didn't go well": "Hypothesis 1 [${VAR:-}] was plausible but incomplete"). In
practice, ${VAR:-default} on a genuine WP built-in (CI_COMMIT_BEFORE,
CI_COMMIT_TARGET_BRANCH, etc.) compiles and runs cleanly — WP substitutes the brace form
at parse time same as bare ${VAR}, and a present-but-unset built-in still resolves through
the default expansion. Confirmed working precedents already live on develop before this
correction: .woodpecker/ci-pr.yaml (BASE="${CI_COMMIT_BEFORE:-}", FALLBACK="${CI_COMMIT_TARGET_BRANCH:-develop}",
merged 2026-07-10, passing as a required check daily since) and this same runbook's own
§Failure mode E-6 fix (${CI_COMMIT_TARGET_BRANCH:-develop}, #4347, merged 2026-07-25).
Re-verified live via a scoped draft-PR pipeline run for .woodpecker/queue-docker-smoke.yaml
(BEFORE="${CI_COMMIT_BEFORE:-}") — see docs/incidents/2026-08-10-queue-docker-smoke-ci-commit-before-unset.md.
The $${VAR:-default} escape is still the correct choice for two distinct, narrower cases —
neither of which applies to WP built-ins:
1. A secret registered via from_secret: that you want resolved by the shell at
runtime from the step's environment: block, not baked into the compiled pipeline
JSON at parse time (secret-leak-into-pipeline-definition risk). See
.woodpecker/console-degraded-auto-file.yaml's $${CI_FORGE_TOKEN} usage.
2. A variable that is not a WP built-in and not registered as a step environment: key at
all (WP has nothing to substitute it with) — bare ${VAR} in that case silently
resolves to empty string at parse time (see Failure mode E-4) rather than raising a
compile error, so $${VAR:-default} is the only way to defer resolution to the shell.
Updated fix/lint guidance:
# Always invalid — scan and remove/reword even in comments:
grep -rn '\${{' .woodpecker/
# ${VAR:-default} on a WP built-in (CI_*, WOODPECKER_*): fine as-is, no escape needed.
# ${VAR:-default} on a secret or step-local shell var: escape with $$ so the shell
# resolves it at runtime instead of WP substituting (or silently blanking) it at parse time.
Failure mode E-6: stale-branch-guard red repo-wide — two independent dimensions
Symptom: ci/woodpecker/pr/ci-pr fails on the stale-branch-guard step across MULTIPLE unrelated open PRs simultaneously (not just one branch) — a strong signal this is a guard-logic bug, not a genuinely-stale individual branch.
Cause: stale-branch-guard (.woodpecker/ci-pr.yaml + scripts/ci/check_stale_branch.sh) has two independent bugs found so far, each of which can false-fail every open PR at once. Diagnose which one (or both) is active before touching thresholds:
- Base-ref hardcoded (fixed 2026-07-25, #4347): the guard always compared against
origin/developregardless of the PR's real base. PRs based onmainorrelease(ADR-0115 emergency-hotfix path) always failed. Fixed by derivingGITHUB_BASE_REFfrom${CI_COMMIT_TARGET_BRANCH:-develop}. - Absolute fork-point age (fixed 2026-07-30): the age check computed
now - <fork-point commit's own timestamp>. For a branch cut fresh off the base branch's current tip, the fork point IS the base tip — so whenever the base branch (develop) went quiet for longer thanSTALE_BRANCH_MAX_AGE_HOURS(48h), EVERY PR branch failed the age check with0commits behind. Fixed by measuring age from the OLDEST commit the PR branch is missing from the base branch, and skipping the age check entirely whenCOMMITS_BEHIND == 0.
Diagnose which dimension is active:
git fetch origin develop --quiet
git log -1 --format='%H %ci %s' origin/develop # is develop's tip itself old (>48h)?
# For the specific failing PR branch:
git fetch origin <pr-branch> --quiet
FORK_SHA=$(git merge-base origin/develop <pr-branch>)
git rev-list --count "${FORK_SHA}..origin/develop" # commits behind — should be low for a fresh branch
git log -1 --format='%ci' "${FORK_SHA}" # fork-point date — if old AND commits-behind is 0,
# this is dimension 2 (absolute-age bug)
If commits-behind is 0 (or small) but the fork-point date is old, this is dimension 2 — the base branch has gone quiet, not the PR branch falling behind. Confirm scripts/ci/check_stale_branch.sh measures age from the oldest MISSING commit (not the fork-point's own date) — if it still uses FORK_TS=$(git log -1 --format="%ct" "${FORK_SHA}"), the 2026-07-30 fix has regressed or was reverted.
Fix: See scripts/ci/check_stale_branch.sh for the corrected logic (relative-to-base-progression age, skipped when 0 commits behind). Do NOT raise STALE_BRANCH_MAX_AGE_HOURS as a workaround — that weakens the gate for genuinely-stale branches without fixing the underlying measurement bug.
Verification: Cut a fresh test branch off origin/develop HEAD and run the guard locally:
git fetch origin develop --quiet
git checkout -b /tmp-stale-guard-check origin/develop
GITHUB_BASE_REF=develop bash scripts/ci/check_stale_branch.sh
# Expected: "Commits behind origin/develop: 0" and exit 0, regardless of how old
# develop's tip commit is.
git checkout - && git branch -D /tmp-stale-guard-check
RCA: docs/incidents/2026-07-25-ci-pr-stale-branch-guard-hardcoded-base.md (dimension 1), docs/incidents/2026-07-30-ci-pr-stale-branch-guard-absolute-age.md (dimension 2)
Failure mode F: RDS storage alarm firing
Symptom: ci-rds-storage-low CloudWatch alarm in ALARM state.
Cause: Woodpecker pipeline history accumulating; database approaching 16 GB.
Fix:
# Option 1: Purge old build logs in Woodpecker UI (Admin → Repositories)
# Option 2: Increase RDS allocated storage via Terraform:
# In terraform.tfvars, allocated_storage is managed by auto-scaling.
# The RDS resource has max_allocated_storage=100, so RDS auto-scales
# up to 100 GB automatically. If already at 100 GB, increase max_allocated_storage
# in rds.tf and apply.
# Check current storage:
aws rds describe-db-instances \
--db-instance-identifier ci-woodpecker \
--region us-east-2 \
--query "DBInstances[0].AllocatedStorage"
Verification: ci-rds-storage-low alarm returns to OK state.
Failure mode F: Login buttons dead — "Login to CI" / "Abort" do nothing
Symptom: Browser passes the Google OIDC gate and reaches the Woodpecker login screen, but clicking "Login to CI" or "Abort" has no effect. No GitHub OAuth redirect occurs.
Cause: The ALB default OIDC rule is intercepting Woodpecker's own auth paths (/api/*, /authorize, /login, /logout). The Woodpecker SPA makes XHR calls to /api/* to initiate the GitHub OAuth flow; if those calls get a Google 302 redirect instead of a Woodpecker JSON response, the JavaScript login handler silently fails. Additionally, the GitHub OAuth callback at /authorize?code=...&state=... must reach Woodpecker directly — an OIDC interception at that path loses the GitHub code and state parameters.
Diagnose:
# Each of these should NOT redirect to accounts.google.com.
# Expected: 401 JSON (api/user), 303 to github.com (authorize/login), 303 to / (logout)
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-redirs 0 https://ci.moosequest.net/api/user
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-redirs 0 https://ci.moosequest.net/authorize
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-redirs 0 https://ci.moosequest.net/login
# Confirm ALB rule at priority 25 exists:
aws elbv2 describe-rules \
--listener-arn arn:aws:elasticloadbalancing:us-east-2:521228113048:listener/app/ci-woodpecker-alb/2bf78b6ff4a0780f/2b0157eb96f64e49 \
--region us-east-2 \
--query 'Rules[?Priority==`25`]'
Fix (if priority-25 rule is missing — e.g., after a destructive terraform apply that lost the rule):
LISTENER_ARN="arn:aws:elasticloadbalancing:us-east-2:521228113048:listener/app/ci-woodpecker-alb/2bf78b6ff4a0780f/2b0157eb96f64e49"
TG_ARN="arn:aws:elasticloadbalancing:us-east-2:521228113048:targetgroup/ci-woodpecker-server-tg/89042c9f57a2d3ad"
aws elbv2 create-rule \
--listener-arn "$LISTENER_ARN" \
--priority 25 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/api/*","/authorize","/login","/logout"]}}]' \
--actions "[{\"Type\":\"forward\",\"TargetGroupArn\":\"$TG_ARN\"}]" \
--tags '[{"Key":"Name","Value":"ci-woodpecker-auth-bypass-oidc"},{"Key":"Purpose","Value":"woodpecker-own-auth-endpoints"}]' \
--region us-east-2
Verification:
# /api/user must return 401 (not a Google redirect)
curl -sS https://ci.moosequest.net/api/user
# Expected: "User not authorized" with HTTP 401
# /authorize must redirect to github.com
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-redirs 0 https://ci.moosequest.net/authorize
# Expected: 303 https://github.com/login/oauth/authorize?...
Then retry the login in a fresh browser tab.
Security note: Removing the OIDC gate from /api/*, /authorize, /login, and /logout does NOT expose these paths — Woodpecker enforces its own GitHub-OAuth + session auth on every /api/* endpoint (returns 401 for unauthenticated requests) and validates a signed JWT state on the OAuth callback. The WAF rate-limit rule still applies.
Emergency stop
To immediately halt all CI builds (no deploy risk):
# Option 1 (fastest — 30 seconds): Scale agents to 0
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 0 \
--region us-east-2
# Option 2: Stop Woodpecker server entirely (queues all new jobs)
INSTANCE_ID="$(terraform -chdir=infra/ci output -raw woodpecker_server_instance_id)"
aws ssm start-session --target "$INSTANCE_ID" --region us-east-2
# In session:
sudo systemctl stop woodpecker
# Option 3: Quarantine at ALB level (blocks all traffic including webhooks)
# Update ALB listener to return 503 maintenance response:
# (do via AWS Console or CLI; do NOT `terraform apply` the change as it
# will create drift — this is a break-glass manual action)
To resume: restart the service (sudo systemctl start woodpecker) and/or scale agents back up.
Teardown
# IMPORTANT: Remove the prevent_destroy lifecycle guard in rds.tf first
# by setting db_deletion_protection = false and applying, then destroy.
cd infra/ci
terraform apply -var="db_deletion_protection=false"
terraform destroy
# Bootstrap teardown (ONLY if you want to delete the state bucket too —
# this destroys the state of all other Terraform roots using this bucket):
# cd infra/ci/bootstrap
# Edit bootstrap/main.tf: remove lifecycle { prevent_destroy = true } from both resources
# terraform destroy
# (This also deletes moosequest-tf-state-us-east-2 and all state within it)
Security considerations
No SSH keys
All instances use SSM Session Manager for break-glass access. Key-pair based SSH is
explicitly disabled (key_name = null). If SSM is unreachable, the VPC endpoints
(ssm, ssmmessages, ec2messages) must be functioning. Check VPC endpoint health first.
IMDSv2
All EC2 instances have http_tokens = required. The instance metadata service is
protected against SSRF. Containers cannot reach IMDS (hop limit 1 on server; limit
2 on agents to allow builds that need IAM credentials from the instance role).
Docker socket on agents
The Woodpecker agent container bind-mounts /var/run/docker.sock. This grants build
containers root-equivalent access to the agent host. Acceptable for Phase 1 with
internal-only, trusted pipeline definitions. Action item before adding external
contributors: evaluate rootless Docker or Kaniko for image builds.
Trusted-repo posture (WP Docker volume mounts) — enabled 2026-07-09 (#4107)
What "trusted" means in Woodpecker v3:
Woodpecker v3 exposes three trust dimensions per repo:
- trusted.network — pipeline steps can use host networking
- trusted.volumes — pipeline steps can mount host paths via volumes: in step YAML
- trusted.security — pipeline steps can request privileged containers
Current state (raxx-app/TradeMasterAPI, repo ID = 1):
| Flag | Value | Rationale |
|---|---|---|
trusted.network |
true |
Set at activation; required for gRPC connectivity in build steps |
trusted.volumes |
true |
Set 2026-07-09 (#4107) — enables pipeline steps to mount /var/run/docker.sock from the host (already bind-mounted into the agent container by user_data_agent.sh.tpl) |
trusted.security |
false |
Not needed; DinD is NOT the accepted posture (OQ-1 resolved = socket-mount) |
Why trusted.volumes = true is required:
The host Docker socket is bind-mounted into the WP agent container at startup
(/var/run/docker.sock:/var/run/docker.sock in agent docker-compose.yml). For a
PIPELINE STEP to reach the same socket, the pipeline YAML must declare a volumes:
block that mounts the socket into the step container. Woodpecker only permits this when
the repo has trusted.volumes = true. Without it, the step container cannot reach the
host daemon and docker buildx build fails with "Cannot connect to the Docker daemon".
Security trade-off:
Any pipeline step can mount arbitrary host paths and interact with the host Docker daemon,
giving it root-equivalent access to the agent host. This is accepted for Phase 1 because:
1. Single-repo WP instance — pipeline YAML is controlled by the operator alone
2. No external contributors — no untrusted code executes in pipelines
3. Prod secrets are restricted by event type on WP secrets (push, pull_request only,
not manual for prod-credential secrets)
4. Agent instances are spot/ephemeral — compromised agent is terminated on scale-in
Action items before adding external contributors:
- Evaluate rootless Docker (no root socket exposure) or Kaniko (no Docker daemon)
- OR implement fork isolation (WP require_approval: all for non-org members)
- See #4106 tracking note
How to view/change the trusted state:
WP_TOKEN="<admin JWT from ci.moosequest.net>"
# View:
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: raxx-agent/1.0" \
"https://ci.moosequest.net/api/repos/1" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['trusted'])"
# Set (trusted.volumes=true, trusted.security=false):
curl -sf -X PATCH \
-H "Authorization: Bearer $WP_TOKEN" \
-H "User-Agent: raxx-agent/1.0" \
-H "Content-Type: application/json" \
-d '{"trusted":{"network":true,"volumes":true,"security":false}}' \
"https://ci.moosequest.net/api/repos/1"
This setting persists in the Woodpecker database — it is not managed by Terraform (WP API-only). If the WP server is rebuilt from scratch, re-apply this setting before W7/W8 pipelines run.
State encryption
Terraform state in S3 is encrypted (AES-256 via aws/s3 KMS). The ALB OIDC client
secret appears in state because the aws_lb_listener resource stores it inline.
Mitigations: state bucket access restricted to terraform-executor role, TLS enforced
on all S3 access.
Secret rotation
All secrets in SSM are versioned (20 versions retained). Zero-downtime rotation:
1. Update SSM parameter value (--overwrite).
2. Restart Woodpecker server (reads SSM at boot via user-data).
3. For agent-secret: scale ASG to 0, update SSM, scale back up (agents read SSM at boot).
4. For OIDC client secret: update SSM → terraform apply (ALB listener rule update propagates within seconds, no downtime).
Phase 2 — cron pilot soak (active, started 2026-07-04)
Pilot crons
Three low-risk cron workflows run on Woodpecker agents IN PARALLEL with their GH Actions counterparts during the 48-hour soak window. GH Actions versions remain authoritative (issue filing, PR creation, notifications) until Phase 5 decommission per ADR-0134.
| Woodpecker pipeline | GH Actions counterpart | Schedule (UTC) | Secrets needed |
|---|---|---|---|
.woodpecker/security-scan-nightly.yaml |
nightly-security-scan.yml |
7 8 * * * |
none |
.woodpecker/alembic-version-check.yaml |
alembic-version-cron.yml (head job only) |
0 6 * * * |
none |
.woodpecker/maxmind-asn-refresh.yaml |
maxmind-asn-refresh.yml (download+validate only) |
0 3 1 * * |
MAXMIND_LICENSE_KEY (optional) |
Cron registration — COMPLETED 2026-07-04 via API (cron IDs 1, 2, 3):
All 3 crons registered and enabled via POST /api/repos/1/cron + PATCH /api/repos/1/cron/{id} (enabled=true). Repo ID = 1, pipeline definitions live in .woodpecker/ directory.
To re-register if needed (repo ID = 1, TOKEN = admin Woodpecker JWT):
curl -sf -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"name":"security-scan-nightly","schedule":"7 8 * * *","branch":"develop"}' \
https://ci.moosequest.net/api/repos/1/cron
# PATCH to enable: curl -X PATCH ... -d '{"enabled":true}' .../cron/{id}
Repo activation — COMPLETED 2026-07-04 (repo ID = 1):
Repo raxx-app/TradeMasterAPI activated via POST /api/repos?forge_remote_id=1172174585.
GitHub webhook ID: 649361254 (active, events: push, pull_request, pull_request_review, deployment).
Enabling the repo installs the Woodpecker webhook on the GitHub repo and lets
Woodpecker post commit statuses back to GitHub.
Trigger manual pilot runs (after repo enabled and PR merged to develop):
# Option A: Woodpecker UI — TradeMasterAPI → run pipeline → select each .woodpecker/ file
# Option B: Woodpecker API (replace TOKEN and ORG):
curl -X POST "https://ci.moosequest.net/api/repos/raxx-app/TradeMasterAPI/pipelines" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"branch": "develop"}'
Scale up 1 agent before manual trigger (if desired_capacity is 0):
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 1 \
--region us-east-2
Scale back when pilot builds finish:
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 0 \
--region us-east-2
48-hour soak plan
Window: 2026-07-04 through 2026-07-06 (48 hours from pilot registration)
What is being watched:
| Signal | Green | Red |
|---|---|---|
| Build success parity | Woodpecker security-scan + alembic-check exit 0 within 5 min of GH Actions counterpart | Either exits non-zero where GH is green |
| Agent scale-up/down | ASG launches spot instance, agent connects, job runs, agent terminates after scale-in | Agent stuck in "launching" or build never picked up |
| GitHub commit status | Woodpecker posts green status check on HEAD commit after each successful run | Status missing or red when build was green |
| Cost | Agent hours during soak < $1 total (2 daily short jobs, 1 manual) | Runaway agent billing (stuck job, desired stays >0) |
| Flake rate | Zero unexplained failures in 2 daily runs per pilot | Any failure not explained by a known GH Actions failure in the same window |
Success criteria for go/no-go on Phase 3: 1. All 3 pilot pipelines run successfully on real agents (build numbers captured) 2. GitHub commit statuses appear on the triggered commit within 5 minutes of build completion 3. No agent stuck-running cost event (desired > 0 more than 60 min after a job completes) 4. security-scan and alembic-check run successfully at their scheduled times on at least one natural cron trigger during the soak window 5. Zero data integrity events (no writes to prod, no emails sent, no GH issues created by the Woodpecker versions)
Go/no-go decision: operator reviews soak results on 2026-07-06. Green = proceed to Phase 3 (PR-gate workflow migration). Red = investigate before advancing.
Phase 2 enhancements (deferred until after soak)
- Queue-depth-based ASG autoscaling (custom CloudWatch metric from Woodpecker API → ASG scaling policy)
- CloudWatch agent on server instance for disk metrics (enables the disk alarm provisioned but currently INSUFFICIENT_DATA)
- Slack integration for SNS alerts (currently email-only)
- Rootless Docker evaluation for agent isolation
- ALB access logging to S3 for long-term audit trail
- Multi-AZ RDS (upgrade path: one AWS API call)
Secrets — Wave A bootstrap set
Created: 2026-07-04 UTC Card: #4037 (Wave A PRE-REQ) ADR: ADR-0135 §1 Scope: Org-level (org_id=2, org=raxx-app) — inherited by all repos in the org.
These 5 secrets are the ONLY credentials stored in Woodpecker. All application secrets
continue to live in Infisical vault and are fetched at pipeline runtime by the
wpc_load_vault_secrets.py init step.
Bootstrap secret list
| WP secret name | What it holds | Vault source path |
|---|---|---|
INFISICAL_CLIENT_ID |
Infisical Universal Auth machine identity client ID | Injected from GHA env at Wave A PRE-REQ; rotate via vault path raxx-secrets /Raxx/CI |
INFISICAL_CLIENT_SECRET |
Infisical Universal Auth machine identity client secret | Same as above |
INFISICAL_PROJECT_ID |
Infisical project ID (MooseQuest workspace) | Same as above |
CF_ACCESS_CLIENT_ID |
CF Access service token ID for vault.raxx.app |
Same as above |
CF_ACCESS_CLIENT_SECRET |
CF Access service token secret for vault.raxx.app |
Same as above |
Event restrictions (bootstrap secrets): push, pull_request, tag, cron, manual.
These are intentionally broad — they enable vault access for ALL pipeline types.
Prod-only secrets (e.g. HEROKU_API_KEY prod) will be created with restricted events
(tag, manual only) in Wave C.
How to verify secrets are present
WP_TOKEN="<admin JWT>"
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/orgs/2/secrets" \
| python3 -c "
import json, sys
secrets = json.load(sys.stdin)
names = {s['name'] for s in secrets}
required = {'INFISICAL_CLIENT_ID','INFISICAL_CLIENT_SECRET','INFISICAL_PROJECT_ID',
'CF_ACCESS_CLIENT_ID','CF_ACCESS_CLIENT_SECRET'}
missing = required - names
if missing:
print('MISSING:', missing); sys.exit(1)
print(f'All 5 bootstrap secrets present. Total org secrets: {len(secrets)}')
"
How to rotate a bootstrap secret
Zero-downtime rotation. No deploy or agent restart needed — WP injects secrets at pipeline start, not agent boot.
# 1. Update the value in Infisical vault (via vault UI or REST API)
# 2. Read the new value from vault (never paste inline):
NEW_VALUE="$(infisical secrets get <SECRET_NAME> --path /Raxx/CI --env prod --plain)"
# 3. PATCH the Woodpecker org secret (replaces value, preserves event restrictions):
WP_TOKEN="<admin JWT>"
curl -sf -X PATCH \
-H "Authorization: Bearer $WP_TOKEN" \
-H "User-Agent: sre-agent/1.0" \
-H "Content-Type: application/json" \
-d "{\"name\":\"<SECRET_NAME>\",\"value\":\"${NEW_VALUE}\"}" \
"https://ci.moosequest.net/api/orgs/2/secrets/<SECRET_NAME>" \
&& echo "rotated"
unset NEW_VALUE
# 4. Verify the next pipeline run using the secret completes successfully.
# No pipeline restart needed — rotation takes effect at next pipeline start.
WOODPECKER_ENV_FILE gap — FIXED (Wave A A1)
Finding (WP build #6, 2026-07-04): vault auth from a real WP agent succeeded
but WOODPECKER_ENV_FILE was not set in the step environment. The inter-step env
propagation write was silently skipped; the smoke-verify step failed with exit 1.
Root cause: Woodpecker v3 runs each step in a separate Docker container.
Unlike GHA, there is no automatic inter-step env propagation. WOODPECKER_ENV_FILE
is not set in step context on v3.16.0 — the GHA analogy was incorrect.
Fix (ADR-0135 Wave A A1): wpc_load_vault_secrets.py now writes export KEY=…
lines (base64-encoded) to a shell-sourceable file (/tmp/vault-env.sh by default),
which the SAME step sources before running its real work:
- pip install --quiet requests
- python3 scripts/ci/wpc_load_vault_secrets.py
- . /tmp/vault-env.sh && rm -f /tmp/vault-env.sh
- # real work that uses the vault-fetched env vars
Live proof: WP pipeline .woodpecker/vault-secret-smoke.yaml, captured in PR
for ADR-0135 A1. All 30 Wave A vault-dependent pipelines must use the same pattern.
See docs/architecture/woodpecker-wpc-step-usage.md §1 for the canonical snippet.
Wave B (ADR-0135) — heavy CI + scale arbiter
Status: active soak (2026-07-05 +). Go/no-go gate: §Gate-B2 in ADR-0135.
Scale arbiter (Lambda ci-scale-arbiter)
What it does: An EventBridge rate(2 minutes) rule invokes Lambda ci-scale-arbiter.
The Lambda calls GET /api/queue/info on the WP server (via internal ALB, Bearer token
from SSM /ci/woodpecker/admin-api-token), then:
- pending > 0 AND running == 0 AND desired == 0 → SetDesiredCapacity 1 (burst scale-up)
- pending == 0 AND running == 0 AND desired > 0 → SetDesiredCapacity 0 (idle scale-down)
- Otherwise → no action (work in flight or already scaling)
IaC: infra/ci/lambda.tf — Lambda + EventBridge rule + EventBridge target + permission.
Logs: /aws/lambda/ci-scale-arbiter (90-day retention).
Verify scale arbiter is active:
# Check EventBridge rule is enabled:
aws events describe-rule --name ci-scale-arbiter-trigger --region us-east-2 \
--query "State"
# Expected: "ENABLED"
# Check last 5 invocations:
aws logs filter-log-events \
--log-group-name /aws/lambda/ci-scale-arbiter \
--region us-east-2 \
--start-time $(python3 -c "import time; print(int((time.time()-3600)*1000))") \
| python3 -c "
import sys, json
for ev in json.load(sys.stdin).get('events', []):
print(ev['timestamp'], ev['message'][:120])
"
Manual scale-up (emergency — bypasses arbiter):
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 1 \
--region us-east-2
Manual scale-down (after incident resolved):
aws autoscaling set-desired-capacity \
--auto-scaling-group-name ci-woodpecker-agents-asg \
--desired-capacity 0 \
--region us-east-2
Failure mode J: Scale arbiter sets desired=0 while jobs are executing
Symptom: Pipelines fail with received sigterm termination signal part-way through
execution. Agent CloudWatch logs show "grpc error: done(): code: Unknown desc = sql: no rows in result set" immediately after SIGTERM. Scale arbiter logs show SCALE DOWN: queue idle — setting desired=0 (or desired=min_size) at the same timestamp as the job failures.
Cause: WP's /api/queue/info running_count transitions to 0 before agents have finished executing all steps. This is a WP v3 queue-tracking edge case: once a task is dispatched to an agent and moves from "dispatched" to "executing", the server may clear it from the running queue, causing running_count=0 while workers are still actively processing. The arbiter's scale-down condition (pending==0 AND running==0) fires on the stale count.
Observed (2026-07-13 18:12:35 UTC): pending=0, running=0, workers=4 — four workers actively running pipeline 1067 (workflows 2962-2964), but arbiter scaled desired→0.
Fix: The arbiter now targets min_size on scale-down (desired=1 with the on-demand floor) instead of desired=0. This reduces blast radius: only spot burst instances are terminated, the on-demand floor survives and CI keeps running. Full protection requires the floor (see §On-demand floor).
Verify:
# Check scale-down target in arbiter logs — should say "desired X→1 (floor)", not "desired X→0"
aws logs filter-log-events \
--log-group-name /aws/lambda/ci-scale-arbiter \
--region us-east-2 \
--filter-pattern "SCALE DOWN" \
--start-time $(python3 -c "import time; print(int((time.time()-3600)*1000))") \
| python3 -c "import json,sys; [print(e['message']) for e in json.load(sys.stdin).get('events',[])]"
Prevention: On-demand floor (min_size=1) means even if the arbiter fires prematurely, the floor instance stays alive and absorbs the next queue entry without a cold-start delay.
Failure mode G: Scale arbiter not triggering scale-up
Symptom: Push event triggered a pipeline, but no agent picks it up within 5 minutes. ASG desired stays 0.
Diagnose:
# Check EventBridge rule is enabled:
aws events describe-rule --name ci-scale-arbiter-trigger --region us-east-2 --query "State"
# Check Lambda error rate in last 30 min:
aws logs filter-log-events \
--log-group-name /aws/lambda/ci-scale-arbiter \
--region us-east-2 \
--start-time $(python3 -c "import time; print(int((time.time()-1800)*1000))") \
--filter-pattern "ERROR"
# Manually invoke the arbiter to test it:
aws lambda invoke \
--function-name ci-scale-arbiter \
--region us-east-2 \
--payload '{}' \
/tmp/arbiter-result.json \
--log-type Tail \
--query "LogResult" --output text | base64 -d
cat /tmp/arbiter-result.json
Common causes:
- SSM /ci/woodpecker/admin-api-token expired or wrong → Lambda logs WP API error 401
- WP server unhealthy (500 from /api/queue/info) → Lambda logs WP API error 500; fix WP server first
- Lambda execution role lost SSM permission → Lambda logs permission error
Fix: If Lambda is healthy but ASG is stuck, scale up manually (see above). Then investigate the root cause from Lambda logs before trusting automatic scale-down.
Verification: aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names ci-woodpecker-agents-asg --region us-east-2 --query "AutoScalingGroups[0].DesiredCapacity" returns 1.
Postgres heavy CI pipeline (.woodpecker/backend-tests-postgres.yaml)
Check context posted to GitHub: ci/woodpecker/backend-tests-postgres
What it runs:
1. path-guard — exit 0 if no backend_v2/** changes (informational, never blocks)
2. wait-for-postgres — polls pg_isready -h postgres -p 5432 until the postgres:15 service container is accepting connections
3. backend-tests-postgres:
- apt-get: postgresql-client (psql for create_ci_postgres_roles.sh) + postgresql (pg_ctl for pytest-postgresql)
- pip: root requirements.txt + backend_v2/requirements.txt (pytest-postgresql≥5.0) + pytest/cov/timeout
- PATH extended to /usr/lib/postgresql/15/bin for pg_ctl binary
- vault secrets loaded via wpc_load_vault_secrets.py (VAULT_ALLOW_MISSING=true — vault failure is non-fatal)
- create_ci_postgres_roles.sh against the postgres service container
- (cd backend_v2 && alembic upgrade head) — subshell keeps CWD at repo root
- pytest run (first pass: collect failures, --maxfail=5; second pass: coverage)
- python -m coverage report --fail-under=30 — gate
Service container behavior: The postgres:15 service container is on the same Docker
bridge network as build steps. Connect via hostname postgres (= service name), port 5432.
No port-mapping needed. pg_isready -h postgres is the correct readiness check.
conftest.py postgres path: When DATABASE_URL starts with postgresql://, conftest.py
does NOT connect to the service container directly. It imports pytest-postgresql and
calls postgresql_proc to spawn an EPHEMERAL pg_ctl-managed postgres instance on a
random port. The service container is used only for the explicit alembic upgrade head
step. This design (from ADR-0070) provides test isolation — each test session gets a
fresh, isolated DB. Consequence: postgresql server package + pytest-postgresql>=5.0
are both required in the pipeline, not just postgresql-client.
Wave B coexistence: The GHA backend-tests-postgres job (in .github/workflows/ci.yml)
continues to run during the Wave B soak. Remove the GHA job only after 2-week green parity
per ADR-0135 §Gate-B2.
Failure mode H: backend-tests-postgres step exits 1 with no coverage data
Symptom: backend-tests-postgres step exits with exit_code=1. Coverage report logs
Coverage.py warning: No data was collected. or similar.
Causes (in order of probability):
1. cd persisting — a command runs cd backend_v2 && ... without a subshell, so subsequent
pytest commands look in backend_v2/backend_v2/tests which does not exist. Pytest exits 4
(path not found), swallowed by || echo; coverage has no data. Fix: use (cd backend_v2 && ...).
2. pg_ctl not on PATH — pytest-postgresql cannot spawn ephemeral postgres. Fix: install
the full postgresql server package (not just postgresql-client) and export
/usr/lib/postgresql/15/bin to PATH.
3. pytest-postgresql not installed — conftest.py falls through to pytest.skip() on all
DB fixtures; tests are collected but skipped; coverage may be below 30%. Fix: install
-r backend_v2/requirements.txt in the pipeline step.
4. Alembic migration fails — a new migration has a syntax error or references a missing role.
Check: (cd backend_v2 && python3 -m alembic upgrade head) logs.
Diagnose:
# Check the pipeline step output — look for these signals:
# "No such file or directory: backend_v2/tests" → cause 1 (cd persistence)
# "FileNotFoundError: [Errno 2]" or "pg_ctl: command not found" → cause 2
# "pytest-postgresql is not installed" → cause 3
# "alembic.exc.CommandError" or migration error → cause 4
Verification: Pipeline step exits 0; coverage report logs TOTAL ... XX% where XX ≥ 30.
Branch protection — Wave B dual-gate coexistence
Principle (ADR-0135 §4): During the Wave B soak, develop branch protection requires BOTH GHA and WP status checks. WP checks are added ALONGSIDE GHA — GHA checks are NOT removed until §Gate-B2 parity is confirmed (2-week green run).
Currently required checks on develop:
GHA (app_id: null):
smoke_suite
base_branch_lint
stale-branch-guard
pii-scan
flag_promotion_check
migration-gate
WP (added by Wave B, context prefix: ci/woodpecker/):
ci/woodpecker/backend-tests-postgres
ci/woodpecker/ci-pr
ci/woodpecker/migration-collision-check
To add a new WP check to branch protection (operator or sre-agent):
# Read current protection config:
gh api repos/raxx-app/TradeMasterAPI/branches/develop/protection \
--jq '.required_status_checks'
# Add a check (PATCH is additive; include all existing + new contexts):
gh api repos/raxx-app/TradeMasterAPI/branches/develop/protection \
-X PUT \
--input - <<'EOF'
{
"required_status_checks": {
"strict": false,
"contexts": [
"smoke_suite",
"base_branch_lint",
"stale-branch-guard",
"pii-scan",
"flag_promotion_check",
"migration-gate",
"ci/woodpecker/backend-tests-postgres",
"ci/woodpecker/ci-pr",
"ci/woodpecker/migration-collision-check"
]
},
"enforce_admins": false,
"required_pull_request_reviews": null,
"restrictions": null
}
EOF
# Verify after update:
gh api repos/raxx-app/TradeMasterAPI/branches/develop/protection \
--jq '.required_status_checks.contexts'
To remove GHA checks (§Gate-B2 — only after 2-week green parity):
Repeat the PUT above with only the WP contexts in the contexts array, omitting the
GHA app_id: null entries. The GHA workflow files in .github/workflows/ remain for
reference but their check contexts are no longer blocking.
WP status context name format (confirmed 2026-07-05):
WP v3 posts GitHub status checks using ci/woodpecker/{event}/{pipeline-name} format, where
{event} is push for push events and pr for pull_request events. Example:
- ci/woodpecker/pr/backend-tests-postgres (PR trigger)
- ci/woodpecker/push/backend-tests-postgres (push trigger)
- ci/woodpecker/pr/ci-pr (ci-pr pipeline, PR trigger)
Note: some older YAML pipeline comments use ci/woodpecker/{pipeline-name} without the event prefix — this was a WP v2 / early v3 format. The {event} prefix has been confirmed in production on WP v3.16.0.
WP built-in variables in environment: blocks use the brace form ${CI_COMMIT_BRANCH},
${CI_REPO}, ${CI_FORGE_TOKEN} — WP substitutes these at YAML parse time. Using bare
$CI_COMMIT_BRANCH in an environment: value passes the literal dollar-sign string to
the container, NOT the variable value. See Failure mode E-4 exception. Fixed in Wave B
for ci-pr.yaml, console-degraded-auto-file.yaml, migration-collision-check.yaml.
ci/woodpecker/pr/ci-pr known failure (Wave A issue):
The ci-pr.yaml pipeline reports failure on every PR since its introduction in Wave A batch 2
(PR #4051, 2026-07-05). Now that the ALB /api/* OIDC bypass rule is deployed and in Terraform
state (priority 25: /api/*, /authorize, /login, /logout — imported 2026-07-08), the WP
API is accessible from external clients without SSM. The ci-pr failure can now be diagnosed
directly via the WP API. Do NOT add ci/woodpecker/pr/ci-pr to develop branch protection
until this failure is diagnosed and resolved.
WP API access from external clients: The ALB now has a priority-25 OIDC-bypass rule
for /api/*, /authorize, /login, and /logout (deployed 2026-07-08, managed as
aws_lb_listener_rule.woodpecker_own_auth in infra/ci/alb.tf). Direct WP API calls
from outside the VPC now work without authentication at the ALB layer — Woodpecker enforces
its own token auth on all /api/* requests. Example:
curl -s -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
https://ci.moosequest.net/api/queue/info | python3 -m json.tool
Wave C — Console Ops Dispatch cutover (#4038, ADR-0135)
Status: implementation landed behind FLAG_OPS_DISPATCH_USE_WOODPECKER
(default OFF). Flip at Wave C prod cutover per the operator decision below.
What changed: console/app/blueprints/ops.py (POST /ops/dispatch/<action_id>)
can now dispatch via either the GitHub Actions workflow_dispatch API (unchanged,
flag off) or the Woodpecker API (flag on). See
console/app/services/ops_dispatch.py module docstring for the full design.
Safety constraint (mandatory — do not relax without a new incident review):
the Woodpecker dispatch path NEVER issues a bare POST /api/repos/1/pipelines
(event:manual) call. That endpoint fans out to every .woodpecker/*.yaml file
whose when: block matches event: manual — roughly 65 pipeline files as of
2026-08, most unrelated to the action an operator clicked (see the "Warning —
manual trigger fires all N workflows" note earlier in this doc, and
.woodpecker/deploy-prod.yaml's TRIGGERS comment re: incident #110). Instead,
the Console only ever calls the documented per-cron endpoint,
POST /api/repos/1/cron/{cron_id}, and only for WorkflowSpec entries with an
explicit wp_cron_id allowlist mapping (an operator-curated list, not
free-form input).
Current allowlist (as of #4038):
| Console action | wp_cron_id |
Notes |
|---|---|---|
nightly_security_scan |
1 | Maps to the security-scan-nightly pilot cron registered in "Phase 2 — cron pilot soak" above. Re-verify the ID via GET /api/repos/1/cron before flipping the flag on in prod — cron IDs are not guaranteed stable across a server rebuild. |
deploy_heroku_prod |
(none) | No safe scoped WP trigger exists yet — .woodpecker/deploy-prod.yaml is tag + break-glass-manual with an unscoped when: event: manual block. Hidden from the ops menu whenever the flag is on (hide-don't-gray, #1961); stays on the GHA dispatcher until a dedicated scoped trigger is built. |
Operator decision (2026-07-05, issue #4038): Option C — a project-scoped
Woodpecker API token if v3.16 supports it, stored at vault path
/MooseQuest/console/WP_API_TOKEN and exposed to the Console app as the
WP_API_TOKEN Heroku config var (same pattern as FREESCOUT_API_KEY /
HEROKU_API_KEY — read via os.environ, never inlined). Falls back to a
dedicated raxx-ci-bot WP user with minimal org access if v3.16 lacks
project-scoped tokens.
Follow-up before prod flip: provision WP_API_TOKEN per the operator
decision above, confirm cron ID 1 still maps to security-scan-nightly via
GET /api/repos/1/cron, and register a scoped WP trigger for
deploy_heroku_prod (or an equivalent staging-deploy action) before that
action can be exposed over Woodpecker.
ECR layer cache — raxx-queue-vcpkg-cache (#4107, 2026-07-09)
Purpose: Stores Docker build-cache layers for the queue C++ vcpkg dependency tree.
Consumed by W7 (deploy-queue) and W8 (queue-docker-smoke) pipelines. A warm cache
reduces vcpkg build time from ~25-30 min (cold) to ~2-3 min.
Repository:
Name: raxx-queue-vcpkg-cache
Region: us-east-2
URI: 521228113048.dkr.ecr.us-east-2.amazonaws.com/raxx-queue-vcpkg-cache
ARN: arn:aws:ecr:us-east-2:521228113048:repository/raxx-queue-vcpkg-cache
Lifecycle policy: Untagged images are expired after 14 days. Tagged layers
(e.g., vcpkg-cache-bookworm-<sha>) are retained until explicitly deleted.
IaC: infra/ci/ecr-cache.tf — ECR repo + lifecycle policy + agent IAM inline policy.
Do NOT edit iam.tf for ECR-related changes — ecr-cache.tf owns the agent ECR policy.
Agent IAM policy ci-woodpecker-agent-ecr (inline on ci-woodpecker-agent-role):
- ecr:GetAuthorizationToken on * (IAM limitation — cannot scope to one repo)
- Pull actions (BatchCheckLayerAvailability, GetDownloadUrlForLayer, BatchGetImage) on repo ARN only
- Push actions (PutImage, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload) on repo ARN only
Authenticate agent to ECR (used in W7/W8 pipeline steps):
# WP pipeline step YAML (agent fetches token via instance role — no credential needed):
aws ecr get-login-password --region us-east-2 \
| docker login --username AWS --password-stdin \
521228113048.dkr.ecr.us-east-2.amazonaws.com
Bootstrap cache (one-time before first W7 run — avoids 25-30 min cold build):
Run from a machine or GHA runner with ECR push rights to raxx-queue-vcpkg-cache:
# Build the vcpkg base layer and push to ECR:
cd queue/
docker buildx build \
--cache-to type=registry,ref=521228113048.dkr.ecr.us-east-2.amazonaws.com/raxx-queue-vcpkg-cache:vcpkg-cache-bookworm-bootstrap,mode=max \
--push \
-f Dockerfile.vcpkg \
.
# Verify the tag exists:
aws ecr describe-images \
--repository-name raxx-queue-vcpkg-cache \
--region us-east-2 \
--query "imageDetails[*].{tags:imageTags,pushed:imagePushedAt,size:imageSizeInBytes}" \
--output table
Failure mode — ECR push/pull fails from agent with "no credentials":
- Confirm the ci-woodpecker-agent-ecr inline policy is attached: aws iam get-role-policy --role-name ci-woodpecker-agent-role --policy-name ci-woodpecker-agent-ecr
- Confirm IMDSv2 hop limit is 2 on the agent LT (allows containers to reach IMDS for STS): aws ec2 describe-launch-template-versions --launch-template-name ci-woodpecker-agent-lt --query "LaunchTemplateVersions[-1].LaunchTemplateData.MetadataOptions"
- The agent must call aws ecr get-login-password BEFORE docker login in the same step — the token expires after 12h
Failure mode I: Stale Go HTTP connection pool — context deadline exceeded on GitHub API calls
Symptom: A pipeline triggered by a tag (or push) event is created immediately with status=error and zero steps executed. Server logs show context deadline exceeded on a pipeline-fetch path. The WP server is otherwise healthy (/healthz 200, queue not paused, agents connected). Typically surfaces after 4–7 days of continuous server uptime.
Cause: Go's net/http default transport pools idle keep-alive connections. After multi-day uptime, connections to api.github.com held in the pool have been silently closed server-side (TCP RST or keepalive timeout), but the WP process has not detected the close. On the next GitHub API call (fetching .woodpecker/*.yaml), WP reuses a dead connection; the TCP write fails; the request-scoped deadline expires before a successful retry completes.
Fix:
# 1. Confirm the symptom in WP server logs:
aws ssm send-command \
--region us-east-2 \
--instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["sudo docker compose -f /opt/woodpecker/docker-compose.yml logs --tail=30 woodpecker-server 2>&1 | grep -E \"context deadline|unexpected status\" | tail -10"]}' \
--output json | python3 -c "import json,sys; print(json.load(sys.stdin)['Command']['CommandId'])"
# Poll get-command-invocation until Status=Success, then read StandardOutputContent.
# 2. Restart to clear the pool (~30s downtime; ensure WP queue is EMPTY first):
aws ssm send-command \
--region us-east-2 \
--instance-ids i-082ee835595d90ae0 \
--document-name AWS-RunShellScript \
--parameters '{"commands":["sudo systemctl restart woodpecker && echo RESTART_OK"]}' \
--output json | python3 -c "import json,sys; print(json.load(sys.stdin)['Command']['CommandId'])"
# Poll; expect Status=Success + StandardOutputContent=RESTART_OK
# 3. Confirm healthy after ~30s:
curl -sS -o /dev/null -w "%{http_code}\n" -H "User-Agent: raxx-agent/1.0" https://ci.moosequest.net/api/version
# Expected: 200
Re-fire the failed pipeline after restart:
Option A — Redeliver the tag webhook via GitHub API (requires admin:repo_hook scope — preferred, preserves tag event context):
# WP webhook ID: 649361254
gh api repos/raxx-app/TradeMasterAPI/hooks/649361254/deliveries \
--jq '.[] | select(.event == "create") | {id,delivered_at}' | head -4
gh api -X POST repos/raxx-app/TradeMasterAPI/hooks/649361254/deliveries/<delivery_id>/attempts
Option B — Break-glass manual trigger (use when admin:repo_hook unavailable):
WP_TOKEN="<admin WP JWT from ci.moosequest.net>"
# Note: ALB may return 504 even if WP accepted the request (60s idle timeout).
# Always confirm creation via GET after a 504.
curl -s -X POST \
-H "Authorization: Bearer $WP_TOKEN" \
-H "User-Agent: raxx-agent/1.0" \
-H "Content-Type: application/json" \
-d '{"branch":"main","variables":{"DEPLOY_PROD_CONFIRM":"yes"}}' \
"https://ci.moosequest.net/api/repos/1/pipelines"
curl -s -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: raxx-agent/1.0" \
"https://ci.moosequest.net/api/repos/1/pipelines?page=1&perPage=3" \
| python3 -c "import json,sys; [print(f'#{p[\"number\"]} {p[\"event\"]} {p[\"branch\"]} {p[\"status\"]}') for p in json.load(sys.stdin)]"
Warning — manual trigger fires all 59 workflows: A manual trigger on main dispatches every .woodpecker/*.yaml file that matches event: manual, not just deploy-prod. Most are harmless (cron workflows that error on non-scheduled contexts), but deploy-staging, cut-release-candidate, and deploy-velvet may also run. Prefer Option A (webhook redeliver) for a clean tag-only trigger. As of #4474, deploy-velvet's manual-triggered prod path now fails closed rather than silently deploying: it requires DEPLOY_PROD_CONFIRM=deploy-prod-now (not deploy-prod.yaml's yes — different pipeline, different confirm value) on branch main, so the Option B DEPLOY_PROD_CONFIRM=yes example above will make deploy-velvet fail its own guard (harmless, no Heroku push) rather than deploy Velvet to prod as an unintended side effect.
Verification: deploy-prod workflow in the new pipeline reaches state=success for all 9 steps. Confirm api.raxx.app/health → 200.
Long-term mitigations:
1. Weekly WP server restart cron (Sunday 04:00 UTC) via AWS EventBridge + SSM Run Command
2. Tune Go HTTP transport via WP env var WOODPECKER_FORGE_TIMEOUT (or similar) if supported in v3.x
3. Cache git subtree split to avoid 17+ min cold splits on ephemeral agents (first run = 1073s on 6-month-history repo)
RCA: docs/incidents/2026-07-09-wp-stale-http-pool-prod-deploy.md
Escalation
Wake the operator when: - RDS data integrity suspected (unclean shutdown, storage corruption) - GitHub OAuth App or Google OIDC credentials compromised - ACM certificate cannot be renewed (Dyn CNAME missing after 60-day renewal attempt) - Woodpecker server repeatedly crashes and restart loop does not recover within 15 minutes - WAF blocking appears to be a false positive on production pipeline webhooks
Monitoring — external watchdog (2026-07-07)
Permanent GHA stay (ADR-0136 Decision 1; #4109): .github/workflows/woodpecker-healthcheck.yml
is classified GENUINELY-STAYS-ON-GHA. It is not a pending migration item. Do not port it
to Woodpecker and do not retire it. See migration table §2b for the formal record.
Woodpecker health is monitored by scripts/ops/woodpecker_healthcheck.py, run
every 15 min by .github/workflows/woodpecker-healthcheck.yml — intentionally
on GitHub Actions, because a monitor hosted on Woodpecker can't fire when
Woodpecker is down.
Checks: (1) server reachable (/api/version 200), (2) TLS cert expiry (< 21d
fails), (3) pipeline health (stuck > 90 min, or ≥4 of last 5 finished failed) —
tier 3 only runs if a WOODPECKER_API_TOKEN repo secret is set.
Alerting is edge-triggered via a woodpecker-health GitHub issue anchor: one
email to kris@moosequest.net on healthy→unhealthy, one on recovery — no per-run
spam during a sustained outage. Email goes via Postmark (POSTMARK_OPS_ALERT_TOKEN
+ OPS_ALERT_FROM secrets).
Verify the alert path any time: Actions → "Woodpecker healthcheck" → Run workflow
→ tick test_email (sends one confirmation email, then exits). Or locally:
POSTMARK_SERVER_TOKEN=... python3 scripts/ops/woodpecker_healthcheck.py --test-email.
To enable the richer pipeline checks, add a WOODPECKER_API_TOKEN repo secret
(a WP user API token from the ci.moosequest.net UI).
Failure mode J: woodpecker-restart.service crash loop — 502 since 08:00 UTC
Symptom: ALB returns 502 for all requests. systemctl status woodpecker shows the
service cycling through "Starting → Finished → Stopping → Stopped → Starting" every ~2
seconds. journalctl -u woodpecker -u woodpecker-restart.service shows this alternating
pattern repeating continuously:
systemd: Finished woodpecker.service
systemd: Starting woodpecker-restart.service...
systemd: woodpecker-restart.service: Main process exited, code=killed, status=15/TERM
systemd: woodpecker-restart.service: Failed with result 'signal'.
systemd: Stopping woodpecker.service...
systemd: woodpecker.service: Deactivated successfully.
systemd: Starting woodpecker.service...
Cause: woodpecker-restart.service has Requires=woodpecker.service. When the
timer fires and woodpecker-restart.service runs systemctl restart woodpecker,
that command internally stops woodpecker.service. The moment woodpecker.service
goes inactive, the Requires= dependency causes systemd to SIGTERM
woodpecker-restart.service before the restart process can complete. Systemd then
restores woodpecker.service (queued start from the partial restart), and also
attempts to re-activate woodpecker-restart.service (systemd heals collaterally
stopped units). The cycle repeats with no exit condition: ~2 seconds per loop,
indefinitely. The WP container never stays up long enough for the ALB target group
health check to pass. The loop starts the moment the timer fires (08:00 UTC daily)
and the outage persists until the loop is manually broken.
Fix (break the loop + patch the unit file):
# 1. Break the loop — stop both units
sudo systemctl stop woodpecker-restart.timer woodpecker-restart.service woodpecker
# 2. Verify stopped (should say "failed" or "inactive", never "active")
sudo systemctl is-active woodpecker && echo STILL_UP || echo STOPPED_OK
sudo docker ps | grep woodpecker && echo CONTAINER_STILL_UP || echo DOWN_OK
# 3. Remove the faulty Requires= line
sudo sed -i '/^Requires=woodpecker.service/d' /etc/systemd/system/woodpecker-restart.service
# 4. Confirm the patch (should show After= but NO Requires=)
sudo cat /etc/systemd/system/woodpecker-restart.service
# 5. Reload daemon and restart cleanly
sudo systemctl daemon-reload
sudo systemctl start woodpecker
# 6. Confirm container is up and healthz returns 200
sudo docker ps | grep woodpecker
curl -sf http://localhost:8000/healthz && echo OK
# 7. Re-enable the timer
sudo systemctl start woodpecker-restart.timer
sudo systemctl status woodpecker-restart.timer --no-pager
# 8. Verify no restart.service start fires immediately
sleep 5
sudo journalctl -u woodpecker-restart.service --since "1 minute ago" --no-pager
# Expected: no new "Starting woodpecker-restart.service" entries
Verification:
# External endpoint must return 200
curl -s -o /dev/null -w "%{http_code}" https://ci.moosequest.net/api/version
# Expected: 200
# Service must be stable (active, not cycling)
sudo systemctl status woodpecker --no-pager
# Expected: Active: active (exited) — no repeated Starting/Stopping
Permanent fix — IaC: The woodpecker-restart.service unit must be created
WITHOUT Requires=woodpecker.service. After=woodpecker.service alone is sufficient.
This is now codified in infra/ci/templates/user_data_server.sh.tpl (section 9) so
a server rebuild will not re-introduce the bug.
Correct woodpecker-restart.service:
[Unit]
Description=Woodpecker CI daily restart -- clears Go HTTP connection-pool staleness (RCA 2026-07-09)
After=woodpecker.service
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart woodpecker
StandardOutput=journal
StandardError=journal
Why Requires= is wrong here: systemctl restart woodpecker = stop + start. The
stop phase puts woodpecker.service into inactive state. Requires=woodpecker.service
propagates that stop to woodpecker-restart.service (killing the restart process
mid-execution). After=woodpecker.service gives the same boot-ordering guarantee
without the stop-propagation side effect.
RCA: docs/incidents/2026-07-11-woodpecker-restart-loop.md
Incident log: 2026-07-11 — loop started 08:00 UTC, detected 13:36 UTC, resolved 13:47 UTC
Failure mode K: "functioning correctly" cron claims go stale — always re-verify via a real scoped trigger before enabled:true
Symptom: A cron created and validated in the same PR (e.g. "fixed +
confirmed working" in the PR description) but never issued the follow-up
PATCH .../cron/{id} {"enabled":true} sits dark (next_exec frozen at
its creation-time computed value) for weeks or months. When someone
finally goes to enable it, blindly trusting the original PR's claim risks
silently arming a job that either always fails, or worse, always
false-succeeds without doing its actual job.
Cause: A pipeline step exiting 0 is not proof the step did anything useful — only that nothing inside it raised past whatever error handling it has. Two concrete cases found on the same day (2026-08-12, cards
4477/#4478, following the #4429/#4450 precedent that first established
this pattern for tickets-e2e-smoke-daily):
e2e-smoke-nightly(cron 18):npm ciinfrontend/raxx-nextfails outright under the CI image's npm version — this one fails loudly, but had never actually been exercised in that exact image/npm-version combination since the lockfile drift was introduced (PR #2917, 2026-05-27, ~2.5 months before this cron was even created).historical-bars-warm-nightly(cron 20): the underlying command (warm_historical_bars_1min.py) never bootstraps its DB engine (init_engine()) — every real invocation raisesRuntimeError: get_engine() called before init_engine(), which is caught by a broadexcept Exceptionand silently downgraded to "no symbols to warm," so the step reportssuccesswhile doing nothing. This is the more dangerous case: a naive re-enable based on the step's green exit code alone would have armed a cron that runs "successfully" forever without ever warming a single row.
Diagnose (do this before every enabled:true PATCH on a cron that has
never actually fired, regardless of how recently or confidently a prior
PR claimed it works):
# 1. Baseline — confirm current enabled/next_exec state
WP_TOKEN="<admin JWT, from SSM /ci/woodpecker/admin-api-token>"
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/repos/1/cron/<CRON_ID>"
# next_exec frozen at creation time == zero fires since creation.
# 2. Check WP queue is idle before triggering (avoid stacking on live PR load)
curl -sf -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/queue/info"
# 3. Trigger via the SCOPED single-workflow endpoint — never a bare manual
# dispatch (fans out to every event:manual workflow in the repo, ~65+
# unrelated pipelines as of 2026-08).
curl -sf -X POST -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/repos/1/cron/<CRON_ID>"
# Note: as of 2026-08-12 this endpoint did NOT reliably scope to exactly
# one workflow in this repo — `ci-boundary` rode along on both cron 18
# and cron 20 triggers despite its own `when:` not matching a cron event.
# See #4490 (open investigation). Do not be alarmed by an unrelated
# second workflow in the response's `workflows` array; just evaluate the
# TARGET workflow's own state independently of the pipeline's aggregate
# status.
# 4. Read the FULL step log (not just the summary exit code) for the
# target workflow's real-work step. The v3 log endpoint returns
# base64-encoded lines:
curl -sS -H "Authorization: Bearer $WP_TOKEN" -H "User-Agent: sre-agent/1.0" \
"https://ci.moosequest.net/api/repos/1/logs/<PIPELINE_NUMBER>/<STEP_ID>" \
| python3 -c "
import json,sys,base64
for l in json.load(sys.stdin):
data = l.get('data')
if data:
print(base64.b64decode(data).decode('utf-8', errors='replace'), end='')
"
# Read for the ACTUAL side effect described in the step's own log lines
# (rows loaded, files uploaded, bars warmed, etc.) — not just "exit 0" /
# a generic "PASS" echo. A step can print PASS and exit 0 while its own
# earlier log lines show an ERROR that was silently caught.
Fix: There is no generic fix — each case is its own root cause (see
4488, #4489 for the two concrete examples above). The fix IS the
process: never PATCH enabled:true on the strength of a stale PR claim
alone. Always re-trigger via the scoped endpoint and read the real step
output for evidence of actual work done, not just a clean exit code.
Verification: Re-trigger via the scoped endpoint after the underlying
fix lands; confirm the step's own log shows the real expected side effect
(not a masked "nothing to do" path); only then PATCH
.../cron/{id} {"enabled":true} and confirm next_exec moves forward.
RCA: docs/incidents/2026-08-12-cron-reverify-batch-e2e-warm-waf.md,
docs/incidents/2026-08-09-tickets-e2e-smoke-mailto-drift.md (originating
precedent)
Incident log
| Date | Duration | Cause | Fix applied |
|---|---|---|---|
| 2026-07-04 | ~2h | WAF SizeRestrictions_BODY + WP v3 YAML ${{ parse error |
WAF override + pipeline YAML fixes (see RCA) |
| 2026-07-11 | ~5h 47m (08:00–13:47 UTC) | woodpecker-restart.service Requires=woodpecker.service crash loop triggered by 08:00 timer — SEV-2 |
Removed Requires= line via SSM sed; service stable; IaC template patched |
| 2026-08-12 | N/A (verification pass, not an outage) | e2e-smoke-nightly/historical-bars-warm-nightly crons re-verified per #4477/#4478; both found real pre-existing bugs (lockfile gap, missing init_engine()) rather than being safe to enable — SEV-3 |
Filed #4488/#4489 for feature-developer; left both crons disabled; filed #4490 for the cron-trigger co-scoping anomaly observed during the same pass |