Skip to content
GitHub
Decisions

ADR-062: CI secrets handling for integration tests

Status: Accepted (2026-04-25)

Context

CI integration tests need access to several classes of secrets: Supabase staging plus Management API tokens; the Render API key for deploy workflows (TA-26); Cloudflare API tokens for edge configuration (TA-22); LLM provider keys for nightly drift detection (TA-24); OAuth provider test app credentials (TA-18); JWT signing fixtures.

Spectral operates in solo-builder mode (per AGENTS.md) — no fork PRs are expected at alpha. The fork-PR privilege-escalation threat has zero structural surface in the current operating mode. The discipline this ADR lands is operational hygiene plus a forward-trigger-ready posture in case external contributors emerge later.

The decisions here extend the existing GitHub Environment + protection-rule pattern from TA-26 D17 with a third Environment for the live-LLM-provider drift workflow, set explicit rules for which workflow triggers may use real secrets, and name the multi-layer leakage scanning that catches a secret at multiple stages (commit, workflow log, structured log, cassette commit).

Decision

D1 — GitHub Environments for secret scoping

Three GitHub Environments hold scoped secrets, each with required-reviewer = self per the TA-26 D17 protection-rule pattern:

  • staging — Supabase staging anon + service-role keys; Render API key (TA-21 D12); Cloudflare API token (TA-22 D6); OAuth provider test app credentials.
  • production — production equivalents of staging secrets.
  • test-live — LLM provider API keys (Anthropic / OpenAI / Google) on a dedicated daily-capped test-account; used by the TA-24 D4 nightly live-drift workflow only.

The protection-rule plus required-reviewer pattern from TA-26 D17 carries forward; no net-new substrate.

D2 — Fork PR safety: mock-first; no pull_request_target

Default pull_request event runs fork PRs with read-only GITHUB_TOKEN and no secret access — the safe default. Real secrets are gated to non-PR triggers:

  • Default PR CI = unit + contract + integration tests with FakeLLMProvider (TA-24 D7), testcontainers Postgres (TA-23 D5), VCR cassette replay (TA-24 D2). No external service calls.
  • Live-secret runs gated to push (merge-to-main), schedule (nightly), workflow_dispatch (manual ops). Fork PRs never trigger these.
  • Three workflows that need real secrets — TA-24 D4 nightly drift; staging-integration-on-merge; deploy workflows (TA-26 D17). All gated to non-PR triggers.

pull_request_target is not used at alpha. The threat is structurally zero (no fork PRs planned per project posture); the discipline is hygiene plus a forward-trigger-ready posture. If external contributors emerge and a specific PR-time secret-using workflow becomes necessary, add a manual-approval gate via GH Environment required-reviewer plus workflow_run chain — not pull_request_target. The blanket “no pull_request_target” rule eliminates a whole class of mis-configurations (e.g., checking out fork code into the privileged context).

D3 — Time-boxed credentials via OIDC where supported; long-lived GitHub secrets where not

Render and Cloudflare do not have first-class OIDC at the time of this disposition; their API tokens land as long-lived GitHub secrets in scoped Environments (D1) with rotation per D5. Supabase service-role tokens for testing are scoped and rotated per D5. LLM provider keys live on a dedicated daily-capped test-account per TA-24 D6.

Forward trigger: migrate any provider to OIDC when their support lands. Each migration is documented in this ADR’s evidence log when it occurs.

D4 — Multi-layer leakage scanning (defense in depth)

Four layers, no single substrate:

  • GitHub Actions auto-redaction — registered secrets auto-redacted in workflow logs (built-in)
  • gitleaks pre-commit — the pre-commit config runs gitleaks on every commit and blocks commits that contain detected secret patterns
  • Structlog redaction filter (per TA-16) — known sensitive field names (api_key, authorization, password, token, secret) auto-redacted in structured logs
  • Cassette content lint (TA-24 D8) — tools/quality/check_cassette_redaction.py blocks Authorization: Bearer ... patterns in committed cassettes; wired into the pre-push gate per TA-26

D5 — Rotation cadence: quarterly plus on-incident

Applies to:

  • LLM provider API keys (test-live + production accounts)
  • Render API key (TA-21 D12)
  • Cloudflare API token (TA-22 D6)
  • Supabase service-role tokens
  • OAuth provider test app secrets (TA-18 D2)

Rotation procedure documented in docs/runbooks/ci-secrets.md. Rotation events recorded in the operational log; triggered immediately on any leak suspicion. Cofounder personal access keys discipline (per TA-17 D5 + TA-20 documentation discipline) is private and not in system docs; ADR-037’s evidence log holds rotation lineage if needed.

D6 — CI workflow secret-handling discipline

Hard rules for every workflow file:

  • Secrets referenced via ${{ secrets.NAME }} only inside env: blocks of jobs that need them
  • Never echo secrets to logs; never echo $SECRET; never include in error messages
  • Never pass secrets to third-party actions without explicit allowlist
  • Action allowlist: pinned versions only (no @main, @latest); GitHub-verified or org-allowlisted publishers only
  • Workflow files reviewed for these rules before merge; a quality lint may land post-alpha to enforce mechanically

