From 69d3b812924a5219e3da3e92508cc94fbe1aa9d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:57:53 +0000 Subject: [PATCH 1/7] fix(gates): use only action owners the callers' Actions policy admits; pin Alire by hash; re-pin the theatre gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live caller of rust-ci.yml (plasma-parser-writer#108, run 39251770946) died at startup: The action embarkstudios/cargo-deny-action@3c634983… is not allowed in hyperpolymath/plasma-parser-writer because all actions must be from a repository owned by hyperpolymath, created by GitHub, verified in the GitHub Marketplace, or match one of the patterns: … while creusot-gate.yml from the same commit ran. The Actions policy is evaluated in the CALLER's repository, so a reusable may only use what every caller is allowed, and nothing in this repository's own CI could ever see the refusal (#54's self-test exercises the scripts, not a cross-repository call). * rust-ci.yml: cargo-deny is installed through taiki-e/install-action — the installer the coverage job and hypatia's green workflows already use — at the version cargo-deny-action shipped (0.20.2, new input cargo_deny_version), and run as plain `cargo deny` steps with the same argument shape the action used (global args, manifest, command). * spark-gate.yml: Alire is a sha256-pinned release binary (alr 2.1.1 x86_64 Linux, the version setup-alire v6.0.0 defaults to; new inputs alr_version / alr_sha256, hash recorded from the downloaded asset) and `alr -n toolchain --select gnat_native= gprbuild`, which is what alire-project/setup-alire does on Linux. That action is also outside the allowed owners, and nests actions/cache/restore@v4 and actions/setup-node@v4 — floating tags no caller under sha_pinning_required may carry. The nested standards theatre gate moves from 95de3df to 75bd599: since ~2026-10-15 GitHub validates a called reusable against the callee's actions.lock and rejects records whose ref is their own SHA; 95de3df's lock has them (hypatia PIN-003 lists 545 such commits), 75bd599 is the first that does not. The theatre gate's content is identical at both. * tests/gate-action-owners.sh, run by language-gates-self-test.yml: every `uses:` in the four gates names an owner in {actions, github, hyperpolymath, dtolnay, swatinem, goto-bus-stop, taiki-e} at a full SHA; a planted disallowed owner and a planted tag pin must both be refused or the guard reports NO CHECK (exit 2). * README: what a gate may use, what the callee lock must look like, and how a consumer with a lock-sync gate onboards a caller (cicd-squabbler #135 is the worked example). Verified: actionlint clean on the three workflows; shellcheck clean on the guard; the four gate test suites (31/27/19/19) and the composite shell contract (28) still pass. tests/lock-transitive-closure.sh fails 6 checks on this branch and on main alike (manifest-check pinned at 1aa6bfb in main-estate-audit.yml, the subject of #56); nothing here touches it. Not yet witnessed live: the pinned-alr path and `cargo deny` through install-action run for the first time on the first consumer that pins this commit; the planted controls still make a broken install report void, never green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .../workflows/language-gates-self-test.yml | 6 ++ .github/workflows/rust-ci.yml | 47 ++++++++++--- .github/workflows/spark-gate.yml | 50 ++++++++++++-- README.adoc | 40 +++++++++++ tests/gate-action-owners.sh | 68 +++++++++++++++++++ 5 files changed, 196 insertions(+), 15 deletions(-) create mode 100755 tests/gate-action-owners.sh diff --git a/.github/workflows/language-gates-self-test.yml b/.github/workflows/language-gates-self-test.yml index 1e715d5..e337be9 100644 --- a/.github/workflows/language-gates-self-test.yml +++ b/.github/workflows/language-gates-self-test.yml @@ -98,6 +98,12 @@ jobs: run: bash actions/spark-check/test.sh - name: miniKanren gate run: bash actions/kanren-check/test.sh + - name: Every gate uses only action owners the callers' Actions policy admits, at full SHAs + # The policy is evaluated in the CALLER's repository, so cicd-suite's own + # CI never sees a refusal; this guard is that refusal, moved here + # (plasma-parser-writer#108: embarkstudios/cargo-deny-action was refused + # at startup while the Creusot gate from the same commit ran). + run: bash tests/gate-action-owners.sh - name: Caller templates carry the placeholder, a full-SHA form and no secrets run: | set -euo pipefail diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 1fa9aab..64c4199 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -119,6 +119,11 @@ on: type: boolean required: false default: true + cargo_deny_version: + description: cargo-deny release installed for the deny job (prebuilt, checksummed by taiki-e/install-action) + type: string + required: false + default: "0.20.2" deny_args: description: Global cargo-deny arguments (placed before the command) type: string @@ -514,22 +519,44 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: cargo-deny — advisories, bans, sources - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + # cargo-deny is installed as a prebuilt, checksummed binary through + # taiki-e/install-action — the installer the coverage job already uses + # and hypatia's green workflows use — and run as plain `cargo deny` + # steps. It was EmbarkStudios/cargo-deny-action until 2026-10-19: + # that action is neither hyperpolymath-owned, GitHub-made nor + # Marketplace-verified, and the estate's Actions policy refused it at + # startup for every caller ("The action embarkstudios/cargo-deny-action + # … is not allowed", plasma-parser-writer#108 run 39251770946) while the + # Creusot gate from the same commit ran. tests/gate-action-owners.sh + # keeps every gate inside the owners the policy admits. + # The pinned cargo-deny is the version that action shipped (0.20.2). + - name: Install cargo-deny ${{ inputs.cargo_deny_version }} (prebuilt, pinned) + uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 with: - command: check advisories bans sources - arguments: ${{ inputs.deny_args }} - manifest-path: ${{ inputs.working_directory }}/Cargo.toml + tool: cargo-deny@${{ inputs.cargo_deny_version }} + - name: cargo-deny — advisories, bans, sources + env: + WORKING_DIRECTORY: ${{ inputs.working_directory }} + DENY_ARGS: ${{ inputs.deny_args }} + # Same shape cargo-deny-action used: global args, then the manifest, + # then the command. DENY_ARGS is word-split on purpose: it is a + # caller-authored argument list, never data. + run: | + set -euo pipefail + # shellcheck disable=SC2086 + cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check advisories bans sources - name: cargo-deny — licences (only against an owner-authored deny.toml) # Licence policy is manual and owner-only (doctrine #6). With no # deny.toml there is no policy to check against, and the gate does # not invent one. if: ${{ needs.detect.outputs.has_deny_toml == 'true' }} - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 - with: - command: check licenses - arguments: ${{ inputs.deny_args }} - manifest-path: ${{ inputs.working_directory }}/Cargo.toml + env: + WORKING_DIRECTORY: ${{ inputs.working_directory }} + DENY_ARGS: ${{ inputs.deny_args }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 + cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check licenses coverage: name: llvm-cov line coverage diff --git a/.github/workflows/spark-gate.yml b/.github/workflows/spark-gate.yml index 8736f89..dbde8e3 100644 --- a/.github/workflows/spark-gate.yml +++ b/.github/workflows/spark-gate.yml @@ -71,6 +71,16 @@ on: type: string required: false default: "16.1.0" + alr_version: + description: Alire release installed from alire-project/alire's GitHub release (x86_64 Linux zip), the version setup-alire v6.0.0 defaults to + type: string + required: false + default: "2.1.1" + alr_sha256: + description: sha256 of that release zip (alr--bin-x86_64-linux.zip); the install refuses a mismatch. Recorded 2026-10-19 from the downloaded asset. + type: string + required: false + default: "09c66bcd8c35dd4b97b72c3d9b76e44caa6964a2db35aba069f396f00f1f64c7" gnatprove_version: description: Alire gnatprove crate version type: string @@ -91,7 +101,7 @@ defaults: jobs: theatre: name: SPARK theatre gate (standards) - uses: hyperpolymath/standards/.github/workflows/spark-theatre-gate.yml@95de3dfc3fcf09a1cf3325ab42723e3d81682b45 # standards main 2026-10-14 + uses: hyperpolymath/standards/.github/workflows/spark-theatre-gate.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 # standards main 2026-10-19 — the first commit whose own actions.lock GitHub accepts at job setup (hypatia PIN-003) permissions: actions: read contents: read @@ -206,10 +216,40 @@ jobs: env: EXPECTED: ${{ steps.suite.outputs.sha }} run: bash .cicd-suite/scripts/verify-suite-checkout.sh - - name: Set up Alire with GNAT ${{ inputs.gnat_version }} and gprbuild - uses: alire-project/setup-alire@fc4c8ce471aa30b3af0d39ce15add1ee9eaf28f2 # v6.0.0 - with: - toolchain: gnat_native=${{ inputs.gnat_version }} gprbuild + # Alire is a pinned release binary, sha256-verified, not the + # alire-project/setup-alire action: that action is neither + # hyperpolymath-owned, GitHub-made nor Marketplace-verified, so the + # estate's Actions policy would refuse it at startup for every caller + # (as it refused EmbarkStudios/cargo-deny-action, plasma-parser-writer#108), + # and it nests `actions/cache/restore@v4` and `actions/setup-node@v4`, + # floating tags no caller under sha_pinning_required may carry. The + # steps below are what that action does on Linux, pinned by hash. + - name: Install Alire ${{ inputs.alr_version }} (pinned release, sha256-verified) + env: + ALR_VERSION: ${{ inputs.alr_version }} + ALR_SHA256: ${{ inputs.alr_sha256 }} + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) arch=x86_64 ;; + *) echo "::error::Alire release binaries are pinned for x86_64 only; runner is $(uname -m). NO CHECK WAS PERFORMED."; exit 2 ;; + esac + zip="alr-${ALR_VERSION}-bin-${arch}-linux.zip" + curl -fsSL --retry 3 -o "$zip" "https://github.com/alire-project/alire/releases/download/v${ALR_VERSION}/${zip}" + echo "${ALR_SHA256} ${zip}" | sha256sum -c - + mkdir -p "$HOME/.local/alr" + unzip -o -q "$zip" 'bin/alr*' -d "$HOME/.local/alr" + rm -f "$zip" + echo "$HOME/.local/alr/bin" >> "$GITHUB_PATH" + "$HOME/.local/alr/bin/alr" --version + - name: Select GNAT ${{ inputs.gnat_version }} and gprbuild through Alire + env: + GNAT_VERSION: ${{ inputs.gnat_version }} + run: | + set -euo pipefail + alr -n settings --global --set msys2.do_not_install true >/dev/null 2>&1 || true + alr -n toolchain --select "gnat_native=${GNAT_VERSION}" gprbuild + alr -n toolchain - name: Cache the GNATprove ${{ inputs.gnatprove_version }} install id: cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/README.adoc b/README.adoc index be602b5..d992217 100644 --- a/README.adoc +++ b/README.adoc @@ -287,6 +287,46 @@ first live subject for SPARK is cicd-squabbler's `+spark/squabble_gate.gpr+`; no repository in the first rollout carries Creusot obligations yet. +==== What a gate may use, and what a consumer's lock must hold + +Two constraints are evaluated in the *caller's* repository, so nothing in +this repository's own CI can witness them; both are now guarded here. + +* *Actions policy.* The estate's GitHub Actions policy admits actions that + are hyperpolymath-owned, GitHub-made, Marketplace-verified, or on a short + pattern list. A reusable that uses anything else startup-fails for every + caller with "The action … is not allowed" — which is how + `EmbarkStudios/cargo-deny-action` killed the first live Rust gate caller + (plasma-parser-writer#108, 2026-10-19) while the Creusot gate from the same + commit ran. The gates now use only `actions/*`, `github/*`, + `hyperpolymath/*`, `dtolnay`, `swatinem`, `goto-bus-stop` and `taiki-e`, + every one at a full SHA, and `tests/gate-action-owners.sh` (run by the + self-test workflow) refuses any other owner or a tag pin. cargo-deny is + installed through `taiki-e/install-action`; Alire is a sha256-pinned + release binary rather than `alire-project/setup-alire`, which also nested + floating `@v4` tags. + +* *Callee lock.* Since about 2026-10-15 GitHub validates a called reusable + against the *callee* repository's `actions.lock` at the called SHA and + rejects a dependency record whose `ref:` is its own commit SHA + ("invalid lockfile pin"). The gate workflows are deliberately not keyed in + this repository's lock, and that resolves (witnessed: the Creusot gate on + plasma-parser-writer#108). The SPARK gate's nested standards theatre gate + is pinned at standards `75bd599`, the first standards commit whose lock + passes that validation; hypatia's PIN-003 lists the 545 that do not. + +* *Consumers with a lock-sync gate.* A repository whose CI requires every + job-level reusable ref to be keyed in its own `actions.lock` with a + transitively closed `dependencies:` section (cicd-squabbler's + `scripts/check-lock-sync.sh`) cannot de-onboard the caller as the + templates assume. Such a repository onboards instead: one + `hyperpolymath/cicd-suite@` record whose `uses:` is the union of + what the called gates use at that SHA, plus a record for each of those + copied from a lock GitHub accepts, with the tag — never the SHA — as + `ref:`. cicd-squabbler#135 is the worked example (for its standards + callers); gitbot-fleet's `scripts/fix-lock-self-sha-refs.sh` and + `scripts/lib/action-ref-tags.tsv` carry the tag map. + === Licence Code MPL-2.0, prose CC-BY-SA-4.0. See link:LICENSE[`+LICENSE+`]. diff --git a/tests/gate-action-owners.sh b/tests/gate-action-owners.sh new file mode 100755 index 0000000..6970003 --- /dev/null +++ b/tests/gate-action-owners.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# gate-action-owners.sh — every `uses:` in the four language gates names an +# owner the estate's GitHub Actions policy admits, at a full commit SHA. +# +# MEASURED 2026-10-19 (plasma-parser-writer#108, run 39251770946): a caller of +# rust-ci.yml died at startup with +# The action embarkstudios/cargo-deny-action@3c634983… is not allowed in +# hyperpolymath/plasma-parser-writer because all actions must be from a +# repository owned by hyperpolymath, created by GitHub, verified in the +# GitHub Marketplace, or match one of the patterns: … +# while creusot-gate.yml from the same commit ran. The policy is evaluated in +# the CALLER's repository, so a reusable can only use what every caller is +# allowed, and nothing in cicd-suite's own CI would ever see the refusal. +# This guard is that refusal, moved to where a change to a gate is reviewed. +# +# The allowed set is the owners the gates use today and that estate +# repositories run green with: actions/* and github/* (GitHub-made), +# hyperpolymath/* (owned), dtolnay and swatinem (Marketplace-verified), and +# goto-bus-stop and taiki-e (used by standards' and hypatia's green +# workflows). Adding an owner here is a claim that every caller's policy +# admits it: prove it on a caller first. +# +# Exit 0 when every ref passes; 1 on any violation; 2 when a gate file is +# missing or the planted control did not trip (the guard cannot fail). +set -euo pipefail +cd "$(dirname "$0")/.." + +ALLOWED_OWNERS='^(actions|github|hyperpolymath|dtolnay|swatinem|goto-bus-stop|taiki-e)$' +GATES=(rust-ci creusot-gate spark-gate kanren-gate) + +check_file() { # -> prints violations, returns 1 if any + local f="$1" ref owner bad=0 + while IFS= read -r ref; do + owner="${ref%%/*}" + if ! [[ "$owner" =~ $ALLOWED_OWNERS ]]; then + echo "FAIL $f: uses $ref — owner '$owner' is outside the set every caller's Actions policy admits"; bad=1 + fi + if ! [[ "$ref" =~ @[0-9a-f]{40}$ ]]; then + echo "FAIL $f: uses $ref — not pinned at a full 40-hex commit SHA (sha_pinning_required)"; bad=1 + fi + done < <(grep -oE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+' "$f" | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//') + return "$bad" +} + +fail=0 +for g in "${GATES[@]}"; do + f=".github/workflows/$g.yml" + [ -f "$f" ] || { echo "NO CHECK: $f is missing"; exit 2; } + if check_file "$f"; then echo "ok $f"; else fail=1; fi +done + +# Planted control: the guard must be able to fail, on the exact ref that +# killed the first live caller. +tmp=$(mktemp) +printf 'jobs:\n a:\n steps:\n - uses: embarkstudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25\n - uses: actions/checkout@v7.0.1\n' > "$tmp" +if check_file "$tmp" >/dev/null 2>&1; then + echo "NO CHECK: the planted disallowed owner and the planted tag pin were not refused — the guard cannot fail"; rm -f "$tmp"; exit 2 +fi +n=$(check_file "$tmp" 2>/dev/null | grep -c '^FAIL' || true); rm -f "$tmp" +[ "$n" -eq 2 ] || { echo "NO CHECK: expected 2 planted failures, saw $n"; exit 2; } +echo "ok planted control: a disallowed owner and a tag pin are both refused" + +if [ "$fail" -ne 0 ]; then + echo "gate-action-owners: a gate uses an action the estate's Actions policy would refuse at the caller's startup."; exit 1 +fi +echo "gate-action-owners: every gate ref is an allowed owner at a full SHA" From 1b240c85d2206a5f2f47effd5017579c99e2a797 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:58:02 +0000 Subject: [PATCH 2/7] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 failed pre-merge check. Co-authored-by: CodeRabbit --- tests/gate-action-owners.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/gate-action-owners.sh b/tests/gate-action-owners.sh index 6970003..d71bb24 100755 --- a/tests/gate-action-owners.sh +++ b/tests/gate-action-owners.sh @@ -30,6 +30,12 @@ cd "$(dirname "$0")/.." ALLOWED_OWNERS='^(actions|github|hyperpolymath|dtolnay|swatinem|goto-bus-stop|taiki-e)$' GATES=(rust-ci creusot-gate spark-gate kanren-gate) +# Scan a workflow file's `uses:` lines against ALLOWED_OWNERS and the +# full-SHA pin requirement. +# $1 - path to the workflow file to check +# Prints one "FAIL : uses — " line per violation found. +# Returns 0 when every `uses:` ref is from an allowed owner and pinned at a +# full 40-hex commit SHA; 1 if any ref fails either check. check_file() { # -> prints violations, returns 1 if any local f="$1" ref owner bad=0 while IFS= read -r ref; do From 9813b4cf8ba105cc734ee2bd030e3adab5df1479 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:32:19 +0000 Subject: [PATCH 3/7] fix(gates): correct cargo-deny argument order, require Linux x86_64 for Alire, and validate action references with YAML parsing --- .../workflows/language-gates-self-test.yml | 2 + .github/workflows/rust-ci.yml | 13 +++-- .github/workflows/spark-gate.yml | 7 +-- tests/gate-action-owners.sh | 53 +++++++++++++++---- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/.github/workflows/language-gates-self-test.yml b/.github/workflows/language-gates-self-test.yml index e337be9..182c55d 100644 --- a/.github/workflows/language-gates-self-test.yml +++ b/.github/workflows/language-gates-self-test.yml @@ -25,6 +25,7 @@ on: - "actions/kanren-check/**" - "scripts/verify-suite-checkout.sh" - "scripts/deonboard-workflow.sh" + - "tests/gate-action-owners.sh" - ".github/workflows/rust-ci.yml" - ".github/workflows/creusot-gate.yml" - ".github/workflows/spark-gate.yml" @@ -39,6 +40,7 @@ on: - "actions/kanren-check/**" - "scripts/verify-suite-checkout.sh" - "scripts/deonboard-workflow.sh" + - "tests/gate-action-owners.sh" - ".github/workflows/rust-ci.yml" - ".github/workflows/creusot-gate.yml" - ".github/workflows/spark-gate.yml" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 64c4199..32fd970 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -125,10 +125,10 @@ on: required: false default: "0.20.2" deny_args: - description: Global cargo-deny arguments (placed before the command) + description: cargo-deny check arguments (placed after check) type: string required: false - default: "--all-features --locked --warn unmaintained --warn yanked" + default: "--warn unmaintained --warn yanked" enable_audit: description: >- Accepted for compatibility with standards' rust-ci-reusable; advisories @@ -538,13 +538,12 @@ jobs: env: WORKING_DIRECTORY: ${{ inputs.working_directory }} DENY_ARGS: ${{ inputs.deny_args }} - # Same shape cargo-deny-action used: global args, then the manifest, - # then the command. DENY_ARGS is word-split on purpose: it is a - # caller-authored argument list, never data. + # Global options precede check; lint options follow it. DENY_ARGS is + # word-split on purpose: it is a caller-authored argument list. run: | set -euo pipefail # shellcheck disable=SC2086 - cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check advisories bans sources + cargo deny --log-level warn --all-features --locked --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_ARGS advisories bans sources - name: cargo-deny — licences (only against an owner-authored deny.toml) # Licence policy is manual and owner-only (doctrine #6). With no # deny.toml there is no policy to check against, and the gate does @@ -556,7 +555,7 @@ jobs: run: | set -euo pipefail # shellcheck disable=SC2086 - cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check licenses + cargo deny --log-level warn --all-features --locked --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_ARGS licenses coverage: name: llvm-cov line coverage diff --git a/.github/workflows/spark-gate.yml b/.github/workflows/spark-gate.yml index dbde8e3..ce415af 100644 --- a/.github/workflows/spark-gate.yml +++ b/.github/workflows/spark-gate.yml @@ -33,6 +33,7 @@ on: workflow_call: inputs: runs-on: + description: Linux x86_64 runner label (the pinned Alire archive supports Linux x86_64 only) type: string required: false default: ubuntu-24.04 @@ -230,9 +231,9 @@ jobs: ALR_SHA256: ${{ inputs.alr_sha256 }} run: | set -euo pipefail - case "$(uname -m)" in - x86_64) arch=x86_64 ;; - *) echo "::error::Alire release binaries are pinned for x86_64 only; runner is $(uname -m). NO CHECK WAS PERFORMED."; exit 2 ;; + case "$(uname -s)/$(uname -m)" in + Linux/x86_64) arch=x86_64 ;; + *) echo "::error::Alire release binary is pinned for Linux x86_64 only; runner is $(uname -s)/$(uname -m). NO CHECK WAS PERFORMED."; exit 2 ;; esac zip="alr-${ALR_VERSION}-bin-${arch}-linux.zip" curl -fsSL --retry 3 -o "$zip" "https://github.com/alire-project/alire/releases/download/v${ALR_VERSION}/${zip}" diff --git a/tests/gate-action-owners.sh b/tests/gate-action-owners.sh index d71bb24..579be72 100755 --- a/tests/gate-action-owners.sh +++ b/tests/gate-action-owners.sh @@ -23,21 +23,32 @@ # admits it: prove it on a caller first. # # Exit 0 when every ref passes; 1 on any violation; 2 when a gate file is -# missing or the planted control did not trip (the guard cannot fail). +# missing, yq is unavailable, or a planted control did not trip. set -euo pipefail cd "$(dirname "$0")/.." ALLOWED_OWNERS='^(actions|github|hyperpolymath|dtolnay|swatinem|goto-bus-stop|taiki-e)$' GATES=(rust-ci creusot-gate spark-gate kanren-gate) -# Scan a workflow file's `uses:` lines against ALLOWED_OWNERS and the +command -v yq >/dev/null || { echo "yq is required (mikefarah v4)"; exit 2; } + +# Scan a workflow file's `uses` values against ALLOWED_OWNERS and the # full-SHA pin requirement. # $1 - path to the workflow file to check # Prints one "FAIL : uses — " line per violation found. # Returns 0 when every `uses:` ref is from an allowed owner and pinned at a # full 40-hex commit SHA; 1 if any ref fails either check. check_file() { # -> prints violations, returns 1 if any - local f="$1" ref owner bad=0 + local f="$1" refs ref owner bad=0 + # Parse quoted keys, flow mappings and aliases as YAML. Capture the + # status before iterating: a failed process substitution would look empty + # and silently pass. An empty extraction is also a failure for these gates. + if ! refs="$(yq -r 'explode(.) | .. | select(tag == "!!map") | select(has("uses")) | .uses' "$f")"; then + echo "FAIL $f: cannot inspect workflow YAML"; return 1 + fi + if [ -z "$refs" ]; then + echo "FAIL $f: no action references found"; return 1 + fi while IFS= read -r ref; do owner="${ref%%/*}" if ! [[ "$owner" =~ $ALLOWED_OWNERS ]]; then @@ -46,7 +57,7 @@ check_file() { # -> prints violations, returns 1 if any if ! [[ "$ref" =~ @[0-9a-f]{40}$ ]]; then echo "FAIL $f: uses $ref — not pinned at a full 40-hex commit SHA (sha_pinning_required)"; bad=1 fi - done < <(grep -oE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+' "$f" | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//') + done <<< "$refs" return "$bad" } @@ -57,16 +68,36 @@ for g in "${GATES[@]}"; do if check_file "$f"; then echo "ok $f"; else fail=1; fi done -# Planted control: the guard must be able to fail, on the exact ref that -# killed the first live caller. +# Planted controls: the guard must refuse both violations in each YAML +# layout, including the exact ref that killed the first live caller. tmp=$(mktemp) -printf 'jobs:\n a:\n steps:\n - uses: embarkstudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25\n - uses: actions/checkout@v7.0.1\n' > "$tmp" +trap 'rm -f "$tmp" "${tmp}.valid"' EXIT +check_control() { + local layout="$1" output n + if output="$(check_file "$tmp")"; then + echo "NO CHECK: $layout planted violations were not refused"; exit 2 + fi + n=$(printf '%s\n' "$output" | grep -c '^FAIL' || true) + [ "$n" -eq 2 ] || { echo "NO CHECK: $layout expected 2 planted failures, saw $n"; exit 2; } + # The same layout must accept allowed owners at full SHAs. + sed 's#embarkstudios/cargo-deny-action#actions/checkout#g; s#@v7.0.1#@3c6349835b2b7b196a839186cb8b78e02f7b5f25#g' "$tmp" > "${tmp}.valid" + if ! check_file "${tmp}.valid"; then + echo "NO CHECK: $layout valid references were refused"; exit 2 + fi + echo "ok planted control: $layout accepts full SHAs and refuses a disallowed owner and a tag pin" +} +for key in uses "'uses'" '"uses"' 'uses '; do + printf 'jobs:\n a:\n steps:\n - %s: embarkstudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25\n - %s: actions/checkout@v7.0.1\n' "$key" "$key" > "$tmp" + check_control "key $key" +done +printf 'jobs: {a: {steps: [{uses : embarkstudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25}, {"uses": actions/checkout@v7.0.1}]}}\n' > "$tmp" +check_control 'flow mappings' + +printf 'jobs: [\n' > "$tmp" if check_file "$tmp" >/dev/null 2>&1; then - echo "NO CHECK: the planted disallowed owner and the planted tag pin were not refused — the guard cannot fail"; rm -f "$tmp"; exit 2 + echo "NO CHECK: malformed YAML was accepted"; exit 2 fi -n=$(check_file "$tmp" 2>/dev/null | grep -c '^FAIL' || true); rm -f "$tmp" -[ "$n" -eq 2 ] || { echo "NO CHECK: expected 2 planted failures, saw $n"; exit 2; } -echo "ok planted control: a disallowed owner and a tag pin are both refused" +echo "ok planted control: malformed YAML fails" if [ "$fail" -ne 0 ]; then echo "gate-action-owners: a gate uses an action the estate's Actions policy would refuse at the caller's startup."; exit 1 From dd51948c644c6416494c8b934c3a7eb40ea9b774 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:25:03 +0000 Subject: [PATCH 4/7] ci(gates): https-only Alire download, harden-runner in the pin tracker, lint nit * spark-gate.yml: the Alire archive download now refuses any non-HTTPS scheme on the request and on redirects (`--proto '=https' --proto-redir '=https'`); the digest check already stood behind it. * templates/track-estate-audit.yml: a step-security/harden-runner step at a full SHA with egress blocked to api.github.com and github.com, plus a 20-minute job timeout (hypatia RE001 / WF013 / WH006 on every repository that carries the template). The header and README say what a repository with an actions.lock must add; everything else in the file stays a plain `run:` so a stale cicd-suite pin can never stop the tracker. * language-gates-self-test.yml: `EXPECTED=''` (shellcheck SC1007). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .../workflows/language-gates-self-test.yml | 2 +- .github/workflows/spark-gate.yml | 2 +- README.adoc | 5 ++++- templates/track-estate-audit.yml | 21 +++++++++++++++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/language-gates-self-test.yml b/.github/workflows/language-gates-self-test.yml index 182c55d..c31fede 100644 --- a/.github/workflows/language-gates-self-test.yml +++ b/.github/workflows/language-gates-self-test.yml @@ -87,7 +87,7 @@ jobs: - name: The suite-checkout verifier refuses an empty or wrong SHA run: | set -euo pipefail - rc=0; EXPECTED= SUITE_DIR=. bash scripts/verify-suite-checkout.sh || rc=$? + rc=0; EXPECTED='' SUITE_DIR=. bash scripts/verify-suite-checkout.sh || rc=$? [ "$rc" -eq 2 ] || { echo "verifier accepted an empty SHA (rc=$rc)"; exit 1; } rc=0; EXPECTED=0000000000000000000000000000000000000000 SUITE_DIR=. bash scripts/verify-suite-checkout.sh || rc=$? [ "$rc" -eq 2 ] || { echo "verifier accepted a wrong SHA (rc=$rc)"; exit 1; } diff --git a/.github/workflows/spark-gate.yml b/.github/workflows/spark-gate.yml index ce415af..d048014 100644 --- a/.github/workflows/spark-gate.yml +++ b/.github/workflows/spark-gate.yml @@ -236,7 +236,7 @@ jobs: *) echo "::error::Alire release binary is pinned for Linux x86_64 only; runner is $(uname -s)/$(uname -m). NO CHECK WAS PERFORMED."; exit 2 ;; esac zip="alr-${ALR_VERSION}-bin-${arch}-linux.zip" - curl -fsSL --retry 3 -o "$zip" "https://github.com/alire-project/alire/releases/download/v${ALR_VERSION}/${zip}" + curl -fsSL --proto '=https' --proto-redir '=https' --retry 3 -o "$zip" "https://github.com/alire-project/alire/releases/download/v${ALR_VERSION}/${zip}" echo "${ALR_SHA256} ${zip}" | sha256sum -c - mkdir -p "$HOME/.local/alr" unzip -o -q "$zip" 'bin/alr*' -d "$HOME/.local/alr" diff --git a/README.adoc b/README.adoc index d992217..c5fa6a7 100644 --- a/README.adoc +++ b/README.adoc @@ -184,7 +184,10 @@ one fixture per supported schema (v0.0.2, v0.0.3). moves the pin to cicd-suite HEAD; that PR's own audit run is the check. It needs an `+AUTOMATION_TOKEN+` secret (a fine-grained token on that repository with Contents, Pull requests and Workflows set to read/write), -because `+GITHUB_TOKEN+` may not write under `+.github/workflows/+`. +because `+GITHUB_TOKEN+` may not write under `+.github/workflows/+`. Its one +`+uses:+` is `+step-security/harden-runner+` at a full SHA (egress to GitHub +only); a repository that carries `+actions.lock+` must add that record +(`+gh actions-lock+`) or the first run dies at Set up job. `+rollout_estate.sh+` does all three for every local estate clone. It writes files and does not commit them. diff --git a/templates/track-estate-audit.yml b/templates/track-estate-audit.yml index 87bae80..3cdecdc 100644 --- a/templates/track-estate-audit.yml +++ b/templates/track-estate-audit.yml @@ -10,8 +10,13 @@ name: Track latest cicd-suite gates # The file keeps its original name (track-estate-audit.yml) so repositories # that already carry it receive this version in place. # -# ⚠ NO `uses:` ANYWHERE, DELIBERATELY: actions.lock is keyed by workflow path, -# and this must run even when the lock is stale. +# The only `uses:` here is step-security/harden-runner (hypatia RE001: a job +# that holds a token monitors its egress), pinned at a full SHA. Everything +# else is plain `run:`, so a stale cicd-suite pin can never stop the workflow +# that moves cicd-suite pins. A repository that carries +# .github/workflows/actions.lock must hold a record for this workflow +# (`gh actions-lock`), or its first run dies at Set up job with "invalid +# lockfile pin". # # Token: AUTOMATION_TOKEN, a fine-grained token on this repository with # Contents, Pull requests and Workflows set to read/write. In practice it is @@ -34,6 +39,9 @@ concurrency: jobs: bump: runs-on: ubuntu-latest + # A hung `gh` call must not hold a runner for the default six hours + # (hypatia WF013 / WH006). The whole job is a clone, a sed and a PR. + timeout-minutes: 20 permissions: contents: write pull-requests: write @@ -42,6 +50,15 @@ jobs: BRANCH: ci/track-estate-audit BOT_EMAIL: 41898583+github-actions[bot]@users.noreply.github.com steps: + - name: Harden the runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + # The job clones, reads cicd-suite HEAD and pushes one branch: it + # talks to GitHub only, so block (RE002) rather than audit. + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 - name: Move every cicd-suite pin to HEAD and open a PR run: | set -euo pipefail From 5e98a6a7de0c8d0224e3abd2523cdf339cd2f5e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:31:09 +0000 Subject: [PATCH 5/7] ci(rust): keep deny_args global; add deny_check_args for `check` options A caller that set `deny_args: "--offline"` would have had it placed after `check`, where cargo-deny 0.20.2 rejects it. `deny_args` is again the global option list (default `--all-features --locked`, before `check`) and the new `deny_check_args` carries the check options (default `--warn unmaintained --warn yanked`, after it), in both deny commands. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- .github/workflows/rust-ci.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 32fd970..4370bba 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -125,7 +125,17 @@ on: required: false default: "0.20.2" deny_args: - description: cargo-deny check arguments (placed after check) + description: >- + cargo-deny global options, placed before `check` (e.g. `--offline`, + `--all-features`, `--locked`). Replaces the default; keep its two + flags unless you mean to drop them. + type: string + required: false + default: "--all-features --locked" + deny_check_args: + description: >- + `cargo deny check` options, placed after `check` (e.g. `--warn + unmaintained`, `--hide-inclusion-graph`). type: string required: false default: "--warn unmaintained --warn yanked" @@ -538,12 +548,14 @@ jobs: env: WORKING_DIRECTORY: ${{ inputs.working_directory }} DENY_ARGS: ${{ inputs.deny_args }} - # Global options precede check; lint options follow it. DENY_ARGS is - # word-split on purpose: it is a caller-authored argument list. + DENY_CHECK_ARGS: ${{ inputs.deny_check_args }} + # Global options (deny_args) precede `check`; check options + # (deny_check_args) follow it. Both are word-split on purpose: they are + # caller-authored argument lists. run: | set -euo pipefail # shellcheck disable=SC2086 - cargo deny --log-level warn --all-features --locked --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_ARGS advisories bans sources + cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_CHECK_ARGS advisories bans sources - name: cargo-deny — licences (only against an owner-authored deny.toml) # Licence policy is manual and owner-only (doctrine #6). With no # deny.toml there is no policy to check against, and the gate does @@ -552,10 +564,11 @@ jobs: env: WORKING_DIRECTORY: ${{ inputs.working_directory }} DENY_ARGS: ${{ inputs.deny_args }} + DENY_CHECK_ARGS: ${{ inputs.deny_check_args }} run: | set -euo pipefail # shellcheck disable=SC2086 - cargo deny --log-level warn --all-features --locked --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_ARGS licenses + cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_CHECK_ARGS licenses coverage: name: llvm-cov line coverage From 960de7db5603bcd95effaa02769d39579a0f4834 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:39:25 +0000 Subject: [PATCH 6/7] ci(tracker): refuse an unresolved cicd-suite HEAD before rewriting pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git ls-remote … HEAD | cut -f1` can succeed with an empty result; the sed that follows would then strip every caller pin. The tracker now exits 1 unless the lookup returned a full 40-hex SHA (review on plasma-parser-writer#108). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xn9nqHVbUTRLgBdedoXAc8 --- templates/track-estate-audit.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/track-estate-audit.yml b/templates/track-estate-audit.yml index 3cdecdc..09e7464 100644 --- a/templates/track-estate-audit.yml +++ b/templates/track-estate-audit.yml @@ -74,6 +74,13 @@ jobs: exit 1 fi head=$(git ls-remote https://github.com/hyperpolymath/cicd-suite.git HEAD | cut -f1) + # A lookup that succeeds but returns no HEAD leaves $head empty, and + # the sed below would then strip every pin. Refuse anything that is + # not a full SHA. + if ! [[ "$head" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::could not resolve cicd-suite HEAD (got '${head}'); no pin was touched." + exit 1 + fi changed=0 for caller in "${callers[@]}"; do while IFS= read -r pinned; do From bbe88d48f42bfef776bd562ed4f740e72c14e244 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:37:06 +0000 Subject: [PATCH 7/7] docs(gates): document check_control behavior, argument, and exit status --- tests/gate-action-owners.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/gate-action-owners.sh b/tests/gate-action-owners.sh index 579be72..22d7a41 100755 --- a/tests/gate-action-owners.sh +++ b/tests/gate-action-owners.sh @@ -72,6 +72,10 @@ done # layout, including the exact ref that killed the first live caller. tmp=$(mktemp) trap 'rm -f "$tmp" "${tmp}.valid"' EXIT +# Verify that the planted YAML in $tmp rejects a disallowed owner and a tag +# pin, then accepts the same layout with allowed owners at full commit SHAs. +# $1 - YAML layout label used in diagnostic messages +# Prints a success message and returns 0, or exits 2 if a control fails. check_control() { local layout="$1" output n if output="$(check_file "$tmp")"; then