From b7d2bed24b3173f0c4c75214ae906e38205b4002 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:04:18 +0200 Subject: [PATCH 1/9] ci: reuse next mutation gate on promotion --- .github/workflows/cli-ci.yml | 63 +++++++++++++---- .../2026_09_09_promotion-ci-reuse/phase-1.md | 68 +++++++++++++++++++ 2 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 9d223e5ac..41ecc0fb1 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -21,6 +21,7 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: + actions: read contents: read jobs: @@ -29,6 +30,7 @@ jobs: runs-on: ubuntu-latest outputs: relevant: ${{ steps.filter.outputs.relevant }} + trusted_promotion: ${{ steps.promotion.outputs.trusted }} mutation_scopes: ${{ steps.mutation.outputs.scopes }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -81,25 +83,60 @@ jobs: echo "relevant=$relevant" >> "$GITHUB_OUTPUT" + # A promotion branch is a snapshot of next, not the live next branch. Reusing its + # mutation result is safe only when this exact snapshot already passed this workflow's + # required gate from a push to next. API trouble deliberately reads as untrusted. + - name: Check whether a promotion snapshot passed next + id: promotion + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + trusted=false + + if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ ]]; then + run_ids="$(gh api --paginate "/repos/$REPO/actions/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ + --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null || true)" + while IFS= read -r run_id; do + [[ -z "$run_id" ]] && continue + if gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \ + --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null | grep -qx true; then + trusted=true + break + fi + done <<< "$run_ids" + fi + + echo "trusted=$trusted" >> "$GITHUB_OUTPUT" + - name: Decide which mutation scopes a change can move id: mutation run: | set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - else - BASE="${{ github.event.before }}" - HEAD="${{ github.sha }}" - fi - if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then - changed="" - all=true + if [[ "${{ steps.promotion.outputs.trusted }}" == "true" ]]; then + scopes='[]' else - changed="$(git diff --name-only "$BASE" "$HEAD")" - all=false + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then + changed="" + all=true + else + changed="$(git diff --name-only "$BASE" "$HEAD")" + all=false + fi + scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)" fi - scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)" echo "mutation scopes: $scopes" echo "scopes=$scopes" >> "$GITHUB_OUTPUT" diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md new file mode 100644 index 000000000..b3c42f37d --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md @@ -0,0 +1,68 @@ +--- +status: done +--- + +# Instruction: Reuse a validated promotion snapshot + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── .github/ + └── workflows/ + └── cli-ci.yml ✏️ classify promotion snapshots, verify a prior next gate, and skip only the duplicate mutation matrix +``` + +## User Journey + +```mermaid +flowchart TD + A[system: bot opens promote/next-to-main snapshot PR] --> B{same SHA has successful push gate on next?} + B -- yes --> C[run normal merge-ref checks without mutations] + C --> D[cli / gate passes] + B -- no --> E[run normal CLI and selected mutation jobs] + E --> D +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + promotion snapshot with a successful next gate => workflow receives its head SHA: 5: system + section Happy path + trigger promotion PR => mutations skip while normal checks validate the merge ref: 5: system + section Edge case - missing proof + no successful next gate for the SHA => normal CLI and mutation jobs remain required: 5: system +``` + +## Tasks to do + +### `1)` Classify a trusted promotion snapshot + +> Prove the snapshot was already gated on `next`, fail closed otherwise. + +1. Add the least privilege needed to read workflow runs. +2. Query successful `push` runs on `next` for the exact PR head SHA and require the `cli / gate` job to have succeeded. +3. Expose an empty mutation scope list only after that proof; preserve normal scope selection otherwise. + +### `2)` Keep merge integration coverage + +> Remove only repeated source mutation testing from the PR merge ref. + +1. Skip only `cli-mutation` for a trusted promotion snapshot. +2. Preserve all existing non-mutation jobs and their gate wiring. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Only a `promote/next-to-main-` PR whose exact head SHA already has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. | +| 1 | Missing, failed, or unreadable validation proof does not skip mutations. | +| 2 | A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. | +| 2 | Ordinary pull requests retain their existing job and mutation behavior. | From d98164b1cef0610c2f2a49108a616395251dfea9 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:04:41 +0200 Subject: [PATCH 2/9] test(ci): lock promotion mutation reuse --- aidd_docs/memory/deployment.md | 2 +- .../2026_09_09_promotion-ci-reuse/phase-2.md | 66 +++++++++++++++++++ .../cli-ci-gate-covers-every-job.test.js | 46 +++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index c7a486b95..89446641d 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | | `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | -| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose | +| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. A `main` promotion PR skips mutation jobs only when its numeric `promote/next-to-main-*` snapshot SHA has already passed `cli / gate` in a successful push run on `next`; missing, failed, or unreadable proof keeps the normal scopes. All non-mutation checks still run on the PR merge ref. | | `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | | `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md new file mode 100644 index 000000000..e015bb948 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md @@ -0,0 +1,66 @@ +--- +status: done +--- + +# Instruction: Lock the workflow contract + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── scripts/ +│ └── __tests__/ +│ └── cli-ci-gate-covers-every-job.test.js ✏️ assert promotion reuse and required-gate coverage +└── aidd_docs/ + └── memory/ + └── deployment.md ✏️ describe promotion-specific mutation reuse +``` + +## User Journey + +```mermaid +flowchart TD + A[system: workflow configuration changes] --> B[static workflow contract test] + B --> C{promotion safety and gate fan-in hold?} + C -- yes --> D[CI configuration is valid] + C -- no --> E[descriptive test failure] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + CI workflow YAML and its contract test => workflow parsed: 5: system + section Happy path + run the workflow contract test => trusted promotion, fallback, retained merge checks, and gate wiring are asserted: 5: system + section Edge case - future job + add an ungated job => gate-coverage test fails: 5: system +``` + +## Tasks to do + +### `1)` Assert promotion safety structurally + +> Make regressions in the promotion fast path visible before merge. + +1. Extend the existing workflow contract test with the trusted-promotion and fallback invariants. +2. Assert a trusted promotion empties only mutation scopes and retains the existing gate fan-in. + +### `2)` Record the operating model + +> Keep deployment memory aligned with the workflow. + +1. Replace the generic CLI CI description with its promotion reuse rule and fail-closed fallback. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. | +| 2 | Deployment memory accurately states when promotion skips mutations and what still runs. | diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index eac6ce08f..f57c32ed2 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -59,3 +59,49 @@ test("the changes filter names the workflow file itself as relevant", () => { `${ownPath} must be a case of the relevance filter, on its own line` ); }); + +test("only a previously gated next snapshot skips the promotion mutation matrix", () => { + const workflow = cliCiWorkflow(); + const changes = workflow.jobs.changes; + const promotion = changes.steps.find((step) => step.id === "promotion"); + const mutation = changes.steps.find((step) => step.id === "mutation"); + + assert.equal(workflow.permissions.actions, "read"); + assert.equal(changes.outputs.trusted_promotion, "${{ steps.promotion.outputs.trusted }}"); + assert.equal(promotion.name, "Check whether a promotion snapshot passed next"); + assert.match(promotion.run, /EVENT_NAME.*pull_request/); + assert.match(promotion.run, /BASE_REF.*main/); + assert.match(promotion.run, /HEAD_REF.*\^promote\/next-to-main-\[0-9\]\+\$/); + assert.match(promotion.run, /branch=next&event=push&status=completed&head_sha=\$HEAD_SHA/); + assert.match(promotion.run, /\.conclusion == "success"/); + assert.match(promotion.run, /\.name == "cli \/ gate" and \.conclusion == "success"/); + assert.match(promotion.run, /2>\/dev\/null \|\| true/); + + assert.match(mutation.run, /steps\.promotion\.outputs\.trusted.*== "true"/); + assert.match(mutation.run, /scopes='\[\]'/); + assert.match(mutation.run, /else[\s\S]*mutation-scopes-to-run\.mjs/); + assert.equal(workflow.jobs["cli-mutation"].if, "needs.changes.outputs.mutation_scopes != '[]'"); + + // These checks validate GitHub's pull-request merge ref; they are not evidence that next's + // source snapshot passed, so mutation reuse must not turn any of them off. + for (const name of [ + "cli-typecheck", + "cli-lint", + "cli-architecture", + "cli-coverage", + "cli-smoke", + "cli-build", + "cli-knip", + "identifier-join", + "cli-jscpd", + "kanban-checks", + "windows", + ]) { + assert.deepEqual(workflow.jobs[name].needs, ["changes"], `${name} must still depend on changes`); + assert.equal( + workflow.jobs[name].if, + "needs.changes.outputs.relevant == 'true'", + `${name} must still run for a relevant promotion PR` + ); + } +}); From 7d043bb73c9589de7645cf269213f13a802ea705 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:04:49 +0200 Subject: [PATCH 3/9] docs(ci): record promotion mutation reuse --- .../2026_09_09_promotion-ci-reuse/plan.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md new file mode 100644 index 000000000..5f7826474 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md @@ -0,0 +1,34 @@ +--- +objective: "A next-to-main promotion reuses its already-passing mutation gate while still validating the merge result." +status: implemented +--- + +# Plan: Reuse validated next CI during promotion + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Remove the duplicate mutation matrix from promotion PRs without weakening normal PR gates. | +| **Source** | User request: SDLC the promotion-pipeline review towards `next`. | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Reuse a validated promotion snapshot | [`phase-1.md`](./phase-1.md) | +| 2 | Lock the workflow contract | [`phase-2.md`](./phase-2.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| https://docs.github.com/en/rest/actions/workflow-runs | Workflow runs can be filtered by branch, event, and head SHA with Actions read permission. | +| https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows | A pull-request checkout uses the synthetic merge ref, so a promotion-specific smoke can validate the merged result. | + +## Decisions + +| Decision | Why | +| --- | --- | +| Reuse only a successful `push` run for the exact snapshot SHA on `next`. | The promotion source has already satisfied `next`'s required gate; absent or unsuccessful proof must not bypass mutations. | +| Keep all non-mutation jobs on the promotion PR merge ref. | Coverage, smoke, build, and platform checks still validate the merge of release metadata from `main`. | From d32204c4a75e3daad99c2f86d43204cb49e00bd3 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:08:27 +0200 Subject: [PATCH 4/9] fix(ci): bind promotion proof to cli CI --- .github/workflows/cli-ci.yml | 2 +- scripts/__tests__/cli-ci-gate-covers-every-job.test.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 41ecc0fb1..cbb0c0a25 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -100,7 +100,7 @@ jobs: trusted=false if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ ]]; then - run_ids="$(gh api --paginate "/repos/$REPO/actions/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ + run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null || true)" while IFS= read -r run_id; do [[ -z "$run_id" ]] && continue diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index f57c32ed2..66e7fd092 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -72,7 +72,10 @@ test("only a previously gated next snapshot skips the promotion mutation matrix" assert.match(promotion.run, /EVENT_NAME.*pull_request/); assert.match(promotion.run, /BASE_REF.*main/); assert.match(promotion.run, /HEAD_REF.*\^promote\/next-to-main-\[0-9\]\+\$/); - assert.match(promotion.run, /branch=next&event=push&status=completed&head_sha=\$HEAD_SHA/); + assert.match( + promotion.run, + /actions\/workflows\/cli-ci\.yml\/runs\?branch=next&event=push&status=completed&head_sha=\$HEAD_SHA/ + ); assert.match(promotion.run, /\.conclusion == "success"/); assert.match(promotion.run, /\.name == "cli \/ gate" and \.conclusion == "success"/); assert.match(promotion.run, /2>\/dev\/null \|\| true/); From d73f1d113ba860f7264541fcff65f5554ccaac8e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:10:45 +0200 Subject: [PATCH 5/9] ci: explain promotion mutation fallback --- .github/workflows/cli-ci.yml | 32 +++++++++++++------ .../cli-ci-gate-covers-every-job.test.js | 4 ++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index cbb0c0a25..4d4f90bf8 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -98,20 +98,32 @@ jobs: run: | set -euo pipefail trusted=false + reason="not a promotion snapshot" if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ ]]; then - run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ - --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null || true)" - while IFS= read -r run_id; do - [[ -z "$run_id" ]] && continue - if gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \ - --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null | grep -qx true; then - trusted=true - break - fi - done <<< "$run_ids" + reason="no successful cli CI push run found for the snapshot SHA" + if run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ + --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null)"; then + while IFS= read -r run_id; do + [[ -z "$run_id" ]] && continue + if gate="$(gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \ + --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null)"; then + if [[ "$gate" == "true" ]]; then + trusted=true + reason="reusing the successful cli CI gate from next run $run_id" + break + fi + reason="cli / gate did not pass in next run $run_id" + else + reason="could not inspect cli CI run $run_id" + fi + done <<< "$run_ids" + else + reason="could not list successful cli CI push runs" + fi fi + echo "promotion mutation reuse: $reason" echo "trusted=$trusted" >> "$GITHUB_OUTPUT" - name: Decide which mutation scopes a change can move diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index 66e7fd092..a796fd582 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -78,7 +78,9 @@ test("only a previously gated next snapshot skips the promotion mutation matrix" ); assert.match(promotion.run, /\.conclusion == "success"/); assert.match(promotion.run, /\.name == "cli \/ gate" and \.conclusion == "success"/); - assert.match(promotion.run, /2>\/dev\/null \|\| true/); + assert.match(promotion.run, /reason="could not list successful cli CI push runs"/); + assert.match(promotion.run, /reason="could not inspect cli CI run \$run_id"/); + assert.match(promotion.run, /echo "promotion mutation reuse: \$reason"/); assert.match(mutation.run, /steps\.promotion\.outputs\.trusted.*== "true"/); assert.match(mutation.run, /scopes='\[\]'/); From 7571606bdd9787c1ef1160e20274c896eafe8732 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 22:11:57 +0200 Subject: [PATCH 6/9] docs(ci): record promotion review --- .../2026_09_09_promotion-ci-reuse/review.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md new file mode 100644 index 000000000..7a0c00863 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md @@ -0,0 +1,36 @@ +# Review: Reuse validated next CI during promotion + +- **Verdict**: approve +- **Diff**: `origin/next...HEAD` +- **Axes run**: code, functional, relevancy +- **Date**: 2026_09_09 +- **Findings**: 0 critical, 0 warning, 0 minor + +## Phases + +### Phase 1 — Reuse a validated promotion snapshot + +- [x] Only a `promote/next-to-main-` PR whose exact head SHA already has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. — `.github/workflows/cli-ci.yml:103-127` +- [x] Missing, failed, or unreadable validation proof does not skip mutations. — `.github/workflows/cli-ci.yml:100-127` +- [x] A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. — `.github/workflows/cli-ci.yml:143-476` +- [x] Ordinary pull requests retain their existing job and mutation behavior. — `.github/workflows/cli-ci.yml:133-151` + +### Phase 2 — Lock the workflow contract + +- [x] The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:15-26,63-107` +- [x] Deployment memory accurately states when promotion skips mutations and what still runs. — `aidd_docs/memory/deployment.md:12` + +## Findings + +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | +| — | — | — | — | None. | — | + +## Verification + +| Metric | Value | +| --- | --- | +| Verified | 100% (6/6) | +| Files checked | `.github/workflows/cli-ci.yml`, `scripts/__tests__/cli-ci-gate-covers-every-job.test.js`, `aidd_docs/memory/deployment.md`, `.github/workflows/promote.yml`, `.github/rulesets/main.json`, `.github/rulesets/next.json` | +| Unchecked | none | +| Unplanned | none | From dd8808b5e8d1e5f97a858630d5bad882a26667ff Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 06:48:53 +0200 Subject: [PATCH 7/9] fix(ci): prove promotion mutation reuse --- .github/workflows/cli-ci.yml | 65 ++++++++++++++-- aidd_docs/memory/deployment.md | 2 +- .../2026_09_09_promotion-ci-reuse/phase-3.md | 69 +++++++++++++++++ .../2026_09_09_promotion-ci-reuse/phase-4.md | 76 +++++++++++++++++++ .../2026_09_09_promotion-ci-reuse/plan.md | 13 +++- .../cli-ci-gate-covers-every-job.test.js | 31 +++++++- 6 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md create mode 100644 aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 4d4f90bf8..e07161b41 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -23,6 +23,7 @@ concurrency: permissions: actions: read contents: read + pull-requests: read jobs: changes: @@ -83,24 +84,28 @@ jobs: echo "relevant=$relevant" >> "$GITHUB_OUTPUT" - # A promotion branch is a snapshot of next, not the live next branch. Reusing its - # mutation result is safe only when this exact snapshot already passed this workflow's - # required gate from a push to next. API trouble deliberately reads as untrusted. - - name: Check whether a promotion snapshot passed next + # A promotion branch is a snapshot of next, not the live next branch. Reuse only proves + # a tree already gated on next: every missing Git or API proof falls back to mutations. + - name: Check whether a promotion snapshot or main merge passed next id: promotion env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + CURRENT_SHA: ${{ github.sha }} BASE_REF: ${{ github.event.pull_request.base.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} run: | set -euo pipefail trusted=false - reason="not a promotion snapshot" + reason="not a reusable promotion snapshot" - if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ ]]; then + has_successful_next_gate() { + local run_ids run_id gate reason="no successful cli CI push run found for the snapshot SHA" if run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null)"; then @@ -109,9 +114,8 @@ jobs: if gate="$(gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \ --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null)"; then if [[ "$gate" == "true" ]]; then - trusted=true reason="reusing the successful cli CI gate from next run $run_id" - break + return 0 fi reason="cli / gate did not pass in next run $run_id" else @@ -121,6 +125,51 @@ jobs: else reason="could not list successful cli CI push runs" fi + return 1 + } + + # GitHub tests pull requests at a synthetic merge ref. The source gate may be reused + # only if main is already in the snapshot and that merge ref has the same file tree. + if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ && "$HEAD_REPO" == "$REPO" ]]; then + if ! git merge-base --is-ancestor "$BASE_SHA" "$HEAD_SHA" 2>/dev/null; then + reason="main base is not an ancestor of the promotion snapshot" + elif ! snapshot_tree="$(git rev-parse "$HEAD_SHA^{tree}" 2>/dev/null)" || ! merge_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then + reason="could not prove the promotion merge tree" + elif [[ "$merge_tree" != "$snapshot_tree" ]]; then + reason="promotion merge tree differs from the snapshot" + elif has_successful_next_gate; then + trusted=true + fi + # A main push is reusable only for the exact merge of a numbered, same-repository + # promotion PR. Parent two is its source snapshot; neither a squash nor an unrelated + # merge can satisfy this proof. + elif [[ "$EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; then + if ! parents="$(git rev-list --parents -n 1 "$CURRENT_SHA" 2>/dev/null)" || ! read -r merge_sha first_parent snapshot_sha extra_parent <<< "$parents" || [[ "$merge_sha" != "$CURRENT_SHA" || -z "$first_parent" || -z "$snapshot_sha" || -n "${extra_parent:-}" ]]; then + reason="main commit is not a two-parent merge" + elif ! snapshot_tree="$(git rev-parse "$snapshot_sha^{tree}" 2>/dev/null)" || ! main_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then + reason="could not read the main merge or snapshot tree" + elif [[ "$main_tree" != "$snapshot_tree" ]]; then + reason="main merge tree differs from the promotion snapshot" + elif ! prs="$(gh api "/repos/$REPO/commits/$CURRENT_SHA/pulls" \ + --jq '.[] | select(.base.ref == "main" and (.head.ref | test("^promote/next-to-main-[0-9]+$")) and .merged_at != null) | [.base.repo.full_name, .head.repo.full_name, .head.ref, .head.sha, .merged_at, .merge_commit_sha] | @tsv' 2>/dev/null)"; then + reason="could not inspect pull requests associated with the main commit" + else + matching_prs=0 + while IFS=$'\t' read -r base_repo head_repo head_ref pr_head_sha merged_at merge_commit_sha; do + if [[ "$base_repo" == "$REPO" && "$head_repo" == "$REPO" && "$head_ref" =~ ^promote/next-to-main-[0-9]+$ && "$pr_head_sha" == "$snapshot_sha" && -n "$merged_at" && "$merge_commit_sha" == "$CURRENT_SHA" ]]; then + ((matching_prs += 1)) + fi + done <<< "$prs" + + if [[ "$matching_prs" -ne 1 ]]; then + reason="no unique matching promotion pull request proved this main merge" + else + HEAD_SHA="$snapshot_sha" + if has_successful_next_gate; then + trusted=true + fi + fi + fi fi echo "promotion mutation reuse: $reason" diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 89446641d..c11fbe972 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | | `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | -| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. A `main` promotion PR skips mutation jobs only when its numeric `promote/next-to-main-*` snapshot SHA has already passed `cli / gate` in a successful push run on `next`; missing, failed, or unreadable proof keeps the normal scopes. All non-mutation checks still run on the PR merge ref. | +| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. | | `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | | `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md new file mode 100644 index 000000000..d85d6e8e5 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md @@ -0,0 +1,69 @@ +--- +status: done +--- + +# Instruction: Bind promotion reuse to the tested merge tree + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .github/ +│ └── workflows/ +│ └── cli-ci.yml ✏️ fail closed when main is not already contained by the promotion snapshot +└── scripts/ + └── __tests__/ + └── cli-ci-gate-covers-every-job.test.js ✏️ lock the ancestry requirement +``` + +## User Journey + +```mermaid +flowchart TD + A[promotion snapshot PR to main] --> B{main base is ancestor of snapshot?} + B -- yes, next gate passed --> C[skip duplicate mutations] + B -- no --> D[run selected mutation scopes] + C --> E[run all merge-ref checks] + D --> E +``` + +## Test Scope + +```mermaid +flowchart TD + A[workflow contract] --> B{ancestry guard present before empty scopes?} + B -- yes --> C[contract passes] + B -- no --> D[contract fails] +``` + +## Wireframe + +```txt +No UI: GitHub Actions workflow behavior only. +``` + +## Tasks to do + +### `1)` Prove promotion content is unchanged + +> Reuse a `next` mutation result only when the PR merge cannot add untested main content. + +1. Read the promotion PR base and snapshot SHAs from the event. +2. Require the base SHA to be an ancestor of the snapshot before marking a promotion trusted. +3. Keep every failed or unreadable Git proof on the normal mutation path. + +### `2)` Lock the fail-closed guard + +> Make a future removal of the ancestry check fail locally. + +1. Extend the workflow contract test with the base-to-snapshot proof and fallback expectation. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. | +| 1 | A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. | +| 2 | The contract test fails when the ancestry proof or fail-closed fallback is removed. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md new file mode 100644 index 000000000..7eeb9eea7 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md @@ -0,0 +1,76 @@ +--- +status: done +--- + +# Instruction: Reuse the proven promotion merge on main + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .github/ +│ └── workflows/ +│ └── cli-ci.yml ✏️ recognize a proven promotion merge on main and skip only duplicate mutations +├── scripts/ +│ └── __tests__/ +│ └── cli-ci-gate-covers-every-job.test.js ✏️ lock main-merge proof and unchanged fallback +└── aidd_docs/ + └── memory/ + └── deployment.md ✏️ document both trusted reuse paths +``` + +## User Journey + +```mermaid +flowchart TD + A[push merge commit to main] --> B{matching promotion PR?} + B -- no --> C[run selected mutations] + B -- yes --> D{merge tree equals proven snapshot and next gate passed?} + D -- no --> C + D -- yes --> E[skip duplicate mutations] + C --> F[run normal non-mutation checks] + E --> F +``` + +## Test Scope + +```mermaid +flowchart TD + A[workflow contract] --> B{promotion PR identity, tree equality, and next gate required?} + B -- yes --> C[contract passes] + B -- no --> D[contract fails] +``` + +## Wireframe + +```txt +No UI: GitHub Actions workflow behavior only. +``` + +## Tasks to do + +### `1)` Classify a trusted main promotion merge + +> Skip mutations on main only for the exact content already gated on next. + +1. Identify the merged promotion PR and its snapshot with read-only repository data. +2. Require the final main commit tree to equal that snapshot's tree. +3. Reuse only the successful `cli / gate` run for the snapshot's exact SHA on `next`. + +### `2)` Preserve normal behavior and explain it + +> Keep every ambiguous, failed, or unrelated main push fully protected. + +1. Route missing PR identity, unavailable API data, mismatched trees, and failed gates to normal mutation scope selection. +2. Assert the trusted-main contract structurally and update deployment memory. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Only a merge from the numbered promotion branch whose final tree equals the snapshot may skip mutations on `main`. | +| 1 | The matching snapshot must have a successful `push` `cli / gate` on `next`. | +| 2 | Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. | +| 2 | Non-mutation jobs and gate fan-in remain unchanged for all events. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md index 5f7826474..ffc8c9e04 100644 --- a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md @@ -1,16 +1,16 @@ --- -objective: "A next-to-main promotion reuses its already-passing mutation gate while still validating the merge result." +objective: "A next-to-main promotion and its resulting main merge reuse an already-passing mutation gate only when the exact tested tree is proven unchanged." status: implemented --- -# Plan: Reuse validated next CI during promotion +# Plan: Reuse validated next CI through promotion ## Overview | Field | Value | | --- | --- | -| **Goal** | Remove the duplicate mutation matrix from promotion PRs without weakening normal PR gates. | -| **Source** | User request: SDLC the promotion-pipeline review towards `next`. | +| **Goal** | Remove duplicate mutation matrices from promotion and its resulting `main` merge without weakening any gate. | +| **Source** | User request: strict SDLC after the CI-wide challenge. | ## Phases @@ -18,6 +18,8 @@ status: implemented | --- | --- | --- | | 1 | Reuse a validated promotion snapshot | [`phase-1.md`](./phase-1.md) | | 2 | Lock the workflow contract | [`phase-2.md`](./phase-2.md) | +| 3 | Bind promotion reuse to the tested merge tree | [`phase-3.md`](./phase-3.md) | +| 4 | Reuse the proven promotion merge on `main` | [`phase-4.md`](./phase-4.md) | ## Resources @@ -25,6 +27,7 @@ status: implemented | --- | --- | | https://docs.github.com/en/rest/actions/workflow-runs | Workflow runs can be filtered by branch, event, and head SHA with Actions read permission. | | https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows | A pull-request checkout uses the synthetic merge ref, so a promotion-specific smoke can validate the merged result. | +| Repository evidence: PR #809 | Its merge tree equalled the promoted `next` snapshot, but that invariant was convention-only rather than enforced. | ## Decisions @@ -32,3 +35,5 @@ status: implemented | --- | --- | | Reuse only a successful `push` run for the exact snapshot SHA on `next`. | The promotion source has already satisfied `next`'s required gate; absent or unsuccessful proof must not bypass mutations. | | Keep all non-mutation jobs on the promotion PR merge ref. | Coverage, smoke, build, and platform checks still validate the merge of release metadata from `main`. | +| Require the promotion PR base to be an ancestor of its snapshot before reuse. | A previously green `next` run is insufficient if `main` contributes untested content to the PR merge tree. | +| On a `main` push, require both the matching promotion PR and tree equality with its snapshot. | A matching source SHA alone cannot prove that the merge commit carries the same content. | diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index a796fd582..8890d0ed5 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -60,18 +60,24 @@ test("the changes filter names the workflow file itself as relevant", () => { ); }); -test("only a previously gated next snapshot skips the promotion mutation matrix", () => { +test("only a proven next snapshot tree skips the promotion or main mutation matrix", () => { const workflow = cliCiWorkflow(); const changes = workflow.jobs.changes; const promotion = changes.steps.find((step) => step.id === "promotion"); const mutation = changes.steps.find((step) => step.id === "mutation"); assert.equal(workflow.permissions.actions, "read"); + assert.equal(workflow.permissions["pull-requests"], "read"); assert.equal(changes.outputs.trusted_promotion, "${{ steps.promotion.outputs.trusted }}"); - assert.equal(promotion.name, "Check whether a promotion snapshot passed next"); + assert.equal(promotion.name, "Check whether a promotion snapshot or main merge passed next"); assert.match(promotion.run, /EVENT_NAME.*pull_request/); assert.match(promotion.run, /BASE_REF.*main/); + assert.equal(promotion.env.HEAD_REPO, "${{ github.event.pull_request.head.repo.full_name }}"); assert.match(promotion.run, /HEAD_REF.*\^promote\/next-to-main-\[0-9\]\+\$/); + assert.match(promotion.run, /HEAD_REPO" == "\$REPO/); + assert.match(promotion.run, /git merge-base --is-ancestor "\$BASE_SHA" "\$HEAD_SHA"/); + assert.match(promotion.run, /git rev-parse "\$CURRENT_SHA\^\{tree\}"/); + assert.match(promotion.run, /"\$merge_tree" != "\$snapshot_tree"/); assert.match( promotion.run, /actions\/workflows\/cli-ci\.yml\/runs\?branch=next&event=push&status=completed&head_sha=\$HEAD_SHA/ @@ -82,13 +88,30 @@ test("only a previously gated next snapshot skips the promotion mutation matrix" assert.match(promotion.run, /reason="could not inspect cli CI run \$run_id"/); assert.match(promotion.run, /echo "promotion mutation reuse: \$reason"/); + // Main can skip mutations only for the exact two-parent promotion merge whose tree is the + // snapshot already gated on next. Any git or associated-PR proof mismatch remains untrusted. + assert.match(promotion.run, /EVENT_NAME" == "push" && "\$GITHUB_REF" == "refs\/heads\/main"/); + assert.match(promotion.run, /git rev-list --parents -n 1 "\$CURRENT_SHA"/); + assert.match(promotion.run, /-z "\$snapshot_sha" \|\| -n "\$\{extra_parent:-\}"/); + assert.match(promotion.run, /git rev-parse "\$snapshot_sha\^\{tree\}"/); + assert.match(promotion.run, /"\$main_tree" != "\$snapshot_tree"/); + assert.match(promotion.run, /\/repos\/\$REPO\/commits\/\$CURRENT_SHA\/pulls/); + assert.match(promotion.run, /\.base\.ref == "main"/); + assert.match(promotion.run, /\.head\.ref \| test\("\^promote\/next-to-main-\[0-9\]\+\$"\)/); + assert.match(promotion.run, /\.merged_at != null/); + assert.match(promotion.run, /"\$base_repo" == "\$REPO"/); + assert.match(promotion.run, /"\$head_repo" == "\$REPO"/); + assert.match(promotion.run, /"\$pr_head_sha" == "\$snapshot_sha"/); + assert.match(promotion.run, /"\$merge_commit_sha" == "\$CURRENT_SHA"/); + assert.match(promotion.run, /"\$matching_prs" -ne 1/); + assert.match(promotion.run, /HEAD_SHA="\$snapshot_sha"/); + assert.match(mutation.run, /steps\.promotion\.outputs\.trusted.*== "true"/); assert.match(mutation.run, /scopes='\[\]'/); assert.match(mutation.run, /else[\s\S]*mutation-scopes-to-run\.mjs/); assert.equal(workflow.jobs["cli-mutation"].if, "needs.changes.outputs.mutation_scopes != '[]'"); - // These checks validate GitHub's pull-request merge ref; they are not evidence that next's - // source snapshot passed, so mutation reuse must not turn any of them off. + // These checks validate the current PR merge ref or main commit; reuse never turns them off. for (const name of [ "cli-typecheck", "cli-lint", From 92941865a9c0f59ee7176a7e0803e910b3231ca3 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 06:55:03 +0200 Subject: [PATCH 8/9] fix(ci): scope promotion read access --- .github/workflows/cli-ci.yml | 5 ++++- scripts/__tests__/cli-ci-gate-covers-every-job.test.js | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index e07161b41..3799a9e56 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -23,11 +23,14 @@ concurrency: permissions: actions: read contents: read - pull-requests: read jobs: changes: name: cli / changes + permissions: + actions: read + contents: read + pull-requests: read runs-on: ubuntu-latest outputs: relevant: ${{ steps.filter.outputs.relevant }} diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index 8890d0ed5..d67cee4ab 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -67,7 +67,13 @@ test("only a proven next snapshot tree skips the promotion or main mutation matr const mutation = changes.steps.find((step) => step.id === "mutation"); assert.equal(workflow.permissions.actions, "read"); - assert.equal(workflow.permissions["pull-requests"], "read"); + assert.equal(workflow.permissions.contents, "read"); + assert.equal(workflow.permissions["pull-requests"], undefined); + assert.deepEqual(changes.permissions, { + actions: "read", + contents: "read", + "pull-requests": "read", + }); assert.equal(changes.outputs.trusted_promotion, "${{ steps.promotion.outputs.trusted }}"); assert.equal(promotion.name, "Check whether a promotion snapshot or main merge passed next"); assert.match(promotion.run, /EVENT_NAME.*pull_request/); From 5a9d4ee81ef3543e71e7df9afcbfbfbfea9dfcf0 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 06:56:59 +0200 Subject: [PATCH 9/9] docs(ci): record strict promotion review --- .../2026_09_09_promotion-ci-reuse/review.md | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md index 7a0c00863..52d18a63d 100644 --- a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md @@ -1,25 +1,38 @@ -# Review: Reuse validated next CI during promotion +# Review: Reuse validated next CI through promotion - **Verdict**: approve -- **Diff**: `origin/next...HEAD` +- **Diff**: `origin/next...92941865` - **Axes run**: code, functional, relevancy -- **Date**: 2026_09_09 +- **Date**: 2026_09_10 - **Findings**: 0 critical, 0 warning, 0 minor ## Phases ### Phase 1 — Reuse a validated promotion snapshot -- [x] Only a `promote/next-to-main-` PR whose exact head SHA already has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. — `.github/workflows/cli-ci.yml:103-127` -- [x] Missing, failed, or unreadable validation proof does not skip mutations. — `.github/workflows/cli-ci.yml:100-127` -- [x] A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. — `.github/workflows/cli-ci.yml:143-476` -- [x] Ordinary pull requests retain their existing job and mutation behavior. — `.github/workflows/cli-ci.yml:133-151` +- [x] Only a same-repository `promote/next-to-main-` PR whose exact head SHA has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. — `.github/workflows/cli-ci.yml:110-145,185-202` +- [x] Missing, failed, or unreadable validation proof does not skip mutations. — `.github/workflows/cli-ci.yml:107-131,136-145,185-202` +- [x] A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:120-140` +- [x] Ordinary pull requests retain their existing job and mutation behavior. — `.github/workflows/cli-ci.yml:136-145,185-202` ### Phase 2 — Lock the workflow contract -- [x] The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:15-26,63-107` +- [x] The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:63-140` - [x] Deployment memory accurately states when promotion skips mutations and what still runs. — `aidd_docs/memory/deployment.md:12` +### Phase 3 — Bind promotion reuse to the tested merge tree + +- [x] A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. — `.github/workflows/cli-ci.yml:136-145,185-202` +- [x] A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. — `.github/workflows/cli-ci.yml:136-145` +- [x] The contract test fails when the ancestry proof or fail-closed fallback is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:84,115-117` + +### Phase 4 — Reuse the proven promotion merge on `main` + +- [x] Only a two-parent merge from the numbered, same-repository promotion branch whose final tree equals the snapshot may skip mutations on `main`. — `.github/workflows/cli-ci.yml:149-175,185-202` +- [x] The matching snapshot must have a successful `push` `cli / gate` on `next`. — `.github/workflows/cli-ci.yml:110-131,170-172` +- [x] Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. — `.github/workflows/cli-ci.yml:107-108,149-175,185-202` +- [x] Non-mutation jobs and gate fan-in remain unchanged for all events. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:12-27,120-140` + ## Findings | Sev | Kind | Phase | Location | Issue | Fix | @@ -30,7 +43,7 @@ | Metric | Value | | --- | --- | -| Verified | 100% (6/6) | -| Files checked | `.github/workflows/cli-ci.yml`, `scripts/__tests__/cli-ci-gate-covers-every-job.test.js`, `aidd_docs/memory/deployment.md`, `.github/workflows/promote.yml`, `.github/rulesets/main.json`, `.github/rulesets/next.json` | +| Verified | 100% (13/13) | +| Files checked | `.github/workflows/cli-ci.yml`, `scripts/__tests__/cli-ci-gate-covers-every-job.test.js`, `aidd_docs/memory/deployment.md`, `.github/workflows/promote.yml`, `.github/rulesets/main.json`, `.github/rulesets/next.json`, `phase-1.md`, `phase-2.md`, `phase-3.md`, `phase-4.md` | | Unchecked | none | | Unplanned | none |