Certify CI promotion via OIDC route
Epic: Merge lane — CP-scheduled verification queue, plan-gated. Planner Task 5517; the full PRD is BA Task 5515 and the full architecture is Architect Task 5516. Read architecture L6, §3 (the promotion-skip trap), §4.8 and §6 with `task_read`.
Builds on C3a (`merge_lane_certificates`, `lane_state`, `gate_cmd_hash`).
## Requirement (work item 6, verbatim)
"CI: `test.yml` push-to-develop full run removed when the project's feature is on; promotion PR `test-stack` asks the CP whether the merge-ref tree is certified (node-token route) and exits 0, running the suite only if not. `tenancy-oracles`, `promotion-provenance`, the owner's master gate unchanged."
ARCHITECT RESHAPE (settled, L6 / pre-mortem A3):
- No workflow holds a CP node token. `test.yml:372` is a random CP→node token, and putting a fleet credential in an Actions secret would hand it to PR code.
- So the "node-token route" is realized as a PUBLIC route gated by a **GitHub Actions OIDC token**, following the Google-OIDC-gated Gmail push route (`routes/public.py:216`).
- AC6.4's oracle is restated for OIDC below. Build THAT, not a node-token route.
## Approach (settled)
- **Route.** `POST /api/public/ci/merge-lane`, with header `Authorization: Bearer <GitHub Actions OIDC JWT>`, audience `jaah-merge-lane`. Body `{tree: "<40 hex>", event: "push"|"pull_request"}`.
- Responds 200 `{lane_enabled, certified, certificate: {tree_hash, develop_sha, certified_at, pr_numbers} | null}`.
- A bad, expired or foreign-audience token gets 401.
- **Verifier.** New `apps/api/app/ci_oidc.py`:
- verify the signature against GitHub's JWKS (cached);
- check `iss = https://token.actions.githubusercontent.com`, `aud` and expiry;
- map the `repository` claim to projects whose GitHub `repo_url` slug equals it.
- **Answer semantics.**
- `lane_enabled` means some project on that repository is lane-active.
- `certified` means a certificate exists for `(project, tree, sha256(current gate_full_cmd))`. With two projects on one repo, either one counts.
- Reuse the public-route rate-limit middleware.
- **Workflow** (`.github/workflows/test.yml`; read §6):
- A new job `merge-lane` with `permissions: {contents: read, id-token: write}`:
- it checks out and computes `git rev-parse HEAD^{tree}` (the merge ref on a PR);
- it mints the OIDC token and calls the route with a 10 s timeout, the URL taken from `vars.JAAH_LANE_CP_URL`, default `https://app.jaah.ai`;
- it outputs `lane_enabled` and `certified`, and ANY failure makes both `false` (fail-open).
- ONLY `test-stack` gains `needs: merge-lane` and `if: always() && !(github.event_name == 'push' && needs.merge-lane.outputs.lane_enabled == 'true')`.
- `test-stack` ALWAYS runs on PRs, never as a job-level skip:
- its first step prints `certified: tree <h> at <t> by develop <sha> (PRs …)` on a hit;
- every later step carries `if: needs.merge-lane.outputs.certified != 'true' || github.event_name != 'pull_request'`.
- `workflow_dispatch` behaves like push.
- `test-jaah-scheduler`, `audit-jaah-deps` and `terraform-validate` are unchanged.
## Acceptance (verbatim; AC6.4 as restated by the architecture)
- AC6.1 In `.github/workflows/test.yml`, the push-to-develop run does **not** run `./dc check full` when the lane is ON for this repo's project. A first cheap job asks the CP and the suite jobs are skipped on "ON". On "OFF" **or an unreachable/erroring CP**, the push run behaves exactly as today (fail open to running).
- AC6.2 A promotion PR's `test-stack` (PR into master) first asks the CP whether the **merge-ref tree** (`git rev-parse HEAD^{tree}` of the checked-out merge ref), together with the current `gate_full_cmd`, is certified green.
- Yes → it exits 0 and logs the certificate (tree hash, when, which develop merge).
- No, unreachable, or error → it runs the suite exactly as today.
- This holds whether the lane is ON or OFF; a certificate only exists if the lane produced it.
- AC6.3 `tenancy-oracles`, `promotion-provenance`, `validate.yml` `gate`, both rulesets and `jaah-deploy.yml` are **unchanged** (a diff oracle shows no edits to those files or jobs). Jaah Deploy still fires on every develop push.
- AC6.4 (architecture restatement): the route answers only "certified yes/no + certificate metadata" for the token's own repository.
- A valid token for a FOREIGN repository gets `{lane_enabled:false, certified:false, certificate:null}` (never another repo's certificate).
- A token with the wrong audience gets 401.
- Two projects on one repository answer certified when either holds a certificate for the tree at its own current gate_cmd_hash.
Credentials & reach:
- Route and verifier tests (AC6.4) and the workflow-logic tests: none beyond the worktree's own `./dc` stack (verified).
- AC6.1 OFF mode + AC6.2 uncertified path (read the job list and logs): your own forge login via `jaah-forge` (the standard broker path); GitHub reachable. Status: verified. `token.actions.githubusercontent.com` answered 200 from h2. With the route absent on prod or the lane OFF, the suite runs; this is observable on your own PR's runs.
- AC6.1 ON mode (develop push skips `test-stack`): needs the route deployed on prod `app.jaah.ai` AND the lane ON for jaah there. **Deferred**: the route reaches prod only after the human-gated `master` promotion, and enabling the lane on prod is the operator's call. Prove it with a workflow-logic test that feeds the job outputs.
- AC6.2 certified-skip on a real promotion PR: needs the route on prod plus a lane certificate there. **Deferred**: the same human-gated promotion. Prove it with a route test plus a workflow-step script test; the operator re-runs the oracle on the first promotion after release.
Out of scope:
- Epic (verbatim): "GitHub merge queue / merge_group; any CP-side forge calls; changing the manual develop → master promotion; cgroup tuning of GHA runners; squash merges."
- BA-derived (verbatim): "No edits to `infra/github/rulesets/*.json`, `.github/workflows/validate.yml`, `tenancy-gate.yml`, `jaah-deploy.yml` or `supply-chain.yml`, or to the `tenancy-oracles` and `promotion-provenance` jobs." "No other repos' CI files: item 6 edits jaah's own `test.yml` only."
- Do NOT:
- add a node token or any secret to Actions;
- fold `test-jaah-scheduler`/`audit-jaah-deps` into the lane (deferred);
- skip `test-stack` with a job-level `if` on PRs;
- add any forge repo/PR call to `apps/api`. The JWKS fetch is identity-provider key material, and the AC5.6 grep excludes `ci_oidc.py`.
Done means:
- AC6.4 — API tests with locally signed test JWTs against a stubbed JWKS:
- a valid own-repo token with a certificate → `certified:true` plus metadata;
- a valid FOREIGN-repo token → `{lane_enabled:false, certified:false, certificate:null}`;
- wrong audience → 401;
- expired → 401;
- two projects on one repo → certified when either holds it;
- a certificate under an old `gate_cmd_hash` → `certified:false`.
- AC6.1/AC6.2 — a workflow-logic test (parse `test.yml` or run the step script) shows:
- push + `lane_enabled=true` → `test-stack` skipped;
- push + false, error or timeout → it runs;
- PR + `certified=true` → only the certificate step runs, and it prints the certified line;
- PR + false → the full steps run.
- AC6.1 OFF mode / AC6.2 uncertified path, live: your own PR's run and the develop push after merge show the `merge-lane` job finishing with `lane_enabled=false` and `test-stack` running the suite.
- AC6.1 ON mode live (deferred — needs the route on prod plus the lane ON for jaah; human-gated master promotion).
- AC6.2 certified-skip live (deferred — same reason).
- AC6.3 — `git diff origin/develop --stat` shows no change to `validate.yml`, `tenancy-gate.yml`, `jaah-deploy.yml`, `supply-chain.yml` or `infra/github/rulesets/`, and no change inside the `tenancy-oracles` / `promotion-provenance` job blocks.
Required checks: gate, review-lane
Outcome
CI merge-lane OIDC route + test.yml merge-lane job (fail-open). Full gate green; PR #4828 merged; Jaah Deploy ok; live develop run: lane_enabled=false, test-stack ran. Added: repo claimed by >1 org → not certified. Deferred: AC6.1 ON / AC6.2 skip live (needs prod promotion).
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)