diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b2b29..5b4796f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: code_changed: ${{ steps.classify.outputs.code_changed }} steps: - name: Checkout full history - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Classify changed paths (fail closed on unknown/empty diff) @@ -73,6 +73,24 @@ jobs: echo "code_changed=true" >> "$GITHUB_OUTPUT" fi printf 'Changed files:\n%s\n' "$files" + # Every action reference is pinned to a commit SHA. This runs in the + # `changes` job — which every push and pull request executes, and + # which the `fmt` and `gate` jobs depend on — so an unpinned action + # fails the workflow before any job builds or publishes anything. + - name: Every action is pinned to a commit SHA + run: | + python3 scripts/validate_workflow_pins.py .github/workflows + bash scripts/validate_workflow_pins-selftest.sh + # The release path — the script that stages the artifacts and builds + # the `gh release create` call, and the rule that decides whether a + # tag is a production release or a rehearsal — is exercised here + # against a stub `gh`, because in the workflow it runs once, on a + # tag, with no test around it. + - name: The release path is exercised + run: | + bash tests/release-assets-selftest.sh + bash scripts/classify_release_mode-selftest.sh + bash scripts/validate_release_aggregate-selftest.sh docs: name: docs-only-check runs-on: ubuntu-latest @@ -80,7 +98,7 @@ jobs: if: needs.changes.outputs.docs_only == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate whitespace and merge markers run: | set -euo pipefail @@ -95,9 +113,9 @@ jobs: if: needs.changes.outputs.code_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt - name: Check formatting @@ -111,9 +129,9 @@ jobs: if: needs.changes.outputs.code_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt - name: Install Aegis @@ -128,24 +146,24 @@ jobs: - name: Install wasm32 target run: rustup target add wasm32-unknown-unknown - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/registry key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/git key: cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache build artifacts - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: target key: cargo-build-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: cargo-build-${{ runner.os }}- - name: Cache cargo-llvm-cov id: cache-llvc - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/bin/cargo-llvm-cov key: cargo-llvm-cov-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} @@ -154,7 +172,7 @@ jobs: run: cargo install cargo-llvm-cov --locked - name: Cache trunk id: cache-trunk - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/bin/trunk key: trunk-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index cbfc4b5..d3743d2 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -3,10 +3,14 @@ name: Deploy FireCraft marketing site on: push: branches: [main] + # The trigger is the deployed content, not this file. The workflow's own + # source is not part of the Pages deployment — the deploy step publishes + # `site/` — so listing it here made every edit to this file a production + # publication. Production deploys stay tied to a change in what is + # published; a deliberate redeploy is `workflow_dispatch`. paths: - "site/**" - "wrangler.toml" - - ".github/workflows/deploy-site.yml" workflow_dispatch: permissions: @@ -22,7 +26,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate static site run: | test -s site/index.html @@ -33,7 +37,7 @@ jobs: grep -q 'prefers-reduced-motion' site/styles.css grep -q 'loadRelease' site/app.js - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27d83e2..c1ffa82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,16 @@ name: Release on: push: tags: ["v*"] + # A manual run is a rehearsal of the release path. It can never cut a + # production release: the mode classifier rejects a production-shaped + # tag here (see the "Classify the release mode" step), and the + # rehearsal release is a prerelease titled `TEST `. + workflow_dispatch: + inputs: + rehearsal_tag: + description: "Disposable rehearsal tag, e.g. v0.2.6-dryrun.1 (never a production tag)" + required: true + type: string # Least privilege by default (read-only checkout + artifact upload); # the `release` job widens to `contents: write` because `gh release @@ -42,15 +52,36 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Determine the release version + # The version every artifact carries is the workspace version — + # not the tag. The tag names what is being released; the version + # names what was built. On a tag push the two agree (xtask's + # manifest generator refuses to build a tag that disagrees with + # the tree), and on a rehearsal the tag is a disposable name + # while the artifacts still describe the version they carry. + id: version + run: | + set -euo pipefail + version=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "xtask") | .version') + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "::error::could not read the workspace version from cargo metadata" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "the workspace builds version $version" - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} @@ -136,13 +167,13 @@ jobs: - name: Zip web bundle env: - TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} run: | # The zip name must match the checksum step's path exactly — - # "${TAG}" keeps the "v" (v0.2.2) while the artifacts are - # named without it (0.2.2), matching the native set. + # the artifacts are named by the version they carry, without + # the tag's "v" prefix. cd dist/web - zip -qr "../firecraft-web-${TAG#v}.zip" . + zip -qr "../firecraft-web-${VERSION}.zip" . cd ../.. - name: Generate checksums @@ -153,16 +184,16 @@ jobs: # Bare filenames (sed strips the dist/ prefix) so the aggregate # job's `sha256sum -c` works against the flat artifacts/ dir. ( - sha256sum "dist/native/firecraft-${TAG#v}-linux-x86_64.tar.gz" - sha256sum "dist/native/firecraft-cli-${TAG#v}-linux-x86_64.tar.gz" - sha256sum "dist/native/firecraft_${TAG#v}_amd64.deb" - sha256sum "dist/firecraft-web-${TAG#v}.zip" - sha256sum "dist/source/firecraft-${TAG#v}-source.tar.gz" - sha256sum "dist/sbom/firecraft-${TAG#v}-sbom.cdx.json" + sha256sum "dist/native/firecraft-${VERSION}-linux-x86_64.tar.gz" + sha256sum "dist/native/firecraft-cli-${VERSION}-linux-x86_64.tar.gz" + sha256sum "dist/native/firecraft_${VERSION}_amd64.deb" + sha256sum "dist/firecraft-web-${VERSION}.zip" + sha256sum "dist/source/firecraft-${VERSION}-source.tar.gz" + sha256sum "dist/sbom/firecraft-${VERSION}-sbom.cdx.json" sha256sum dist/native/MANIFEST.sha256 ) | sed 's# dist/native/# #; s# dist/source/# #; s# dist/sbom/# #; s# dist/# #' > dist/RELEASE_SHA256.txt env: - TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} - name: Generate release manifest # The machine-readable release manifest (T27-54): one JSON @@ -178,7 +209,7 @@ jobs: cargo run --quiet --package xtask -- dist --verify-manifest - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-artifacts path: | @@ -212,10 +243,10 @@ jobs: # yet hold a .git, so a checkout placed after the download erases # the artifacts it just downloaded. - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-artifacts path: artifact-tree/ @@ -237,127 +268,97 @@ jobs: # required artifact, misnamed, empty, or whose digests disagree # with RELEASE_SHA256.txt — before gh release create runs. The # release manifest is checked against the same assets: it must - # carry the tag's version, list every published asset, and - # describe each one with the digest and size the asset actually - # has. + # carry the build's version, list every published asset, describe + # each one with the digest and size the asset actually has, and + # represent every required platform. + # + # The gate lives in scripts/validate_release_aggregate.sh so it + # can be exercised by its own self-test: in the workflow it runs + # once, on a tag, with nothing around it to tell it apart from a + # run that is merely quiet. + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + bash scripts/validate_release_aggregate.sh artifacts "$VERSION" + + - name: Classify the release mode + # The tag decides whether this run cuts a release or rehearses + # one. The rule lives in scripts/classify_release_mode.sh and + # fails closed on every tag it does not recognise. A manual run + # may only rehearse: a production release requires a tag push. + id: mode env: - TAG: ${{ github.ref_name }} + EVENT: ${{ github.event_name }} + PUSHED_TAG: ${{ github.ref_name }} + REHEARSAL_TAG: ${{ inputs.rehearsal_tag }} + VERSION: ${{ needs.build.outputs.version }} run: | set -euo pipefail - # Without nullglob an unmatched glob expands to the literal - # pattern, which would be validated as a file name. - shopt -s nullglob - expected=( - "firecraft-${TAG#v}-linux-x86_64.tar.gz" - "firecraft-cli-${TAG#v}-linux-x86_64.tar.gz" - "firecraft_${TAG#v}_amd64.deb" - "firecraft-web-${TAG#v}.zip" - "firecraft-${TAG#v}-source.tar.gz" - "firecraft-${TAG#v}-sbom.cdx.json" - "MANIFEST.sha256" - "RELEASE_SHA256.txt" - "release-manifest.json" - ) - fail=0 - for name in "${expected[@]}"; do - path="artifacts/$name" - if [ ! -f "$path" ]; then - echo "::error::required artifact missing: $name" - fail=1 - elif [ ! -s "$path" ]; then - echo "::error::required artifact empty: $name" - fail=1 + if [ "$EVENT" = workflow_dispatch ]; then + tag="$REHEARSAL_TAG" + if [ -z "$tag" ]; then + echo "::error::a manual run must name its disposable rehearsal tag (e.g. v${VERSION}-dryrun.1)" + exit 1 fi - done - # Every digested file must still match its recorded hash — - # a mismatch after download means a corrupted/mislabeled asset. - # The aggregate includes the native manifest's own digest; - # it is not recursive, so verify it along with every other file. - (cd artifacts && sha256sum -c RELEASE_SHA256.txt) || fail=1 - # The release manifest must agree with the assets it describes: - # the same version, the same digests, the same sizes, and every - # published asset listed. jq ships in the runner image. - manifest="artifacts/release-manifest.json" - if [ -f "$manifest" ]; then - if ! command -v jq >/dev/null 2>&1; then - echo "::error::jq is required to validate the release manifest" - fail=1 - else - if ! schema=$(jq -r '.schema // ""' "$manifest"); then - echo "::error::the release manifest is not valid JSON" - fail=1 - elif [ "$schema" != "firecraft.release-manifest/1" ]; then - echo "::error::the release manifest carries an unknown schema: $schema" - fail=1 - fi - if ! version=$(jq -r '.version // ""' "$manifest"); then - echo "::error::the release manifest is not valid JSON" - fail=1 - elif [ "$version" != "${TAG#v}" ]; then - echo "::error::the release manifest describes version $version, the tag is ${TAG#v}" - fail=1 - fi - # Every artifact the manifest lists must be present with the - # recorded digest and the recorded size. - if jq -r '.artifacts[] | "\(.sha256) \(.name)"' "$manifest" > "$RUNNER_TEMP/manifest.sha256"; then - (cd artifacts && sha256sum -c "$RUNNER_TEMP/manifest.sha256") || fail=1 - else - echo "::error::the release manifest lists no artifacts" - fail=1 - fi - while read -r name size; do - if [ -z "$name" ]; then - continue - fi - actual=$(stat -c%s "artifacts/$name" 2>/dev/null || echo "") - if [ "$actual" != "$size" ]; then - echo "::error::the release manifest records $size B for $name, the asset is ${actual:-missing} B" - fail=1 - fi - done < <(jq -r '.artifacts[] | "\(.name) \(.size)"' "$manifest") - # Every published asset must be listed in the manifest: a - # manifest that omits an asset is as wrong as one that - # misdescribes it. - for name in "${expected[@]}"; do - if [ "$name" != "release-manifest.json" ]; then - if ! jq -e --arg n "$name" '.artifacts | any(.name == $n)' "$manifest" >/dev/null; then - echo "::error::$name is not listed in the release manifest" - fail=1 - fi - fi - done + mode=$(bash scripts/classify_release_mode.sh "$tag" "$VERSION") + if [ "$mode" != test ]; then + echo "::error::a manual run may only rehearse — '$tag' is a production release tag" + exit 1 fi + else + tag="$PUSHED_TAG" + mode=$(bash scripts/classify_release_mode.sh "$tag" "$VERSION") fi - # Version consistency: no artifact of one version may carry a - # different version string in its name than the tag. - for f in artifacts/*; do - case "$(basename "$f")" in - *"${TAG#v}"*) ;; - *.sha256|*.txt) ;; - # The manifest's name is fixed — the release names it - # release-manifest.json — so its version is checked inside - # the document above, not in the file name. - release-manifest.json) ;; - *) echo "::error::artifact name does not carry the tag version: $(basename "$f")"; fail=1 ;; - esac - done - if [ "$fail" -ne 0 ]; then - echo "::error::aggregate validation failed — refusing to create the release" + echo "release_tag=$tag" >> "$GITHUB_OUTPUT" + echo "mode=$mode" >> "$GITHUB_OUTPUT" + echo "release tag $tag is a $mode run of version ${VERSION}" + + - name: Create the rehearsal tag + # A rehearsal tag does not exist yet: `gh release create` would + # create it at the default branch, and the release would then + # describe a commit that did not build the artifacts. The tag is + # created here, at the exact commit the build ran on, so the + # artifacts and the tag bind to one SHA. It is a disposable + # rehearsal tag: it carries the `-dryrun.` marker, the + # classifier has already accepted that shape, and an existing + # tag is never overwritten. + if: steps.mode.outputs.mode == 'test' && github.event_name == 'workflow_dispatch' + env: + RELEASE_TAG: ${{ steps.mode.outputs.release_tag }} + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/$RELEASE_TAG" >/dev/null; then + echo "::error::the tag $RELEASE_TAG already exists — refusing to overwrite it" + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "::error::the tag $RELEASE_TAG already exists on the remote — refusing to overwrite it" exit 1 fi - echo "aggregate validation ok: ${#expected[@]} required artifacts present, digests verified" + git tag "$RELEASE_TAG" "$GITHUB_SHA" + git push origin "refs/tags/$RELEASE_TAG" + echo "created the disposable rehearsal tag $RELEASE_TAG at $GITHUB_SHA" - name: Create draft release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.ref_name }} + TAG: ${{ steps.mode.outputs.release_tag }} + MODE: ${{ steps.mode.outputs.mode }} run: | set -euo pipefail - # Release body: the tracked notes in docs/releases/. Falls back - # to the checksum list only if that file is missing, so a release - # is never published with a bare SHA dump as its description. + # Release body: the tracked notes in docs/releases/. A + # production release must carry them — a release whose + # description is a checksum dump is not a release. A rehearsal + # may fall back to the checksum list, because its description + # is never the published one. notes="docs/releases/${TAG}.md" - if [ ! -f "$notes" ]; then + if [ "$MODE" = production ]; then + if [ ! -f "$notes" ]; then + echo "::error::$notes not found — a production release must carry its tracked release notes" + exit 1 + fi + elif [ ! -f "$notes" ]; then echo "::warning::${notes} not found; using checksum list as release body" notes="artifacts/RELEASE_SHA256.txt" fi @@ -366,7 +367,7 @@ jobs: # aborts the command with "unknown shorthand flag: 'a' in -a". # packaging/release-assets.sh owns that construction so it can # be tested directly instead of asserted on here. - bash packaging/release-assets.sh create "$TAG" release-assets.zlist "$notes" + bash packaging/release-assets.sh create "$TAG" release-assets.zlist "$notes" "$MODE" # Uncomment to also deploy to Cloudflare Pages on every tag push. # Requires CLOUDFLARE_API_TOKEN in repo secrets. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 70e19db..648e9ef 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -411,6 +411,13 @@ Phase 10 defects passed every desktop gate. The working pattern with `gh release create` for the same tag. GitForge carries Git refs/tags, not release assets. Release notes are tracked in `docs/releases/`; `dist/release-notes.txt` is gitignored staging and can be stale. + **Release modes:** the tag decides the mode — `vX.Y.Z` is a production + release, `vX.Y.Z-dryrun.N` is a rehearsal (`TEST`-titled prerelease), + and `scripts/classify_release_mode.sh` refuses every other shape. A + manual `workflow_dispatch` run may only rehearse. The aggregate gate is + `scripts/validate_release_aggregate.sh`; its failure modes are + demonstrated by `scripts/validate_release_aggregate-selftest.sh` + ([decision 0100](decisions/0100-release-mode-classification-and-a-tested-release-gate.md)). - **Pages:** deployment remains manual and is not part of the current tag workflow: `wrangler pages deploy dist/web --project-name firecraft-web` (authenticated via `CLOUDFLARE_API_TOKEN`; direct-upload project). Deploy diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4a54f88..73f62e1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -204,19 +204,21 @@ no git, so `xtask changelog` cannot run there (decision 0086). `.github/workflows/release.yml` fires on a `v*` tag push and runs two jobs: -1. **`build artifacts`** — `dist --native`, `dist --web`, - `dist --source`, `dist --sbom`; zips the web bundle; writes - `dist/RELEASE_SHA256.txt` over the seven digested files plus - `MANIFEST.sha256`; writes `dist/release-manifest.json` with - `dist --manifest` and re-verifies it with `dist --verify-manifest`; - uploads nine paths as one artifact. +1. **`build artifacts`** — derives the version once from `cargo + metadata`; `dist --native`, `dist --web`, `dist --source`, + `dist --sbom`; zips the web bundle; writes `dist/RELEASE_SHA256.txt` + over the seven digested files plus `MANIFEST.sha256`; writes + `dist/release-manifest.json` with `dist --manifest` and re-verifies + it with `dist --verify-manifest`; uploads nine paths as one artifact. 2. **`create draft release`** — downloads that artifact, flattens it with `packaging/release-assets.sh stage`, runs a **fail-closed - aggregate validation** (all nine required artifacts present and - non-empty, `sha256sum -c RELEASE_SHA256.txt` passing, the release - manifest agreeing with the assets it describes, and every artifact - name carrying the tag version), then creates a **draft** GitHub - release with the assets passed as positional arguments. + aggregate validation** (`scripts/validate_release_aggregate.sh`: + all nine required artifacts present and non-empty, nothing else in + the release set, `sha256sum -c RELEASE_SHA256.txt` passing, the + release manifest agreeing with the assets it describes, and every + required platform represented), classifies the tag's release mode, + then creates a **draft** GitHub release with the assets passed as + positional arguments. The workflow does **not** publish the release, does not bump versions, does not run `xtask changelog`, and does not deploy to Cloudflare Pages @@ -227,6 +229,44 @@ does not run `xtask changelog`, and does not deploy to Cloudflare Pages > by hand. The repaired workflow on this branch is unproven end to end: > its first real run is itself the receipt this repository lacks. +### Release modes + +The tag decides what a run is, and the rule is one script — +`scripts/classify_release_mode.sh` — that refuses every tag it does not +recognise: + +| Tag | Mode | What it produces | +|---|---|---| +| `vX.Y.Z` | production | a draft release titled `X.Y.Z`, described by `docs/releases/vX.Y.Z.md` | +| `vX.Y.Z-dryrun.N` | test | a **prerelease** titled `TEST X.Y.Z-dryrun.N`, described by a banner that says it must not be published | + +A production release must carry its tracked release notes; a checksum +dump is not a release description. A rehearsal may fall back to the +checksum list, because its description is never the published one. + +### Rehearsing the release path + +A manual run (`workflow_dispatch`) **may only rehearse**: it requires a +disposable rehearsal tag, and the classifier refuses a production-shaped +tag there. The rehearsal tag is created at the exact commit the build +ran on, so the artifacts and the tag bind to one SHA, and an existing +tag is never overwritten. + + # in the Actions tab: "Release" → "Run workflow" → rehearsal_tag: v0.2.6-dryrun.1 + +A rehearsal leaves a draft prerelease and a disposable tag on the +remote. Both are safe to leave; deleting tags and releases is a +maintainer action. + +### Checking the release path without cutting a release + + bash scripts/validate_release_aggregate-selftest.sh + bash scripts/classify_release_mode-selftest.sh + bash tests/release-assets-selftest.sh + +These are the failure modes of the release path, one deliberately broken +input each. They run in the backup CI's `changes` job. + ## Cloudflare Pages deploy (manual) The Pages deploy is **not** part of the release workflow — the step is diff --git a/docs/TASKS.md b/docs/TASKS.md index 0760f2b..209699d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -58,6 +58,7 @@ |---|---|---|---|---|---| | 5 | 19.6 inert-menu wiring campaign | [tasks/T5-inert-burndown.md](tasks/T5-inert-burndown.md) | `DONE — 2026-11-11` | UI/verb lane | Batches 1–17 are complete. The current pinned census is 2 inert rows of 296; eight other rows remain explicitly feature/evidence-gated and are not part of an active wiring campaign (`menus::tests::inert_count_never_rises`, `menus::tests::menu_item_total_is_pinned`). | | 194 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate (decision 0074) | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md) | `IMPLEMENTED` — the content-keyed gate and its decision record are in the tree; the gate has been exercised on this branch's tree | CI/tooling lane | **landed in `main` (#18)** and merged here; attach a run id before claiming the fleet-wide state | +| — | Release workflow hardening: every action pinned to a commit SHA, a fail-closed pin check in CI (decision 0099), a classified release mode with a fail-closed aggregate gate (decision 0100) | [decisions/0099-workflow-actions-are-pinned.md](decisions/0099-workflow-actions-are-pinned.md) · [decisions/0100-release-mode-classification-and-a-tested-release-gate.md](decisions/0100-release-mode-classification-and-a-tested-release-gate.md) | `IMPLEMENTED` — the 19 action references are pinned; the aggregate gate is a tested script that refuses a missing, empty, extra, mismatched or platform-less artifact set; a rehearsal is a `TEST`-titled prerelease on a disposable tag; the validator and the three release self-tests pass locally; the CI receipt is pending | CI/tooling lane | land the branch on both receipts; the checks run in the backup CI's `changes` job, and mirroring them into the GitForge pipeline waits for the `:2` pipeline shape (PR #123, PR #129); then rehearse the release path end to end through `workflow_dispatch` | | 27 | Cross-platform release engineering: Windows, macOS, Linux package matrix, Android feasibility, source, SBOM/provenance, signing, CI aggregation and certification | [tasks/T27-cross-platform-release.md](tasks/T27-cross-platform-release.md) | `IN PROGRESS` — implementation is ahead of verification; **no target is `CERTIFIED`** and nothing is signed | release-workflow lane | land an end-to-end tag run of the repaired workflow and attach the run id as the RP-07 receipt; consolidate the duplicated `dist --source`/`--sbom` verbs (B-3) | | 26 | Refresh user-visible CS6 parity matrix | [tasks/T26-parity-matrix.md](tasks/T26-parity-matrix.md) | `IMPLEMENTED` — matrix published | parity lane | every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state is classified with an evidence citation and an owning ticket in [`CS6-PARITY.md`](CS6-PARITY.md); the browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22 | | 22 | Phase 22: CS6 shell fidelity and screenshot audit | [tasks/T22-screenshot-ui.md](tasks/T22-screenshot-ui.md) | `IN PROGRESS` | UI lane | screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip landed on `main` with the shell-lineage train (#107); 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | diff --git a/docs/decisions/0099-workflow-actions-are-pinned.md b/docs/decisions/0099-workflow-actions-are-pinned.md new file mode 100644 index 0000000..faa6f99 --- /dev/null +++ b/docs/decisions/0099-workflow-actions-are-pinned.md @@ -0,0 +1,77 @@ +# 0099 — Every workflow action is pinned to a commit, and a production deploy is triggered by content, not by its own workflow file + +Date: 2026-11-11 · Ticket: [T27-57](../tasks/T27-57-workflow-hardening.md) · Issue [#97](https://github.com/aliasfoxkde/FireCraft/issues/97) · Status: accepted + +## Context + +The release campaign's rule is that a release must fail closed: the artifacts +published under a tag are the artifacts the repository's own code produced, and +nothing in the build path may change without a review of this repository. Two +holes in that rule sat in the GitHub Actions workflows. + +1. **Every third-party action was named by a mutable reference.** + `actions/checkout@v4`, `actions/cache@v4`, `actions/upload-artifact@v4`, + `actions/download-artifact@v4`, `cloudflare/wrangler-action@v3` and + `dtolnay/rust-toolchain@stable` all resolve, at job-run time, to whatever + the upstream tag or branch points at that day. A tag can be moved; a branch + moves on its own. The release workflow — the one that builds the desktop, + CLI, web and source artifacts, writes the SBOM, and creates the draft + release — therefore executed code that can change without a diff to review + here. The Aegis scanner was already SHA-pinned; the actions were not, and + the ticket that asks for the hardening (`T27-57`) names exactly this as its + first acceptance criterion. +2. **Editing the deploy workflow published to production.** `deploy-site.yml` + triggered on a push to `main` that touched `site/**`, `wrangler.toml` *or + `.github/workflows/deploy-site.yml`*. The deploy step publishes `site/`; the + workflow's own source is not part of the Pages deployment. Listing it in the + trigger paths meant that every edit to the workflow file — including a + one-line pin — was a production publication of the marketing site. The + campaign keeps production publication gated for explicit maintainer + approval, so a workflow edit must not silently become a deploy. + +## Decision + +- **Every `uses:` that names an action is `owner/repo@<40-hex-sha>`.** The + version the SHA corresponds to is recorded in a trailing comment, so the + pin is auditable without a network call. +- **A local action (`./path`) is allowed.** It is this repository's own code + and it is reviewed in the diff. +- **The rule is enforced by a script, not by review.** `scripts/validate_workflow_pins.py` + walks `.github/workflows/*.yml`, and fails with the file and line of every + unpinned reference. It runs in the backup CI's `changes` job — the job every + push and pull request executes, and the job the `fmt` and `gate` jobs depend + on — so an unpinned action fails the workflow before any job builds or + publishes anything. The script is stdlib-only, like the pipeline validator + beside it. +- **The failure modes are demonstrated, not asserted.** + `scripts/validate_workflow_pins-selftest.sh` feeds the validator one + deliberately broken input per failure mode — a mutable tag, a mutable + branch, a `docker://` image, a short SHA — plus the two inputs it must + accept (a pinned SHA, a local action), and checks the exit code each time. + It runs in the same CI step as the validator itself. +- **A production deploy is triggered by the content it publishes.** The + `deploy-site.yml` trigger paths are `site/**` and `wrangler.toml`; the + workflow's own file is no longer one of them. A deliberate redeploy is + `workflow_dispatch`. + +## Consequences + +- The release workflow's code path is fixed at review time. A new action + version is a deliberate commit: bump the SHA and the version comment. +- `dtolnay/rust-toolchain` is pinned to the SHA of its `stable` branch at the + time of the change. The action's code is fixed; the Rust toolchain it + installs still follows its own default, so the toolchain version can move + under a pinned action. Pinning the action is the part that closes the + supply-chain hole; pinning the toolchain version is a separate decision + that would need the CI image rebuilt with it. +- The pin check runs in the GitHub backup CI. It is not yet in the GitForge + pipeline (the primary CI), because `.gitforge.yml` is being restructured in + flight (PR #123, PR #129); mirroring the step there is a one-line change + once that settles. Both CIs must be green before a merge, so the rule is + still enforced on every pull request. +- The self-test uses `mktemp -d` and removes its fixtures; it writes nothing + outside the temporary directory. +- The deploy workflow no longer redeploys the site when only the workflow + changes. If a future change to `deploy-site.yml` must reach production, it + is a `workflow_dispatch` run — a deliberate act rather than a side effect + of a merge. diff --git a/docs/decisions/0100-release-mode-classification-and-a-tested-release-gate.md b/docs/decisions/0100-release-mode-classification-and-a-tested-release-gate.md new file mode 100644 index 0000000..c53fbbc --- /dev/null +++ b/docs/decisions/0100-release-mode-classification-and-a-tested-release-gate.md @@ -0,0 +1,108 @@ +# 0100 — A release tag is classified before it can cut a release, the aggregate gate is a tested script, and a rehearsal is a prerelease titled `TEST` + +Date: 2026-11-12 · Ticket: [T27-57](../tasks/T27-57-workflow-hardening.md) · Issue [#97](https://github.com/aliasfoxkde/FireCraft/issues/97) · Status: accepted + +## Context + +[T27-57](../tasks/T27-57-workflow-hardening.md) asks for two things the release +workflow did not have. + +1. **A gate that fails on a skipped required platform, and a safe test-release + mode.** The aggregate validation existed, but it was a hundred lines of + inline YAML in `.github/workflows/release.yml` that ran once, on a tag, with + nothing around it to tell it apart from a run that was merely quiet. It + checked that the nine required artifacts were present and non-empty, that + their digests agreed with `RELEASE_SHA256.txt`, and that the release + manifest described them. It did **not** check that the artifact directory + held *only* the published set — a stale artifact from an earlier version + rode along unnoticed — and it had no notion of a platform at all, so a + skipped build was indistinguishable from a build that was never asked for. + + There was no test-release mode either. The rehearsal that + [issue #99](https://github.com/aliasfoxkde/FireCraft/issues/99) records was + performed by hand on the disposable tag `v0.2.5-dryrun.3`: it produced a + draft release whose title was `0.2.5-dryrun.3` and whose `isPrerelease` + flag was **false**. Nothing in the tooling marked it as a rehearsal; the + only thing separating it from a release was the word `dryrun` in the tag, + which the workflow never looked at. + +2. **A rule that lives in one place.** The workflow's artifact names, its + checksum list and the manifest's version were all derived from the *tag* + (`${TAG#v}`), even though the artifacts are named by the *workspace + version* that `xtask dist` actually builds. The two agree on a tag push — + `xtask`'s manifest generator refuses to build a tag that disagrees with the + tree — but they are different facts, and a rehearsal cannot use the tag + for both. + +## Decision + +- **A release tag is classified, and an unrecognised tag is an error.** + `scripts/classify_release_mode.sh` maps a tag to exactly one mode: + `v..` is a production release, + `v..-dryrun.` is a rehearsal, and **everything else + is refused** — a two-component version, a tag without the `v` prefix, an + unknown prerelease suffix (`-rc.1`), a `-dryrun` without a number, a + non-numeric number, a trailing hyphen, and (when the tree's version is + supplied) a tag that names another version. The workflow calls it before it + creates anything, and the release job fails closed when the classifier + refuses. +- **A manual run may only rehearse.** The release workflow gained a + `workflow_dispatch` trigger that requires the operator to name a disposable + rehearsal tag. A manual run whose tag classifies as a production release is + refused: cutting a production release requires a tag push, which is the + reviewable act. +- **A rehearsal is unmistakably a rehearsal.** `packaging/release-assets.sh` + takes a mode. In `production` mode the release is an ordinary draft whose + title is the bare version and whose description must be the tracked release + notes under `docs/releases/` — a release whose description is a checksum + dump is not a release. In `test` mode the title is `TEST `, the + release is a prerelease, and the notes carry a banner that says it must not + be published. The script refuses an unknown mode, a missing notes file, an + empty asset list and a missing staged asset before `gh` is ever called. +- **A rehearsal's artifacts bind to the rehearsal tag.** A rehearsal tag does + not exist in the repository yet, and `gh release create` would create it at + the default branch — a tag that does not point at the commit that built the + artifacts. The workflow therefore creates the disposable tag at the exact + commit the build ran on and pushes it, refusing to overwrite an existing + tag. The tag carries the `-dryrun.` marker, so it cannot be mistaken + for a release tag. +- **The artifacts are named by the version, not by the tag.** The build job + derives the version once from `cargo metadata` and every artifact name, + checksum entry and gate check uses it. The tag names what is being + released; the version names what was built. +- **The aggregate gate is a script with a self-test.** + `scripts/validate_release_aggregate.sh` holds the gate, and + `scripts/validate_release_aggregate-selftest.sh` exercises it against a + synthetic artifact set with exactly one thing broken per case: a missing + artifact, an empty artifact, an unexpected extra artifact, a checksum + mismatch, a manifest version mismatch, a manifest digest mismatch, an + asset the manifest does not list, an unknown manifest schema, a skipped + required platform, and a platform the manifest does not represent. The + platform check is discriminating on its own: the last case has every + artifact present and every digest in agreement, and fails only because + nothing carries the wasm platform's target triple. + +## Consequences + +- The release path has three tested seams instead of none: the classifier, the + gate and the `gh` argument vector. All three self-tests run in the backup + CI's `changes` job, so a pull request that breaks the release path fails + before it can be merged. +- The gate is now stricter in two ways it was not: an artifact the release + does not publish fails the gate, and a required platform with no artifact + fails the gate. Both were silent before. +- The version, not the tag, names the artifacts. On a tag push the two agree + and nothing changes; on a rehearsal the tag is a disposable name and the + artifacts still describe the version they were built from. +- The rehearsal path has not yet been run end to end through + `workflow_dispatch`: it needs this workflow on `main` first. The manual + rehearsal recorded in issue #99 predates the `TEST` title and the + prerelease flag, so it does not yet demonstrate this mode. +- The three self-tests are in the GitHub backup CI only. Mirroring them into + the GitForge pipeline waits for the `:2` pipeline shape (PR #123, PR #129), + the same reason the pin check is not there yet. +- A rehearsal pushes a disposable tag to the repository. It is a tag that no + release ever names, it is refused if it already exists, and the campaign + permits disposable test tags explicitly — but it is still a write to the + remote, and the reusable rehearsal tags from the earlier manual run remain + on the remote until a maintainer authorises their removal. diff --git a/docs/release/EXECUTION-BOARD.md b/docs/release/EXECUTION-BOARD.md index 683757f..7d4d4d3 100644 --- a/docs/release/EXECUTION-BOARD.md +++ b/docs/release/EXECUTION-BOARD.md @@ -234,7 +234,7 @@ condition/verification. **RP** is the release-plan work package the row belongs | T27-54 | Generate and verify a machine-readable release manifest | P0 | IMPLEMENTED | T27-53 | `release-manifest.json` carries version, tag, source SHA, target triple, format, size, SHA-256, run URL and verification status; missing and corrupted assets are rejected | `jq -r '.version,.artifacts[0].sha256' dist/release-manifest.json` | Implemented on `feat/release-manifest` ([decision 0089](../decisions/0089-release-manifest-shape.md)): `dist --manifest` writes `dist/release-manifest.json` for the eight published assets, `dist --verify-manifest` re-checks it against the files, and `release.yml` publishes it as a ninth asset and validates it against the downloads. Unit tests cover the shape, the determinism and every rejection path. **No CI receipt and no published manifest yet** | — | — | | T27-55 | Regenerate and validate the CycloneDX SBOM | P0 | NOT STARTED | T27-06 | The SBOM regenerates from the locked graph, validates against the CycloneDX schema, and is compared component-by-component against `Cargo.lock`; it is published and the download is verified | `cargo run -p xtask -- dist --sbom && jq .metadata.component dist/sbom/firecraft-0.2.5-sbom.cdx.json` | `00fdd61` marks the updated SBOM verification pending; the earlier artifact was JSON-validated only | — | — | | T27-56 | Generate and independently verify build provenance | P1 | NOT STARTED | T27-53 | A provenance format is defined; each attestation binds an artifact digest to the source SHA, workflow and runner; attestations verify independently | `gh attestation verify --repo aliasfoxkde/FireCraft` | Not implemented | — | — | -| T27-57 | Harden the release workflows | P1 | NOT STARTED | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists | `grep -n 'uses:' .github/workflows/*.yml` | `ci.yml`/`release.yml` still use mutable tags (`@v4`, `@stable`) | — | — | +| T27-57 | Harden the release workflows | P1 | IN PROGRESS | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists | `python3 scripts/validate_workflow_pins.py .github/workflows`; `bash scripts/validate_workflow_pins-selftest.sh`; `bash scripts/classify_release_mode-selftest.sh`; `bash scripts/validate_release_aggregate-selftest.sh`; `bash tests/release-assets-selftest.sh` | **Pinning implemented 2026-11-11** (decision [0099](../decisions/0099-workflow-actions-are-pinned.md)): all 19 action references in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA with the version in a trailing comment; `scripts/validate_workflow_pins.py` fails the CI on an unpinned reference, and `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one deliberately broken input. Least-privilege permissions were already declared (`contents: read` at workflow level, `contents: write` only for the draft-release job) and the aggregate gate already fails closed on a missing, empty, misnamed or digest-mismatched artifact. The deploy trigger no longer includes the workflow's own file, so a workflow edit is not a production publication. **Both remaining criteria implemented 2026-11-12** (decision [0100](../decisions/0100-release-mode-classification-and-a-tested-release-gate.md)): the aggregate gate moved out of inline YAML into `scripts/validate_release_aggregate.sh`, which now also rejects an artifact the release does not publish and requires every required platform (`x86_64-unknown-linux-gnu`, `wasm32-unknown-unknown`) to be represented in the release manifest; `scripts/validate_release_aggregate-selftest.sh` breaks a complete artifact set one thing at a time — missing, empty, extra artifact, checksum mismatch, manifest version mismatch, manifest digest mismatch, an unlisted asset, an unknown schema, a skipped platform, and a platform the manifest does not represent. A safe test-release mode exists: `scripts/classify_release_mode.sh` maps `vX.Y.Z` to production and `vX.Y.Z-dryrun.N` to a rehearsal and refuses every other shape; a manual `workflow_dispatch` run may only rehearse; a rehearsal release is a prerelease titled `TEST ` whose notes carry a do-not-publish banner, and its disposable tag is created at the commit that built the artifacts. `scripts/classify_release_mode-selftest.sh` demonstrates each refused shape, and `tests/release-assets-selftest.sh` drives the `gh` argument vector against a stub. **Outstanding**: the rehearsal path has not been run end to end through `workflow_dispatch` (it needs this workflow on `main`); the manual rehearsal in issue #99 predates the `TEST` title and the prerelease flag | — | 2026-11-12: both remaining criteria implemented and locally exercised; the CI receipt is pending | ### RP-07 — Release aggregation and publication @@ -243,7 +243,7 @@ condition/verification. **RP** is the release-plan work package the row belongs | T27-17 | Replace the invalid `gh release create` asset arguments | P0 | BUILD VERIFIED | T27-03 | Assets are passed as positional filenames; the invocation is tested for missing, duplicate and space-containing filenames | `gh release create --help` | `release.yml:207` uses `assets+=(-a "$f")`; `gh release create` has no `-a` flag in gh 2.46 — assets are positional `[...]`. **Exercised**: the rehearsal's `create draft release` job (run 42937560976) succeeded and the draft carries 8 uploaded assets — the positional invocation works against the real CLI | — | 2026-11-11: rehearsal `v0.2.5-dryrun.3`, run 42937560976 | | T27-18 | Scope release permissions and verify the draft-to-published transition | P0 | IN PROGRESS | T27-17 | `contents: write` exists only on the publishing job; a draft is created, assets are uploaded, post-upload digests are validated, and the draft transitions to published | `grep -n 'permissions:' -A3 .github/workflows/release.yml` | `release.yml` build job `contents: read`, release job `contents: write` — appears compliant but untested. **Half exercised**: the rehearsal created a draft with 8 uploaded assets and validated the digests, and the draft is still `draft: true` / `published_at: null`. The draft-to-published transition is untested because publishing is a maintainer-only act (campaign §20) | Maintainer approval for a published release | 2026-11-11: draft creation and digest validation verified (run 42937560976); the published transition awaits authorization | | T27-19 | Exercise the whole draft-release flow on a disposable test tag | P0 | SMOKE TESTED | T27-17, T27-18 | A disposable (non-production) tag produces a draft release whose assets download and match `RELEASE_SHA256.txt` | `gh release download --dir artifacts && (cd artifacts && sha256sum -c RELEASE_SHA256.txt)` | **Done**: the disposable tag `v0.2.5-dryrun.3` → annotated tag `9009d404…` → commit `384687f…` ("Disposable rehearsal tag (not a release)") produced draft release 441136236 with 8 assets, all `state: uploaded`. The downloaded `RELEASE_SHA256.txt` matches the API digests of all 7 assets it covers. The source archive was downloaded and extracted too — and it does **not** clean-build (T27-52). Receipt: [RECEIPT-0.2.5-DRYRUN-3.md](RECEIPT-0.2.5-DRYRUN-3.md) | — | 2026-11-11: rehearsal `v0.2.5-dryrun.3`, run 42937560976 | -| T27-58 | Make the aggregator fail closed on every invalid artifact shape | P0 | BUILD VERIFIED | T27-17 | The aggregator rejects missing, mislabeled, empty, duplicate, wrong-version, wrong-commit and checksum-mismatched artifacts, and fails when a required platform job was skipped | Run the `release.yml` aggregate step with one artifact deliberately removed | `8fc3694` added fail-closed aggregate validation; rehearsed locally negative and positive. **The positive path now runs for real**: the rehearsal's `create draft release` job validated all nine required artifacts, ran `sha256sum -c RELEASE_SHA256.txt`, checked the release manifest against the downloaded assets and every asset name against the tag version, then created the draft (run 42937560976) | — | 2026-11-11: positive path verified end to end by the rehearsal | +| T27-58 | Make the aggregator fail closed on every invalid artifact shape | P0 | BUILD VERIFIED | T27-17 | The aggregator rejects missing, mislabeled, empty, duplicate, wrong-version, wrong-commit and checksum-mismatched artifacts, and fails when a required platform job was skipped | `bash scripts/validate_release_aggregate-selftest.sh` | `8fc3694` added fail-closed aggregate validation; rehearsed locally negative and positive. **The positive path now runs for real**: the rehearsal's `create draft release` job validated all nine required artifacts, ran `sha256sum -c RELEASE_SHA256.txt`, checked the release manifest against the downloaded assets and every asset name against the tag version, then created the draft (run 42937560976). **2026-11-12**: the aggregator moved into `scripts/validate_release_aggregate.sh` and is now exercised by `scripts/validate_release_aggregate-selftest.sh`, which breaks a complete artifact set one thing at a time — a missing artifact, an empty artifact, an unexpected extra artifact, a checksum mismatch, a manifest version mismatch, a manifest digest mismatch, an asset the manifest does not list, an unknown manifest schema, a skipped required platform, and a platform the manifest does not represent. The last is the discriminating case for the platform check: every artifact is present and every digest agrees, and the gate still fails | — | 2026-11-12: every failure mode demonstrated with one deliberately broken input | ### RP-08 — Final certification diff --git a/docs/tasks/T27-57-workflow-hardening.md b/docs/tasks/T27-57-workflow-hardening.md index cb9b5b1..b7ea4a2 100644 --- a/docs/tasks/T27-57-workflow-hardening.md +++ b/docs/tasks/T27-57-workflow-hardening.md @@ -1,6 +1,6 @@ # T27-57 — Harden the release workflows -> **RP:** RP-06 · **Priority:** P1 · **Status:** NOT STARTED +> **RP:** RP-06 · **Priority:** P1 · **Status:** IN PROGRESS > **Board row:** [EXECUTION-BOARD.md §6](../release/EXECUTION-BOARD.md) > **Depends on:** T27-18 @@ -19,12 +19,80 @@ artifacts bind to one tag SHA; the gate fails on missing outputs, a version mism a checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists. -**Verification:** `grep -n 'uses:' .github/workflows/*.yml` +**Verification:** -**Evidence:** `ci.yml` and `release.yml` still reference mutable tags (`@v4`, -`@stable`); only the Aegis scanner is SHA-pinned. +```bash +python3 scripts/validate_workflow_pins.py .github/workflows +bash scripts/validate_workflow_pins-selftest.sh +bash tests/release-assets-selftest.sh +bash scripts/classify_release_mode-selftest.sh +bash scripts/validate_release_aggregate-selftest.sh +``` -**Blockers:** T27-18. +**Evidence (2026-11-11):** decision +[0099](../decisions/0099-workflow-actions-are-pinned.md) — all 19 action references +in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA (the +version is kept in a trailing comment). `scripts/validate_workflow_pins.py` fails +the workflow on an unpinned reference and runs in the backup CI's `changes` job — +the job every push and pull request runs, and the job `fmt` and `gate` depend on. +`scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one +deliberately broken input (a mutable tag, a mutable branch, a `docker://` image, a +short SHA) and the two accepted inputs (a pinned SHA, a local action). The deploy +trigger no longer lists the workflow's own file, so a workflow edit is not a +production publication. + +**Evidence (2026-11-12):** decision +[0100](../decisions/0100-release-mode-classification-and-a-tested-release-gate.md) — +the two remaining acceptance criteria are implemented, and each is demonstrated by a +self-test that runs in the same CI step: + +* *The gate fails on a skipped required platform, and on unexpected extra + artifacts.* The aggregate validation moved out of the workflow into + `scripts/validate_release_aggregate.sh` (it was a hundred lines of inline YAML + that ran once, on a tag, with nothing around it). It now also rejects an + artifact the release does not publish, and requires every required platform + (`x86_64-unknown-linux-gnu`, `wasm32-unknown-unknown`) to be represented in the + release manifest. `scripts/validate_release_aggregate-selftest.sh` builds a + complete, self-consistent artifact set and breaks it one thing at a time: + a missing artifact, an empty artifact, an unexpected extra artifact, a checksum + mismatch, a manifest version mismatch, a manifest digest mismatch, an asset the + manifest does not list, an unknown manifest schema, a skipped required platform, + and a platform the manifest does not represent — the last being the + discriminating case, where every artifact is present and every digest agrees and + only the platform check can fail. +* *A safe test-release mode exists.* `scripts/classify_release_mode.sh` maps + `vX.Y.Z` to a production release and `vX.Y.Z-dryrun.N` to a rehearsal, and + refuses every other shape (two-component version, no `v` prefix, unknown + suffix, a `-dryrun` without a number, a non-numeric number, a trailing hyphen, + a tag that names another version). The release workflow gained a + `workflow_dispatch` trigger that may only rehearse. A rehearsal release is a + prerelease titled `TEST ` whose notes carry a banner saying it must + not be published, and its disposable tag is created at the exact commit that + built the artifacts so the artifacts and the tag bind to one SHA. + `scripts/classify_release_mode-selftest.sh` demonstrates each refused shape, + and `tests/release-assets-selftest.sh` drives `packaging/release-assets.sh` + against a stub `gh` that records its argument vector — asserting the + production shape (a draft titled by the version, no `--prerelease`, tracked + notes) and the rehearsal shape (`TEST ` title, `--prerelease`, the banner in + the notes), and refusing a checksum dump as a production description, a missing + notes file, an empty asset list, an unknown mode, an empty source tree, a + stale destination and a duplicate basename. + +**Already satisfied before this change:** least-privilege permissions (`contents: read` +at workflow level, `contents: write` only for the draft-release job); build and +publish are separate jobs; the artifacts bind to the tag SHA through the checkout; +the aggregate gate fails closed on a missing, empty, misnamed or digest-mismatched +artifact and on a version mismatch. + +**Outstanding:** the rehearsal path has not been run end to end through +`workflow_dispatch` — it needs this workflow on `main` first, and the manual +rehearsal recorded in issue #99 predates the `TEST` title and the prerelease +flag, so it does not yet demonstrate this mode. The three self-tests and the pin +check are in the GitHub backup CI; mirroring them into the GitForge pipeline +waits for the `:2` pipeline shape (PR #123, PR #129). + +**Blockers:** none. The remaining work is the end-to-end rehearsal run and the +GitForge mirror. **Stopping condition:** every `uses:` is a full commit SHA, the permission set is least-privilege, and the failure-mode checks are demonstrated with one deliberately diff --git a/packaging/release-assets.sh b/packaging/release-assets.sh index 6fad414..1698708 100755 --- a/packaging/release-assets.sh +++ b/packaging/release-assets.sh @@ -25,10 +25,21 @@ # asset paths to . The list file is what `create` # consumes; it is the single source of truth for the asset set. # -# create +# create [mode] # Call `gh release create --title ... --draft --notes-file # ` with the staged assets as positional -# arguments. +# arguments. `mode` is `production` (the default) or `test`: +# +# production — the title is the bare version, the release is an +# ordinary draft, and the notes must be the tracked release +# notes under `docs/releases/`; a release whose description is a +# checksum dump is not a release. +# +# test — a rehearsal of the release path. The title is prefixed +# `TEST `, the release is a prerelease, and the notes carry a +# banner that says it must not be published. A test release may +# fall back to the checksum list as its description, because its +# description is never the published one. # # Fail-closed by design: # * an empty source tree aborts before anything is staged; @@ -36,6 +47,9 @@ # files can never be attached to a release; # * an empty asset list aborts before `gh` is called, so a release is # never created with zero assets; +# * a missing notes file aborts, and in production mode the notes file +# must be a tracked `docs/releases/` note; +# * an unknown mode aborts; # * a duplicate basename is skipped with a warning, because a release # asset is named by its basename and two files with one name cannot # both be attached. RELEASE_SHA256.txt and MANIFEST.sha256 are not @@ -142,16 +156,33 @@ cmd_stage() { # Create the draft release with the staged assets as positional arguments. cmd_create() { - if [ "$#" -ne 3 ]; then - die "usage: release-assets.sh create " + if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + die "usage: release-assets.sh create [production|test]" fi local tag=$1 local list=$2 local notes=$3 + local mode=${4:-production} + + case "$mode" in + production|test) ;; + *) die "unknown release mode '$mode' — expected 'production' or 'test'" ;; + esac [ -f "$list" ] || die "asset list not found: $list" [ -f "$notes" ] || die "release notes file not found: $notes" + # A production release carries its tracked release notes. The checksum + # list is a manifest of the artifacts, not a description of the + # release: a draft whose description is a SHA dump is a release nobody + # can read, so the fallback is allowed only for a rehearsal. + if [ "$mode" = production ]; then + case "$notes" in + docs/releases/*) ;; + *) die "a production release must carry its tracked release notes (docs/releases/.md); got: $notes" ;; + esac + fi + local -a assets=() local entry while IFS= read -r -d '' entry; do @@ -170,11 +201,34 @@ cmd_create() { # Assets are the positional filename arguments after the tag. `gh # release create` has no -a/--asset flag. The title is the bare # version ("0.2.6") — the repository name and the asset filenames - # already carry the product name. + # already carry the product name. A rehearsal is titled `TEST ` + # and is a prerelease, so it cannot be mistaken for the release. + local title="${tag#v}" + local -a flags=() + local notes_arg=$notes + local banner="" + if [ "$mode" = test ]; then + title="TEST $title" + flags=(--prerelease) + banner=$(mktemp) + { + echo "**TEST RELEASE — not for publication.**" + echo + echo "This is a rehearsal of the release path. It is a prerelease, its" + echo "title carries the TEST prefix, and it must not be published." + echo + cat "$notes" + } > "$banner" + notes_arg=$banner + # shellcheck disable=SC2064 + trap "rm -f -- '$banner'" EXIT + fi + gh release create "$tag" \ - --title "${tag#v}" \ + --title "$title" \ --draft \ - --notes-file "$notes" \ + ${flags[@]+"${flags[@]}"} \ + --notes-file "$notes_arg" \ "${assets[@]}" } @@ -190,7 +244,7 @@ main() { cmd_create "$@" ;; *) - die "usage: release-assets.sh {stage | create }" + die "usage: release-assets.sh {stage | create [production|test]}" ;; esac } diff --git a/scripts/classify_release_mode-selftest.sh b/scripts/classify_release_mode-selftest.sh new file mode 100644 index 0000000..adc5fdc --- /dev/null +++ b/scripts/classify_release_mode-selftest.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Self-test for scripts/classify_release_mode.sh — the rule that decides +# whether a release tag is a production release or a rehearsal. +# +# The rule is the last thing standing between a rehearsal and a release +# that looks like one, so every input it does not recognise must fail +# closed, and each failure mode is demonstrated here with one deliberately +# broken input. +# +# Usage: bash scripts/classify_release_mode-selftest.sh +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script="$root/scripts/classify_release_mode.sh" + +failures=0 +passed=0 + +ok() { + printf 'ok %s\n' "$1" + passed=$((passed + 1)) +} + +bad() { + printf 'FAIL %s\n' "$1" >&2 + shift + for line in "$@"; do + printf ' %s\n' "$line" >&2 + done + failures=$((failures + 1)) +} + +# expect