D7 — Test-only Supabase project: forward-triggered, not at alpha

TA-23 testcontainers-python (CI) plus the Supabase staging branch (per TA-19 D4) cover alpha needs. A dedicated test-only Supabase project would be added if a forward trigger fires:

  • Live integration tests cannot share staging schema cleanly (e.g., need destructive operations that pollute staging)
  • Test-data privacy concerns (staging holds real customer data exposed to test runs)
  • Cost / quota constraints push test traffic off staging

At alpha, none of these apply.

D8 — Auth test creds: dedicated test-tenant plus test-only signing keys

OAuth provider test apps (Google + GitHub per TA-18 D2): dedicated test-tenant accounts; scoped to test users; secrets in the staging GitHub Environment with manual-approval gate. JWT signing keys for the asymmetric flow (per TA-18 D4a JWKS-local): test fixtures hold dedicated test signing keys (RSA / EdDSA per Supabase support); never reuse staging or prod signing keys; test keys committed to test fixtures (private keys are test-only and contained).

D9 — Audit log of secret access via GitHub Audit Log plus quarterly founder review

GitHub’s built-in audit log captures every secret access (workflow runs that access secrets are logged). Founder review quarterly via the GitHub organization audit log UI. No additional substrate at alpha.

D10 — Operational scaffolds

  • New docs/runbooks/ci-secrets.md — landed in commit a9ba851. Documents Environment scoping (staging / production / test-live); fork-PR posture; rotation cadence; leakage-scanning layers; auth-test-cred shape.
  • New .github/workflows/nightly-live-drift.yml (TA-24 D4 + this ADR) — schedule + workflow_dispatch + on-merge-to-main; references test-live Environment secrets; runs the integration suite with LIVE_PROVIDER=1. Lands in TA-24 close-pass with the first cassette commit.
  • New .github/workflows/integration-staging.yml (this ADR) — runs on push to main; references staging Environment secrets; runs full integration suite against staging Supabase. Lands when the first integration test against staging exists (deferred to consumer epic).
  • .github/workflows/ci.yml already exists per TA-26; no material changes here. Mock-first posture is the existing default.

Alternatives considered

pull_request_target with manual approval. Rejected per D2. Error-prone-config-exposure surface; the blanket “no pull_request_target” rule avoids the entire mistake class.

All-OIDC at alpha. Rejected per D3. Impossible — Render and Cloudflare do not support OIDC at this time. OIDC migration is forward-triggered per provider.

Single-environment flat secrets. Rejected per D1. Eliminates the protection-rule + required-reviewer pattern from TA-26 D17; reduces operational visibility on secret access.

Test-only Supabase project at alpha. Rejected per D7. Staging suffices; forward-triggered.

Annual rotation cadence. Rejected per D5. Quarterly is industry-standard for CI credentials; on-incident handles real leaks.

Reuse staging or prod JWT signing keys for test fixtures. Rejected per D8. Test fixtures committed to the repo with prod keys would mean automatic key compromise.

Consequences

  • Mock-first PR CI is fast (no live API calls), cost-zero (no test-account credit burn), and deterministic.
  • Live-secret workflows scoped to non-PR triggers eliminate the fork-PR privilege-escalation class structurally rather than procedurally.
  • Multi-layer leakage scanning catches secrets at multiple stages (commit, workflow log, structured log, cassette commit).
  • Environment-scoped secrets respect the TA-26 D17 protection-rule pattern — no net-new governance surface.
  • Long-lived GitHub secrets for Render + Cloudflare until OIDC support emerges. Mitigated by quarterly rotation + on-incident response.
  • gitleaks pre-commit can produce false positives that block commits. Developer-side ergonomics are tunable via .gitleaks.toml allowlists.
  • docs/runbooks/ci-secrets.md is the operational source of truth for rotation, Environment shape, and audit; landed at commit a9ba851.

References

  • ADR-037 — TA-17 secrets management baseline (D1/D2 superseded by ADR-046; D5 cofounder discipline carries forward)
  • ADR-039 — TA-18 Supabase Auth + OAuth providers + JWKS-local
  • ADR-046 — TA-21 Render Env Groups + Render API key in GitHub secrets
  • ADR-052 — TA-22 Cloudflare API token
  • ADR-045 — TA-23 CI testcontainers Postgres
  • ADR-053 — TA-26 GitHub Environments + protection rules + required-reviewer pattern
  • ADR-061 — TA-24 (live-drift workflow + dedicated test-account)
  • TA-25 disposition — SPEC-328 comment 907f6c7f
  • TA-25 verification — SPEC-328 comment dfd279c3
  • docs/runbooks/ci-secrets.md — operational scaffold (commit a9ba851)
  • .github/workflows/ci.yml — existing PR CI (mock-first by default)
  • .github/workflows/nightly-live-drift.yml — queued (lands with first cassette commit)
  • .github/workflows/integration-staging.yml — queued (lands with first staging-integration test)