Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 114 additions & 13 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
actions: read
contents: read

jobs:
changes:
name: cli / changes
permissions:
actions: read
contents: read
pull-requests: read
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
Expand Down Expand Up @@ -81,25 +87,120 @@ jobs:

echo "relevant=$relevant" >> "$GITHUB_OUTPUT"

# 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 reusable promotion snapshot"

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
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
reason="reusing the successful cli CI gate from next run $run_id"
return 0
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
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"
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"

Expand Down
2 changes: 1 addition & 1 deletion aidd_docs/memory/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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-<run-id>` 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. |
Original file line number Diff line number Diff line change
@@ -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. |
Original file line number Diff line number Diff line change
@@ -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. |
Loading