diff --git a/.github/workflows/language-gates-self-test.yml b/.github/workflows/language-gates-self-test.yml index 1e715d5..c31fede 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" @@ -85,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; } @@ -98,6 +100,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..4370bba 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -119,11 +119,26 @@ 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) + 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 --warn unmaintained --warn yanked" + 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" enable_audit: description: >- Accepted for compatibility with standards' rust-ci-reusable; advisories @@ -514,22 +529,46 @@ 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 }} + 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 $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 # 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 }} + DENY_CHECK_ARGS: ${{ inputs.deny_check_args }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 + cargo deny --log-level warn $DENY_ARGS --manifest-path "${WORKING_DIRECTORY}/Cargo.toml" check $DENY_CHECK_ARGS licenses coverage: name: llvm-cov line coverage diff --git a/.github/workflows/spark-gate.yml b/.github/workflows/spark-gate.yml index 8736f89..d048014 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 @@ -71,6 +72,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 +102,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 +217,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 -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 --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" + 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..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. @@ -287,6 +290,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/templates/track-estate-audit.yml b/templates/track-estate-audit.yml index 87bae80..09e7464 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 @@ -57,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 diff --git a/tests/gate-action-owners.sh b/tests/gate-action-owners.sh new file mode 100755 index 0000000..22d7a41 --- /dev/null +++ b/tests/gate-action-owners.sh @@ -0,0 +1,109 @@ +#!/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, 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) + +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" 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 + 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 <<< "$refs" + 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 controls: the guard must refuse both violations in each YAML +# 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 + 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: malformed YAML was accepted"; exit 2 +fi +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 +fi +echo "gate-action-owners: every gate ref is an allowed owner at a full SHA"