diff --git a/.github/workflows/assurance-kernel.yml b/.github/workflows/assurance-kernel.yml index 5c3b6b4..588bc45 100644 --- a/.github/workflows/assurance-kernel.yml +++ b/.github/workflows/assurance-kernel.yml @@ -8,12 +8,18 @@ on: paths: - "src/Assurance/**" - "assurance-kernel.ipkg" + - "assurance-github.ipkg" + - "scripts/assurance-github.*" + - "tests/assurance-github.sh" + - "tests/assurance-real-canaries.sh" + - "config/assurance-canaries/**" - "tests/assurance-kernel.sh" - ".github/workflows/assurance-kernel.yml" workflow_dispatch: permissions: contents: read + actions: read # PR updates supersede previous costly compiler boots; manual dispatches are unique. concurrency: @@ -42,7 +48,14 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends chezscheme libgmp-dev git make gcc ca-certificates + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends chezscheme libgmp-dev git make gcc ca-certificates jq gh + - name: Validate bridge shell and qualifier syntax + run: | + set -euo pipefail + for script in scripts/assurance-github.sh tests/assurance-github.sh tests/assurance-real-canaries.sh; do + bash -n "$script" + done + jq -n -L scripts 'include "assurance-github"; true' >/dev/null - name: Bootstrap pinned Idris2 0.7.0 source env: IDRIS2_SOURCE_SHA: 27780073c8631826d846499840b3857d9b9a4fd5 @@ -61,3 +74,20 @@ jobs: echo "$HOME/.idris2/bin" >> "$GITHUB_PATH" - name: Compile model and reject contradictory proof run: bash tests/assurance-kernel.sh + - name: Compile and exercise the new finite GitHub bridge + run: bash tests/assurance-github.sh + - name: Observe passing and defective real consumers + id: real_canaries + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + GH_PROMPT_DISABLED: "1" + run: bash tests/assurance-real-canaries.sh "$RUNNER_TEMP/assurance-real-consumers" + - name: Retain separate real consumer verdicts + if: ${{ always() && github.event_name == 'workflow_dispatch' && steps.real_canaries.outcome != 'skipped' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: assurance-real-consumers-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/assurance-real-consumers/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/code-hygiene-self-test.yml b/.github/workflows/code-hygiene-self-test.yml index a58737c..4eaa33a 100644 --- a/.github/workflows/code-hygiene-self-test.yml +++ b/.github/workflows/code-hygiene-self-test.yml @@ -103,6 +103,11 @@ # which installs none of their tools. run: "bash tests/pass-on-absence.sh", }, + { + # Exercise both exact suite names, stale pins, missing history and + # nonempty populations before checking the real imported source. + run: "bash tests/lock-self-source-controls.sh", + }, { run: "bash tests/lock-transitive-closure.sh", }, diff --git a/.github/workflows/language-gates-self-test.yml b/.github/workflows/language-gates-self-test.yml index 97a7c29..728311a 100644 --- a/.github/workflows/language-gates-self-test.yml +++ b/.github/workflows/language-gates-self-test.yml @@ -21,6 +21,7 @@ on: branches: [main] paths: - "actions/rust-ci-check/**" + - "actions/verus-check/**" - "actions/creusot-check/**" - "actions/spark-check/**" - "actions/kanren-check/**" @@ -30,6 +31,7 @@ on: - "tests/zig-ffi-scope.sh" - "tests/gate-action-owners.sh" - "tests/inspect-pr-ci.sh" + - "tests/rust-doc-package-scope.sh" - "tests/suite-source-compatibility.sh" - "scripts/inspect-pr-ci.sh" - ".github/workflows/rust-ci.yml" @@ -41,6 +43,7 @@ on: pull_request: paths: - "actions/rust-ci-check/**" + - "actions/verus-check/**" - "actions/creusot-check/**" - "actions/spark-check/**" - "actions/kanren-check/**" @@ -50,6 +53,7 @@ on: - "tests/zig-ffi-scope.sh" - "tests/gate-action-owners.sh" - "tests/inspect-pr-ci.sh" + - "tests/rust-doc-package-scope.sh" - "tests/suite-source-compatibility.sh" - "scripts/inspect-pr-ci.sh" - ".github/workflows/rust-ci.yml" @@ -111,8 +115,12 @@ jobs: run: bash tests/zig-ffi-scope.sh - name: Rust gate run: bash actions/rust-ci-check/test.sh + - name: Scoped Rust documentation still rejects broken doctests + run: bash tests/rust-doc-package-scope.sh - name: Creusot gate run: bash actions/creusot-check/test.sh + - name: Verus execution gate controls + run: bash actions/verus-check/test.sh - name: SPARK gate run: bash actions/spark-check/test.sh - name: miniKanren gate diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 386ad6b..fbcfdab 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -99,6 +99,11 @@ on: type: boolean required: false default: true + doc_packages: + description: Cargo package selection for docs and doctests; scope must match the declared consumer boundary + type: string + required: false + default: "--workspace" deny_warnings: description: Export RUSTFLAGS/RUSTDOCFLAGS "-D warnings" for every compile type: boolean @@ -442,6 +447,7 @@ jobs: WORKING_DIRECTORY: ${{ inputs.working_directory }} TEST_ARGS: ${{ inputs.test_args }} DOCTESTS: ${{ inputs.doctests }} + DOC_PACKAGES: ${{ inputs.doc_packages }} DENY_WARNINGS: ${{ inputs.deny_warnings }} run: bash .cicd-suite/actions/rust-ci-check/check.sh @@ -531,6 +537,7 @@ jobs: env: RUST_GATE_PHASE: doc WORKING_DIRECTORY: ${{ inputs.working_directory }} + DOC_PACKAGES: ${{ inputs.doc_packages }} DENY_WARNINGS: ${{ inputs.deny_warnings }} run: bash .cicd-suite/actions/rust-ci-check/check.sh diff --git a/.github/workflows/secret-scanner-self-test.yml b/.github/workflows/secret-scanner-self-test.yml index 9c1ae3a..7c7e910 100644 --- a/.github/workflows/secret-scanner-self-test.yml +++ b/.github/workflows/secret-scanner-self-test.yml @@ -30,6 +30,11 @@ jobs: bash tests/capability-catalogue.sh bash scripts/build-assurance-matrix.sh --check bash tests/assurance-matrix.sh + - name: Non-enforcing CI profile contract and proposed pilot controls + run: | + bash tests/ci-profiles.sh + bash tests/ci-pilot-manifest.sh + bash tests/docs-profile-subject.sh - name: Explicit workflow lock scope and omitted-key controls run: bash tests/workflow-lock-scope.sh - name: Governance pin representation regressions (not full lock verification) diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index d4e9fc1..c2e4a30 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -16,7 +16,7 @@ concurrency: cancel-in-progress: true jobs: scan: - uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e + uses: hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e with: # Keep false until an initial full audit passes and the security owner # accepts introduced-commit PR coverage. All non-PR runs remain full. diff --git a/README.adoc b/README.adoc index ec35136..d82e419 100644 --- a/README.adoc +++ b/README.adoc @@ -3,6 +3,39 @@ *An atlas of composable repository assurance: security, governance, build hygiene, contracts, language verification and ecosystem integration.* +The link:docs/WORKFLOW-SCOPE.adoc[workflow scope proposal] keeps CI/CD and broader +repository workflows together while preserving the current published coordinate. + +== Canonical repository and migration status + +[NOTE] +==== +This section came from `hyperpolymath/proven-cicd` when its history was merged +into this repository on 2026-11-10. It describes that repository as of +2026-11-07. This repository is `hyperpolymath/proven-cicd-and-workflows`, the +renamed `cicd-suite`. The owner has stated that the two repositories are to be +one, under this name. The text below is kept as a dated record until the +migration status is rewritten. +==== + +`hyperpolymath/proven-cicd` is the canonical destination for this suite. +The GitHub import retained the existing Git history and both advertised branch +heads, including the open Creusot execution-evidence work. The import checkpoint +is recorded in link:docs/assurance/2026-11-04/CANONICAL-IMPORT.adoc[the canonical +history and rollout record]. The legacy `hyperpolymath/cicd-suite` repository +remains available for existing full-SHA callers and its review/run evidence. + +Migration is staged: repository protections, two real caller canaries and the +remaining caller inventory must qualify before an estate-wide cutover. Existing +full-SHA pins keep their source identity; this repository's scanner caller is a +coordinate-only canary at the already published `a12b2b4` source. No release or +gate is certified merely by the new repository name. + +The Idris2 verdict kernel is present. Its operational GitHub adapter remains +unpromoted: review reproduced an invalid duplicate-field response becoming a +false passed verdict, and the repair must complete its expanded controls before +publication. See the checkpoint for the exact remaining boundary. + == Discover the suite Start with the link:CATALOGUE.adoc[*CI/CD Capability Atlas*]: *32 composite actions @@ -34,6 +67,10 @@ linked regression controls, explicit evidence gaps and consumer receipts, and the link:docs/assurance/SUITE-CLOSEOUT.adoc[suite-first completion ledger] for remaining shared work. These records do not certify every capability. +The link:profiles/README.adoc[experimental CI profile contract] adds three +proposed mother profiles, typed slots, validation controls and a bounded pilot +manifest. It is non-enforcing and does not qualify consumer CI or add a workflow. + == Estate-wide audit A subset of the suite is orchestrated by the reusable workflow @@ -454,7 +491,20 @@ No logs, artifacts, secrets, policy edits, reruns or merges are requested. See link:docs/CI-TRIAGE-2026-10-30.adoc[the sampled estate diagnosis] and link:docs/SECRET-SCAN-PERFORMANCE.adoc[the secret-scan latency investigation -and implementation handoff]. No replacement scanner has been deployed. +and implementation handoff]. The bounded scanner is deployed to the suite and +Echidna canaries. The earlier paired full-history receipts are retained in +https://github.com/hyperpolymath/cicd-suite/pull/84[suite PR 84]. Canonical-name +hosted canaries subsequently passed in +https://github.com/hyperpolymath/proven-cicd/actions/runs/41910602874[Proven CI/CD run 41910602874] +at subject `5f2fd18c3ec710db00486b9985d0cbb242803463` and +https://github.com/hyperpolymath/echidna/actions/runs/41909747996[Echidna run 41909747996] +at subject `b8dc7fa90f82ab124b6af18398999ef6a9724e32`. +Both runs used scanner source `a12b2b4644db1a1250bc50b58fd24c99f89f564e`. +These receipts qualify those historical heads only; later heads require their +own hosted receipts before promotion. See +link:docs/assurance/2026-11-04/CANONICAL-IMPORT.adoc[the canonical import checkpoint] +for the retained evidence and remaining rollout conditions. An estate-wide +replacement has not completed. === Bounded secret scanner (canary, not estate-deployed) @@ -485,21 +535,19 @@ production rollout; see link:docs/SECRET-SCANNER-ROLLOUT.adoc[rollout status]. ==== Post-merge startup repair and supplemental detector ports -The follow-up described in +The dated checkpoint in link:docs/SCANNER-STARTUP-AND-PARITY.adoc[Startup and supplemental coverage] -repins this repository to the published #62 merge commit. The initial -pre-merge pin failed resolution after merge; the canonical legacy dependency -was then explicitly refused by the caller's Actions admission policy. -Rather than broadening permissions, the suite now carries Rust/shell ports -and 26 differential fixtures against frozen legacy run bodies, plus five -hardening controls. The former legacy comparison workflow runs these local -checks; it no longer invokes standards or repeats full-history Gitleaks. - -The new scanner source includes both ports in its composite verdict. Publish -that implementation before advancing consumer pins: the currently deployed -suite pin still selects the previously published scanner, while separate -local supplement jobs preserve this repository's supplementary checking. -No estate-wide rollout or whole-corpus equivalence is claimed by the fixtures. +records the earlier pre-merge pin and admission failures. Its deployment status +is historical. The integrated source was subsequently published and qualified +with the suite and Echidna callers; the current full source pin is +`a12b2b4644db1a1250bc50b58fd24c99f89f564e`. + +The reusable scanner now provides all three original contexts: Gitleaks, Rust +secrets and shell secrets. Both canonical-name caller runs linked above executed +that exact published source. The separate local supplemental workflow remains +a regression control; it does not supply a missing port in the deployed source. +The 26 differential fixtures and five hardening controls retain their finite +coverage boundary. No estate-wide rollout or whole-corpus equivalence is claimed. == Strict scanner coverage and audit checkpoint @@ -512,6 +560,6 @@ interpret an exit-zero partial scan as assurance. panic-attack assail findings remain report-only; strict coverage is not a security verdict. See link:docs/assurance/AUDIT-2026-11-04.adoc[the adversarial audit checkpoint] -for executed controls, coverage gaps, proof boundaries and the pending -`proven-cicd` rename. Reproduce bounded scanner controls with +for executed controls, coverage gaps and proof boundaries. The canonical +repository now exists; protection and consumer migration are still in progress. Reproduce bounded scanner controls with `bash tests/scanner-adversarial.sh /path/to/pons /path/to/panic-attack`. diff --git a/actions/creusot-check/test.sh b/actions/creusot-check/test.sh index d1e9048..47c8bb9 100755 --- a/actions/creusot-check/test.sh +++ b/actions/creusot-check/test.sh @@ -41,13 +41,16 @@ fresh_fixture() { git -C "$FIX" init -q git -C "$FIX" config user.email t@example.invalid git -C "$FIX" config user.name t + # These temporary fixture commits are not published. A developer's locked + # signing key must not turn fixture creation into missing source evidence. + git -C "$FIX" config commit.gpgsign false printf '[package]\nname = "fx"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > "$FIX/Cargo.toml" printf '//! fixture\npub fn id(x: u32) -> u32 { x }\n' > "$FIX/src/lib.rs" printf '= fx\n\nA small crate.\n' > "$FIX/README.adoc" printf '{"packages":["creusot"],"provers":["z3"]}\n' > "$FIX/why3find.json" - git -C "$FIX" add -A && git -C "$FIX" commit -qm fixture + git -C "$FIX" add -A && git -C "$FIX" commit -qm fixture || exit 2 } -commit() { git -C "$FIX" add -A && git -C "$FIX" commit -qm change >/dev/null; } +commit() { git -C "$FIX" add -A && git -C "$FIX" commit -qm change >/dev/null || exit 2; } run_phase() { # [env...] local phase="$1"; shift : > "$WORK/receipt" diff --git a/actions/rust-ci-check/action.yml b/actions/rust-ci-check/action.yml index 75addf7..56c6f63 100644 --- a/actions/rust-ci-check/action.yml +++ b/actions/rust-ci-check/action.yml @@ -30,6 +30,10 @@ inputs: description: 'Run cargo test --doc as well (true|false)' required: false default: 'true' + doc_packages: + description: 'Cargo package selection for docs and doctests; default covers the whole workspace' + required: false + default: '--workspace' deny_warnings: description: 'Export RUSTFLAGS/RUSTDOCFLAGS=-D warnings for every compile' required: false @@ -50,6 +54,7 @@ runs: CLIPPY_ARGS: ${{ inputs.clippy_args }} TEST_ARGS: ${{ inputs.test_args }} DOCTESTS: ${{ inputs.doctests }} + DOC_PACKAGES: ${{ inputs.doc_packages }} DENY_WARNINGS: ${{ inputs.deny_warnings }} MSRV: ${{ inputs.msrv }} run: bash "$GITHUB_ACTION_PATH/check.sh" diff --git a/actions/rust-ci-check/check.sh b/actions/rust-ci-check/check.sh index 2ff16e2..ceeb16a 100755 --- a/actions/rust-ci-check/check.sh +++ b/actions/rust-ci-check/check.sh @@ -43,6 +43,7 @@ check_args="${CHECK_ARGS:---all-targets}" clippy_args="${CLIPPY_ARGS:---all-targets -- -D warnings}" test_args="${TEST_ARGS:---all-targets}" doctests="${DOCTESTS:-true}" +doc_packages="${DOC_PACKAGES:---workspace}" deny_warnings="${DENY_WARNINGS:-true}" fail=0 @@ -94,9 +95,10 @@ case "$phase" in # shellcheck disable=SC2086 run "cargo test" cargo test --locked $test_args if [ "$doctests" = true ]; then - echo "::group::cargo doctests — cargo test --locked --doc --workspace" + echo "::group::cargo doctests — cargo test --locked --doc $doc_packages" out="$(mktemp)" - if cargo test --locked --doc --workspace 2>&1 | tee "$out"; then + # shellcheck disable=SC2086 # Cargo package selectors are deliberately word-split + if cargo test --locked --doc $doc_packages 2>&1 | tee "$out"; then echo "::endgroup::" echo "ok: cargo doctests" else @@ -107,7 +109,7 @@ case "$phase" in && [ "$(grep -cE '^error' "$out" || true)" -eq 1 ]; then echo "::notice::rust-ci-check[test]: no library target in the workspace, so there are no doctests to run (cargo: 'no library targets found'). This is a fact about the crate, not a skipped check." else - err "cargo doctests failed: cargo test --locked --doc --workspace" + err "cargo doctests failed: cargo test --locked --doc $doc_packages" fi fi rm -f "$out" @@ -116,7 +118,8 @@ case "$phase" in fi ;; doc) - run "cargo doc" cargo doc --locked --no-deps --document-private-items --workspace + # shellcheck disable=SC2086 + run "cargo doc" cargo doc --locked --no-deps --document-private-items $doc_packages ;; msrv) msrv="${MSRV:-}" diff --git a/actions/verus-check/check.sh b/actions/verus-check/check.sh new file mode 100644 index 0000000..8c5dbbe --- /dev/null +++ b/actions/verus-check/check.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Run an installed Verus with planted controls; no source download here. +set -euo pipefail +verifier="${VERUS:-verus}" +command -v "$verifier" >/dev/null || { echo "Verus unavailable: no proof performed" >&2; exit 2; } +[[ $# -gt 0 ]] || { echo "Supply at least one Rust proof file" >&2; exit 2; } +for source in "$@"; do + [[ -f "$source" && "$source" == *.rs && "$source" != -* ]] || { echo "Invalid proof source: $source" >&2; exit 2; } +done +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +cat > "$work/positive.rs" <<'RS' +use vstd::prelude::*; +verus! { pub fn positive() -> (r: u32) ensures r == 2 { 2 } } +RS +cat > "$work/negative.rs" <<'RS' +use vstd::prelude::*; +verus! { pub fn negative() -> (r: u32) ensures r == 0 { 1 } } +RS +if "$verifier" --no-cheating --crate-type lib "$work/negative.rs" >"$work/negative.log" 2>&1; then + echo "False contract accepted: no caller proof credited" >&2; exit 2 +fi +report() { + grep -qE '^verification results:: [1-9][0-9]* verified, 0 errors$' "$1" +} +if ! "$verifier" --no-cheating --crate-type lib "$work/positive.rs" >"$work/positive.log" 2>&1 || ! report "$work/positive.log"; then + cat "$work/positive.log" >&2 + echo "Positive control unavailable: no caller proof credited" >&2; exit 2 +fi +for source in "$@"; do + "$verifier" --no-cheating --crate-type lib "$source" >"$work/caller.log" 2>&1 || { cat "$work/caller.log" >&2; exit 1; } + report "$work/caller.log" || { echo "No nonzero proof report: $source" >&2; exit 2; } + printf 'verified source: %s\n' "$source" + cat "$work/caller.log" +done diff --git a/actions/verus-check/test.sh b/actions/verus-check/test.sh new file mode 100644 index 0000000..6a0e51b --- /dev/null +++ b/actions/verus-check/test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +printf '// fixture\n' >"$work/subject.rs" +cat >"$work/oracle" <<'SH' +#!/usr/bin/env bash +case "${MODE:-good}" in + accept) echo 'verification results:: 1 verified, 0 errors'; exit 0 ;; + reject) exit 1 ;; +esac +case "${!#}" in *negative.rs) exit 1 ;; esac +case "${MODE:-good}" in + empty) echo 'verification results:: 0 verified, 0 errors' ;; + subject-fails) case "${!#}" in *subject.rs) exit 1 ;; esac; echo 'verification results:: 1 verified, 0 errors' ;; + *) echo 'verification results:: 1 verified, 0 errors' ;; +esac +SH +chmod +x "$work/oracle" +for mode in good accept reject empty subject-fails; do + status=0 + MODE="$mode" VERUS="$work/oracle" bash "$here/check.sh" "$work/subject.rs" >"$work/out" 2>&1 || status=$? + case "$mode" in good) expected=0 ;; subject-fails) expected=1 ;; *) expected=2 ;; esac + [[ "$status" -eq "$expected" ]] || { cat "$work/out"; echo "$mode: expected $expected, got $status"; exit 1; } +done +status=0 +VERUS="$work/oracle" bash "$here/check.sh" >"$work/out" 2>&1 || status=$? +[[ "$status" -eq 2 ]] +echo 'Verus gate controls: 6 passed' diff --git a/assurance-github.ipkg b/assurance-github.ipkg new file mode 100644 index 0000000..7e82122 --- /dev/null +++ b/assurance-github.ipkg @@ -0,0 +1,8 @@ +-- SPDX-License-Identifier: MPL-2.0 +package assurance-github +version = 0.1.0 +depends = contrib +sourcedir = "src" +modules = Assurance.Verdict, Assurance.GitHub +main = Assurance.GitHubMain +executable = assurance-github diff --git a/config/assurance-canaries/echidna-3f-chapel.json b/config/assurance-canaries/echidna-3f-chapel.json new file mode 100644 index 0000000..91a427a --- /dev/null +++ b/config/assurance-canaries/echidna-3f-chapel.json @@ -0,0 +1,90 @@ +{ + "schema": "proven-cicd/github-execution/v1", + "category": "experimental", + "route": "direct-head", + "repository": { + "full_name": "hyperpolymath/echidna", + "id": 1101572347, + "owner_id": 6759885 + }, + "subject_sha": "3f475120eaefffec9c964f433e26d9f6a85bb526", + "issued_at": "2026-11-04T22:55:11Z", + "expires_at": "2026-11-05T22:55:11Z", + "max_age_seconds": 86400, + "runs": [ + { + "id": 41927253291, + "attempt": 1, + "workflow_id": 249130639, + "path": ".github/workflows/chapel-ci.yml", + "workflow_blob_sha": "41d6048e0c1ba2c6933e4836759343829f547eb9", + "event": "push", + "head_branch": "arena/4853aec5-echidna", + "jobs": [ + { + "name": "Compile Chapel Metalayer", + "steps": [ + "Set up job", + "Run actions/checkout@v7.0.1", + "Install Chapel 2.8.0 (SHA-pinned .deb)", + "Compile Chapel proof search library (static)", + "Compile + run smoke target", + "Upload Chapel library artifact", + "Post Run actions/checkout@v7.0.1", + "Complete job" + ] + }, + { + "name": "Build & Test Zig FFI Bridge", + "steps": [ + "Set up job", + "Run actions/checkout@v7.0.1", + "Install Zig", + "Build Zig FFI library (with Chapel stubs)", + "Run Zig FFI tests (stub mode)", + "Upload FFI library artifact", + "Post Install Zig", + "Post Run actions/checkout@v7.0.1", + "Complete job" + ] + }, + { + "name": "Rust Build with Chapel Feature", + "steps": [ + "Set up job", + "Run actions/checkout@v7.0.1", + "Install Rust toolchain", + "Rust cache", + "Download FFI library", + "Build with chapel feature", + "Test with chapel feature", + "Post Rust cache", + "Post Run actions/checkout@v7.0.1", + "Complete job" + ] + }, + { + "name": "Rust Build — Real Chapel Library (allow-fail, L2.3+ gate)", + "steps": [ + "Set up job", + "Run actions/checkout@v7.0.1", + "Install Chapel 2.8.0 (SHA-pinned .deb)", + "Install Zig", + "Install Rust", + "Rust cache", + "Download real Chapel library", + "Verify real archive exports", + "Check native link contract controls", + "Build real static Zig bridge (Chapel linked by Cargo)", + "Build Rust with real Chapel library", + "Test Rust with real Chapel library", + "Post Rust cache", + "Post Install Zig", + "Post Run actions/checkout@v7.0.1", + "Complete job" + ] + } + ] + } + ] +} diff --git a/config/assurance-canaries/suite-a96.json b/config/assurance-canaries/suite-a96.json new file mode 100644 index 0000000..4e4f61f --- /dev/null +++ b/config/assurance-canaries/suite-a96.json @@ -0,0 +1,50 @@ +{ + "schema": "proven-cicd/github-execution/v1", + "category": "experimental", + "route": "direct-head", + "repository": { + "full_name": "hyperpolymath/proven-cicd", + "id": 1446155688, + "owner_id": 6759885 + }, + "subject_sha": "a96b85e9d869142757adb9f6a2eb2d40c423d850", + "issued_at": "2026-11-04T22:55:11Z", + "expires_at": "2026-11-05T22:55:11Z", + "max_age_seconds": 86400, + "runs": [ + { + "id": 41924671510, + "attempt": 1, + "workflow_id": 407365438, + "path": ".github/workflows/secret-scanner-self-test.yml", + "workflow_blob_sha": "9c1ae3a84c1a8bfab0a2f8d68a36e64b7271d1ff", + "event": "push", + "head_branch": "fix/canonical-history-and-scanner-canary-20261104", + "jobs": [ + { + "name": "controls", + "steps": [ + "Set up job", + "Run actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "Shell and YAML syntax", + "Capability atlas inventory, navigation and negative controls", + "Explicit workflow lock scope and omitted-key controls", + "Governance pin representation regressions (not full lock verification)", + "Official SonarCloud receipt regressions", + "Shell/control-flow fixtures and vacuous-scanner mutant", + "Compatibility contexts require real SHA-bound detector receipts", + "Both suite coordinates retain strict source pins and checkouts", + "Preserve pinned legacy Rust and shell detection without leaking values", + "Install the pinned real scanner", + "Repeat fixtures against the real detector (not a stand-in)", + "Exact curl placeholder and same-path credential controls", + "Production caller pin is already published on suite main", + "Evidence collector controls", + "Post Run actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "Complete job" + ] + } + ] + } + ] +} diff --git a/config/workflow-lock-scope.json b/config/workflow-lock-scope.json index 1b56476..30fad93 100644 --- a/config/workflow-lock-scope.json +++ b/config/workflow-lock-scope.json @@ -9,7 +9,7 @@ { "path": ".github/workflows/assurance-kernel.yml", "mode": "immutable", - "reason": "Isolated experimental Idris2 compiler canary; SHA-pinned checkout, no lock-map onboarding or consumer enforcement." + "reason": "Experimental Idris2 kernel and read-only execution bridge canary; full-SHA actions, no lock-map onboarding or consumer enforcement." }, { "path": ".github/workflows/code-hygiene-self-test.yml", diff --git a/docs/SECRET-SCANNER-ROLLOUT.adoc b/docs/SECRET-SCANNER-ROLLOUT.adoc index 7d0df73..999d2ff 100644 --- a/docs/SECRET-SCANNER-ROLLOUT.adoc +++ b/docs/SECRET-SCANNER-ROLLOUT.adoc @@ -155,6 +155,22 @@ compatibility implementation, then pin a consumer canary to its main SHA and verify all three actual contexts and fleet's transitive lock-sync gate. The read-only corpus audit passing does not waive those rollout requirements. +Note, 2026-11-10. The warning above about `41f8a3` stays as written. Fleet +later deployed `4404c3c0474e17753fe7689f3485c02890aac113`, which includes the +compatibility result jobs (fleet PR 632, merged as `51298a97...`). On +2026-11-10 fleet moved its caller to `hyperpolymath/proven-cicd` at +`03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409` (fleet PR 656). Fleet closes the +reusable edge with its pinned-source manifest and a reviewed receipt row, not +with a suite record in `actions.lock`. The suite read these facts from Git. It +read no fleet run, so the three contexts and fleet's lock-sync gate are not +verified here. `4404c3c...` admits only a `cicd-suite` caller, and the current +scanner admits `cicd-suite` and `proven-cicd` but not the +`proven-cicd-and-workflows` name, so a move to that name needs a new pin. The +corpus canary manifest still pins `41f8a3` against an older fleet snapshot and +remains an offline audit. The dated evidence and its limits are in +link:assurance/SUITE-CLOSEOUT.adoc[the suite closeout] and +link:assurance/external-progress-2026-11-10.json[the 2026-11-10 snapshot]. + The corpus canary workflow runs only in pull-request context. An initial manual-dispatch trigger was removed after CodeQL flagged dynamic checkouts in a default-branch execution context as a potential cache-poisoning surface. diff --git a/docs/VERUS-CHECK.adoc b/docs/VERUS-CHECK.adoc new file mode 100644 index 0000000..70844c5 --- /dev/null +++ b/docs/VERUS-CHECK.adoc @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Verus execution boundary + +actions/verus-check/check.sh consumes an already installed, independently pinned +Verus binary through VERUS and one or more explicit Rust proof paths. It first +requires a false contract to fail and a true contract to produce a nonzero +verified count. Every caller source must then succeed with a nonzero report. + +Exit 0 means these selected sources verified; 1 is a failed caller invocation; +2 means prerequisites, controls or proof coverage are unavailable. No report +establishes refinement between a model and production code, soundness of trusted +dependencies or completeness of a parser. --no-cheating is always supplied. + +actions/verus-check/test.sh exercises accepted, rejected, empty, missing-scope +and deliberately misleading oracle controls. These stand-ins test the gate; +live positive/negative controls test the installed verifier on each invocation. diff --git a/docs/WORKFLOW-SCOPE.adoc b/docs/WORKFLOW-SCOPE.adoc new file mode 100644 index 0000000..8a5afbd --- /dev/null +++ b/docs/WORKFLOW-SCOPE.adoc @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Workflow scope and repository naming +:status: Proposed organisation; no repository rename or consumer migration + +CI/CD is one use of repository workflows. Scheduled maintenance, documentation +checks, dependency proposals, release preparation and estate audits share the +same execution, permissions, provenance and failure-reporting machinery. +Keeping these capabilities together avoids maintaining separate verification +and distribution systems for overlapping kinds of work. + +== Recommended organisation + +Retain `hyperpolymath/proven-cicd` as the published source coordinate while +existing consumers depend on it. Extend the capability catalogue with workflow +purposes such as CI, delivery, release, maintenance, documentation and estate +audit. These purposes complement the existing clades; they do not replace them +or imply that every capability is implemented or qualified. + +Keep executable GitHub workflow entry points in `.github/workflows/`. Group +supporting specifications, examples and controls by purpose, and keep generic +actions reusable across groups. Put source-level test/benchmark evidence in +`proven-tests-and-benches`, with this suite responsible for orchestrating it and +checking its receipts. Consumers pin both dependencies explicitly. + +`proven-workflows` would be a clearer eventual umbrella name than +`proven-cicd-and-workflows`, since the latter names a category alongside its +superset. A rename remains a separate migration decision. First inventory +full-SHA callers, forge mirrors, action allowlists, documentation links, package +identities and required checks; replay representative callers before changing +the advertised coordinate. A new name does not establish new assurance. + +== When a separate repository is justified + +Split only if a workflow family needs different maintainers, access controls, +release cadence or execution infrastructure. The distinction between scheduled +and event-triggered jobs alone is insufficient. Keep shared verdict logic and +controls in one owner even if deployment-specific adapters later split. + +== Workflow acceptance contract + +Every new workflow names its inputs, side effects, permissions, applicable +subjects, failure/refusal semantics, positive and negative controls, and +execution receipt. A successful setup job does not establish that the intended +subject was checked. + +== Scoped Rust documentation + +`rust-ci.yml` and `rust-ci-check` accept `doc_packages`, defaulting to +`--workspace` so existing consumers retain their coverage. A consumer with +optional GUI or database packages may select explicit `-p package` arguments +for its portable CLI boundary. Document the matching build, test and +documentation scope: excluded packages are not qualified by that job. +Doctests remain enabled, and a selected package's failing documentation fails +the gate. diff --git a/docs/assurance/2026-11-04/CANONICAL-IMPORT.adoc b/docs/assurance/2026-11-04/CANONICAL-IMPORT.adoc new file mode 100644 index 0000000..401df76 --- /dev/null +++ b/docs/assurance/2026-11-04/CANONICAL-IMPORT.adoc @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Canonical history and staged rollout: 2026-11-04 + +== Imported history + +The public legacy repository `hyperpolymath/cicd-suite` (repository ID +1326697643) was imported into `hyperpolymath/proven-cicd` (ID 1446155688). +Both repositories remain available. The import preserved these exact heads: + +[cols="2,3",options="header"] +|=== +|Branch |Commit +|main |b931e8e126644516e292601bc644f480a7b2a5a0 +|fix/148-creusot-execution-evidence |e7f2dc171fbe7a078c0a89809da914cc0fb1d9f2 +|=== + +The main tree is `85a65319b595fbe5e1657c1b0eebb3d142400715`. There were +no advertised tags. A full mirror clone passed `git fsck --full --strict`. +The two branch heads reach 141 distinct commits; all imported refs reach 277, +including importer-retained historical pull-request refs. The common root is +`11b5ab51a1600b7b798572200094e611ae0654c2`. These historical refs were retained. +This was a history-preserving import, not a new root with copied files. + +The legacy review and Actions records remain authoritative evidence at their +original URLs, including https://github.com/hyperpolymath/cicd-suite/pull/80[the +open Creusot execution-evidence PR]. Importing Git objects does not transfer +GitHub review approvals, repository settings or service attestations. + +== Protection and caller admission + +The target's active Branch-Floor ruleset was observed through GitHub's API +as rule 26564812 on 2026-11-04. Tag protection is not yet established: the target ruleset inventory contains +only that branch rule, while the legacy repository retains Immutable-Tags rule +22941142. The remaining service settings still require verification. The settings interface requested +GitHub reauthentication, and the browser subsequently required an extension +update. These access boundaries were retained. Do not infer full protection +parity from repository creation or the branch floor alone. + +The self scanner caller moves only its coordinate to +`hyperpolymath/proven-cicd`, retaining full source commit +`a12b2b4644db1a1250bc50b58fd24c99f89f564e`, all three scanner contexts, +read-only contents permission and full-history coverage. Its local qualification +passed 14 identity controls, 21 receipt controls, 15 publication guards and 17 +real Gitleaks controls, plus workflow syntax and declared lock-scope checks. +The source exists in the imported history and the pinned resolver returned the +canonical coordinate. Hosted qualification subsequently passed at source +`5f2fd18c3ec710db00486b9985d0cbb242803463` in +https://github.com/hyperpolymath/proven-cicd/actions/runs/41910602874[run 41910602874]. +The actual controls jobs 126856829043 and 126856747131 each confirmed the 14, +21, 15 and 17 control counts. Scanner job 126856830383 resolved canonical +`a12b2b4`, verified its checkout and reported successful tree, history and +composite scan verdicts. This is evidence for that exact head, not a claim that +future revisions are already qualified. + +Echidna's matching coordinate-only canary is commit +`b8dc7fa90f82ab124b6af18398999ef6a9724e32` on +https://github.com/hyperpolymath/echidna/pull/455[PR 455]. Its transferred +`standards` mirror caller was independently moved to the canonical owner at +the same source SHA. The native Actions lock must then be regenerated from that +complete immutable source, not edited manually. That pending lock gate is not +part of the scanner equivalence claim. The three canonical scanner jobs passed +in https://github.com/hyperpolymath/echidna/actions/runs/41909747996[run 41909747996], +with whole-job elapsed times of 43 seconds for Gitleaks, three for Rust and four +for shell. Full-history coverage remains configured. + +The suite's existing native `--verify-local` result remains nonzero for its +declared immutable workflow scope (13 of 17 workflows, 43 not-pinned findings). +The scanner caller is outside the managed native workflow map. This canary +neither edits that map nor represents repository-wide native verification as a +pass. The separate explicit workflow-scope check remains required. + +== Dependabot configuration repair + +The imported file contained an empty `package-ecosystem`, causing Dependabot +configuration failure. It now proposes weekly `github-actions` updates at +`/`, using GitHub's documented schema. There is no automatic-merge grant. +Action pin changes still need native recovery where applicable and exact-source +CI qualification. This repair does not dismiss any security alert. + +== Idris2 operational adapter: not yet qualified for publication + +The total verdict model and its three deliberately contradictory proof controls +are retained unchanged. A frozen experimental adapter had passed 49 host +collector controls and eight executable wire controls with source-built Idris2 +0.7.0. Subsequent review reproduced a false `PASSED` when the GitHub response +contained duplicate fields with different JSON value shapes. That discovery +invalidates promotion of the frozen candidate; earlier passing controls are +not a substitute for the missing negative case. + +The local repair checks streamed object-field entries across scalar, array and +object values. Four new end-to-end controls were observed returning `UNKNOWN` +through the actual compiled verdict executable. The expanded full run was still +in flight when the shared execution workspace became unavailable. Completion, +source-hash refresh, policy-digest refresh and the final patch remain unverified. +No adapter source from that interrupted round is published in this change. + +Before promotion, finish the 53 host and eight wire controls, retain the three +negative proofs, independently review the exact refreshed source and policy, +and run real successful and defective consumers. Only direct exact-head +`push` and `workflow_dispatch` observations were in the candidate boundary; +PR merge-context and reusable-workflow provenance remain unsupported. The +adapter does not grant merge authority or training admission. + +== Rollout completion conditions + +. Verify branch and tag protections plus applicable Actions admission settings. +. Retain hosted receipts for both canonical scanner canaries at their exact heads. +. Finish and review the adapter correction before operational qualification. +. Migrate the inventoried consumers through reviewed full-SHA caller changes, + regenerate managed native locks with the official process and verify required + contexts at each resulting head. +. Preserve legacy source, issue, review and run links until every remaining + consumer has an explicit disposition. No estate-wide cutover or retirement + is claimed by this checkpoint. diff --git a/docs/assurance/GITHUB-EXECUTION-BRIDGE.adoc b/docs/assurance/GITHUB-EXECUTION-BRIDGE.adoc new file mode 100644 index 0000000..4546725 --- /dev/null +++ b/docs/assurance/GITHUB-EXECUTION-BRIDGE.adoc @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += New finite GitHub execution bridge +:status: Experimental source candidate; not an enforcing estate gate + +== New source, retained kernel + +This is a new implementation. The interrupted earlier collector was never +published, and its repaired source and complete qualification were not +recovered. Its reported 49/53 host controls and eight wire controls do not +qualify these files. + +Assurance.Verdict and assurance-kernel.ipkg remain unchanged. The new +assurance-github.ipkg builds an executable that imports the actual total +kernel. Successful host acquisition is translated to typed observations and +decided by Assurance.Verdict.decide. The CLI builds this executable in a fresh +directory from immutable Git blob bytes after comparing the worktree source +against that commit. Compiler inputs and the jq module are staged from the +verified blob bytes, not reread later from the worktree. It accepts no +outside verdict executable or pre-admitted receipt. + +The existing compiler lane still builds the kernel and requires all three +contradictory theorems to be rejected with named compiler diagnostics. +The operational I/O entry point is explicitly covering; it is not described +as a proof that an operating system, network or GitHub response is total. + +== Raw JSON boundary + +Every policy and API body passes the new Idris validator before jq may +materialize an object. The pinned Idris2 contrib JSON representation retains +JObject fields as a list. The validator recursively rejects repeated keys, +including object/array values followed by a valid-looking scalar, empty +containers, nested fields, and repeated objects with disjoint child fields. +It also requires one complete JSON document. + +This first schema supports only unescaped ASCII object keys. Escaped or +non-ASCII keys are UNKNOWN, even if a different parser would accept them. +Raw lexer tokens are checked before decoded field equality; this avoids +assuming that the pinned contrib string decoder and jq normalize alternate +escapes identically. Unicode string values are not used as object-key +authority. jq independently checks the JSON it subsequently consumes. + +Primary implementation references for the pinned compiler are: + +* https://github.com/idris-lang/Idris2/blob/27780073c8631826d846499840b3857d9b9a4fd5/libs/contrib/Language/JSON/Data.idr[JSON data representation] +* https://github.com/idris-lang/Idris2/blob/27780073c8631826d846499840b3857d9b9a4fd5/libs/contrib/Language/JSON/Parser.idr[complete-document parser] +* https://github.com/idris-lang/Idris2/blob/27780073c8631826d846499840b3857d9b9a4fd5/libs/contrib/Text/Token.idr[raw token text] + +No compiler execution is inferred from source inspection or from a parser +test implemented in another language. + +== One supported policy route + +The schema is proven-cicd/github-execution/v1, category experimental, +route direct-head. Each policy describes exactly one complete workflow at +one exact subject commit, with one to sixteen mandatory jobs. It admits only +direct push or workflow_dispatch events with an empty, present +referenced_workflows array. PR merge contexts, reusable workflows, advisory +admission, conditional exemptions and automatic policy discovery are absent. + +The initial public repository identities are: + +[cols="3,2,2",options="header"] +|=== +| Coordinate | Repository ID | Owner ID +| hyperpolymath/proven-cicd | 1446155688 | 6759885 +| hyperpolymath/echidna | 1101572347 | 6759885 +|=== + +A policy carries the full subject SHA, numeric run ID and attempt, workflow +ID and path, reviewed workflow Git blob SHA, event and branch, all job names, +and the exact ordered step-name list for each job. Empty, duplicate or +unsupported policy members refuse. The raw policy bytes must match a separate +SHA256 supplied by the trusted caller. The caller must independently approve +the policy and its source contract; computing a digest of arbitrary input does +not establish authority or completeness. + +The collector checks current repository/owner identities, exact commit and +complete Git tree, ordinary workflow file and parent-tree entries, source blob, +same-repository run/head identity, attempt, event and branch. The run inventory +must be complete within the declared limit of 100 entries. The selected run +must remain the latest for that workflow/event/branch at the exact head. +Job inventory likewise requires its total_count to equal the returned array +length, with at most 100 entries. Exceeding either bound is UNKNOWN; this +version does not silently credit the first page. + +For a completed run, the actual job-name set must exactly equal the policy +set. Each job must bind the same run/attempt/head, and all named steps must be +present exactly once in the declared order. Timestamps must be well formed, +ordered within the job/run and not in the future. A source symlink/gitlink, +truncated tree, missing job, missing detector step, ambiguous name or changed +run/attempt cannot pass. The run and inventory are fetched again after job +collection. Relevant identity/status/time fields in each selected inventory +entry must equal the corresponding standalone run snapshot, even if its ID +and attempt stayed the same. Decision time is captured after the final GET; +expiry and freshness are checked again after actual kernel execution. The +result is retained only if the observation list is unchanged. The collection +bound is five minutes and the maximum result age is one day. + +These are bounded observations, not an atomic snapshot of GitHub. A change +after the last read or between unobserved instants is not a recorded history. +A transferred repository, rerun or deleted result observed by the collection +refuses; independent revocation authority must still withhold a superseded +policy digest. No external revocation service is claimed. + +== Results and trust + +[cols="1,1,3",options="header"] +|=== +| Receipt | CLI exit | Meaning +| PASSED | 0 | Every mandatory observation in this named execution policy passed +the checked route, and the actual kernel returned Passed. +| FAILED | 1 | A required job/step has an observed execution failure. Its kernel +admission remains unverified and the kernel blocks. +| PENDING | 2 | A bound run is still nonterminal; the kernel blocks. +| UNKNOWN | 2 | Missing, malformed, stale, skipped, unsupported or unavailable +evidence; the kernel blocks when it could be built. +|=== + +FAILED is not a claim of a proved counterexample, a discovered vulnerability +or a semantic scanner finding. The receipt retains each step's reported +status/conclusion so provisioning and check failures remain visible. +GitHub job metadata does not expose every subprocess exit, binary identity +or failure hidden inside a command. The independently reviewed workflow +contract is therefore part of the trusted boundary. + +AdmissionVerified is constructed internally only for the supported, +successfully checked execution route. JSON constructor names cannot grant it. +A valid GitHub execution receipt does not prove the underlying tool's +soundness, attest hardware, prove the truth of a finding, or certify all +repository branch requirements. In particular, this finite policy is not +discovered required-context closure and gives no merge or training authority. + +The trusted base includes GitHub/TLS and gh, the runner clock and filesystem, +jq, the reviewed collector/policy, and the source-provisioned compiler/runtime. +The workflow fetches Idris2 source +27780073c8631826d846499840b3857d9b9a4fd5 (0.7.0) and checks that checkout. +The CLI records the compiler version and expected source but does not call a +local version string an attestation. Ubuntu/Chez and other build dependencies +retain the existing canary's reproducibility limits. + +The code performs GET requests only. It neither executes target-repository +code nor changes refs, approvals, protections or policy. Receipt artifacts +contain public subject identifiers and step metadata, not job logs or secrets. + +== Authored controls and hosted execution + +tests/assurance-github.sh builds the actual executable once and uses the same +collector with a transport fixture. Its authored matrix has 15 native wire/raw +JSON controls, 59 collector cases, and one separate compiler-absence control. +Those counts describe source, not a passing run. Positive controls bracket +negative controls. Expected refusal reasons are checked, so an unrelated jq +syntax failure cannot satisfy a malformed-input or source-binding control. + +The matrix includes same/mixed-shape duplicate keys, incomplete JSON, +multiple documents, escaped aliases, incorrect policy digest, unsupported +events, wrong repository/head/source, symlink/gitlink parents, incomplete +inventories, superseding runs, reruns/deletion, missing/ambiguous jobs, +missing/reordered/skipped steps, provisioning/check failure, queued work, +stale/future evidence, same-attempt inventory changes and expiry both during +collection and after executable work. + +Pull requests touching these files run the existing Assurance kernel +(Idris2 canary) workflow. It first validates shell/jq syntax, bootstraps the +same pinned compiler, retains the three contradictory-proof controls, then +runs the new executable/collector controls. There is no locally claimed +compiler, syntax, fixture or runtime success for this candidate. + +The first hosted attempt at d625f3f9ecf13cf527d6e0214766df999476fb99, +https://github.com/hyperpolymath/proven-cicd/actions/runs/41931852680/job/126930445366[job 126930445366], +bootstrapped the pinned Idris2 compiler, compiled the unchanged kernel and +rejected all three named contradictory theorems. The bridge then failed to +compile: GitHubMain could not resolve Language.JSON.Data.JSON. Its direct +Language.JSON import is added by this follow-up; the executable/collector +controls and both live consumer receipts still require a fresh successful +hosted run. The unrelated manual run on default main checked the old kernel +only and provides no qualification for this bridge. + +The independent Code Hygiene closure check also exposed the canonical import's +retained legacy composite pins. Its source identity now recognizes exactly +hyperpolymath/cicd-suite and hyperpolymath/proven-cicd when the checkout is one +of those repositories. Both populations still inspect their pinned commits and +current action trees, and absence of any examined self pin still fails. +Twenty authored fixture controls cover both names, unrelated identities, +missing history, stale trees and retained lock coverage. These controls await +hosted execution; consumer pins and native lock bytes are unchanged. + +A manual workflow_dispatch on the published candidate branch additionally +runs tests/assurance-real-canaries.sh. The existing workflow already exists +on default main, so this uses its established dispatch surface without new +inputs or an unrelated workflow. The harness checks two fixed policy digests, +performs read-only live collection, and retains both consumer receipts as +separate artifacts. It fails if a generic UNKNOWN replaces the expected +defective observation. The existing native lock remains untouched; this +workflow retains its declared immutable, full-SHA scope. + +== Exact real subjects and before/after qualification + +The following baselines were read from the actual GitHub run/job APIs. +The new bridge has not yet executed against them. + +[cols="2,3,3",options="header"] +|=== +| Subject | Observed before | Required new observation +| proven-cicd a96b85e9d869142757adb9f6a2eb2d40c423d850 +| https://github.com/hyperpolymath/proven-cicd/actions/runs/41924671510[push run 41924671510]: +one successful job, 18 successful steps; no reusable workflows. +| PASSED for exactly that finite execution policy; pending hosted execution. +| echidna 3f475120eaefffec9c964f433e26d9f6a85bb526 +| https://github.com/hyperpolymath/echidna/actions/runs/41927253291[push run 41927253291]: +overall success, three successful jobs and one failed real-Chapel job +126916004595. Across 43 steps, one failed and two skipped. +| FAILED with kernel Blocked, retaining the actual failed job/steps. +This is a defective real consumer, not a proof counterexample. +|=== + +The workflow source blobs are respectively +9c1ae3a84c1a8bfab0a2f8d68a36e64b7271d1ff and +41d6048e0c1ba2c6933e4836759343829f547eb9. +Their policies include all actual job/step names; the skipped work is not +silently exempted. A passing negative-control harness does not change +Echidna's stored consumer verdict to PASSED. + +The committed policy digests are: + +* config/assurance-canaries/suite-a96.json: + 8b7690f38d1b89a97399db96537fe37311e56cc73dc2828f7fe6f82ec2b7d9c6 +* config/assurance-canaries/echidna-3f-chapel.json: + cfe90f04a14f6937a7ca41ee39119c256926058e96f3827b07f9e3570b033fd9 + +Both expire at 2026-11-05T22:55:11Z; data freshness can refuse earlier. +A later run needs a newly reviewed exact policy/digest and actual evidence. +The harness must not extend expiry, substitute another head or update the +expected result merely to become green. A repaired Chapel successor may +supply a separately reviewed passing after-subject once its actual jobs finish. + +GOVERNANCE.adoc still requires real before/after results for at least two +repositories before adopting a changed gate. The authored fixtures are not +that evidence. These real subjects provide a concrete qualification route; +their after receipts, exact compiler/source receipt, independent source +review and ordinary CI gates remain pending. No estate-wide rollout, +enforcement promotion or recovery of the lost adapter is claimed. diff --git a/docs/assurance/SUITE-CLOSEOUT.adoc b/docs/assurance/SUITE-CLOSEOUT.adoc index a1c15eb..a51e0ff 100644 --- a/docs/assurance/SUITE-CLOSEOUT.adoc +++ b/docs/assurance/SUITE-CLOSEOUT.adoc @@ -74,6 +74,194 @@ on a narrowly reviewed current-source canary; add a regression for any remaining established cause before changing shared tooling. Do not weaken proof checks or reinterpret the skipped proof step as success. +== Fleet scanner observation: 2026-11-10 + +This section adds a later observation. The 2026-10-31 table and snapshot above +are unchanged. Exact SHAs, the Git commands used and every probe that was not +available are in link:external-progress-2026-11-10.json[the 2026-11-10 snapshot]. +For fleet, this observation read Git objects only. It read no GitHub API +record of gitbot-fleet: no run, job, check or branch rule. Run records were +read for this repository and for pons-asinorum. Nothing was changed in fleet +or in any other consumer. + +=== Read from Git + +* Fleet PR 632 is merged as `51298a97b0a322b0b175331b1aa4a61348b253fe` (parent + `83e35bcf...`, 2026-10-31T22:43:29Z). Fleet PR 631 is merged as + `7bf2fd6d9aa3c121877de9d6a0d32e3707a002f2`, its direct child + (2026-10-31T22:43:48Z). This supersedes the OPEN and BLOCKED entries for 631 + and 632 above. +* From `51298a97...` to `8906f47f...` the fleet caller used + `hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113` + with `fast-pr: false`, `contents: read` and no `secrets:` line. +* Fleet PR 656 is merged as `a57cb94088f0fd23e66bd0f0aa833425f90eb64f` + (2026-11-10T01:36:15Z). It moved the caller to + `hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409`. + Fleet `main` (`bf0e2558...`) keeps that pin and the same three settings. +* Fleet represents the job-level reusable edge outside `actions.lock`, where + the entry for `secret-scanner.yml` is `[]` by design. The edge is a + source-authenticated row in `scripts/lib/pinned-reusable-sources.json` (blob + `b75b630a...`) and a reviewed row in `scripts/lib/lock-closure-receipts.tsv` + for `actions/checkout@3d3c42e5...`. The blob and its SHA-256 equal the Git + object in this suite. The empty lock entry is not a defect. +* `4404c3c...` is an ancestor of this repository's `main`. The publication + guard for it exited 2: unavailable, not rejected. The guard for + `proven-cicd` at `03e858b...` exited 0. + +=== Repository names + +* `hyperpolymath/cicd-suite` now resolves to this repository, + `hyperpolymath/proven-cicd-and-workflows`: both names return the same head. +* `hyperpolymath/proven-cicd` is a separate repository. The two histories split + at `b931e8e...`. Since then `proven-cicd` has 10 commits of its own and this + repository has 21; 47 files differ between the two `main` trees. +* The scanner at `4404c3c...` admits only a `cicd-suite` caller. The current + scanner, which is the same blob here and at `proven-cicd` `03e858b...`, + admits `cicd-suite` and `proven-cicd`. Neither it nor the publication guard + admits `proven-cicd-and-workflows`. +* Calls to a reusable workflow under the former name now end with no jobs. + This repository's own caller, `secret-scanner.yml`, uses + `hyperpolymath/cicd-suite/...@a12b2b46...`. Its run 42408029451 had three + jobs and succeeded on 2026-11-07. Run 42597552987 on 2026-11-09 and the + scheduled run 42749630980 on 2026-11-10 failed with no jobs. Between them + only one line of `README.adoc` changed. On pons-asinorum the two callers of + `cicd-suite@bf7dd95c...` succeeded on 2026-11-07 and ended with no jobs on + 2026-11-10 (runs 42836648255 and 42836648268), with no workflow file + changed. The API gives no reason. This agrees with fleet's record that a + reusable `uses:` does not follow a repository rename. A run with no jobs is + not a pass: a required context that never reports is absent, not green. +* Step-level action references under the former name still ran. The Central + Estate CI/CD Audit run 42597553230 succeeded on 2026-11-09, and run + 42839265385 on 2026-11-10 completed its five jobs with success. +* The owner stated on 2026-11-10 that the two repositories are intended to be + one, named `proven-cicd-and-workflows`. The same pull request then merged + the `proven-cicd` history; see "Merge of the proven-cicd history" below. + +=== What this does not establish + +Verified scanner consumer deployments remain *one*. Fleet is merged, and its +own committed evidence reports a successful `main` scanner run (42539733415 at +`8906f47f...`, on the `4404c3c...` pin). The suite did not read that run, so +no fleet receipt is added to `catalogue/assurance.json` and the generated +matrix is unchanged. The result of the three contexts on fleet `main` at the +`proven-cicd` pin is not established here. Fleet's caller states that the +old-pin canary does not attest the new pin. + +=== Fleet-reported handoff status + +These rows restate fleet's committed files at `a57cb940...` +(`docs/migrations/secret-scanner-cicd-suite/`). They are fleet's statements, +not suite observations. + +[cols="2,5",options="header"] +|=== +|Item |Fleet record +|Merge-bound runs 41249456040 and 41249455852 |Cancelled; the supplemental jobs then failed receipt verification. Supersession by the next `main` push is an inference. No cancellation actor or reason was read. +|Real lock regeneration |Run with `gh-actions-lock` v0.4.1 (runs 42607340376 and 42609545584). The generator does not lock job-level reusables; the candidate was not applied. +|Live canary receipt |Recorded for unified-dataset-vocab run 41199623322 at the `4404c3c...` pin. None exists for the `03e858b...` pin. +|Runtime dependencies |Outside the receipt: the suite scripts, the checksum-checked Gitleaks download and the runner tools. +|Rollback pin |`standards@75bd5994...`, recorded as not known-good. +|=== + +=== Open items by owner + +Fleet proposes and reviews its own changes. No change to fleet is requested +from this repository. + +Fleet: + +. Establish the three scanner contexts on fleet `main` at the pin it now uses. + Fleet records an earlier run at that pin (42611087892) that failed on the + Rust supplement. +. Record a live canary for that pin before it relies on one. +. Review the policy difference between the two pins: `03e858b...` adds an + exact `your-key-here` allowlist for `curl-auth-header`; `4404c3c...` has none. +. Keep the required contexts, `fast-pr: false`, `contents: read` and the + absence of `secrets: inherit`. + +Suite: + +. Restore this repository's own scanner. It has had no jobs since + 2026-11-09, and its caller on `main` uses the former name. The merge below + moves the caller to `hyperpolymath/proven-cicd` at `a12b2b46...`, a name + that the scanner at that commit admits. That removes the dead name but + makes this repository depend on `proven-cicd`. The lasting repair is a + published scanner that admits this repository's name, and then a caller pin + to that published commit. +. Open pull request + https://github.com/hyperpolymath/proven-cicd-and-workflows/pull/94[94] in + this repository admits the `proven-cicd-and-workflows` name and moves the + self-calls and templates to it. It is not merged. It states that two + self-callers need a second pin, to its merge commit, before they start. +. Every consumer that calls a suite reusable workflow as `cicd-suite` is + expected to fail in the same way. Only this repository and pons-asinorum + were read here. Pull request 94 reports 91 repositories and 106 workflow + files; that inventory was not read here. +. Complete the move to one caller coordinate. The 10 `proven-cicd` commits + are merged by this pull request. The order of the remaining steps matters: + publish the admission of the `proven-cicd-and-workflows` name (pull request + 94), pin this repository's own callers to that published commit, and only + then ask fleet to move with a canary. A caller of a name the scanner does + not admit stops before it scans. Do not archive `proven-cicd` before fleet + and this repository's scanner caller have moved. +. With an authorised read credential, read a fleet `main` scanner run and its + jobs. Only then add a fleet receipt to `catalogue/assurance.json` and + regenerate the matrix. +. The corpus canary manifest still pins suite `41f8a3...` against fleet + `83e35bcf...`. It remains an offline audit and is unchanged. + +=== Merge of the proven-cicd history + +Later on 2026-11-10, at the owner's request, the same pull request merged +`hyperpolymath/proven-cicd` `main` (`03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409`) +into this repository. The observations above describe both `main` branches +before that merge. + +* The merge keeps the 10 commits with their identities, so `03e858b...` is an + ancestor of the merged branch. +* Four files had conflicts. `rust-ci.yml` keeps `taiki-e/install-action` + v2.90.0, the newer pin. `README.adoc` keeps this repository's title, and a + note marks the imported migration section as a dated record. In + `actions/creusot-check`, both sides had taught the gate the Why3find + single-file report. The merge keeps `has_proved_files` from this `main`, + which requires the success marker, and removes the `proven-cicd` function + `has_proof_report`, which accepted a line with no marker and has no caller + left. The test keeps this `main`'s controls for the real format and takes + the `proven-cicd` change that stops when a fixture commit fails. +* Creusot gate controls: 93 pass on `main`, 89 on `proven-cicd`, and 93 on the + merged tree. The execution-attribution controls pass 53 of 53 on each. +* Local test comparison on x86_64 Linux, `main` `3120495...` against the merged + tree: no test that passes on `main` fails on the merged tree. 23 tests pass + on both. 13 fail on both with the same last line of output; they were not + diagnosed here. Of the 8 tests that the merge adds, 5 pass. The other 3 gave + no result locally: one needs Idris 2, one needs an output directory + argument, and `tests/lock-self-source-controls.sh` fails in the same way on + unmerged `proven-cicd`. The machine has no mikefarah `yq`, so the hosted + checks are the authority for the `yq`-based tests. +* `.github/workflows/secret-scanner.yml` now calls + `hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e`, + as `proven-cicd` `main` does. +* Not changed by the merge: the 21 composite pins in `main-estate-audit.yml` + and the caller in `creusot-live-canary.yml` still name `cicd-suite`. + Pull request 94 renames them. It was written against `main` without the + merge and needs an update after either one lands. +* The merged self-pin section of `tests/lock-transitive-closure.sh` admitted + `cicd-suite` and `proven-cicd` as this suite's names, not + `proven-cicd-and-workflows`. Hosted run 42846788461 (job 129936292444, head + `20f086acbfae00cd8cff534b3337e03021b284b5`) failed there with + `no self-referencing pin examined`. +* The test on `main` accepts only the name of the checkout, and the pins name + `cicd-suite`. By that source, `main` has the same condition after the + rename. No hosted run shows it: the last `main` run of the job + (42408029359, 2026-11-07) passed under the former name. +* A later commit in the same pull request admits + `proven-cicd-and-workflows` as the third exact name and adds 5 controls to + `tests/lock-self-source-controls.sh` (25 in total). A fork or a similar + name stays refused. Read with Git at the merged head: each of the 21 pinned + composites equals the current composite + (`git rev-parse :actions/` against `HEAD:actions/`). + The checks of the pull request record the hosted result of that commit. + == Suite lock-map finding — investigation, not a completed fix At the inspected suite source: diff --git a/docs/assurance/external-progress-2026-11-10.json b/docs/assurance/external-progress-2026-11-10.json new file mode 100644 index 0000000..6328bf8 --- /dev/null +++ b/docs/assurance/external-progress-2026-11-10.json @@ -0,0 +1,437 @@ +{ + "observed_on": "2026-11-10", + "method": "Git objects read from anonymous clones and from this checkout. GitHub API records were read for hyperpolymath/proven-cicd-and-workflows and hyperpolymath/pons-asinorum only. No GitHub API record of hyperpolymath/gitbot-fleet was read; see unavailable. Entries under fleet_reported are copied from files committed in gitbot-fleet and were not read by the suite.", + "git_commands": [ + { + "purpose": "name resolution", + "command": "git ls-remote https://github.com/hyperpolymath/ HEAD refs/heads/main", + "names": [ + "cicd-suite", + "proven-cicd-and-workflows", + "proven-cicd" + ] + }, + { + "purpose": "pin 4404c3c is on suite main", + "command": "git merge-base --is-ancestor 4404c3c0474e17753fe7689f3485c02890aac113 origin/main", + "result": "exit 0" + }, + { + "purpose": "commits behind suite main", + "command": "git rev-list --count 4404c3c0474e17753fe7689f3485c02890aac113..origin/main", + "result": "87" + }, + { + "purpose": "fetch the separate repository", + "command": "git fetch https://github.com/hyperpolymath/proven-cicd main", + "result": "FETCH_HEAD 03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409" + }, + { + "purpose": "merge base of the two mains", + "command": "git merge-base FETCH_HEAD origin/main", + "result": "b931e8e126644516e292601bc644f480a7b2a5a0" + }, + { + "purpose": "commits only in proven-cicd", + "command": "git rev-list --count b931e8e126644516e292601bc644f480a7b2a5a0..FETCH_HEAD", + "result": "10" + }, + { + "purpose": "commits only in this repository", + "command": "git rev-list --count b931e8e126644516e292601bc644f480a7b2a5a0..origin/main", + "result": "21" + }, + { + "purpose": "files that differ between the two mains", + "command": "git diff --stat FETCH_HEAD origin/main", + "result": "47 files changed" + }, + { + "purpose": "blob of a pinned workflow", + "command": "git rev-parse :.github/workflows/secret-scanner-reusable.yml" + }, + { + "purpose": "SHA-256 of a pinned workflow", + "command": "git show :.github/workflows/secret-scanner-reusable.yml | sha256sum" + }, + { + "purpose": "fleet history", + "command": "git clone --shallow-since=2026-10-29 --single-branch https://github.com/hyperpolymath/gitbot-fleet; git log --format='%H %P %cI %s'" + }, + { + "purpose": "fleet pull request heads", + "command": "git fetch origin refs/pull//head; git rev-parse FETCH_HEAD" + }, + { + "purpose": "fleet scanner caller at a commit", + "command": "git show :.github/workflows/secret-scanner.yml" + } + ], + "suite": { + "repository": "hyperpolymath/proven-cicd-and-workflows", + "former_name": "hyperpolymath/cicd-suite", + "name_resolution": { + "command": "git ls-remote HEAD", + "former_name_head": "3120495390635096552935d904652789253938e8", + "current_name_head": "3120495390635096552935d904652789253938e8" + }, + "main": "3120495390635096552935d904652789253938e8", + "separate_repository": { + "repository": "hyperpolymath/proven-cicd", + "main": "03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409", + "merge_base_with_suite_main": "b931e8e126644516e292601bc644f480a7b2a5a0", + "commits_only_in_proven_cicd": 10, + "commits_only_in_suite": 21, + "files_differing_between_mains": 47 + }, + "pins": [ + { + "repository": "hyperpolymath/cicd-suite", + "sha": "4404c3c0474e17753fe7689f3485c02890aac113", + "path": ".github/workflows/secret-scanner-reusable.yml", + "blob": "81b2ba1dfb074f6f8913baa81eb6768b7497cc2a", + "content_sha256": "4745e9318346b1a3eae2e0bf65c6732f73d2ead78ef29de9e20659a71f8057d8", + "admitted_caller_repositories": [ + "hyperpolymath/cicd-suite" + ], + "jobs": [ + "gitleaks", + "supplemental-results" + ], + "contexts": [ + "scan / gitleaks", + "scan / rust-secrets", + "scan / shell-secrets" + ], + "ancestor_of_suite_main": true, + "commits_behind_suite_main": 87, + "publication_guard": { + "command": "scripts/verify-published-scanner-pin.sh 4404c3c0474e17753fe7689f3485c02890aac113 hyperpolymath/cicd-suite", + "exit": 2, + "meaning": "unavailable, not rejected", + "http_status": 403 + } + }, + { + "repository": "hyperpolymath/proven-cicd", + "sha": "03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409", + "path": ".github/workflows/secret-scanner-reusable.yml", + "blob": "b75b630ab6bee9943573502f0ace580078ef92ae", + "content_sha256": "02e34046c20f25c6b1e9ada99bd46294e33cc90a792fefa427f09aef098dcad9", + "admitted_caller_repositories": [ + "hyperpolymath/cicd-suite", + "hyperpolymath/proven-cicd" + ], + "jobs": [ + "gitleaks", + "supplemental-results" + ], + "contexts": [ + "scan / gitleaks", + "scan / rust-secrets", + "scan / shell-secrets" + ], + "is_main_head_of_its_repository": true, + "same_blob_as_suite_main": true, + "publication_guard": { + "command": "scripts/verify-published-scanner-pin.sh 03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409 hyperpolymath/proven-cicd", + "exit": 0, + "meaning": "published on its main" + } + } + ], + "former_name_reusable_calls": { + "summary": "Workflows that call a reusable workflow under hyperpolymath/cicd-suite ended with no jobs from 2026-11-09. The same workflows had jobs and succeeded on 2026-11-07. The caller files did not change. The API gives no reason.", + "observations": [ + { + "repository": "hyperpolymath/proven-cicd-and-workflows", + "caller": ".github/workflows/secret-scanner.yml", + "uses": "hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e", + "last_run_with_jobs": { + "run": 42408029451, + "head_sha": "2dbe6e9c2da71fbce336079f8e016c2de0b90111", + "created_at": "2026-11-07T19:49:02Z", + "conclusion": "success", + "jobs": 3 + }, + "runs_without_jobs": [ + { + "run": 42597552987, + "event": "push", + "head_sha": "3120495390635096552935d904652789253938e8", + "created_at": "2026-11-09T09:59:32Z", + "conclusion": "failure", + "jobs": 0 + }, + { + "run": 42749630980, + "event": "schedule", + "head_sha": "3120495390635096552935d904652789253938e8", + "created_at": "2026-11-10T03:28:05Z", + "conclusion": "failure", + "jobs": 0 + } + ], + "change_between": "README.adoc only, one line" + }, + { + "repository": "hyperpolymath/pons-asinorum", + "callers": [ + ".github/workflows/main-estate-audit.yml", + ".github/workflows/creusot-gate.yml" + ], + "uses": "hyperpolymath/cicd-suite/.github/workflows/{main-estate-audit,creusot-gate}.yml@bf7dd95cf49733019343ff485c2f45c6b261839c", + "last_runs_with_jobs": { + "runs": [ + 42400643588, + 42400643729 + ], + "head_sha": "612145d805c6ec519d60fc65e406e9c091462848", + "created_at": "2026-11-07T18:18:41Z", + "conclusion": "success" + }, + "runs_without_jobs": [ + { + "run": 42836648255, + "event": "push", + "head_sha": "d4adad08293aa8d0edb1409bb28bb2b4e3d3d0d5", + "created_at": "2026-11-10T14:41:31Z", + "conclusion": "failure", + "jobs": 0 + }, + { + "run": 42836648268, + "event": "push", + "head_sha": "d4adad08293aa8d0edb1409bb28bb2b4e3d3d0d5", + "created_at": "2026-11-10T14:41:31Z", + "conclusion": "failure", + "jobs": 0 + } + ], + "change_between": "no workflow file changed" + } + ], + "step_level_action_calls": { + "note": "Workflows whose steps use actions under the former name still ran with jobs after the first failure above.", + "runs": [ + { + "run": 42597553230, + "workflow": "Central Estate CI/CD Audit", + "event": "push", + "head_sha": "3120495390635096552935d904652789253938e8", + "created_at": "2026-11-09T09:59:32Z", + "conclusion": "success" + }, + { + "run": 42839265385, + "workflow": "Central Estate CI/CD Audit", + "event": "pull_request", + "head_sha": "769088de0492ec8ab5c6570ee98de3acdc9fd83e", + "created_at": "2026-11-10T14:56:53Z", + "jobs": { + "governance": "success", + "hygiene": "success", + "manifests": "success", + "languages": "success", + "advisory": "success" + } + } + ], + "uses_example": "hyperpolymath/cicd-suite/actions/required-files-check@c519c452" + } + }, + "open_repair": { + "url": "https://github.com/hyperpolymath/proven-cicd-and-workflows/pull/94", + "title": "Admit the renamed repository and call it by its new name", + "state": "OPEN", + "headRefOid": "7e066e8d08ad195afd5e797a9957e0554e7e61f9", + "created_at": "2026-11-09T15:15:10Z", + "changed_files": 24, + "read": "GitHub API record and file list, 2026-11-10. Not merged. Its statements about other callers (91 repositories, 106 workflow files) were not read here." + } + }, + "fleet": { + "repository": "hyperpolymath/gitbot-fleet", + "main": "bf0e25587eb9f98c4e1a1d412cdd1c618c2e072c", + "pull_requests": [ + { + "url": "https://github.com/hyperpolymath/gitbot-fleet/pull/632", + "headRefOid": "2811e196a456d0bfb186764a4a2d3226a966f42c", + "mergeCommit": "51298a97b0a322b0b175331b1aa4a61348b253fe", + "mergeCommitParents": [ + "83e35bcfd04d43960d635fec5397da8dfa344268" + ], + "mergeCommitDate": "2026-10-31T22:43:29Z", + "state": "merged per Git history; API state not read" + }, + { + "url": "https://github.com/hyperpolymath/gitbot-fleet/pull/631", + "headRefOid": "b97b51e6f2b8334d5b87d25836f4cc7ae2b43028", + "mergeCommit": "7bf2fd6d9aa3c121877de9d6a0d32e3707a002f2", + "mergeCommitParents": [ + "51298a97b0a322b0b175331b1aa4a61348b253fe" + ], + "mergeCommitDate": "2026-10-31T22:43:48Z", + "state": "merged per Git history; API state not read" + }, + { + "url": "https://github.com/hyperpolymath/gitbot-fleet/pull/656", + "headRefOid": "f597f2a211a2963f35ce833a8ccc9053e7eb9eeb", + "mergeCommit": "a57cb94088f0fd23e66bd0f0aa833425f90eb64f", + "mergeCommitParents": [ + "8906f47f945d4675d7375eb78898ed0f1c48cc95" + ], + "mergeCommitDate": "2026-11-10T01:36:15Z", + "state": "merged per Git history; API state not read" + } + ], + "scanner_caller_history": [ + { + "commit": "83e35bcfd04d43960d635fec5397da8dfa344268", + "uses": "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66" + }, + { + "commit": "51298a97b0a322b0b175331b1aa4a61348b253fe", + "uses": "hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113" + }, + { + "commit": "8906f47f945d4675d7375eb78898ed0f1c48cc95", + "uses": "hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113" + }, + { + "commit": "a57cb94088f0fd23e66bd0f0aa833425f90eb64f", + "uses": "hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409" + }, + { + "commit": "bf0e25587eb9f98c4e1a1d412cdd1c618c2e072c", + "uses": "hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409" + } + ], + "scanner_caller_at_main": { + "path": ".github/workflows/secret-scanner.yml", + "fast_pr": false, + "secrets_line": false, + "job_permissions": "contents: read", + "triggers": [ + "pull_request", + "push to main" + ] + }, + "closure_at_main": { + "actions_lock_entry": "'.github/workflows/secret-scanner.yml': []", + "pinned_source": { + "file": "scripts/lib/pinned-reusable-sources.json", + "repository": "hyperpolymath/proven-cicd", + "commit": "03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409", + "blob": "b75b630ab6bee9943573502f0ace580078ef92ae", + "content_sha256": "02e34046c20f25c6b1e9ada99bd46294e33cc90a792fefa427f09aef098dcad9", + "role": "active", + "blob_and_sha256_match_suite_git": true + }, + "receipt_row": { + "file": "scripts/lib/lock-closure-receipts.tsv", + "repository": "hyperpolymath/proven-cicd", + "commit": "03e858b6015e67cc1f9bc31ac1e29ddc1b6fd409", + "edge": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" + } + }, + "fleet_reported": { + "read_by_suite": false, + "source_commit": "a57cb94088f0fd23e66bd0f0aa833425f90eb64f", + "source_files": [ + "docs/migrations/secret-scanner-cicd-suite/2026-11-09-fleet-evidence.md", + "docs/migrations/secret-scanner-cicd-suite/2026-11-09-repair-notes.md", + "docs/migrations/secret-scanner-cicd-suite/canary/receipt.json" + ], + "items": [ + { + "claim": "Main scanner run with all three jobs successful, at the cicd-suite pin 4404c3c", + "subject_sha": "8906f47f945d4675d7375eb78898ed0f1c48cc95", + "run": 42539733415 + }, + { + "claim": "Merge-bound Secret Scanner and Lock Sync Gate runs were cancelled; the supplemental jobs then failed receipt verification. Supersession by the next main push is an inference; no cancellation actor or reason was read.", + "subject_sha": "51298a97b0a322b0b175331b1aa4a61348b253fe", + "runs": [ + 41249456040, + 41249455852 + ] + }, + { + "claim": "The next main push runs succeeded", + "subject_sha": "7bf2fd6d9aa3c121877de9d6a0d32e3707a002f2", + "runs": [ + 41249478795, + 41249478405 + ] + }, + { + "claim": "Live canary receipt: three contexts successful on unified-dataset-vocab at suite pin 4404c3c, full mode, no secrets passed", + "subject_sha": "4921ac0d161d5619c3186b2e916ed3f6e2c8c89f", + "run": 41199623322, + "measured_at": "2026-11-09T11:08:00Z" + }, + { + "claim": "Real gh-actions-lock v0.4.1 regeneration. The regenerated vendor lock lacked three SHA-keyed records; the scoped regeneration changed only the caller reference and left the vendor lock byte-identical.", + "runs": [ + 42607340376, + 42609545584 + ] + }, + { + "claim": "A fleet run at the proven-cicd pin 03e858b started all three contexts and failed: the Rust supplement flagged four upstream-pinned Echidnabot lines. The commit was pushed before authorisation and reverted.", + "subject_sha": "9e27c0744130357c7ba0165f33dc8bc9383781d9", + "run": 42611087892 + }, + { + "claim": "The receipt covers the reusable workflow and its actions/checkout edge only. Suite scripts, the Gitleaks download and runner tools are outside it." + }, + { + "claim": "Historical rollback pin, recorded as not known-good", + "uses": "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66" + } + ] + }, + "not_established": [ + "The result of the three scanner contexts on fleet main after the move to proven-cicd@03e858b (commits a57cb940 and bf0e2558)", + "A live canary for the proven-cicd pin 03e858b. The fleet caller states that the old-pin canary does not attest the new pin." + ] + }, + "unavailable": [ + { + "probe": "GitHub API fields of gitbot-fleet pull requests 632, 631 and 656 (state, merge_commit_sha)", + "reason": "no API credential for gitbot-fleet in this session; the request for one was denied", + "http_status": null + }, + { + "probe": "scripts/inspect-pr-ci.sh hyperpolymath/gitbot-fleet 632", + "reason": "not run; it needs the same API", + "http_status": null + }, + { + "probe": "Workflow runs, attempts, jobs and step conclusions for the fleet merge commits and main", + "reason": "no API credential for gitbot-fleet in this session", + "http_status": null + }, + { + "probe": "Check-run producer of the scanner contexts", + "reason": "no API credential for gitbot-fleet in this session", + "http_status": null + }, + { + "probe": "Branch rules and required contexts of fleet main", + "reason": "no API credential for gitbot-fleet in this session", + "http_status": null + }, + { + "probe": "Raw runner logs and artifacts", + "reason": "no API credential for gitbot-fleet in this session", + "http_status": null + }, + { + "probe": "Publication guard for hyperpolymath/cicd-suite at 4404c3c", + "reason": "GitHub API access under the former repository name is not enabled for this session; ancestry was verified with Git instead", + "http_status": 403 + } + ] +} diff --git a/profiles/README.adoc b/profiles/README.adoc new file mode 100644 index 0000000..cbdb982 --- /dev/null +++ b/profiles/README.adoc @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += CI Profile Contract — Proposal v1 + +This is the first bounded implementation of the CI consolidation proposal: +three mother profiles, typed repository slots, a read-only validator, regression +fixtures and six proposed pilot candidates. It adds no consumer workflow, +release, required check or protection change. It does not generate or execute CI. + +== Contract and authority + +link:mothers.json[mothers.json] defines the three provisional mother identities. +link:../catalogue/capabilities.json[The existing capability catalogue] remains +the identity source for actions and their eight clades. A mother selects +capabilities; it does not duplicate their implementation or create a new clade. +Flat composition is intentional: v1 has no inheritance chain or override DSL. + +Each profile has exactly these fields: + +[cols="1,3",options="header"] +|=== +|Field |Meaning +|`schema_version` |Exactly `1`; incompatible changes need a new version. +|`stage` |Exactly `proposal`. This tool cannot promote a profile. +|`repository` |An `owner/name` coordinate, not proof of ownership or authority. +|`mother` |One versioned identity from the reviewed mother catalogue. +|`lifecycles` |Exactly `validate` and `maintenance`, each once. These are proposed entry points, not generated workflows or scheduled tasks. +|`capabilities` |Unique existing catalogue IDs with `required` or `advisory` obligations. Unknown/stub capabilities are rejected. Advisory actions cannot satisfy a required obligation. +|`slots` |Only the selected mother's typed inputs, described below. +|=== + +The illustrative common minimum is `secret-scan` plus `spdx-license`; the Rust +mother additionally requires `rust-ci`. Removing or demoting these capabilities +fails validation. This minimum is *not* a complete estate policy and cannot +replace a repository's existing obligations. K9, canon lockstep, Test/Aqua, +SPARK, release gates and other actual obligations still require explicit mapping. + +[cols="1,2,3",options="header"] +|=== +|Mother |Slots |Daughter adapter boundary +|`docs-data-v1` |`root`, nonempty unique `documents` paths |`docs-data` is unimplemented. Document/link checking still needs an executable contract and positive/negative controls. +|`julia-library-v1` |`root`, nonempty unique `julia_versions` |`julia-pkg` is unimplemented here. It is not a new capability ID and must preserve the actual Pkg/Test/Aqua obligations when implemented. +|`rust-library-v1` |`root`, `msrv` |`rust-cargo` is unqualified. Existing `rust-ci` supplies implementation to reuse; a profile adapter and end-to-end qualification are still needed. +|=== + +Versions are explicit numeric major/minor or major/minor/patch strings. Floating +labels and expressions are rejected. Paths use a deliberately narrow portable +relative syntax; absolute paths, `..`, shell fragments and expressions fail. +The fixtures are synthetic examples, not inferred toolchain requirements for +the real pilots. There are no arbitrary command, permissions, secret, exemption +or publication slots. Extend the contract and its controls when a real need +cannot fit; do not hide executable policy in a string slot. + +The validator rejects duplicate JSON members (including escaped names and +repeated containers), multiple documents, unknown fields and unsupported +versions. It uses the repository's existing Bash/jq tooling, with no new service +or runtime dependency. A successful exit means *structurally valid proposal*. + +The mother catalogue, capability catalogue and validator are reviewed local +inputs; this command does not authenticate them. A PR could edit them, change +its mother, or change its repository coordinate. Therefore this command must +not decide whether a PR may weaken policy. A future resolver must load the +trusted baseline independently of candidate changes, bind repository/profile +identity to that baseline, reject requirement removal, and admit explicit +policy changes through the owning authority. That mechanism is not implemented. +Path validation is lexical: a future executor must additionally check resolved +paths, symlinks, tool availability, actual versions and source identity. + +== Run the controls + +[source,shell] +---- +bash scripts/validate-ci-profile.sh tests/fixtures/ci-profiles/rust.json +bash tests/ci-profiles.sh +bash tests/ci-pilot-manifest.sh +---- + +Controls cover all three mothers, an honest advisory extension, required +capability removal/demotion, placeholders, unknown inputs, lifecycle changes, +paths, versions, duplicate JSON members, malformed input and self-promotion. +They run as steps in the existing suite self-test workflow; no new workflow +instance is added. These controls test the proposal validator, not GitHub +execution, policy completeness or the six repositories' code. + +`bash scripts/check-docs-profile-subject.sh PROFILE.json REPOSITORY_DIR +EXPECTED_COMMIT` makes a narrower executable observation for a `docs-data-v1` +profile: it checks the checkout's GitHub origin and exact commit, rejects +changed tracked files, and verifies each declared document is tracked, +nonempty and stays inside the selected root after resolving symlinks. Its +JSON output records the repository, commit and document hashes. This is a +*document-presence observation*, not a green CI verdict. It does not parse +links, validate external URLs, run a secret scanner or publish Pages. The +mother remains unimplemented as a complete adapter. The fixture tests real +Git checkouts, source mismatch, missing/empty files, dirty source and escapes. + +== Proposed pilots and evidence + +link:pilot.json[pilot.json] records six candidates and source snapshots inspected +on 6 November 2026. Their default-head commit IDs and listed root paths were +read from GitHub. No profile has been installed or qualified. The manifest's +`qualification: not-run` and open questions prevent its use as a promotion list. +The candidate limit is six for this tranche, not an estate-wide contract limit. + +* https://github.com/stuff-of-awe/awesome-coq/tree/84055bfa27f2ff98641b5c6b3b48da4d430fa9b3[awesome-coq]: minimal documentation candidate. +* https://github.com/stuff-of-awe/awesome-idris2/tree/51ca1d36642dea0efe3faf251a2e3836b2e8bb0a[awesome-idris2]: documentation plus existing test material; applicability unresolved. +* https://github.com/hyperpolymath/Protoctist.jl/tree/bdf2c0f7abbca2a9ea955eb710c267c43ce90f21[Protoctist.jl]: Julia and the original recurring-failure case. +* https://github.com/metadatastician/ZeroInflatedCounts.jl/tree/e1a9f7ed2a42470d29de5ade5b7662afaa154fc0[ZeroInflatedCounts.jl]: second Julia candidate across an organisation boundary; changed since the November 5 inventory. +* https://github.com/hyperpolymath/plasma-parser-writer/tree/5737ce8343d9e4eb4384afe5bd9e96f5eebeb85f[plasma-parser-writer]: Rust manifest and lock; layout/MSRV still to derive. +* https://github.com/hyperpolymath/cicd-squabbler/tree/79d7dbacc8a02b4a4388b4a36fe46893b5b7ce73[cicd-squabbler]: integration candidate with additional SPARK obligations. The proposed Rust mother alone is insufficient; extend composition or replace this candidate before qualification. + +Only file presence and snapshot identity are claimed here. A directory named +`tests` does not establish what runs. Existing workflow runs and branch/ruleset +obligations must be refreshed and bound to the chosen source before shadowing. + +== Dependencies and next bounded delivery + +[cols="1,3",options="header"] +|=== +|Owner |Boundary to settle before enforcement +|https://github.com/metadatastician/standards[standards] |Own policy and applicability, canonical identities, trusted baseline selection, version compatibility and legitimate exemptions. The example minima here have not been adopted as canon. +|proven-cicd |Resolve admitted profiles into deterministic plans and thin immutable callers; retain native lock generation. Implement/qualify the three daughter adapters and two entry points with real positive and negative runs. +|https://github.com/hyperpolymath/cicd-squabbler[cicd-squabbler] |Observe exact-source gates, dependencies and zero-job failures; map actual findings into the existing squabble/proven-tests contracts. Do not add an implicit dependency to the standalone engine or assume its unfinished webhook surface exists. +|https://github.com/hyperpolymath/hypatia[hypatia] |Group confirmed shared causes and retain recipe/outcome provenance. Many affected consumers are one repair lineage. Confidence is not authority to reduce obligations. +|=== + +The existing link:../src/Assurance/Verdict.idr[Idris2 verdict kernel] remains +experimental. This PR adds *no* formally verified CI implementation. Profile +resolution, policy admission and the adapter-to-receipt mapping still need +explicit properties and real boundary tests. JSON validation is not an Idris2 +proof and does not establish complete, authentic execution evidence. + +Next: derive pilot obligations and slots, reject or replace the unsuitable +candidate, then implement one complete validation path before broadening it. +Prove deterministic rendering, a real pass, a planted defect, missing-tool and +zero-job detection, trusted-baseline downgrade rejection, native lock handling +and rollback. Shadow old/new obligations on matching source for a bounded +period. Promote only the inspected release and consumer set after review; +remove superseded workflows only after the replacement qualifies. + +Do not change required contexts, auto-merge, repository protections, release +credentials or consumer pins as part of this contract PR. Keep a short ledger +of release/consumer states and unresolved incidents. Measure usage and runner +cost during the pilot; repeat inventory only for changed sources/dependencies. diff --git a/profiles/mothers.json b/profiles/mothers.json new file mode 100644 index 0000000..072e9b4 --- /dev/null +++ b/profiles/mothers.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "stage": "proposal", + "mothers": [ + { + "id": "docs-data-v1", + "adapter": "docs-data", + "required_capabilities": ["secret-scan", "spdx-license"], + "required_slots": ["root", "documents"], + "adapter_state": "unimplemented" + }, + { + "id": "julia-library-v1", + "adapter": "julia-pkg", + "required_capabilities": ["secret-scan", "spdx-license"], + "required_slots": ["root", "julia_versions"], + "adapter_state": "unimplemented" + }, + { + "id": "rust-library-v1", + "adapter": "rust-cargo", + "required_capabilities": ["secret-scan", "spdx-license", "rust-ci"], + "required_slots": ["root", "msrv"], + "adapter_state": "unqualified" + } + ] +} diff --git a/profiles/pilot-obligations.adoc b/profiles/pilot-obligations.adoc new file mode 100644 index 0000000..47f3a07 --- /dev/null +++ b/profiles/pilot-obligations.adoc @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Pilot obligation reconnaissance +:revdate: 2026-11-06 + +This note records source-visible obligations for the first two documentation +pilots. It is an input to plan resolution, not a policy decision or a +replacement checklist. Required GitHub contexts and branch rules are still +unresolved. Source snapshots are pinned in link:pilot.json[pilot.json]. + +== awesome-coq + +At `84055bfa27f2ff98641b5c6b3b48da4d430fa9b3` the root contains +`README.adoc` and no `README.md`. The six workflow files include +https://github.com/stuff-of-awe/awesome-coq/blob/84055bfa27f2ff98641b5c6b3b48da4d430fa9b3/.github/workflows/awesome-bot.yml[Link check], +https://github.com/stuff-of-awe/awesome-coq/blob/84055bfa27f2ff98641b5c6b3b48da4d430fa9b3/.github/workflows/deploy-pages.yml[Deploy to GitHub Pages], +and https://github.com/stuff-of-awe/awesome-coq/blob/84055bfa27f2ff98641b5c6b3b48da4d430fa9b3/.github/workflows/secret-scanner.yml[Secret Scanner]. + +The link job passes `README.md` to Lychee; Pages copies `README.md` to its +build directory. Both refer to a file absent at this commit. That is a +source-level mismatch, independent of whether the corresponding jobs were +required or happened to run recently. The shared secret-scanner caller is a +separate obligation. The new document-presence observation can check the +actual `README.adoc`, but it cannot replace either link validation or Pages. +The link-check exclusions and accepted HTTP status codes also need explicit +disposition in any replacement. + +== awesome-idris2 + +At `51ca1d36642dea0efe3faf251a2e3836b2e8bb0a`, `README.adoc` and +`tests/README.adoc` are present. The latter explicitly says the list has no +executable code and that link validation is handled by CI. The repository has +14 workflow files, including separate quality, secret-scanner, governance, +security, Pages and advisory workflows. A `tests` directory is therefore not +evidence of a test runner. The documentation mother must preserve the actual +link, security and publication obligations after reviewing their current +configuration. Presence alone supplies no link-verification receipt. + +== Remaining candidates + +The November 5 inventory lists 36 workflows for Protoctist.jl, 38 for +ZeroInflatedCounts.jl, 18 for plasma-parser-writer and 31 for cicd-squabbler. +These are historical source-inventory counts, not current required-context or +status counts. Their obligation maps still need source, workflow and protection +inspection. ZeroInflatedCounts.jl has a newer default-head commit than the +audit. Squabbler's SPARK obligations do not fit the provisional Rust mother. + +Before a shadow comparison, record each pilot's required checks, event and +source bindings, publication privileges, lock closure and exemptions. Compare +matched obligations and actual subject evidence; do not infer equivalence from +passing colors or from a declared mother profile. diff --git a/profiles/pilot.json b/profiles/pilot.json new file mode 100644 index 0000000..40c5888 --- /dev/null +++ b/profiles/pilot.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "stage": "proposal", + "inspected_on": "2026-11-06", + "qualification": "not-run", + "candidates": [ + { + "repository": "stuff-of-awe/awesome-coq", + "mother": "docs-data-v1", + "snapshot_commit": "84055bfa27f2ff98641b5c6b3b48da4d430fa9b3", + "evidence_paths": ["README.adoc"], + "reason": "Small documentation candidate for the minimal profile.", + "open_questions": ["Derive current required checks and document/link-check scope."] + }, + { + "repository": "stuff-of-awe/awesome-idris2", + "mother": "docs-data-v1", + "snapshot_commit": "51ca1d36642dea0efe3faf251a2e3836b2e8bb0a", + "evidence_paths": ["README.adoc", "TEST-NEEDS.adoc", "tests"], + "reason": "Documentation candidate with an existing tests directory; compare against the minimal case.", + "open_questions": ["Inspect test contents before deciding documentation-only applicability."] + }, + { + "repository": "hyperpolymath/Protoctist.jl", + "mother": "julia-library-v1", + "snapshot_commit": "bdf2c0f7abbca2a9ea955eb710c267c43ce90f21", + "evidence_paths": ["Project.toml", "test", "tests"], + "reason": "Julia candidate from the original recurring-CI incident.", + "open_questions": ["Preserve Test/Aqua, version matrix, K9 and canon obligations; derive them from current source and protection settings."] + }, + { + "repository": "metadatastician/ZeroInflatedCounts.jl", + "mother": "julia-library-v1", + "snapshot_commit": "e1a9f7ed2a42470d29de5ade5b7662afaa154fc0", + "evidence_paths": ["Project.toml", "test", "tests"], + "reason": "Second Julia candidate across an organisation boundary.", + "open_questions": ["Head changed after the November 5 inventory; refresh source-bound evidence and requirements before deriving slots."] + }, + { + "repository": "hyperpolymath/plasma-parser-writer", + "mother": "rust-library-v1", + "snapshot_commit": "5737ce8343d9e4eb4384afe5bd9e96f5eebeb85f", + "evidence_paths": ["Cargo.toml", "Cargo.lock", "tests"], + "reason": "Rust candidate with a checked-in Cargo manifest and lock.", + "open_questions": ["Inspect workspace layout, MSRV, feature flags and required checks before selecting slots."] + }, + { + "repository": "hyperpolymath/cicd-squabbler", + "mother": "rust-library-v1", + "snapshot_commit": "79d7dbacc8a02b4a4388b4a36fe46893b5b7ce73", + "evidence_paths": ["Cargo.toml", "Cargo.lock", "tests"], + "reason": "Rust integration candidate that also exercises the diagnostic tool's own requirements.", + "open_questions": ["A Rust profile is insufficient for its SPARK obligations: extend composition or replace this candidate before qualification.", "Keep repair execution outside shadow validation."] + } + ] +} diff --git a/profiles/validate.jq b/profiles/validate.jq new file mode 100644 index 0000000..ca2eb25 --- /dev/null +++ b/profiles/validate.jq @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MPL-2.0 +# Structural proposal validation. No runtime verdict or policy admission. +def exact($fields): type == "object" and (keys == ($fields | sort)); +def unique_items: length == (unique | length); +def clean_string: type == "string" and (explode | all(. >= 32 and . != 127)); +def ident: clean_string and test("^[a-z][a-z0-9-]*$"); +def repository: clean_string and test("^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$"); +# A lexical slot only; the future executor must also reject symlink escapes. +def relative_path: + clean_string and + (. == "." or (test("^[A-Za-z0-9_][A-Za-z0-9_.-]*(/[A-Za-z0-9_][A-Za-z0-9_.-]*)*$") and + (split("/") | all(. != "." and . != "..")))); +def version: clean_string and test("^[0-9]+[.][0-9]+([.][0-9]+)?$"); +def nonempty_array: type == "array" and length > 0 and unique_items; +def slots($adapter): + (.root | relative_path) and + if $adapter == "docs-data" then + (.documents | nonempty_array and all(.[]; relative_path and . != ".")) + elif $adapter == "julia-pkg" then + (.julia_versions | nonempty_array and all(.[]; version)) + elif $adapter == "rust-cargo" then (.msrv | version) + else false end; +def valid_mothers: + exact(["schema_version", "stage", "mothers"]) and .schema_version == 1 and .stage == "proposal" and + (.mothers | nonempty_array) and ([.mothers[].id] | unique_items) and + all(.mothers[]; + exact(["id", "adapter", "required_capabilities", "required_slots", "adapter_state"]) and + (.id | ident) and + (.adapter as $a | ["docs-data", "julia-pkg", "rust-cargo"] | index($a) != null) and + (.adapter_state as $s | ["unimplemented", "unqualified"] | index($s) != null) and + (.required_capabilities | nonempty_array and all(.[]; ident)) and + (.required_slots == (if .adapter == "docs-data" then ["root", "documents"] + elif .adapter == "julia-pkg" then ["root", "julia_versions"] else ["root", "msrv"] end))); +def valid_profile($mothers; $catalogue): + . as $p | + ([$mothers.mothers[] | select(.id == $p.mother)]) as $matches | + exact(["schema_version", "stage", "repository", "mother", "lifecycles", "capabilities", "slots"]) and + .schema_version == 1 and .stage == "proposal" and + (.repository | repository) and + (.mother | ident) and + (.lifecycles | type == "array" and (sort == ["maintenance", "validate"])) and + (.capabilities | nonempty_array) and + ([.capabilities[].id] | unique_items) and + all(.capabilities[]; + exact(["id", "obligation"]) and (.id | ident) and + (.obligation == "required" or .obligation == "advisory") and + . as $requirement | + ([$catalogue.capabilities[] | select(.id == $requirement.id)] | length == 1) and + ([$catalogue.capabilities[] | select(.id == $requirement.id)][0] | + .status != "stub" and + ($requirement.obligation != "required" or .status == "enforcing" or .status == "tool-dependent"))) and + ($matches | length == 1) and + ($matches[0] as $mother | + ($p.slots | exact($mother.required_slots) and slots($mother.adapter)) and + all($mother.required_capabilities[]; + . as $id | any($p.capabilities[]; .id == $id and .obligation == "required"))); +# Slurped inputs must each contain exactly one JSON document. +($mothers | length == 1) and ($catalogue | length == 1) and +($mothers[0] | valid_mothers) and +(length == 1) and (.[0] | valid_profile($mothers[0]; $catalogue[0])) diff --git a/scripts/assurance-github.jq b/scripts/assurance-github.jq new file mode 100644 index 0000000..d289739 --- /dev/null +++ b/scripts/assurance-github.jq @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: MPL-2.0 +# Finite direct-head GitHub execution policy. This module cannot authorize +# exemptions, advisory findings, PR merge contexts, reusable workflows or writes. + +def keys_are($names): type == "object" and keys == ($names | sort); +def text_value: type == "string" and length > 0 and test("[^[:space:]]"); +def sha: type == "string" and test("^[0-9a-f]{40}$"); +def natural: type == "number" and . >= 0 and . <= 9007199254740991 and floor == .; +def positive: natural and . > 0; +def epoch: + . as $value + | if type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + then try (fromdateiso8601 as $seconds | if ($seconds | todateiso8601) == $value then $seconds else null end) catch null + else null end; +def time_value: (epoch | type) == "number"; +def optional_time: . == null or time_value; +def unique_values: length == (unique | length); +def approved_repository: + keys_are(["full_name","id","owner_id"]) + and .owner_id == 6759885 + and ((.full_name == "hyperpolymath/proven-cicd" and .id == 1446155688) + or (.full_name == "hyperpolymath/echidna" and .id == 1101572347)); +def policy_job: + keys_are(["name","steps"]) + and (.name | text_value) + and (.steps | type == "array" and length > 0 and length <= 128 + and unique_values and all(.[]; text_value)); +def policy_run: + keys_are(["id","attempt","workflow_id","path","workflow_blob_sha","event","head_branch","jobs"]) + and (.id | positive) and (.attempt | positive) and (.workflow_id | positive) + and (.path | type == "string" and test("^\\.github/workflows/[A-Za-z0-9_.-]+\\.ya?ml$")) + and (.workflow_blob_sha | sha) + and (.event == "push" or .event == "workflow_dispatch") + and (.head_branch | type == "string" and test("^[A-Za-z0-9_./-]+$")) + and (.jobs | type == "array" and length > 0 and length <= 16 and all(.[]; policy_job)) + and ([.jobs[].name] | unique_values); +def valid_policy: + keys_are(["schema","category","route","repository","subject_sha","issued_at","expires_at","max_age_seconds","runs"]) + and .schema == "proven-cicd/github-execution/v1" + and .category == "experimental" + and .route == "direct-head" + and (.repository | approved_repository) + and (.subject_sha | sha) + and (.issued_at | time_value) and (.expires_at | time_value) + and ((.expires_at | epoch) > (.issued_at | epoch)) + and ((.expires_at | epoch) - (.issued_at | epoch) <= 86400) + and (.max_age_seconds | positive and . <= 86400) + and (.runs | type == "array" and length == 1 and all(.[]; policy_run)) + and ([.runs[].id] | unique_values) + and ([.runs[].workflow_id] | unique_values); + +def require($condition; $reason): + if $condition then . else error($reason) end; + +def repository_matches($p): + type == "object" and .id == $p.repository.id + and .full_name == $p.repository.full_name + and .owner.id == $p.repository.owner_id and .private == false; + +def run_status: + .status == "completed" or .status == "queued" or .status == "in_progress" + or .status == "waiting" or .status == "pending" or .status == "requested"; +def conclusion_value: + . == null or . == "success" or . == "failure" or . == "skipped" + or . == "cancelled" or . == "timed_out" or . == "neutral" + or . == "action_required" or . == "stale" or . == "startup_failure"; +def status_consistent: + run_status and (.conclusion | conclusion_value) + and (if .status == "completed" then .conclusion != null else .conclusion == null end); +def run_identity($p; $r): + type == "object" and .id == $r.id and .run_attempt == $r.attempt + and .workflow_id == $r.workflow_id and .path == $r.path + and .event == $r.event and .head_branch == $r.head_branch + and .head_sha == $p.subject_sha and .head_commit.id == $p.subject_sha + and (.run_number | positive) + and (.repository | repository_matches($p)) + and (.head_repository | repository_matches($p)) + and (.referenced_workflows | type == "array" and length == 0) + and (.created_at | time_value) and (.updated_at | time_value) + and (.run_started_at | optional_time) + and status_consistent + and ((.updated_at | epoch) >= (.created_at | epoch)); +def run_projection: + {id, run_attempt, workflow_id, path, event, head_branch, head_sha, run_number, + status, conclusion, created_at, updated_at, run_started_at, referenced_workflows, + head_commit: .head_commit.id, repository: [.repository.id,.repository.full_name,.repository.owner.id,.repository.private], + head_repository: [.head_repository.id,.head_repository.full_name,.head_repository.owner.id,.head_repository.private]}; + +def run_page: + type == "object" and (.total_count | natural and . <= 100) + and (.workflow_runs | type == "array") and .total_count == (.workflow_runs | length) + and ([.workflow_runs[].id] | unique_values) + and all(.workflow_runs[]; + type == "object" and (.id | positive) and (.workflow_id | positive) + and (.run_number | positive) and (.run_attempt | positive) + and (.head_sha | sha) and (.head_branch | text_value) and (.event | text_value) + and (.repository.id | positive) and (.head_repository.id | positive) + and (.created_at | time_value) and status_consistent); +def latest_run($p; $r): + [.workflow_runs[] + | select(.workflow_id == $r.workflow_id and .event == $r.event + and .head_branch == $r.head_branch and .head_sha == $p.subject_sha)] + | if length > 0 then max_by([.run_number,.run_attempt,.id]) else null end; + +def workflow_blob_matches($r): + .tree as $entries + | ([$entries[] | select(.path == ".github" and .type == "tree" and .mode == "040000")] | length) == 1 + and ([$entries[] | select(.path == ".github/workflows" and .type == "tree" and .mode == "040000")] | length) == 1 + and ([$entries[] | select(.path == $r.path and .type == "blob" + and (.mode == "100644" or .mode == "100755") + and .sha == $r.workflow_blob_sha)] | length) == 1; +def tree_shape: + type == "object" and (.sha | sha) and .truncated == false + and (.tree | type == "array" and length > 0 and length <= 50000) + and all(.tree[]; (.path | text_value) and (.sha | sha) + and (.mode | type == "string") and (.type | type == "string")) + and ([.tree[].path] | unique_values); + +def step_shape: + type == "object" and (.name | text_value) and (.number | positive) + and status_consistent and (.started_at | optional_time) and (.completed_at | optional_time) + and (if .status == "completed" then + (.started_at | time_value) and (.completed_at | time_value) + and ((.completed_at | epoch) >= (.started_at | epoch)) + else .completed_at == null end); +def job_shape($p; $r): + type == "object" and (.id | positive) and .run_id == $r.id and .run_attempt == $r.attempt + and .head_sha == $p.subject_sha and .head_branch == $r.head_branch + and .run_url == ("https://api.github.com/repos/" + $p.repository.full_name + "/actions/runs/" + ($r.id | tostring)) + and (.name | text_value) and status_consistent + and (.created_at | time_value) and (.started_at | optional_time) and (.completed_at | optional_time) + and (.steps | type == "array" and length <= 128 and all(.[]; step_shape)) + and ([.steps[].name] | unique_values) + and ([.steps[].number] | unique_values) + and ([.steps[].number] == ([.steps[].number] | sort)) + and (if .status == "completed" then + (.started_at | time_value) and (.completed_at | time_value) + and ((.started_at | epoch) >= (.created_at | epoch)) + and ((.completed_at | epoch) >= (.started_at | epoch)) + and all(.steps[]; .status == "completed") + else .completed_at == null end); +def jobs_page($p; $r): + type == "object" and (.total_count | natural and . <= 100) + and (.jobs | type == "array") and .total_count == (.jobs | length) + and ([.jobs[].id] | unique_values) and ([.jobs[].name] | unique_values) + and all(.jobs[]; job_shape($p; $r)); +def completed_job_times($run; $now): + . as $job + | (.status == "completed") + and ((.started_at | epoch) >= ($run.run_started_at | epoch)) + and ((.completed_at | epoch) <= ($run.updated_at | epoch)) + and ((.completed_at | epoch) <= $now) + and all(.steps[]; + (.status == "completed") + and ((.started_at | epoch) >= ($job.started_at | epoch)) + and ((.completed_at | epoch) <= ($job.completed_at | epoch))); + +def classify_job($run; $job): + if $job.conclusion == "failure" or any($job.steps[]; .conclusion == "failure") + then "FAILED" + elif $job.conclusion == "success" and all($job.steps[]; .conclusion == "success") + and ($run.conclusion == "success" or $run.conclusion == "failure") + then "PASSED" + else "UNKNOWN" end; + +def qualify($p; $b; $now): + null + | require(($p | valid_policy); "invalid_policy") + | require(($now | positive) and ($b.started_at | positive) + and $now >= $b.started_at and $now - $b.started_at <= 300; "collection_expired") + | require($now >= ($p.issued_at | epoch) and $now <= ($p.expires_at | epoch); "policy_expired") + | require(($b.repository_before | repository_matches($p)) + and ($b.repository_after | repository_matches($p)); "repository_binding") + | require($b.commit.sha == $p.subject_sha and ($b.commit.tree.sha | sha); "subject_binding") + | require(($b.tree | tree_shape) and $b.tree.sha == $b.commit.tree.sha; "incomplete_tree") + | require(($b.runs_before | run_page) and ($b.runs_after | run_page); "incomplete_run_inventory") + | require(($b.runs | type == "array") and ($b.runs | length) == ($p.runs | length); "missing_run_snapshot") + | [ + range(0; $p.runs | length) as $index + | $p.runs[$index] as $policy_run + | $b.runs[$index] as $snapshot + | $snapshot.final as $run + | require(($snapshot.initial | run_identity($p; $policy_run)) + and ($run | run_identity($p; $policy_run)); "run_binding") + | require(($snapshot.initial | run_projection) == ($run | run_projection); "run_changed") + | require(($run.created_at | epoch) <= $now and ($run.updated_at | epoch) <= $now + and ($run.run_started_at == null + or (($run.run_started_at | epoch) >= ($run.created_at | epoch) + and ($run.run_started_at | epoch) <= ($run.updated_at | epoch))); "invalid_run_time") + | ($b.runs_before | latest_run($p; $policy_run)) as $listed_before + | ($b.runs_after | latest_run($p; $policy_run)) as $listed_after + | require($listed_before.id == $policy_run.id and $listed_before.run_attempt == $policy_run.attempt; "superseded_initial_run") + | require($listed_after.id == $policy_run.id and $listed_after.run_attempt == $policy_run.attempt; "superseded_final_run") + | require(($listed_before | run_projection) == ($snapshot.initial | run_projection); "initial_inventory_changed") + | require(($listed_after | run_projection) == ($run | run_projection); "final_inventory_changed") + | require(($b.tree | workflow_blob_matches($policy_run)); "workflow_source_binding") + | require(($snapshot.jobs | jobs_page($p; $policy_run)); "invalid_jobs") + | require(all($snapshot.jobs.jobs[]; .name as $name | any($policy_run.jobs[]; .name == $name)); "unexpected_job") + | if $run.status != "completed" then + $policy_run.jobs[] + | {run_id:$run.id,attempt:$run.run_attempt,job:.name,state:"PENDING", + reason:"run_not_completed",github_conclusion:$run.conclusion} + else + require(($run.run_started_at | time_value) + and ($run.updated_at | epoch) <= $now + and $now - ($run.updated_at | epoch) <= $p.max_age_seconds; "stale_run") + | require(([$snapshot.jobs.jobs[].name] | sort) == ([$policy_run.jobs[].name] | sort); "missing_job") + | require(all($snapshot.jobs.jobs[]; completed_job_times($run; $now)); "invalid_execution_times") + | require($run.conclusion != "failure" or any($snapshot.jobs.jobs[]; + .conclusion == "failure" or any(.steps[]; .conclusion == "failure")); "unexplained_run_failure") + | $policy_run.jobs[] as $policy_job + | ($snapshot.jobs.jobs[] | select(.name == $policy_job.name)) as $job + | require([$job.steps[].name] == $policy_job.steps; "step_contract_mismatch") + | classify_job($run; $job) as $state + | {run_id:$run.id,attempt:$run.run_attempt,job:$job.name,state:$state, + reason:(if $state == "PASSED" then "reviewed_steps_completed" + elif $state == "FAILED" then "observed_execution_failure" + else "execution_not_verified" end), + github_conclusion:$job.conclusion, + steps:[$job.steps[]|{name,status,conclusion}]} + end + ]; diff --git a/scripts/assurance-github.sh b/scripts/assurance-github.sh new file mode 100644 index 0000000..146585a --- /dev/null +++ b/scripts/assurance-github.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Experimental, read-only bridge for a named finite execution policy. +# Usage: assurance-github.sh POLICY_JSON EXPECTED_POLICY_SHA256 NEW_OUTPUT_DIR +# No outside receipt or verdict executable is accepted by this CLI. +set -euo pipefail + +ASSURANCE_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +ASSURANCE_IDRIS_SOURCE=27780073c8631826d846499840b3857d9b9a4fd5 + +assurance_setup_unknown() { + printf '{"schema":"proven-cicd/github-execution-receipt/v1","category":"experimental","verdict":"UNKNOWN","kernel_verdict":"NOT_RUN","reason":"%s"}\n' "$1" + return 2 +} + +# A fresh build from checked Git source, never an executable supplied by a caller. +# The hosted job provisions the pinned compiler. A version string is recorded, +# not misrepresented as a compiler attestation for arbitrary local callers. +assurance_build() { + local directory=$1 path source expected hash + for command in idris2 jq gh git sha256sum timeout cmp cp date wc; do + command -v "$command" >/dev/null 2>&1 || return 2 + done + ASSURANCE_SOURCE_SHA=$(git -C "$ASSURANCE_ROOT" rev-parse --verify HEAD) || return 2 + [[ $ASSURANCE_SOURCE_SHA =~ ^[0-9a-f]{40}$ ]] || return 2 + mkdir -p "$directory/src/Assurance" || return 2 + : >"$directory/source-manifest.txt" || return 2 + for path in assurance-github.ipkg src/Assurance/Verdict.idr \ + src/Assurance/GitHub.idr src/Assurance/GitHubMain.idr \ + scripts/assurance-github.sh scripts/assurance-github.jq; do + source="$ASSURANCE_ROOT/$path" + expected="$directory/expected-source" + git -C "$ASSURANCE_ROOT" show "$ASSURANCE_SOURCE_SHA:$path" >"$expected" || return 2 + cmp -s "$source" "$expected" || return 2 + hash=$(sha256sum "$expected") || return 2 + printf '%s %s\n' "${hash%% *}" "$path" >>"$directory/source-manifest.txt" || return 2 + # Stage the immutable bytes just compared, never a later worktree read. + case "$path" in + assurance-github.ipkg|src/Assurance/Verdict.idr|\ + src/Assurance/GitHub.idr|src/Assurance/GitHubMain.idr) + cp "$expected" "$directory/$path" || return 2 ;; + scripts/assurance-github.jq) + cp "$expected" "$directory/assurance-github.jq" || return 2 ;; + scripts/assurance-github.sh) : ;; + *) return 2 ;; + esac + done + hash=$(sha256sum "$directory/source-manifest.txt") || return 2 + ASSURANCE_SOURCE_BUNDLE_SHA=${hash%% *} + ASSURANCE_COMPILER_VERSION=$(idris2 --version) || return 2 + if ! (cd "$directory" && timeout 180 idris2 --build assurance-github.ipkg) \ + >"$directory/compiler.log" 2>&1; then + cat "$directory/compiler.log" >&2 + return 2 + fi + ASSURANCE_BINARY="$directory/build/exec/assurance-github" + ASSURANCE_JQ_DIR="$directory" + [[ -x $ASSURANCE_BINARY ]] || return 2 +} + +# The fixture harness sources this function after one real source build, so +# every case uses the same compiled kernel/collector without recompiling it. +# The production CLI always calls assurance_build itself first. +assurance_collect() ( + set -euo pipefail + local policy_file=$1 expected_digest=$2 output=$3 + local work finished=0 started now actual repository subject tree_sha index run_id probe=policy + local kernel_exit kernel_result kernel_verdict verdict response_hash + [[ $expected_digest =~ ^[0-9a-f]{64}$ ]] || + { assurance_setup_unknown invalid_policy_digest; exit 2; } + mkdir "$output" || { assurance_setup_unknown output_not_new; exit 2; } + work=$(mktemp -d) || { assurance_setup_unknown temporary_storage_unavailable; exit 2; } + unknown() { + local reason=$1 observed=NOT_RUN result status=0 + # Malformed acquisition still reaches the actual kernel as UNKNOWN + # when compilation succeeded; missing tools are never called proof. + if [[ -x ${ASSURANCE_BINARY:-} ]]; then + printf '["UNKNOWN"]\n' >"$work/unknown-wire.json" + result=$(timeout 20 "$ASSURANCE_BINARY" decide "$work/unknown-wire.json") || status=$? + if [[ $status == 1 && $result == BLOCKED ]]; then + observed=BLOCKED + else + reason=kernel_unavailable + fi + fi + finished=1 + printf '{"schema":"proven-cicd/github-execution-receipt/v1","category":"experimental","verdict":"UNKNOWN","kernel_verdict":"%s","reason":"%s","probe":"%s","source_commit":"%s"}\n' \ + "$observed" "$reason" "$probe" "$ASSURANCE_SOURCE_SHA" >"$output/receipt.json" + cat "$output/receipt.json" + rm -rf "$work" + exit 2 + } + cleanup() { + local status=$? + if [[ $finished != 1 ]]; then + # unexpected shell/JSON/tool failures cannot leave an apparent pass + trap - EXIT + unknown unexpected_error + fi + rm -rf "$work" + return "$status" + } + trap cleanup EXIT + cp "$policy_file" "$work/policy.json" || unknown policy_unreadable + actual=$(sha256sum "$work/policy.json") || unknown policy_unreadable + [[ ${actual%% *} == "$expected_digest" ]] || unknown policy_digest_mismatch + local validation validation_exit=0 + validation=$(timeout 20 "$ASSURANCE_BINARY" validate "$work/policy.json") || validation_exit=$? + [[ $validation_exit == 0 && $validation == VALID_JSON ]] || unknown invalid_raw_policy_json + jq -e -L "$ASSURANCE_JQ_DIR" 'include "assurance-github"; valid_policy' \ + "$work/policy.json" >/dev/null || unknown invalid_policy_schema + + repository=$(jq -er '.repository.full_name' "$work/policy.json") || unknown invalid_policy_schema + subject=$(jq -er '.subject_sha' "$work/policy.json") || unknown invalid_policy_schema + started=$(date -u +%s) || unknown invalid_clock + [[ $started =~ ^[0-9]+$ ]] || unknown invalid_clock + : >"$work/responses.sha256" || unknown temporary_storage_unavailable + + request() { + local name=$1 endpoint=$2 status=0 size checked check_status=0 stamp hash + probe=$name + stamp=$(date -u +%s) || unknown invalid_clock + [[ $stamp =~ ^[0-9]+$ ]] || unknown invalid_clock + (( stamp >= started && stamp - started <= 300 )) || unknown collection_expired + timeout 60 gh api --hostname github.com --method GET \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' "$endpoint" \ + >"$work/$name.json" 2>"$work/$name.error" || status=$? + [[ $status == 0 ]] || unknown request_failed + size=$(wc -c <"$work/$name.json") || unknown response_size + (( size > 0 && size <= 8388608 )) || unknown response_size + checked=$(timeout 20 "$ASSURANCE_BINARY" validate "$work/$name.json") || check_status=$? + [[ $check_status == 0 && $checked == VALID_JSON ]] || unknown invalid_raw_response_json + # Idris checks raw field identity first; jq only sees admitted JSON. + jq -e 'true' "$work/$name.json" >/dev/null || unknown invalid_response_json + hash=$(sha256sum "$work/$name.json") || unknown response_digest_error + printf '%s %s.json\n' "${hash%% *}" "$name" >>"$work/responses.sha256" || unknown response_digest_error + } + + request repository-before "repos/$repository" + jq -e --slurpfile policy "$work/policy.json" -L "$ASSURANCE_JQ_DIR" \ + 'include "assurance-github"; repository_matches($policy[0])' \ + "$work/repository-before.json" >/dev/null || unknown repository_binding + request commit "repos/$repository/git/commits/$subject" + jq -e --arg subject "$subject" -L "$ASSURANCE_JQ_DIR" \ + 'include "assurance-github"; .sha == $subject and (.tree.sha | sha)' \ + "$work/commit.json" >/dev/null || unknown subject_binding + tree_sha=$(jq -er '.tree.sha' "$work/commit.json") || unknown subject_binding + request tree "repos/$repository/git/trees/$tree_sha?recursive=1" + request runs-before "repos/$repository/actions/runs?head_sha=$subject&per_page=100&page=1" + : >"$work/snapshots.jsonl" || unknown temporary_storage_unavailable + local count + count=$(jq -er '.runs | length' "$work/policy.json") || unknown invalid_policy_schema + for ((index=0; index>"$work/snapshots.jsonl" || unknown temporary_storage_unavailable + done + request runs-after "repos/$repository/actions/runs?head_sha=$subject&per_page=100&page=1" + request repository-after "repos/$repository" + # Decision time is taken AFTER the final GET, not before collection. + now=$(date -u +%s) || unknown invalid_clock + [[ $now =~ ^[0-9]+$ ]] || unknown invalid_clock + jq -n --argjson started "$started" \ + --slurpfile before "$work/repository-before.json" \ + --slurpfile after "$work/repository-after.json" \ + --slurpfile commit "$work/commit.json" --slurpfile tree "$work/tree.json" \ + --slurpfile runs_before "$work/runs-before.json" --slurpfile runs_after "$work/runs-after.json" \ + --slurpfile snapshots "$work/snapshots.jsonl" \ + '{started_at:$started,repository_before:$before[0],repository_after:$after[0], + commit:$commit[0],tree:$tree[0],runs_before:$runs_before[0], + runs_after:$runs_after[0],runs:$snapshots}' >"$work/bundle.json" || unknown temporary_storage_unavailable + qualify_at() { + local decision=$1 destination=$2 + jq -n -L "$ASSURANCE_JQ_DIR" --slurpfile policy "$work/policy.json" \ + --slurpfile bundle "$work/bundle.json" --argjson now "$decision" \ + 'include "assurance-github"; qualify($policy[0];$bundle[0];$now)' \ + >"$destination" 2>"$work/qualification.error" || { + local diagnostic reason pattern='^jq: error \(at \): ([a-z_]+)$' + diagnostic=$(<"$work/qualification.error") + if [[ $diagnostic =~ $pattern ]]; then + reason=${BASH_REMATCH[1]} + case "$reason" in + invalid_policy|collection_expired|policy_expired|repository_binding|subject_binding|\ + incomplete_tree|incomplete_run_inventory|missing_run_snapshot|run_binding|run_changed|\ + invalid_run_time|superseded_initial_run|superseded_final_run|workflow_source_binding|\ + initial_inventory_changed|final_inventory_changed|\ + invalid_jobs|unexpected_job|stale_run|missing_job|invalid_execution_times|\ + unexplained_run_failure|step_contract_mismatch) + unknown "$reason" ;; + *) unknown qualification_tool_error ;; + esac + fi + unknown qualification_tool_error + } + } + qualify_at "$now" "$work/observations.json" + jq -e --slurpfile policy "$work/policy.json" ' + type == "array" and length == ($policy[0].runs|map(.jobs|length)|add) + and all(.[]; .state=="PASSED" or .state=="FAILED" or .state=="PENDING" or .state=="UNKNOWN")' \ + "$work/observations.json" >/dev/null || unknown invalid_kernel_wire + jq '[.[].state]' "$work/observations.json" >"$work/wire.json" || unknown invalid_kernel_wire + kernel_exit=0 + kernel_result=$(timeout 20 "$ASSURANCE_BINARY" decide "$work/wire.json") || kernel_exit=$? + case "$kernel_exit:$kernel_result" in + 0:PASSED) kernel_verdict=PASSED; verdict=PASSED ;; + 1:BLOCKED) + kernel_verdict=BLOCKED + verdict=$(jq -er ' + if length == 0 or any(.[]; .state == "UNKNOWN") then "UNKNOWN" + elif any(.[]; .state == "FAILED") then "FAILED" + elif any(.[]; .state == "PENDING") then "PENDING" + else "UNKNOWN" end' "$work/observations.json") || unknown invalid_kernel_wire + ;; + *) unknown kernel_unavailable ;; + esac + # The pure kernel has decided this exact observation list. Recheck expiry + # after executable work and retain that result only if the list is unchanged. + now=$(date -u +%s) || unknown invalid_clock + [[ $now =~ ^[0-9]+$ ]] || unknown invalid_clock + qualify_at "$now" "$work/final-observations.json" + cmp -s "$work/observations.json" "$work/final-observations.json" || unknown evidence_changed + response_hash=$(sha256sum "$work/responses.sha256") || unknown response_digest_error + jq -n --arg verdict "$verdict" --arg kernel "$kernel_verdict" \ + --arg policy_digest "$expected_digest" --arg source "$ASSURANCE_SOURCE_SHA" \ + --arg source_digest "$ASSURANCE_SOURCE_BUNDLE_SHA" \ + --arg compiler_source "$ASSURANCE_IDRIS_SOURCE" --arg compiler_version "$ASSURANCE_COMPILER_VERSION" \ + --arg response_digest "${response_hash%% *}" --argjson decision "$now" --argjson started "$started" \ + --slurpfile policy "$work/policy.json" --slurpfile observations "$work/observations.json" \ + '{schema:"proven-cicd/github-execution-receipt/v1",category:"experimental", + verdict:$verdict,kernel_verdict:$kernel,scope:"named-finite-execution-policy", + repository:$policy[0].repository,subject_sha:$policy[0].subject_sha, + policy_sha256:$policy_digest,source_commit:$source,source_bundle_sha256:$source_digest, + compiler:{expected_source:$compiler_source,version:$compiler_version, + provenance:"requires-pinned-workflow-provisioning"}, + decision_at:$decision,collection_started_at:$started, + responses_manifest_sha256:$response_digest,observations:$observations[0]}' >"$output/receipt.json" || unknown receipt_write_failed + cp "$work/responses.sha256" "$output/responses.sha256" || unknown receipt_write_failed + cp "$work/policy.json" "$output/policy.json" || unknown receipt_write_failed + finished=1 + cat "$output/receipt.json" || unknown receipt_emit_failed + case "$verdict" in + PASSED) exit 0 ;; + FAILED) exit 1 ;; + PENDING|UNKNOWN) exit 2 ;; + *) exit 2 ;; + esac +) + +assurance_main() ( + set -euo pipefail + [[ $# == 3 ]] || { assurance_setup_unknown invalid_arguments; exit 2; } + local build + build=$(mktemp -d) + trap 'rm -rf "$build"' EXIT + if ! assurance_build "$build"; then + assurance_setup_unknown source_or_compiler_unavailable + exit 2 + fi + assurance_collect "$@" +) + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + assurance_main "$@" +fi diff --git a/scripts/check-docs-profile-subject.sh b/scripts/check-docs-profile-subject.sh new file mode 100644 index 0000000..61102cd --- /dev/null +++ b/scripts/check-docs-profile-subject.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Exact-commit document-presence observation. It is not a complete CI verdict. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +[[ $# == 3 && -f $1 && -d $2 && $3 =~ ^[a-f0-9]{40}$ ]] || { + echo 'usage: check-docs-profile-subject.sh PROFILE.json REPOSITORY_DIR EXPECTED_COMMIT' >&2; exit 2; +} +for tool in jq git realpath sha256sum mktemp; do + command -v "$tool" >/dev/null || { echo "$tool required" >&2; exit 2; } +done +bash "$ROOT/scripts/validate-ci-profile.sh" "$1" >/dev/null +[[ $(jq -r .mother "$1") == docs-data-v1 ]] || { echo 'docs-data profile required' >&2; exit 1; } +coordinate=$(jq -r .repository "$1") +repo=$(realpath -e "$2") +[[ $(git -C "$repo" rev-parse --show-toplevel 2>/dev/null) == "$repo" ]] || { + echo 'repository root required' >&2; exit 1; +} +origin=$(git -C "$repo" remote get-url origin 2>/dev/null) || { + echo 'repository origin unavailable' >&2; exit 1; +} +[[ $origin == "https://github.com/$coordinate" || $origin == "https://github.com/$coordinate.git" ]] || { + echo 'profile repository differs from checkout origin' >&2; exit 1; +} +[[ $(git -C "$repo" rev-parse HEAD) == "$3" ]] || { echo 'wrong source commit' >&2; exit 1; } +git -C "$repo" diff --quiet HEAD -- || { echo 'checkout has changed tracked files' >&2; exit 1; } +root_slot=$(jq -r .slots.root "$1") +subject_root=$(realpath -e "$repo/$root_slot") || { echo 'document root absent' >&2; exit 1; } +[[ -d $subject_root && ( $subject_root == "$repo" || $subject_root == "$repo/"* ) ]] || { + echo 'document root escapes repository' >&2; exit 1; +} +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +while IFS= read -r document; do + path="$root_slot/$document" + [[ $root_slot != . ]] || path=$document + git -C "$repo" ls-files --error-unmatch -- "$path" >/dev/null 2>&1 || { + echo "document is untracked or missing: $path" >&2; exit 1; + } + resolved=$(realpath -e "$subject_root/$document") || { echo "document absent: $path" >&2; exit 1; } + [[ $resolved == "$subject_root/"* && -f $resolved && -s $resolved ]] || { + echo "document empty or escapes root: $path" >&2; exit 1; + } + digest=$(sha256sum "$resolved") + printf '%s\t%s\n' "$path" "${digest%% *}" >>"$tmp" +done < <(jq -r '.slots.documents[]' "$1") +jq -Rn --arg repository "$coordinate" --arg commit "$3" ' + {repository:$repository, commit:$commit, observation:"document-presence-passed", + documents:[inputs | split("\t") | {path:.[0], sha256:.[1]}]} +' <"$tmp" diff --git a/scripts/validate-ci-profile.sh b/scripts/validate-ci-profile.sh new file mode 100644 index 0000000..914cb37 --- /dev/null +++ b/scripts/validate-ci-profile.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Read-only proposal check, deliberately not an execution/merge gate. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +[[ $# == 1 && -f $1 ]] || { echo 'usage: validate-ci-profile.sh PROFILE.json' >&2; exit 2; } +command -v jq >/dev/null || { echo 'jq required' >&2; exit 2; } +jq -se 'length == 1 and (.[0] | type == "object")' "$1" >/dev/null || { + echo 'expected one JSON object' >&2; exit 1; +} +# jq otherwise accepts duplicate object keys with last-value-wins semantics. +# Streaming exposes decoded key paths. Remember completed leaves and containers; +# revisiting one (including a parent of a new leaf) means a duplicate member. +jq --stream -s -e ' + reduce .[] as $event ({closed: {}, valid: true}; + if ($event | length) == 2 then + . as $state | $event[0] as $path | + .valid = (.valid and all(range(1; ($path | length) + 1); + . as $n | $state.closed[($path[:$n] | tojson)] != true)) | + .closed[($path | tojson)] = true + else .closed[($event[0][:-1] | tojson)] = true end) | .valid +' "$1" >/dev/null || { echo 'duplicate JSON member' >&2; exit 1; } +jq -e -f "$ROOT/catalogue/validate.jq" "$ROOT/catalogue/capabilities.json" >/dev/null +jq -se --slurpfile mothers "$ROOT/profiles/mothers.json" \ + --slurpfile catalogue "$ROOT/catalogue/capabilities.json" \ + -f "$ROOT/profiles/validate.jq" "$1" >/dev/null || { + echo 'invalid CI profile proposal (identity, capability, obligation, lifecycle or slot)' >&2; exit 1; + } +echo 'valid CI profile proposal; execution and policy admission remain unqualified' diff --git a/src/Assurance/GitHub.idr b/src/Assurance/GitHub.idr new file mode 100644 index 0000000..c3c13f5 --- /dev/null +++ b/src/Assurance/GitHub.idr @@ -0,0 +1,84 @@ +-- SPDX-License-Identifier: MPL-2.0 +module Assurance.GitHub + +import Assurance.Verdict +import Data.List +import Data.String +import Language.JSON +import Language.JSON.Lexer +import Language.JSON.Parser +import Language.JSON.Tokens +import Text.Token + +%default total + +-- The pinned contrib parser retains JObject fields as a list. Checking that +-- list before jq materializes objects detects repeated keys for every value +-- shape, including an object/array followed by an admissible scalar. +-- +-- This finite API schema supports unescaped ASCII object keys only. Inspect +-- their raw tokens first: alternate escapes and non-ASCII keys are UNKNOWN, +-- not delegated to potentially different string-normalization rules. +plainKey : String -> Bool +plainKey raw = all (\c => ord c >= 32 && ord c <= 126 && c /= '\\') (unpack raw) + +plainKeys : List (WithBounds JSONToken) -> Bool +plainKeys [] = True +plainKeys [_] = True +plainKeys (first :: second :: rest) = + let this = case (first.val, second.val) of + (Tok JTString raw, Tok (JTPunct Colon) _) => plainKey raw + _ => True + in this && plainKeys (second :: rest) + +mutual + uniqueFields : JSON -> Bool + uniqueFields (JObject fields) = uniqueObject [] fields + uniqueFields (JArray values) = uniqueValues values + uniqueFields _ = True + + uniqueObject : List String -> List (String, JSON) -> Bool + uniqueObject seen [] = True + uniqueObject seen ((key, value) :: rest) = + not (elem key seen) && uniqueFields value && uniqueObject (key :: seen) rest + + uniqueValues : List JSON -> Bool + uniqueValues [] = True + uniqueValues (value :: rest) = uniqueFields value && uniqueValues rest + +public export +strictJSON : String -> Maybe JSON +strictJSON raw = do + tokens <- lexJSON raw + if plainKeys (filter (not . ignored) tokens) + then do + value <- parseJSON tokens + if uniqueFields value then Just value else Nothing + else Nothing + +-- These are internal wire labels emitted only by the checked collector. +-- No external JSON constructor name grants AdmissionVerified or an exemption. +-- This initial route has no advisory/conditional/exemption admission. +observation : JSON -> Maybe Observation +observation (JString "PASSED") = + Just (MkObservation Mandatory Applies CompletedSuccess CurrentRevision AdmissionVerified) +observation (JString "FAILED") = + Just (MkObservation Mandatory Applies CompletedFailure CurrentRevision AdmissionUnverified) +observation (JString "PENDING") = + Just (MkObservation Mandatory Applies StillRunning CurrentRevision AdmissionUnverified) +observation (JString "UNKNOWN") = + Just (MkObservation Mandatory Applies NeverStarted RevisionUnknown AdmissionUnverified) +observation _ = Nothing + +wireValues : List JSON -> Maybe (List Observation) +wireValues [] = Just [] +wireValues (value :: rest) = do + first <- observation value + remaining <- wireValues rest + Just (first :: remaining) + +public export +wireObservations : JSON -> Maybe (List Observation) +wireObservations (JArray values) = + if length values <= 64 then wireValues values else Nothing +wireObservations _ = Nothing diff --git a/src/Assurance/GitHubMain.idr b/src/Assurance/GitHubMain.idr new file mode 100644 index 0000000..184dc58 --- /dev/null +++ b/src/Assurance/GitHubMain.idr @@ -0,0 +1,47 @@ +-- SPDX-License-Identifier: MPL-2.0 +module Assurance.GitHubMain + +import Assurance.GitHub +import Assurance.Verdict +import Language.JSON +import System +import System.File + +%default total + +covering +unknown : IO () +unknown = do + putStrLn "UNKNOWN" + exitWith (ExitFailure 2) + +covering +run : String -> String -> IO () +run mode path = do + input <- readFile path + case input of + Left _ => unknown + Right raw => + case strictJSON raw of + Nothing => unknown + Just value => + case mode of + "validate" => putStrLn "VALID_JSON" + "decide" => + case wireObservations value of + Nothing => unknown + Just observations => + case decide observations of + Passed => putStrLn "PASSED" + Blocked => do + putStrLn "BLOCKED" + exitWith (ExitFailure 1) + _ => unknown + +covering +main : IO () +main = do + args <- getArgs + case args of + [_, mode, path] => run mode path + _ => unknown diff --git a/tests/assurance-github.sh b/tests/assurance-github.sh new file mode 100644 index 0000000..0cffddd --- /dev/null +++ b/tests/assurance-github.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Synthetic transport controls through the actual source-built Idris executable. +# These are not real-consumer receipts and cannot satisfy GOVERNANCE by themselves. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +for tool in idris2 jq gh git sha256sum timeout; do + command -v "$tool" >/dev/null 2>&1 || { echo "$tool missing; NOT RUN" >&2; exit 2; } +done +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export ASSURANCE_REAL_DATE +ASSURANCE_REAL_DATE=$(command -v date) +export ASSURANCE_FIXTURE_DIR="" +mkdir "$work/bin" +cat >"$work/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +[[ $# == 10 && $1 == api && $2 == --hostname && $3 == github.com && + $4 == --method && $5 == GET ]] || exit 95 +fixture=$ASSURANCE_FIXTURE_DIR +endpoint=${!#} +repo=$(jq -r '.repository.full_name' "$fixture/policy.json") +subject=$(jq -r '.subject_sha' "$fixture/policy.json") +tree=$(jq -r '.tree.sha' "$fixture/commit.json") +run=$(jq -r '.runs[0].id' "$fixture/policy.json") +printf '%s\n' "$endpoint" >>"$fixture/requests" +case "$endpoint" in + "repos/$repo") key=repository ;; + "repos/$repo/git/commits/$subject") key=commit ;; + "repos/$repo/git/trees/$tree?recursive=1") key=tree ;; + "repos/$repo/actions/runs?head_sha=$subject&per_page=100&page=1") key=runs ;; + "repos/$repo/actions/runs/$run") key=run ;; + "repos/$repo/actions/runs/$run/jobs?filter=latest&per_page=100&page=1") key=jobs ;; + *) exit 96 ;; +esac +case "$key" in + repository|runs|run) + count=0 + if [[ -f "$fixture/count-$key" ]]; then read -r count <"$fixture/count-$key"; fi + printf '%s\n' "$((count+1))" >"$fixture/count-$key" + if [[ $count == 0 ]]; then file="$key-before"; else file="$key-after"; fi + ;; + *) file=$key ;; +esac +[[ ! -f "$fixture/fail-$file" ]] || exit 22 +if [[ $file == repository-after && -f "$fixture/finish-clock" ]]; then + cp "$fixture/finish-clock" "$fixture/clock" +fi +cat "$fixture/$file.json" +GH +cat >"$work/bin/date" <<'DATE' +#!/usr/bin/env bash +set -euo pipefail +if [[ $# == 2 && $1 == -u && $2 == +%s && -n ${ASSURANCE_FIXTURE_DIR:-} ]]; then + count=0 + if [[ -f "$ASSURANCE_FIXTURE_DIR/clock-calls" ]]; then + read -r count <"$ASSURANCE_FIXTURE_DIR/clock-calls" + fi + count=$((count+1)) + printf '%s\n' "$count" >"$ASSURANCE_FIXTURE_DIR/clock-calls" + # One start, nine request guards, then pre/post-kernel decision clocks. + if [[ $count -ge 12 && -f "$ASSURANCE_FIXTURE_DIR/after-kernel-clock" ]]; then + cp "$ASSURANCE_FIXTURE_DIR/after-kernel-clock" "$ASSURANCE_FIXTURE_DIR/clock" + fi + cat "$ASSURANCE_FIXTURE_DIR/clock" +else + exec "$ASSURANCE_REAL_DATE" "$@" +fi +DATE +chmod +x "$work/bin/gh" "$work/bin/date" +export PATH="$work/bin:$PATH" +# shellcheck source=scripts/assurance-github.sh +source "$root/scripts/assurance-github.sh" +if ! assurance_build "$work/compiler"; then + echo "real bridge compilation unavailable; NOT RUN" >&2 + exit 2 +fi + +native_controls=0 +wire_control() { + local label=$1 payload=$2 expected_exit=$3 expected_output=$4 mode=${5:-decide} + local status=0 result + printf '%s\n' "$payload" >"$work/native-input.json" + result=$(timeout 20 "$ASSURANCE_BINARY" "$mode" "$work/native-input.json") || status=$? + [[ $status == "$expected_exit" && $result == "$expected_output" ]] || + { echo "FAIL native $label (status=$status output=$result)" >&2; exit 1; } + ((native_controls+=1)) +} +wire_control valid '["PASSED"]' 0 PASSED +wire_control empty '[]' 1 BLOCKED +wire_control failed '["PASSED","FAILED"]' 1 BLOCKED +wire_control pending '["PASSED","PENDING"]' 1 BLOCKED +wire_control unknown '["PASSED","UNKNOWN"]' 1 BLOCKED +wire_control no-constructor-authority '["AdmissionVerified"]' 2 UNKNOWN +wire_control no-json-witness '[{"state":"PASSED","admission":"AdmissionVerified"}]' 2 UNKNOWN +wire_control malformed '["PASSED"' 2 UNKNOWN +wire_control multiple-documents '["PASSED"] ["PASSED"]' 2 UNKNOWN +wire_control oversized-wire "$(jq -cn '[range(65)|"PASSED"]')" 2 UNKNOWN +wire_control nested-positive '{"a":{"id":1},"b":{"id":2},"items":[{"id":3},{"id":4}]}' 0 VALID_JSON validate +wire_control nested-duplicate '{"a":[{"id":{},"id":1}]}' 2 UNKNOWN validate +wire_control distinct-object-fields '{"a":{"x":1},"a":{"y":2}}' 2 UNKNOWN validate +wire_control escaped-key '{"\u0069d":1}' 2 UNKNOWN validate +wire_control non-ascii-key '{"é":1}' 2 UNKNOWN validate + +fixture_count=0 +collector_controls=0 +baseline() { + ((fixture_count+=1)) + fixture="$work/fixture-$fixture_count" + mkdir "$fixture" + export ASSURANCE_FIXTURE_DIR=$fixture + now=$("$ASSURANCE_REAL_DATE" -u +%s) + printf '%s\n' "$now" >"$fixture/clock" + : >"$fixture/requests" + jq -cn --argjson now "$now" '{ + schema:"proven-cicd/github-execution/v1",category:"experimental",route:"direct-head", + repository:{full_name:"hyperpolymath/proven-cicd",id:1446155688,owner_id:6759885}, + subject_sha:"1111111111111111111111111111111111111111", + issued_at:(($now-120)|todateiso8601),expires_at:(($now+600)|todateiso8601),max_age_seconds:3600, + runs:[{id:1001,attempt:1,workflow_id:2001,path:".github/workflows/fixture.yml", + workflow_blob_sha:"2222222222222222222222222222222222222222", + event:"push",head_branch:"main", + jobs:[{name:"Required execution",steps:["Provision verifier","Execute required control"]}]}] + }' >"$fixture/policy.json" + jq -cn '{id:1446155688,full_name:"hyperpolymath/proven-cicd",owner:{id:6759885},private:false}' \ + >"$fixture/repository-before.json" + cp "$fixture/repository-before.json" "$fixture/repository-after.json" + jq -cn '{sha:"1111111111111111111111111111111111111111", + tree:{sha:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' >"$fixture/commit.json" + jq -cn '{sha:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",truncated:false,tree:[ + {path:".github",mode:"040000",type:"tree",sha:"3333333333333333333333333333333333333333"}, + {path:".github/workflows",mode:"040000",type:"tree",sha:"4444444444444444444444444444444444444444"}, + {path:".github/workflows/fixture.yml",mode:"100644",type:"blob",sha:"2222222222222222222222222222222222222222"} + ]}' >"$fixture/tree.json" + jq -cn --argjson now "$now" --slurpfile repository "$fixture/repository-before.json" '{ + id:1001,run_attempt:1,workflow_id:2001,path:".github/workflows/fixture.yml", + event:"push",head_branch:"main",head_sha:"1111111111111111111111111111111111111111", + head_commit:{id:"1111111111111111111111111111111111111111"},run_number:1, + repository:$repository[0],head_repository:$repository[0],referenced_workflows:[], + created_at:(($now-40)|todateiso8601),run_started_at:(($now-30)|todateiso8601), + updated_at:(($now-10)|todateiso8601),status:"completed",conclusion:"success" + }' >"$fixture/run-before.json" + cp "$fixture/run-before.json" "$fixture/run-after.json" + jq -cn --slurpfile run "$fixture/run-before.json" '{total_count:1,workflow_runs:$run}' \ + >"$fixture/runs-before.json" + cp "$fixture/runs-before.json" "$fixture/runs-after.json" + jq -cn --argjson now "$now" '{total_count:1,jobs:[{ + id:3001,run_id:1001,run_attempt:1,head_branch:"main", + head_sha:"1111111111111111111111111111111111111111", + run_url:"https://api.github.com/repos/hyperpolymath/proven-cicd/actions/runs/1001", + name:"Required execution",status:"completed",conclusion:"success", + created_at:(($now-25)|todateiso8601),started_at:(($now-24)|todateiso8601), + completed_at:(($now-20)|todateiso8601), + steps:[ + {name:"Provision verifier",number:1,status:"completed",conclusion:"success", + started_at:(($now-24)|todateiso8601),completed_at:(($now-22)|todateiso8601)}, + {name:"Execute required control",number:2,status:"completed",conclusion:"success", + started_at:(($now-22)|todateiso8601),completed_at:(($now-20)|todateiso8601)} + ] + }]}' >"$fixture/jobs.json" +} +change() { + local file=$1 filter=$2 + jq -c "$filter" "$fixture/$file.json" >"$fixture/changed.json" + mv "$fixture/changed.json" "$fixture/$file.json" +} +change_run() { + change run-before "$1" + cp "$fixture/run-before.json" "$fixture/run-after.json" + jq -cn --slurpfile run "$fixture/run-before.json" '{total_count:1,workflow_runs:$run}' \ + >"$fixture/runs-before.json" + cp "$fixture/runs-before.json" "$fixture/runs-after.json" +} +raw_replace() { + local file=$1 old=$2 replacement=$3 raw + raw=$(<"$fixture/$file.json") + [[ $raw == *"$old"* ]] || { echo "missing raw fixture preimage" >&2; exit 1; } + printf '%s\n' "${raw/"$old"/"$replacement"}" >"$fixture/$file.json" +} +check_case() { + local label=$1 expected=$2 expected_exit=$3 expected_reason=${4:-} expected_probe=${5:-} + local digest status output="$fixture/result" + digest=$(sha256sum "$fixture/policy.json") + digest=${digest%% *} + if [[ -f "$fixture/wrong-digest" ]]; then digest=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; fi + set +e + assurance_collect "$fixture/policy.json" "$digest" "$output" >"$fixture/stdout" 2>"$fixture/stderr" + status=$? + set -e + [[ $status == "$expected_exit" ]] || + { echo "FAIL collector $label exit=$status" >&2; cat "$fixture/stderr" >&2; exit 1; } + jq -e --arg verdict "$expected" \ + '.verdict == $verdict and .kernel_verdict == (if $verdict=="PASSED" then "PASSED" else "BLOCKED" end)' \ + "$output/receipt.json" >/dev/null || + { echo "FAIL collector $label receipt" >&2; cat "$output/receipt.json" >&2; exit 1; } + if [[ -n $expected_reason ]]; then + jq -e --arg reason "$expected_reason" '.reason == $reason' "$output/receipt.json" >/dev/null || + { echo "FAIL collector $label reason" >&2; exit 1; } + fi + if [[ -n $expected_probe ]]; then + jq -e --arg probe "$expected_probe" '.probe == $probe' "$output/receipt.json" >/dev/null || + { echo "FAIL collector $label rejected another probe" >&2; exit 1; } + fi + if [[ $expected == PASSED ]]; then + jq -e --arg digest "$digest" --arg source "$ASSURANCE_SOURCE_SHA" \ + '.policy_sha256 == $digest and .source_commit == $source and + (.observations|length)>0 and all(.observations[];.state=="PASSED")' "$output/receipt.json" >/dev/null + cmp "$fixture/policy.json" "$output/policy.json" + [[ $(wc -l <"$fixture/requests") == 9 ]] + fi + ((collector_controls+=1)) + printf 'ok: %s -> %s\n' "$label" "$expected" +} + +baseline +check_case valid PASSED 0 +# Same shape and mixed shape duplicates must fail BEFORE jq drops a field. +for prefix in '1446155688' '{}' '[]' '{"nested":1}' '[1]' 'null' 'false' '"wrong"'; do + baseline + raw_replace repository-before '"id":1446155688' "\"id\":$prefix,\"id\":1446155688" + check_case duplicate-repository-id UNKNOWN 2 invalid_raw_response_json repository-before +done +for prefix in '"success"' '{}' '[]' '{"nested":1}'; do + baseline + raw_replace run-before '"conclusion":"success"' "\"conclusion\":$prefix,\"conclusion\":\"success\"" + check_case duplicate-run-conclusion UNKNOWN 2 invalid_raw_response_json run-0-before +done +baseline +raw_replace jobs '"number":1' '"number":{},"number":1' +check_case nested-job-duplicate UNKNOWN 2 invalid_raw_response_json jobs-0 +baseline +raw_replace repository-before '"id":1446155688' '"id":1446155688,"\u0069d":1446155688' +check_case escaped-alias UNKNOWN 2 invalid_raw_response_json repository-before +baseline +printf '[]\n' >>"$fixture/run-before.json" +check_case trailing-document UNKNOWN 2 invalid_raw_response_json run-0-before +baseline +printf '{"id":' >"$fixture/repository-before.json" +check_case malformed-document UNKNOWN 2 invalid_raw_response_json repository-before +baseline +: >"$fixture/wrong-digest" +check_case wrong-policy-digest UNKNOWN 2 policy_digest_mismatch +baseline +change policy '.runs[0].event="pull_request"' +check_case unsupported-policy-event UNKNOWN 2 invalid_policy_schema +baseline +change policy '.runs=[]' +check_case empty-policy UNKNOWN 2 invalid_policy_schema +baseline +change policy '.runs += [.runs[0] | .id=1002 | .workflow_id=2002]' +check_case unsupported-multiple-workflows UNKNOWN 2 invalid_policy_schema +baseline +change repository-before '.id=1' +check_case wrong-repository UNKNOWN 2 repository_binding +baseline +change repository-after '.owner.id=1' +check_case changed-owner UNKNOWN 2 repository_binding +baseline +change commit '.sha="ffffffffffffffffffffffffffffffffffffffff"' +check_case wrong-commit UNKNOWN 2 subject_binding +baseline +change tree '.truncated=true' +check_case truncated-tree UNKNOWN 2 incomplete_tree +baseline +change tree '.tree[0].mode="120000" | .tree[0].type="blob"' +check_case symlink-parent UNKNOWN 2 workflow_source_binding +baseline +change tree '.tree[2].mode="160000" | .tree[2].type="commit"' +check_case gitlink-workflow UNKNOWN 2 workflow_source_binding +baseline +change tree '.tree[2].sha="ffffffffffffffffffffffffffffffffffffffff"' +check_case wrong-workflow-source UNKNOWN 2 workflow_source_binding +baseline +change_run '.head_sha="ffffffffffffffffffffffffffffffffffffffff"' +check_case wrong-head UNKNOWN 2 run_binding +baseline +change_run '.event="pull_request"' +check_case merge-context UNKNOWN 2 run_binding +baseline +change_run '.referenced_workflows=[{path:"other/repo/.github/workflows/reuse.yml@abc",sha:"abc"}]' +check_case reusable-context UNKNOWN 2 run_binding +baseline +change_run 'del(.referenced_workflows)' +check_case missing-provenance UNKNOWN 2 run_binding +baseline +change runs-before '.total_count=2' +check_case truncated-run-page UNKNOWN 2 incomplete_run_inventory +baseline +change runs-after '.workflow_runs += [.workflow_runs[0] | .id=1002 | .run_number=2] | .total_count=2' +check_case superseded-during-collection UNKNOWN 2 superseded_final_run +baseline +change runs-before '.workflow_runs[0].status="in_progress" | .workflow_runs[0].conclusion=null' +check_case initial-inventory-same-attempt-change UNKNOWN 2 initial_inventory_changed +baseline +change runs-after '.workflow_runs[0].status="in_progress" | .workflow_runs[0].conclusion=null' +check_case final-inventory-same-attempt-change UNKNOWN 2 final_inventory_changed +baseline +change run-after '.run_attempt=2' +check_case rerun-attempt UNKNOWN 2 run_binding +baseline +: >"$fixture/fail-run-after" +check_case revoked-run UNKNOWN 2 request_failed +baseline +change jobs '.jobs=[] | .total_count=0' +check_case missing-job UNKNOWN 2 missing_job +baseline +change jobs '.total_count=2' +check_case truncated-job-page UNKNOWN 2 invalid_jobs +baseline +change jobs '.jobs += [.jobs[0] | .id=3002] | .total_count=2' +check_case ambiguous-job-name UNKNOWN 2 invalid_jobs +baseline +change jobs '.jobs[0].steps |= .[0:1]' +check_case missing-detector UNKNOWN 2 step_contract_mismatch +baseline +change jobs '.jobs[0].steps |= reverse' +check_case wrong-step-order UNKNOWN 2 invalid_jobs +baseline +change jobs '.jobs[0].steps[1].conclusion="skipped"' +check_case skipped-detector UNKNOWN 2 +baseline +change jobs '.jobs[0].steps[0].conclusion="failure" | .jobs[0].conclusion="failure"' +check_case provisioning-failure FAILED 1 +baseline +change jobs '.jobs[0].steps[1].conclusion="failure"' +check_case masked-step-failure FAILED 1 +baseline +change_run '.conclusion="failure"' +change jobs '.jobs[0].conclusion="failure" | .jobs[0].steps[1].conclusion="failure"' +check_case observed-job-failure FAILED 1 +baseline +change_run '.conclusion="failure"' +check_case unexplained-run-failure UNKNOWN 2 unexplained_run_failure +baseline +change_run '.status="queued" | .conclusion=null | .run_started_at=null' +change jobs '.jobs=[] | .total_count=0' +check_case queued-run PENDING 2 +baseline +change policy '.max_age_seconds=5' +check_case stale-completion UNKNOWN 2 stale_run +baseline +change policy '.expires_at=((.issued_at|fromdateiso8601)+125|todateiso8601)' +printf '%s\n' "$((now+6))" >"$fixture/finish-clock" +check_case expired-during-collection UNKNOWN 2 policy_expired +baseline +change policy '.expires_at=((.issued_at|fromdateiso8601)+125|todateiso8601)' +printf '%s\n' "$((now+6))" >"$fixture/after-kernel-clock" +check_case expired-after-kernel UNKNOWN 2 policy_expired +baseline +printf '%s\n' "$((now+301))" >"$fixture/finish-clock" +check_case collection-bound UNKNOWN 2 collection_expired +baseline +change jobs '.jobs[0].completed_at="2099-01-01T00:00:00Z"' +check_case future-job UNKNOWN 2 invalid_execution_times +baseline +change_run '.updated_at="2026-02-31T00:00:00Z"' +check_case invalid-calendar-time UNKNOWN 2 run_binding + +# Positive aggregation: a second mandatory job and an explicit supported event. +baseline +change policy '.runs[0].jobs += [{name:"Second required execution",steps:["Provision verifier","Execute required control"]}]' +change jobs '.jobs += [.jobs[0] | .id=3002 | .name="Second required execution"] | .total_count=2' +check_case two-required-jobs PASSED 0 +baseline +change policy '.runs[0].event="workflow_dispatch"' +change_run '.event="workflow_dispatch"' +check_case direct-dispatch PASSED 0 + +# The second admitted repository is still a SYNTHETIC shape control. +baseline +change policy '.repository={full_name:"hyperpolymath/echidna",id:1101572347,owner_id:6759885}' +change repository-before '.id=1101572347 | .full_name="hyperpolymath/echidna"' +cp "$fixture/repository-before.json" "$fixture/repository-after.json" +change_run '.repository.id=1101572347 | .repository.full_name="hyperpolymath/echidna" | + .head_repository.id=1101572347 | .head_repository.full_name="hyperpolymath/echidna"' +change jobs '.jobs[0].run_url="https://api.github.com/repos/hyperpolymath/echidna/actions/runs/1001"' +check_case synthetic-echidna-contract PASSED 0 + +# A compiler/setup refusal is NOT a proof rejection or an executed observation. +mkdir "$work/no-compiler" +printf '#!/usr/bin/env bash\nexit 127\n' >"$work/no-compiler/idris2" +chmod +x "$work/no-compiler/idris2" +digest=$(sha256sum "$fixture/policy.json") +set +e +PATH="$work/no-compiler:$PATH" bash "$root/scripts/assurance-github.sh" \ + "$fixture/policy.json" "${digest%% *}" "$work/no-compiler-result" >"$work/missing-compiler.json" +status=$? +set -e +[[ $status == 2 ]] +jq -e '.verdict=="UNKNOWN" and .kernel_verdict=="NOT_RUN" and .reason=="source_or_compiler_unavailable"' \ + "$work/missing-compiler.json" >/dev/null + +baseline +check_case final-positive PASSED 0 +printf 'real Idris bridge controls: %s wire/raw JSON; %s collector; 1 compiler-absence\n' \ + "$native_controls" "$collector_controls" diff --git a/tests/assurance-real-canaries.sh b/tests/assurance-real-canaries.sh new file mode 100644 index 0000000..bf53aff --- /dev/null +++ b/tests/assurance-real-canaries.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Manual, read-only qualification against reviewed immutable public subjects. +# A satisfied negative control retains the consumer's FAILED receipt. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +[[ $# == 1 ]] || { echo 'usage: assurance-real-canaries.sh NEW_OUTPUT_DIR' >&2; exit 2; } +output=$1 +mkdir "$output" +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT +# shellcheck source=scripts/assurance-github.sh +source "$root/scripts/assurance-github.sh" +if ! assurance_build "$build"; then + echo 'real compiler/source unavailable; canaries NOT RUN' >&2 + exit 2 +fi +observe() { + local name=$1 policy=$2 digest=$3 expected=$4 expected_exit=$5 expected_states=$6 status + set +e + assurance_collect "$root/$policy" "$digest" "$output/$name" >"$output/$name.stdout.json" + status=$? + set -e + [[ $status == "$expected_exit" ]] || + { echo "$name: unexpected collector exit $status; qualification incomplete" >&2; exit 1; } + jq -e --arg expected "$expected" --arg digest "$digest" \ + --arg source "$ASSURANCE_SOURCE_SHA" --argjson states "$expected_states" ' + .verdict == $expected + and .kernel_verdict == (if $expected=="PASSED" then "PASSED" else "BLOCKED" end) + and .policy_sha256 == $digest and .source_commit == $source + and ([.observations[].state]|sort) == ($states|sort)' \ + "$output/$name/receipt.json" >/dev/null || + { echo "$name: expected real observation was not established" >&2; exit 1; } + printf '%s: consumer verdict %s retained; actual compiled kernel executed\n' "$name" "$expected" +} +observe suite-a96 config/assurance-canaries/suite-a96.json \ + 8b7690f38d1b89a97399db96537fe37311e56cc73dc2828f7fe6f82ec2b7d9c6 \ + PASSED 0 '["PASSED"]' +observe echidna-3f-chapel config/assurance-canaries/echidna-3f-chapel.json \ + cfe90f04a14f6937a7ca41ee39119c256926058e96f3827b07f9e3570b033fd9 \ + FAILED 1 '["PASSED","PASSED","PASSED","FAILED"]' +jq -s '{ + schema:"proven-cicd/github-execution-canary-observations/v1", + category:"experimental", + observation:"expected_real_subject_results_recorded", + promotion_authorized:false, + receipts:. +}' "$output/suite-a96/receipt.json" "$output/echidna-3f-chapel/receipt.json" \ + >"$output/observations.json" diff --git a/tests/ci-pilot-manifest.sh b/tests/ci-pilot-manifest.sh new file mode 100644 index 0000000..0c39d70 --- /dev/null +++ b/tests/ci-pilot-manifest.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +validate() { + jq -e --slurpfile mothers "$ROOT/profiles/mothers.json" ' + def exact($k): type == "object" and keys == ($k | sort); + def text: type == "string" and test("[^[:space:]]"); + exact(["schema_version", "stage", "inspected_on", "qualification", "candidates"]) and + .schema_version == 1 and .stage == "proposal" and .qualification == "not-run" and + (.inspected_on | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (.candidates | type == "array" and length > 0 and length <= 6) and + ([.candidates[].repository] | length == (unique | length)) and + all(.candidates[]; + exact(["repository", "mother", "snapshot_commit", "evidence_paths", "reason", "open_questions"]) and + (.repository | test("^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")) and + (.mother as $m | [$mothers[0].mothers[].id] | index($m) != null) and + (.snapshot_commit | test("^[a-f0-9]{40}$")) and + (.evidence_paths | type == "array" and length > 0 and all(.[]; text)) and + (.reason | text) and + (.open_questions | type == "array" and length > 0 and all(.[]; text))) + ' "$1" >/dev/null +} +validate "$ROOT/profiles/pilot.json" +for mutation in \ + '.qualification="passed"' \ + '.stage="enforcing"' \ + '.candidates += [.candidates[0]]' \ + '.candidates[0].snapshot_commit="main"' \ + '.candidates[0].mother="unknown-v1"' \ + '.candidates[0].open_questions=[]' \ + '.candidates[0].enabled=true'; do + jq "$mutation" "$ROOT/profiles/pilot.json" >"$tmp/pilot.json" + if validate "$tmp/pilot.json"; then + echo "FAIL: accepted pilot mutation: $mutation" >&2; exit 1 + fi +done +echo 'ok: proposed pilot manifest and seven negative controls (no remote qualification)' diff --git a/tests/ci-profiles.sh b/tests/ci-profiles.sh new file mode 100644 index 0000000..11443d0 --- /dev/null +++ b/tests/ci-profiles.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +validator=$ROOT/scripts/validate-ci-profile.sh +fixtures=$ROOT/tests/fixtures/ci-profiles +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +count=0 +for fixture in "$fixtures"/*.json; do + bash "$validator" "$fixture" +done +expect_failure() { + if bash "$validator" "$tmp/profile.json" >"$tmp/log" 2>&1; then + echo "FAIL: accepted $1" >&2; exit 1 + fi + count=$((count + 1)) + echo "ok: rejected $1" +} +mutate() { + jq "$2" "$fixtures/${3:-rust}.json" >"$tmp/profile.json" + expect_failure "$1" +} +mutate 'missing required capability' '.capabilities |= map(select(.id != "secret-scan"))' +mutate 'required capability demoted' '(.capabilities[] | select(.id == "rust-ci")).obligation = "advisory"' +mutate 'required capability exempted' '.capabilities[0].obligation = "not-applicable"' +mutate 'unknown capability' '.capabilities += [{id:"invented",obligation:"required"}]' +mutate 'placeholder as assurance' '.capabilities += [{id:"prat",obligation:"required"}]' +mutate 'advisory as required' '.capabilities += [{id:"badges",obligation:"required"}]' +mutate 'duplicate capability' '.capabilities += [.capabilities[0]]' +mutate 'unknown mother' '.mother = "invented-v1"' +mutate 'unknown slot' '.slots.run = "echo passed"' +mutate 'missing slot' 'del(.slots.msrv)' +mutate 'wrong slot type' '.slots.msrv = 1.85' +mutate 'floating version' '.slots.msrv = "stable"' +mutate 'newline in version' '.slots.msrv = "1.85\n"' +mutate 'newline in path' '.slots.root = "crates/library\n"' +mutate 'expression in version' '.slots.msrv = "${{ github.ref }}"' +mutate 'shell in root' '.slots.root = "$(id)"' +mutate 'absolute root' '.slots.root = "/tmp/project"' +mutate 'parent traversal' '.slots.root = "crates/../project"' +mutate 'Windows traversal' '.slots.root = "C:\\project"' +mutate 'URL instead of repository' '.repository = "https://github.com/example/project"' +mutate 'unsupported schema' '.schema_version = 2' +mutate 'self promotion' '.stage = "qualified"' +mutate 'permission extension' '.permissions = {contents:"write"}' +mutate 'missing lifecycle' '.lifecycles = ["validate"]' +mutate 'duplicate lifecycle' '.lifecycles += ["validate"]' +mutate 'release lifecycle' '.lifecycles += ["release"]' +mutate 'empty capability list' '.capabilities = []' +mutate 'empty Julia matrix' '.slots.julia_versions = []' julia +mutate 'duplicate Julia matrix' '.slots.julia_versions = ["1.9", "1.9"]' julia +mutate 'document path escape' '.slots.documents = ["../README.adoc"]' docs-data +mutate 'no documents' '.slots.documents = []' docs-data +mutate 'scalar profile' 'true' +mutate 'array profile' '[.]' +# Positive extensions must preserve their honest advisory classification. +jq '.capabilities += [{id:"badges",obligation:"advisory"}]' "$fixtures/rust.json" >"$tmp/profile.json" +bash "$validator" "$tmp/profile.json" +# Duplicate keys must be rejected before jq normalises the object. These +# otherwise-valid examples include escaped names and disjoint container members. +for duplicate in \ + '"stage":"qualified",' \ + '"sta\u0067e":"qualified",' \ + '"slots":{},' \ + '"slots":{"other":"discarded"},' \ + '"capabilities":[{"id":"invented","obligation":"required"}],'; do + { printf '{%s\n' "$duplicate"; tail -n +2 "$fixtures/rust.json"; } >"$tmp/profile.json" + expect_failure "duplicate JSON member: $duplicate" +done +sed 's/"root":/"root":"discarded", "root":/' "$fixtures/rust.json" >"$tmp/profile.json" +expect_failure 'nested duplicate member' +cat "$fixtures/rust.json" "$fixtures/rust.json" >"$tmp/profile.json" +expect_failure 'multiple JSON documents' +printf '{"broken":' >"$tmp/profile.json" +expect_failure 'malformed JSON' +: >"$tmp/profile.json" +expect_failure 'empty input' +echo "ok: three profile fixtures, advisory extension and $count negative controls" diff --git a/tests/docs-profile-subject.sh b/tests/docs-profile-subject.sh new file mode 100644 index 0000000..0102c71 --- /dev/null +++ b/tests/docs-profile-subject.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +repo=$tmp/repo +mkdir -p "$repo/docs" +git -C "$repo" init -q +git -C "$repo" config user.name Fixture +git -C "$repo" config user.email fixture@example.invalid +git -C "$repo" config commit.gpgsign false +git -C "$repo" remote add origin https://github.com/example/documentation.git +printf '%s\n' '= Documentation' >"$repo/README.adoc" +printf '%s\n' '= Index' >"$repo/docs/index.adoc" +git -C "$repo" add README.adoc docs/index.adoc +git -C "$repo" commit -qm fixture +head=$(git -C "$repo" rev-parse HEAD) +profile=$ROOT/tests/fixtures/ci-profiles/docs-data.json +runner=$ROOT/scripts/check-docs-profile-subject.sh +bash "$runner" "$profile" "$repo" "$head" | jq -e ' + .repository=="example/documentation" and (.commit|length)==40 and + .observation=="document-presence-passed" and (.documents|length)==2 +' >/dev/null +mkdir "$tmp/no-jq" +ln -s /usr/bin/dirname "$tmp/no-jq/dirname" +if PATH="$tmp/no-jq" /usr/bin/bash "$runner" "$profile" "$repo" "$head" >"$tmp/log" 2>&1; then + echo 'FAIL: accepted missing jq' >&2; exit 1 +fi +# These controls exercise real checked-out files, source identity and escapes. +expect_failure() { + if bash "$runner" "$1" "$repo" "$2" >"$tmp/log" 2>&1; then + echo "FAIL: accepted $3" >&2; exit 1 + fi +} +expect_failure "$profile" 0000000000000000000000000000000000000000 'wrong commit' +jq '.repository="example/wrong"' "$profile" >"$tmp/wrong.json" +expect_failure "$tmp/wrong.json" "$head" 'wrong repository' +rm "$repo/docs/index.adoc" +expect_failure "$profile" "$head" 'missing document' +printf '%s\n' '= Index' >"$repo/docs/index.adoc" +printf 'changed\n' >>"$repo/README.adoc" +expect_failure "$profile" "$head" 'changed source' +git -C "$repo" restore README.adoc +printf '' >"$repo/docs/index.adoc" +expect_failure "$profile" "$head" 'empty document' +git -C "$repo" restore docs/index.adoc +ln -s "$tmp/escape.adoc" "$repo/docs/escape.adoc" +git -C "$repo" add docs/escape.adoc +git -C "$repo" commit -qm symlink +head=$(git -C "$repo" rev-parse HEAD) +jq '.slots.documents=["docs/escape.adoc"]' "$profile" >"$tmp/escape.json" +expect_failure "$tmp/escape.json" "$head" 'symlink escape' +echo 'ok: exact-source document presence, missing/empty/dirty and symlink controls' diff --git a/tests/fixtures/ci-profiles/docs-data.json b/tests/fixtures/ci-profiles/docs-data.json new file mode 100644 index 0000000..bbe12f0 --- /dev/null +++ b/tests/fixtures/ci-profiles/docs-data.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "stage": "proposal", + "repository": "example/documentation", + "mother": "docs-data-v1", + "lifecycles": ["validate", "maintenance"], + "capabilities": [ + {"id": "secret-scan", "obligation": "required"}, + {"id": "spdx-license", "obligation": "required"} + ], + "slots": {"root": ".", "documents": ["README.adoc", "docs/index.adoc"]} +} diff --git a/tests/fixtures/ci-profiles/julia.json b/tests/fixtures/ci-profiles/julia.json new file mode 100644 index 0000000..e6a0043 --- /dev/null +++ b/tests/fixtures/ci-profiles/julia.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "stage": "proposal", + "repository": "example/Library.jl", + "mother": "julia-library-v1", + "lifecycles": ["validate", "maintenance"], + "capabilities": [ + {"id": "secret-scan", "obligation": "required"}, + {"id": "spdx-license", "obligation": "required"} + ], + "slots": {"root": ".", "julia_versions": ["1.9", "1.11"]} +} diff --git a/tests/fixtures/ci-profiles/rust.json b/tests/fixtures/ci-profiles/rust.json new file mode 100644 index 0000000..170d323 --- /dev/null +++ b/tests/fixtures/ci-profiles/rust.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "stage": "proposal", + "repository": "example/rust-library", + "mother": "rust-library-v1", + "lifecycles": ["validate", "maintenance"], + "capabilities": [ + {"id": "secret-scan", "obligation": "required"}, + {"id": "spdx-license", "obligation": "required"}, + {"id": "rust-ci", "obligation": "required"} + ], + "slots": {"root": "crates/library", "msrv": "1.85.0"} +} diff --git a/tests/lock-self-source-controls.sh b/tests/lock-self-source-controls.sh new file mode 100755 index 0000000..9e58373 --- /dev/null +++ b/tests/lock-self-source-controls.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Exercise the real closure guard in disposable Git repositories. The lock +# fixtures are the minimal shape read by that guard, not native-tool receipts. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +command -v git >/dev/null || { echo 'git is required' >&2; exit 2; } +command -v yq >/dev/null || { echo 'mikefarah yq v4 is required' >&2; exit 2; } +tmp=$(mktemp -d) +trap 'rm -rf -- "$tmp"' EXIT +canonical=hyperpolymath/proven-cicd +legacy=hyperpolymath/cicd-suite +renamed=hyperpolymath/proven-cicd-and-workflows +remote=actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +missing=0000000000000000000000000000000000000000 +cases=0 +fixtures=0 +fixture= +pin= + +commit_fixture() { + git -C "$fixture" add . + git -C "$fixture" -c core.hooksPath=/dev/null -c commit.gpgsign=false \ + -c user.name='CI control' -c user.email='ci-control@example.invalid' \ + commit -qm "$1" +} +new_fixture() { + fixtures=$((fixtures + 1)) + fixture="$tmp/case-$fixtures" + mkdir -p "$fixture/tests" "$fixture/actions/probe" "$fixture/.github/workflows" + cp "$root/tests/lock-transitive-closure.sh" "$fixture/tests/" + git init -q "$fixture" + cat > "$fixture/actions/probe/action.yml" < "$fixture/.github/workflows/probe.yml" <<'YAML' +name: Local composite +jobs: + probe: + runs-on: ubuntu-24.04 + steps: + - uses: $/actions/probe +YAML + cat > "$fixture/.github/workflows/actions.lock" < "$fixture/.github/workflows/self.yml" < "$tmp/log" 2>&1 || actual=$? + if [[ "$actual" -ne "$status" ]] || ! grep -Fq -- "$message" "$tmp/log"; then + cat "$tmp/log" + printf 'FAIL %s: expected status %s and diagnostic %s; got %s\n' \ + "$label" "$status" "$message" "$actual" >&2 + exit 1 + fi + if [[ "$status" -eq 0 ]] && ! grep -Eq '^PASS=[1-9][0-9]* FAIL=0$' "$tmp/log"; then + cat "$tmp/log" + echo "FAIL $label: no nonempty successful guard result" >&2 + exit 1 + fi + cases=$((cases + 1)) + printf 'ok: %s\n' "$label" +} + +# Each checkout name must inspect every exact source coordinate. +for checkout in "$canonical" "$legacy" "$renamed"; do + for source in "$canonical" "$legacy" "$renamed"; do + new_fixture + literal_pin "$source" "$pin" + expect 0 "$checkout" "$checkout checks literal $source" \ + 'examined 1 self-referencing pin(s)' + done +done +for checkout in "$canonical" "$legacy"; do + new_fixture + source=$legacy + [[ "$checkout" != "$legacy" ]] || source=$canonical + branch_pin "$source" "$pin" + expect 0 "$checkout" "$checkout checks locked branch $source" \ + 'serves the current actions/ tree' +done + +new_fixture +literal_pin "$legacy" "$pin" +printf ' - uses: %s/actions/probe@%s\n' "$canonical" "$pin" \ + >> "$fixture/.github/workflows/self.yml" +expect 0 "$canonical" 'both names are examined, not collapsed into one unchecked pin' \ + 'examined 2 self-referencing pin(s)' + +# An unrelated checkout retains its own exact self identity, not suite aliases. +new_fixture +literal_pin 'example/fork' "$pin" +expect 0 'example/fork' 'unrelated checkout keeps exact self-reference checks' \ + 'examined 1 self-referencing pin(s)' +for source in example/proven-cicd hyperpolymath/proven-cicd-extra hyperpolymath.extra/proven-cicd; do + new_fixture + literal_pin "$source" "$pin" + expect 1 "$canonical" "unadmitted literal $source cannot supply a population" \ + 'no self-referencing pin examined' +done +new_fixture +literal_pin "$canonical" "$pin" +expect 1 'example/fork' 'a fork does not inherit the suite alias family' \ + 'no self-referencing pin examined' +new_fixture +branch_pin example/proven-cicd "$pin" +expect 1 "$canonical" 'unadmitted locked branch cannot supply a population' \ + 'no self-referencing pin examined' + +new_fixture +literal_pin "$legacy" "$missing" +expect 1 "$canonical" 'legacy literal still requires the pinned commit locally' \ + 'which is not in this clone (need fetch-depth: 0)' +new_fixture +branch_pin "$canonical" "$missing" +expect 1 "$legacy" 'canonical locked branch still requires the pinned commit locally' \ + 'which is not in this clone (need fetch-depth: 0)' + +new_fixture +old=$pin +printf '# Changed source tree\n' >> "$fixture/actions/probe/action.yml" +commit_fixture 'changed composite' +current=$(git -C "$fixture" rev-parse HEAD) +literal_pin "$legacy" "$old" +printf ' - uses: %s/actions/probe@%s\n' "$canonical" "$current" \ + >> "$fixture/.github/workflows/self.yml" +expect 1 "$canonical" 'a current canonical pin cannot hide a stale legacy composite' \ + "which differs from HEAD's" +grep -Fq 'examined 2 self-referencing pin(s)' "$tmp/log" + +new_fixture +old=$pin +printf '# Changed source tree\n' >> "$fixture/actions/probe/action.yml" +commit_fixture 'changed composite' +branch_pin "$canonical" "$old" +expect 1 "$legacy" 'canonical locked branch still requires the whole current actions tree' \ + 'whose actions/ tree differs from HEAD' + +new_fixture +expect 1 "$canonical" 'absence of either self-reference population is a refusal' \ + 'no self-referencing pin examined' + +new_fixture +literal_pin "$legacy" "$pin" +yq -i '.workflows[".github/workflows/probe.yml"] = []' \ + "$fixture/.github/workflows/actions.lock" +expect 1 "$canonical" 'alias admission does not waive transitive declarations' \ + 'through a composite but does not declare it' + +new_fixture +literal_pin "$legacy" "$pin" +ref="$remote" yq -i 'del(.dependencies[strenv(ref)])' \ + "$fixture/.github/workflows/actions.lock" +expect 1 "$canonical" 'alias admission does not waive dependency entries' \ + 'but dependencies: has no such key' + +[[ "$cases" -eq 25 ]] || { echo "Expected 25 controls, got $cases" >&2; exit 1; } +printf 'lock self-source controls: %s passed\n' "$cases" diff --git a/tests/lock-transitive-closure.sh b/tests/lock-transitive-closure.sh index 31cc27d..4379e5c 100755 --- a/tests/lock-transitive-closure.sh +++ b/tests/lock-transitive-closure.sh @@ -122,6 +122,26 @@ echo "== a self-referencing pin serves the CURRENT composites ==" # In Actions $GITHUB_REPOSITORY is authoritative; the remote is the local path. selfrepo="${GITHUB_REPOSITORY:-$(git -C "$ROOT" remote get-url origin 2>/dev/null \ | sed -E 's#\.git$##; s#^.*[:/]([^/]+/[^/]+)$#\1#')}" +# The history-preserving canonical import retains reviewed legacy pins. The +# suite has three exact names: cicd-suite (the name before the rename), +# proven-cicd (the separate import) and proven-cicd-and-workflows (the name +# now). They denote this suite only when the checkout itself is one of them; +# a fork or a similarly named repository does not inherit this admission. +self_repositories=("$selfrepo") +case "$selfrepo" in + hyperpolymath/cicd-suite|hyperpolymath/proven-cicd|hyperpolymath/proven-cicd-and-workflows) + self_repositories=(hyperpolymath/cicd-suite hyperpolymath/proven-cicd + hyperpolymath/proven-cicd-and-workflows) + ;; + *) ;; +esac +is_self_repository() { + local candidate="$1" repository + for repository in "${self_repositories[@]}"; do + [ "$candidate" = "$repository" ] && return 0 + done + return 1 +} selfchecked=0 # serves_current