diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index bcc373e..7bf318d 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -9,7 +9,7 @@ workflows: '.github/workflows/container-build.yml': - 'actions/checkout@v7.0.1' '.github/workflows/creusot.yml': - - 'hyperpolymath/cicd-suite@bf7dd95cf49733019343ff485c2f45c6b261839c' + - 'hyperpolymath/cicd-suite@47a5fe0aa1039ad8a6a0614137d575e66649a174' '.github/workflows/dependabot-automerge.yml': - 'dependabot/fetch-metadata@v3.1.0' '.github/workflows/dogfood-gate.yml': @@ -19,17 +19,17 @@ workflows: '.github/workflows/estate-rules.yml': - 'actions/checkout@v7.0.1' '.github/workflows/governance.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/guix-policy.yml': - 'actions/checkout@v7.0.1' '.github/workflows/hypatia-scan.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/instant-sync.yml': [] '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/openssf-compliance.yml': - 'actions/checkout@v7.0.1' '.github/workflows/pages.yml': @@ -53,16 +53,16 @@ workflows: '.github/workflows/runtime-policy.yml': - 'actions/checkout@v7.0.1' '.github/workflows/rust-ci.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/scorecard.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/security-policy.yml': - 'actions/checkout@v7.0.1' '.github/workflows/squabble-action.yml': [] '.github/workflows/standards-pipeline.yml': - - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.2' @@ -197,9 +197,9 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'hyperpolymath/cicd-suite@bf7dd95cf49733019343ff485c2f45c6b261839c': - ref: 'bf7dd95cf49733019343ff485c2f45c6b261839c' - commit: 'sha1-bf7dd95cf49733019343ff485c2f45c6b261839c' + 'hyperpolymath/cicd-suite@47a5fe0aa1039ad8a6a0614137d575e66649a174': + ref: '47a5fe0aa1039ad8a6a0614137d575e66649a174' + commit: 'sha1-47a5fe0aa1039ad8a6a0614137d575e66649a174' owner_id: 6759885 repo_id: 1326697643 uses: @@ -211,24 +211,15 @@ dependencies: commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/standards@d55304a01b5260b0511aae0d14ed8b8770b00293': - ref: 'd55304a01b5260b0511aae0d14ed8b8770b00293' - commit: 'sha1-d55304a01b5260b0511aae0d14ed8b8770b00293' - owner_id: 297579217 - repo_id: 1116521501 - uses: - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' - - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - 'metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d': - ref: '8e48b1caade08a552646adeab66558ac43b4360d' - commit: 'sha1-8e48b1caade08a552646adeab66558ac43b4360d' + 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12': + ref: 'a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + commit: 'sha1-a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' owner_id: 297579217 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' @@ -237,11 +228,21 @@ dependencies: - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - - 'hyperpolymath/standards@d55304a01b5260b0511aae0d14ed8b8770b00293' + - 'metadatastician/standards@d55304a01b5260b0511aae0d14ed8b8770b00293' - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' - - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'metadatastician/standards@d55304a01b5260b0511aae0d14ed8b8770b00293': + ref: 'd55304a01b5260b0511aae0d14ed8b8770b00293' + commit: 'sha1-d55304a01b5260b0511aae0d14ed8b8770b00293' + owner_id: 297579217 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': ref: 'v2.5.0' commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' @@ -262,6 +263,11 @@ dependencies: commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 + 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': + ref: 'v2.9.3' + commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' + owner_id: 580492 + repo_id: 298565987 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' diff --git a/.github/workflows/creusot.yml b/.github/workflows/creusot.yml index beaf8a3..756803e 100644 --- a/.github/workflows/creusot.yml +++ b/.github/workflows/creusot.yml @@ -33,7 +33,7 @@ permissions: jobs: creusot: - uses: hyperpolymath/cicd-suite/.github/workflows/creusot-gate.yml@bf7dd95cf49733019343ff485c2f45c6b261839c + uses: hyperpolymath/cicd-suite/.github/workflows/creusot-gate.yml@47a5fe0aa1039ad8a6a0614137d575e66649a174 with: # Paired inputs: Creusot v0.14.0 is built against nightly-2026-08-03 # (creusot-rs/creusot's own rust-toolchain at that tag, mirrored in diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 0b86783..1d28f68 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -18,7 +18,7 @@ permissions: jobs: governance: - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 permissions: actions: read contents: read diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index ca0ef8e..bedb277 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -27,4 +27,4 @@ permissions: jobs: hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 7c4cc39..0f98e6c 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -13,7 +13,7 @@ permissions: contents: read jobs: mirror: - uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # Only the seven forge keys the callee declares (standards#1108 §6b); # `secrets: inherit` forwarded every repository secret (WH008, zizmor # secrets-inherit) to a workflow that asks for exactly these. diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index b9fa591..515a007 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -16,7 +16,7 @@ permissions: contents: read jobs: rust-ci: - uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 permissions: actions: read contents: read diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0ef1c9d..046b918 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -16,7 +16,7 @@ permissions: jobs: scorecard: - uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 permissions: actions: read contents: read diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index c594a1e..7272c99 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -22,7 +22,7 @@ permissions: # .github/workflows/README.adoc. jobs: scan: - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # No `secrets:` block, deliberately: secret-scanner-reusable.yml @75bd5994 # declares no caller secrets and reads no `secrets.*` context, so # `secrets: inherit` only forwarded every repository secret to a workflow diff --git a/.github/workflows/standards-pipeline.yml b/.github/workflows/standards-pipeline.yml index ee24ab1..d999ee8 100644 --- a/.github/workflows/standards-pipeline.yml +++ b/.github/workflows/standards-pipeline.yml @@ -39,4 +39,4 @@ jobs: # `workflow_call.inputs` only, has no `secrets:` block and references no # `secrets.*` anywhere; `secrets: inherit` would hand the whole store to a # workflow that consumes none of it. - uses: metadatastician/standards/.github/workflows/ci-pipeline.yml@8e48b1caade08a552646adeab66558ac43b4360d + uses: metadatastician/standards/.github/workflows/ci-pipeline.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/README.adoc b/README.adoc index ba2387d..a9ecaae 100644 --- a/README.adoc +++ b/README.adoc @@ -49,11 +49,11 @@ workflows and pinned actions. Nothing is written anywhere. squabble chains . ../standards gh:hyperpolymath/typed-wasm --follow 1 ---- -* *Blocking* (exit 4): dependency cycles, and dead upstreams. A deleted action - repo produces no check run at all, so the board reads green. +* *Blocking* (exit 4): dependency cycles, dead upstreams, and moved upstream + coordinates confirmed in requested subjects or through exact pinned + dependency routes. A deleted reusable workflow can fail before any job reports. * *Warnings*: diamonds (one repo's CI receives an upstream at two pins, one - of them via a reusable workflow), pin skew across repos, tag/branch pins, - and **moved upstreams** (see below). + of them via a reusable workflow), pin skew across repos, and tag/branch pins. * It also prints an upstreams-first landing order and the longest chain. Every upstream coordinate is resolved through REST with redirects followed, and @@ -61,9 +61,14 @@ its canonical `full_name` is compared with the name as written — names, never pins. A difference is reported as a moved upstream: a reusable coordinate under a renamed/transferred owner can die as a zero-job `startup_failure` ("workflow was not found") that no PR check list shows, so it is named *before* CI goes -quiet. It stays a warning while the redirect still works (that is a risk, not an -observed failure); `squabble zero-job` is what turns an observed failure into a -blocking finding. The same canonicalisation groups pin skew, so the old and new +quiet. A confirmed reference blocks even when the repository API follows a +redirect: Actions does not redirect the `uses:` reference. A reference found +only on an upstream's default branch remains an unconfirmed warning unless +every hop from a requested subject matches the scanned revision and called +workflow file. The report retains both confirmed and unconfirmed locations. +`squabble zero-job` separately reports +the failed runs that were actually observed. The same canonicalisation groups +pin skew, so the old and new spellings of one upstream cannot hide a split. `--no-canonical` skips the REST reads and says so — a deliberate blind spot, not a clean bill. @@ -76,7 +81,8 @@ layer. Some workflows fail *before any job exists*. GitHub creates no check run, the PR check list stays tidy, and a required context is simply absent. Two first-class -classes are modelled, each with its own move: +coordinate classes are modelled, each with its own move. Optional native lock +verification adds source diagnoses which never propose permission changes: [cols="2,1,2"] |=== @@ -96,20 +102,67 @@ classes are modelled, each with its own move: (a different coordinate may already be admitted, and under `verified_allowed` an uncovered action is reported `Unattributed` rather than refused, because creator status cannot be resolved here). + +| *invalid native Actions lock* +| Diagnosis only +| The official native parser rejected the lock at the failed run's exact + source revision. Retain its exit and diagnostic, then review a candidate + produced by the repository's official regeneration chain. + +| *Actions lock/workflow mismatch* +| Diagnosis only +| The official offline verifier reported coverage findings for this workflow + at the failed run's exact source revision. This is separate from malformed + lock syntax and from a tool or setup failure. |=== [source,sh] ---- squabble zero-job . --limit 30 # attribute the latest zero-job runs squabble zero-job . --limit 30 --json # the same, machine-readable +squabble zero-job . --actions-lock-verifier /verified/gh-actions-lock --json ---- Per run it reports the repo, the workflow, the coordinate **as written**, the canonical target, and the required allow-list pattern — plus the sequencing: fix the repoint first, then admit the canonical pattern if the run is still refused. -Exit `4` means zero-job runs were observed, attributed or not: a run it cannot -attribute is still a run that never reported. Exit `0` speaks only for the -scanned window. +Both `startup_failure` and ordinary `failure` conclusions are investigated; +GitHub reports both for runs which created no jobs. The jobs endpoint must +independently establish a zero count before source attribution. Ordinary +failures with observed jobs belong to execution-phase diagnosis. Unreadable +counts and contradictory startup evidence remain visible and blocking without +inventing a zero count or proposing a repair. + +The run's full commit ID must match the local repository root and its +unmodified workflow tree. A different revision, nested directory, unreadable +source, or changed workflow stays `Unattributed`; a current checkout cannot +explain a historical run by coincidence. Exit `4` means a failed candidate +remains in the report, attributed or not. Exit `0` speaks only for the scanned +window. + +`--actions-lock-verifier` takes an absolute path to an independently verified +official `gh-actions-lock` v0.2.0 binary. It invokes only `--version` and +`--verify-local --json --hostname github.com`: no download, resolution, auth, +network request or write mode. Tokens and subject-override environment variables +are removed from that child process. The JSON report retains the tool version, +exact source, exit, stdout, stderr and classification. + +The native probe requires a *pristine full checkout*, including ignored and +untracked files, with no symlink/gitlink inputs or hidden index changes. Use a +fresh checkout at the failed run's commit. This stricter opt-in requirement +includes same-repository `$/...` action definitions outside workflows: an +ignored `action.yml` can shadow a committed `action.yaml`. If that complete +input set cannot be bound to the source, the probe is explicitly unverified. +The command never removes or changes a dirty file to meet the requirement. +An unavailable optional lock probe is retained beside any independently +established coordinate attribution; it neither hides that diagnosis nor +establishes a lock repair or a successful verification. + +The native reader checks the official exit/JSON contract and complete workflow +population. Empty, malformed, partial, duplicate-key, contradictory or unknown +verifier-version output cannot become verified. A local verification pass is coverage +evidence only; a reproduced source defect is not proof of the sole GitHub +startup cause. Neither is a CI pass, repair outcome or training success. `zero-job` is *outside* `squabble-contract/1.0.0`: that revision's surface stays at four subcommands, and nothing here adds meaning to it. Like `chains`, the diff --git a/crates/squabble-cli/src/chains.rs b/crates/squabble-cli/src/chains.rs index 6c532ba..76fcaf4 100644 --- a/crates/squabble-cli/src/chains.rs +++ b/crates/squabble-cli/src/chains.rs @@ -27,8 +27,8 @@ //! as it is when `--follow` finds it. //! //! Nothing is written anywhere. Exit `0` = no blocking finding, `4` = at -//! least one blocking finding (a cycle or a dead upstream). A moved upstream -//! is a warning. Exit `2` = usage or tool failure, including a run where no +//! least one blocking finding (a cycle, dead upstream or moved coordinate). +//! Exit `2` = usage or tool failure, including a run where no //! repository could be read at all. use squabble_core::chains::{ @@ -371,9 +371,20 @@ fn details(f: &Finding) -> Vec { }; match f { Finding::Cycle { .. } => Vec::new(), - Finding::DeadUpstream { consumers, .. } | Finding::MovedUpstream { consumers, .. } => { - consumers.iter().map(at).collect() - } + Finding::DeadUpstream { consumers, .. } => consumers.iter().map(at).collect(), + Finding::MovedUpstream { + consumers, + unconfirmed_consumers, + .. + } => consumers + .iter() + .map(at) + .chain( + unconfirmed_consumers + .iter() + .map(|edge| format!("{} (upstream revision unconfirmed)", at(edge))), + ) + .collect(), Finding::Diamond { routes, .. } => routes .iter() .map(|r| { @@ -626,6 +637,7 @@ mod tests { written, canonical, consumers, + .. } => Some((written, canonical, consumers)), _ => None, }) @@ -648,21 +660,76 @@ mod tests { text.contains("zero-job `startup_failure`"), "the finding must name the invisible class: {text}" ); - assert_eq!(exit_for(&r), ExitCode::SUCCESS); - assert!(r.findings.iter().any(|f| f.severity() == Severity::Warning)); + assert_eq!(exit_for(&r), ExitCode::from(BLOCKING_EXIT)); + assert!(r + .findings + .iter() + .any(|f| f.severity() == Severity::Blocking)); } #[test] fn a_dead_upstream_is_found_by_rest_even_without_follow() { - let rest = CanonicalRest(vec![("me/dead-action", CanonicalResolution::NotFound)]); - let r = collect(&args(0), &canned(), &rest); + let r = collect( + &args(0), + &gone_owner_fixture(), + &AbsentRepository { + owner_exists: false, + }, + ); assert!(r.has_blocking(), "{}", narrate(&r)); assert!(r.findings.iter().any(|f| matches!( f, - Finding::DeadUpstream { target, .. } if target == &RepoId::new("me/dead-action") + Finding::DeadUpstream { target, .. } if target == &RepoId::new("gone/dead-action") ))); // It was probed, so it is no longer claimed as an unprobed frontier. - assert!(!r.frontier.contains(&RepoId::new("me/dead-action"))); + assert!(!r.frontier.contains(&RepoId::new("gone/dead-action"))); + assert_eq!( + r.cost.as_ref().unwrap().rest_calls, + 2, + "repository and owner absence are both observed" + ); + } + + fn gone_owner_fixture() -> Canned { + Canned(vec![("me/app", Some(vec![("ci.yml", "jobs:\n scan:\n steps:\n - uses: gone/dead-action@1111111111111111111111111111111111111111\n")]))]) + } + + struct AbsentRepository { + owner_exists: bool, + } + + impl RestTransport for AbsentRepository { + fn get(&self, path: &str) -> Result { + let status = match path { + "repos/gone/dead-action" => 404, + "users/gone" if !self.owner_exists => 404, + "users/gone" => 200, + _ => return Err(format!("unexpected path {path}")), + }; + Ok(RestResponse { + status, + location: None, + body: "{}".into(), + }) + } + } + + #[test] + fn repository_404_with_an_existing_owner_does_not_prove_a_dead_upstream() { + let r = collect( + &args(0), + &gone_owner_fixture(), + &AbsentRepository { owner_exists: true }, + ); + assert!(!r.has_blocking()); + assert!(!r + .findings + .iter() + .any(|f| matches!(f, Finding::DeadUpstream { .. }))); + assert!(r + .notes + .iter() + .any(|n| n.contains("absence is not established"))); } #[test] diff --git a/crates/squabble-cli/src/lock_verifier.rs b/crates/squabble-cli/src/lock_verifier.rs new file mode 100644 index 0000000..e814f1b --- /dev/null +++ b/crates/squabble-cli/src/lock_verifier.rs @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +//! Read-only native Actions lock diagnosis. The official tool owns parsing; +//! squabbler preserves its output and checks the observation's provenance. + +use serde::Deserialize; +use squabble_core::startup::{same_source_revision, NativeLockStatus, NativeLockVerification}; +use std::collections::BTreeSet; +use std::path::Path; +use std::process::{Command, Output}; + +const SUPPORTED_VERSION: &str = "actions-lock version v0.2.0"; + +/// Return the checkout's full commit ID when its workflow tree is unmodified. +/// +/// Returns an error if `root` is not the repository root, Git or the checkout +/// cannot be read, or the workflow inputs are dirty or unsuitable for binding. +pub(crate) fn checkout_revision(root: &Path) -> Result { + source_revision(root, false) +} + +/// Return the full commit ID after checking the selected source scope. +/// `full_tree` checks the whole checkout, including same-repository action +/// definitions; otherwise only the workflow tree is checked. +/// +/// Returns an error if the root, Git identity, index or selected source cannot +/// be established as an unmodified checkout, including ignored and untracked +/// inputs and symlinks or gitlinks in the selected tree. +fn source_revision(root: &Path, full_tree: bool) -> Result { + let git = |args: &[&str]| -> Result { + Command::new("git") + .current_dir(root) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .output() + .map_err(|e| format!("cannot read checkout identity: {e}")) + }; + let top = git(&["rev-parse", "--show-toplevel"])?; + let canonical = root + .canonicalize() + .map_err(|e| format!("cannot resolve checkout root: {e}"))?; + let top_path = String::from_utf8_lossy(&top.stdout).trim().to_string(); + if !top.status.success() + || Path::new(&top_path).canonicalize().ok().as_ref() != Some(&canonical) + { + return Err( + "path is not the repository root; nested workflow trees cannot explain this run".into(), + ); + } + for path in [root.join(".github"), root.join(".github/workflows")] { + if path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + return Err("workflow source contains a symlink component".into()); + } + } + let head = git(&["rev-parse", "--verify", "HEAD"])?; + let sha = String::from_utf8_lossy(&head.stdout).trim().to_string(); + if !head.status.success() || !same_source_revision(Some(&sha), Some(&sha)) { + return Err("checkout HEAD is not a readable full commit ID".into()); + } + let scope = if full_tree { "." } else { ".github/workflows" }; + let state = git(&[ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + "--", + scope, + ])?; + if !state.status.success() || !state.stdout.is_empty() { + return Err("source tree has uncommitted, untracked, ignored or unreadable inputs; use a pristine checkout at the run's commit".into()); + } + // Assume-unchanged / skip-worktree flags can otherwise hide source drift. + let tracked = git(&["ls-files", "-v", "--", scope])?; + if !tracked.status.success() + || String::from_utf8_lossy(&tracked.stdout) + .lines() + .any(|line| !line.starts_with("H ")) + { + return Err("workflow index cannot establish an unmodified checkout".into()); + } + let tree = git(&["ls-tree", "-r", "-z", "HEAD", "--", scope])?; + if !tree.status.success() + || tree + .stdout + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + .any(|entry| !entry.starts_with(b"100644 blob ") && !entry.starts_with(b"100755 blob ")) + { + return Err("source tree contains a symlink, gitlink or unsupported file mode".into()); + } + Ok(sha) +} + +/// Prepare a verifier command in `root` without the supported GitHub token +/// and verifier directory override environment variables. +fn command(binary: &Path, root: &Path) -> Command { + let mut cmd = Command::new(binary); + cmd.current_dir(root); + for name in [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_ACTIONS_LOCK_WORKFLOWS_DIR", + "GH_ACTIONS_LOCK_REPO_ROOT", + ] { + cmd.env_remove(name); + } + cmd +} + +/// Invoke an explicitly selected, operator-verified official binary. There +/// is no download, resolver, credential requirement, write mode or fallback. +/// +/// `head_sha` identifies the expected checkout and `workflows` lists the +/// expected workflow paths. A dirty or mismatched checkout, unsupported version, +/// execution failure or inconclusive output yields unverified evidence. The +/// result retains the verifier's output and exit code when available. +pub(crate) fn verify( + binary: &Path, + root: &Path, + head_sha: &str, + workflows: &[String], +) -> NativeLockVerification { + let mut evidence = NativeLockVerification { + head_sha: head_sha.into(), + verifier: binary.display().to_string(), + tool_version: String::new(), + exit_code: None, + status: NativeLockStatus::Unverified, + stdout: String::new(), + stderr: String::new(), + reason: String::new(), + }; + match source_revision(root, true) { + Ok(observed) if same_source_revision(Some(&observed), Some(head_sha)) => {} + result => { + evidence.reason = format!("native verifier not run: full input closure is not a pristine checkout at the run's source ({result:?})"); + return evidence; + } + } + let version = match command(binary, root).arg("--version").output() { + Ok(output) => output, + Err(error) => { + evidence.reason = format!("native lock verifier did not run: {error}"); + return evidence; + } + }; + evidence.tool_version = String::from_utf8_lossy(&version.stdout).trim().into(); + if !version.status.success() + || evidence.tool_version != SUPPORTED_VERSION + || !version.stderr.is_empty() + { + evidence.stderr = String::from_utf8_lossy(&version.stderr).into(); + evidence.reason = + "unsupported or unreadable verifier version; no lock result inferred".into(); + return evidence; + } + match command(binary, root) + .args(["--verify-local", "--json", "--hostname", "github.com"]) + .output() + { + Ok(output) => { + evidence.exit_code = output.status.code(); + evidence.stdout = String::from_utf8_lossy(&output.stdout).into(); + evidence.stderr = String::from_utf8_lossy(&output.stderr).into(); + let (status, reason) = classify( + evidence.exit_code, + &evidence.stdout, + &evidence.stderr, + workflows, + ); + evidence.status = status; + evidence.reason = reason; + } + Err(error) => evidence.reason = format!("native lock verifier did not run: {error}"), + } + // A concurrent checkout/edit invalidates the source binding, even when + // the subprocess returned a syntactically valid positive result. + if !same_source_revision(source_revision(root, true).ok().as_deref(), Some(head_sha)) { + evidence.status = NativeLockStatus::Unverified; + evidence.reason = + "checkout changed or became unreadable during native lock verification".into(); + } + evidence +} + +#[derive(Deserialize)] +struct NativeReport { + cli_version: String, + lockfile_version: String, + valid: bool, + findings: Vec, + workflows: Vec, +} + +#[derive(Deserialize)] +struct NativeWorkflow { + path: String, + valid: bool, + findings: Vec, +} + +#[derive(Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +struct NativeFinding { + workflow: String, + category: String, + severity: String, + detail: String, + #[serde(default)] + dependency: Option, +} + +/// v0.2.0 contract: 0 = valid, 1 = reported blocking findings, 2 = tool error. +/// Source: github/gh-actions-lock d5dca22a06ff8590d50b41612038996800cfb336, +/// cmd/gh-actions-lock/{root,verify}.go. Only a parser diagnostic within exit +/// 2 establishes malformed syntax; an IO error must remain unverified. +/// `workflows` is the expected set of workflow paths; incomplete or +/// contradictory reports are unverified. +fn classify( + exit: Option, + stdout: &str, + stderr: &str, + workflows: &[String], +) -> (NativeLockStatus, String) { + let unknown = || { + (NativeLockStatus::Unverified, "native verifier output is incomplete, contradictory or a tool failure; inspect the retained exit and output".into()) + }; + if exit == Some(2) + && stdout.trim().is_empty() + && stderr.trim_start().starts_with( + "opening lockfile: lockfile is unreadable: .github/workflows/actions.lock: line ", + ) + { + return (NativeLockStatus::Malformed, "the official native parser rejected this source lock; GitHub's own startup cause has not been independently read".into()); + } + if !matches!(exit, Some(0 | 1)) || !stderr.trim().is_empty() { + return unknown(); + } + // Typed decoding rejects duplicate known keys (Value would silently keep + // the last `valid` field and could turn contradictory output into green). + let Ok(mut report) = serde_json::from_str::(stdout) else { + return unknown(); + }; + if report.cli_version != "v0.2.0" || report.lockfile_version.is_empty() { + return unknown(); + } + let expected: BTreeSet<_> = workflows.iter().map(String::as_str).collect(); + let mut seen = BTreeSet::new(); + let mut all_valid = true; + let mut flattened = Vec::new(); + for workflow in &report.workflows { + if !seen.insert(workflow.path.as_str()) + || (workflow.valid && !workflow.findings.is_empty()) + || (!workflow.valid + && !workflow + .findings + .iter() + .any(|finding| finding.severity == "error")) + || workflow.findings.iter().any(|finding| { + finding.workflow != workflow.path + || finding.category.is_empty() + || finding.detail.is_empty() + }) + { + return unknown(); + } + all_valid &= workflow.valid; + flattened.extend(workflow.findings.iter().cloned()); + } + flattened.sort(); + report.findings.sort(); + if expected.is_empty() + || seen != expected + || report.valid != all_valid + || flattened != report.findings + { + return unknown(); + } + match (exit, report.valid, report.findings.is_empty()) { + (Some(0), true, true) => (NativeLockStatus::Verified, "official offline verification covered every local workflow; this is not a CI pass or a training outcome".into()), + (Some(1), false, false) => (NativeLockStatus::CoverageMismatch, "official offline verification reported workflow/lock findings; inspect the workflow-specific evidence, not a settings change".into()), + _ => unknown(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + struct Checkout(PathBuf); + + impl Checkout { + fn new() -> Self { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir() + .join(format!("squabble-native-{}-{stamp}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let fixture = Self(root); + fixture.git(&["init", "-q"]); + for dir in [ + ".github/workflows", + "nested/.github/workflows", + "actions/demo", + ] { + std::fs::create_dir_all(fixture.0.join(dir)).unwrap(); + } + std::fs::write(fixture.0.join(".github/workflows/ci.yml"), "name: fixture\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: $/actions/demo\n").unwrap(); + std::fs::write( + fixture.0.join("nested/.github/workflows/ci.yml"), + "name: wrong source\n", + ) + .unwrap(); + std::fs::write( + fixture.0.join("actions/demo/action.yaml"), + "name: fixture\nruns:\n using: composite\n steps: []\n", + ) + .unwrap(); + std::fs::write(fixture.0.join(".gitignore"), "actions/demo/action.yml\n").unwrap(); + fixture.commit(); + fixture + } + + fn git(&self, args: &[&str]) { + let output = Command::new("git") + .current_dir(&self.0) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn commit(&self) { + self.git(&["add", "--all"]); + // Disposable test repositories only; no author or signing + // configuration of the actual project is changed. + self.git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ]); + } + } + + impl Drop for Checkout { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[test] + fn source_identity_requires_the_repository_root_and_unmodified_workflows() { + let checkout = Checkout::new(); + assert!(checkout_revision(&checkout.0).is_ok()); + assert!(source_revision(&checkout.0, true).is_ok()); + assert!(checkout_revision(&checkout.0.join("nested")) + .unwrap_err() + .contains("repository root")); + std::fs::write(checkout.0.join(".github/workflows/ci.yml"), "changed\n").unwrap(); + assert!(checkout_revision(&checkout.0).is_err()); + checkout.git(&[ + "update-index", + "--assume-unchanged", + ".github/workflows/ci.yml", + ]); + assert!( + checkout_revision(&checkout.0).is_err(), + "index flags cannot hide modified source" + ); + } + + #[test] + fn changed_or_ignored_shadowing_self_actions_prevent_a_native_source_claim() { + let checkout = Checkout::new(); + let head = checkout_revision(&checkout.0).unwrap(); + let action = checkout.0.join("actions/demo/action.yaml"); + let original = std::fs::read(&action).unwrap(); + std::fs::write(&action, "changed self action\n").unwrap(); + assert!( + checkout_revision(&checkout.0).is_ok(), + "coordinate-only workflow source is unchanged" + ); + assert!(source_revision(&checkout.0, true).is_err()); + let result = verify( + Path::new("/must-not-be-executed"), + &checkout.0, + &head, + &[".github/workflows/ci.yml".into()], + ); + assert_eq!(result.status, NativeLockStatus::Unverified); + assert_eq!(result.exit_code, None); + assert!(result.reason.contains("not a pristine checkout")); + std::fs::write(&action, original).unwrap(); + assert!(source_revision(&checkout.0, true).is_ok()); + // The official reader prefers action.yml to action.yaml. Even an + // ignored file can therefore replace the committed action input. + std::fs::write( + checkout.0.join("actions/demo/action.yml"), + "ignored shadow\n", + ) + .unwrap(); + assert!(source_revision(&checkout.0, true).is_err()); + } + + #[cfg(unix)] + #[test] + fn committed_self_action_symlinks_are_not_treated_as_immutable_input_bytes() { + let checkout = Checkout::new(); + let action = checkout.0.join("actions/demo/action.yaml"); + std::fs::remove_file(&action).unwrap(); + std::os::unix::fs::symlink("../../.gitignore", &action).unwrap(); + checkout.commit(); + assert!(source_revision(&checkout.0, true) + .unwrap_err() + .contains("symlink")); + } + + fn coverage(valid: bool) -> String { + let findings = if valid { + json!([]) + } else { + json!([{"workflow": ".github/workflows/ci.yml", "category": "stale", "severity": "error", "detail":"unused lock entry"}]) + }; + json!({"cli_version": "v0.2.0", "lockfile_version":"v0.0.3", "valid": valid, "findings": findings, + "workflows": [{"path": ".github/workflows/ci.yml", "valid": valid, "findings": findings}]}).to_string() + } + + #[test] + fn native_parser_failure_is_distinct_from_io_and_coverage_findings() { + let files = vec![".github/workflows/ci.yml".into()]; + // Observed from the official v0.2.0 binary on Hypatia's historical lock. + let malformed = "opening lockfile: lockfile is unreadable: .github/workflows/actions.lock: line 347, column 5: dependency key \"google/clusterfuzzlite/actions/run_fuzzers@v1\" is not a valid pin (expected OWNER/REPO@REF)\n"; + assert_eq!( + classify(Some(2), "", malformed, &files).0, + NativeLockStatus::Malformed + ); + assert_eq!( + classify(Some(2), "", "opening lockfile: permission denied", &files).0, + NativeLockStatus::Unverified + ); + assert_eq!( + classify(Some(2), "", "unsupported future lockfile version", &files).0, + NativeLockStatus::Unverified + ); + assert_eq!( + classify(Some(1), &coverage(false), "", &files).0, + NativeLockStatus::CoverageMismatch + ); + assert_eq!( + classify(Some(0), &coverage(true), "", &files).0, + NativeLockStatus::Verified + ); + } + + #[test] + fn empty_malformed_partial_contradictory_and_wrong_version_results_never_verify() { + let files = vec![".github/workflows/ci.yml".into()]; + for (exit, body) in [ + (Some(0), "".into()), + (Some(0), "[]".into()), + (Some(0), "{".into()), + (Some(0), coverage(false)), + (Some(1), coverage(true)), + (None, coverage(true)), + (Some(0), coverage(true).replace("v0.2.0", "v999.0.0")), + (Some(0), coverage(true).replace("ci.yml", "other.yml")), + ( + Some(0), + coverage(true).replacen("\"valid\":true", "\"valid\":false,\"valid\":true", 1), + ), + ( + Some(0), + json!({"cli_version":"v0.2.0", "valid":true, "findings":[], "workflows":[]}) + .to_string(), + ), + ] { + assert_eq!( + classify(exit, &body, "", &files).0, + NativeLockStatus::Unverified, + "{exit:?}: {body}" + ); + } + let mut partial = files.clone(); + partial.push(".github/workflows/missing.yml".into()); + assert_eq!( + classify(Some(0), &coverage(true), "", &partial).0, + NativeLockStatus::Unverified + ); + assert_eq!( + classify( + Some(0), + &coverage(true), + "opening lockfile: lockfile is unreadable: .github/workflows/actions.lock: line 1", + &files + ) + .0, + NativeLockStatus::Unverified + ); + let mut contradictory: serde_json::Value = serde_json::from_str(&coverage(false)).unwrap(); + contradictory["findings"] = json!([]); + assert_eq!( + classify(Some(1), &contradictory.to_string(), "", &files).0, + NativeLockStatus::Unverified + ); + contradictory["valid"] = json!(true); + contradictory["workflows"][0]["valid"] = json!(true); + assert_eq!( + classify(Some(0), &contradictory.to_string(), "", &files).0, + NativeLockStatus::Unverified + ); + } + + #[test] + fn verifier_child_has_no_tokens_or_subject_redirect_environment() { + let cmd = command(Path::new("/verified/actions-lock"), Path::new("/checkout")); + for key in [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_ACTIONS_LOCK_WORKFLOWS_DIR", + "GH_ACTIONS_LOCK_REPO_ROOT", + ] { + assert!(cmd + .get_envs() + .any(|(name, value)| name == key && value.is_none())); + } + } +} diff --git a/crates/squabble-cli/src/main.rs b/crates/squabble-cli/src/main.rs index 93b245c..2312258 100644 --- a/crates/squabble-cli/src/main.rs +++ b/crates/squabble-cli/src/main.rs @@ -17,8 +17,8 @@ //! - `0` — success. //! - `2` — a genuine failure: bad usage, `gh` failed, unparseable input. //! - `4` — **blocking finding** (`squabble chains` or `squabble zero-job`): a -//! dependency cycle, a dead upstream, or any observed zero-job run (a moved -//! upstream in `chains` is a warning). Like `3`, a reportable finding, not a +//! dependency cycle, a dead or moved upstream, or any observed zero-job +//! failure candidate. Like `3`, a reportable finding, not a //! tool failure. //! - `5` — **not done** (`squabble verify-satisfied`): agent items remain on //! the PR (unresolved threads, a failing required check, automerge unarmed…). @@ -48,6 +48,7 @@ mod chains; mod fetch; mod fight; mod inbox; +mod lock_verifier; mod phase; mod verify; mod zero_job; diff --git a/crates/squabble-cli/src/phase.rs b/crates/squabble-cli/src/phase.rs index e05ea60..b9b2680 100644 --- a/crates/squabble-cli/src/phase.rs +++ b/crates/squabble-cli/src/phase.rs @@ -88,8 +88,8 @@ fn parse_args(rest: &[String]) -> Result { /// and parses to an empty list (which the classifier treats as absence of /// evidence, never as evidence of anything). /// A non-array `steps` value also yields an empty list. Job status and conclusion -/// are passed unchanged to `CheckRun::from_github`, which recognises uppercase -/// values; unrecognised values can result in a pending job. +/// are converted to uppercase before `CheckRun::from_github`; unrecognised +/// values can result in a pending job. /// /// Returns an error for invalid JSON, a non-object value other than null, or a /// non-empty object without a string `status` or `conclusion`. @@ -99,8 +99,8 @@ fn parse_job(text: &str) -> Result<(Option, Vec), String> if let serde_json::Value::Object(o) = &v { let is_job = !o.is_empty() - && (o.get("status").map_or(false, |s| s.is_string()) - || o.get("conclusion").map_or(false, |s| s.is_string())); + && (o.get("status").is_some_and(|s| s.is_string()) + || o.get("conclusion").is_some_and(|s| s.is_string())); if !o.is_empty() && !is_job { return Err("not a jobs-API job object (no `status` or `conclusion`)".to_string()); } diff --git a/crates/squabble-cli/src/zero_job.rs b/crates/squabble-cli/src/zero_job.rs index a1e568e..5b2a742 100644 --- a/crates/squabble-cli/src/zero_job.rs +++ b/crates/squabble-cli/src/zero_job.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -//! `squabble zero-job` — attribute every zero-job `startup_failure` run. +//! `squabble zero-job` — investigate failed runs with separate job evidence. //! //! A run that dies before any job exists creates **no check run**, so it never //! appears red on a PR: the board simply looks quiet. This command finds those @@ -10,7 +10,7 @@ //! repo · workflow · coordinate as written · canonical target · required allow-list pattern //! ``` //! -//! The two classes are first-class and distinct +//! The two coordinate classes are first-class and distinct //! (`squabble_core::startup::StartupFailureClass`): //! //! * **moved coordinate** — a reusable coordinate under a renamed/transferred @@ -19,23 +19,29 @@ //! the caller's `selected` actions allow-list. Move: admit the reported //! `owner/repo@*` pattern. //! -//! Inputs: a local checkout (``) for the workflow text the -//! run used, `gh` access for the runs/jobs endpoints, and the live Actions +//! Optional official native lock verification adds distinct parser and +//! workflow-coverage diagnoses, bound to a pristine checkout at the failed +//! run's full commit ID. Neither can prescribe permission changes or green. +//! +//! Inputs: a source-matched local checkout (``), `gh` access +//! for the runs/jobs endpoints, and the live Actions //! posture for the allow-list. The REST canonical-name read follows redirects //! and compares `full_name`s — names, never pins. //! //! This command **diagnoses**; it never edits settings, repoints a workflow, or -//! declares anything green. Exit `4` means zero-job runs were observed (a -//! blocking finding) — including runs it could not attribute, because silence +//! declares anything green. Exit `4` means failed candidates remain (a +//! blocking finding) — including unreadable/contradictory evidence, because silence //! is not a clean result. Exit `0` means none were observed in the scanned //! window, which is a statement about that window only. Exit `2` is a tool //! failure. use crate::chains::BLOCKING_EXIT; use crate::fetch; +use crate::lock_verifier; use squabble_core::chains::{RepoId, ScanStatus, WorkflowFile}; use squabble_core::startup::{ - AllowList, CanonicalResolution, ZeroJobReport, ZeroJobRun, ZeroJobRunAttribution, + same_source_revision, AllowList, CanonicalResolution, NativeLockVerification, ZeroJobReport, + ZeroJobRun, ZeroJobRunAttribution, }; use squabble_fight::chains::{extract_edges, snapshot_local}; use squabble_fight::startup::{attribute_run, parse_job_count, parse_startup_runs}; @@ -44,7 +50,7 @@ use std::collections::BTreeSet; use std::path::Path; use std::process::ExitCode; -pub(crate) const USAGE: &str = "usage: squabble zero-job [--limit N] [--json]"; +pub(crate) const USAGE: &str = "usage: squabble zero-job [--limit N] [--actions-lock-verifier /absolute/path] [--json]"; struct Args { path: String, @@ -52,22 +58,37 @@ struct Args { /// How many of the repo's most recent runs to scan for startup failures. limit: usize, json: bool, + lock_verifier: Option, +} + +struct SourceEvidence { + head_sha: Option, + lock: Option, } /// Parse one checkout path with an optional `=owner/repo` identity, a run -/// limit (default `30`) and JSON output. Returns an error for a missing path, -/// extra paths, unknown flags or a missing or invalid limit (must be 1–100). +/// limit (default `30`), JSON output and an optional absolute verifier path. +/// Returns an error for a missing path, extra paths, unknown flags, a missing +/// or invalid limit (must be 1–100), or a missing or relative verifier path. fn parse_args(rest: &[String]) -> Result { let mut a = Args { path: String::new(), slug: None, limit: 30, json: false, + lock_verifier: None, }; let mut it = rest.iter(); while let Some(arg) = it.next() { match arg.as_str() { "--json" => a.json = true, + "--actions-lock-verifier" => { + let path = it + .next() + .filter(|p| Path::new(p).is_absolute()) + .ok_or("--actions-lock-verifier requires an absolute executable path")?; + a.lock_verifier = Some(path.clone()); + } "--limit" => { a.limit = it .next() @@ -96,11 +117,11 @@ fn parse_args(rest: &[String]) -> Result { Ok(a) } -/// Scan recent runs and print startup-failure attributions as text or JSON. -/// Returns `4` when any startup failure is listed, even if its job count could -/// not be read; `0` means none in the returned page. Usage, checkout, run -/// listing and serialisation failures return `2`; policy and name-resolution -/// failures are retained as report notes. +/// Scan recent failed runs and print attributions as text or JSON. +/// Returns `4` when any candidate remains, including one with unreadable or +/// contradictory job evidence; `0` means none remain in the returned page. +/// Usage, checkout, run listing, job evidence parsing and serialisation failures +/// return `2`; policy and name-resolution failures are retained as report notes. pub fn run(rest: &[String]) -> ExitCode { let args = match parse_args(rest) { Ok(a) => a, @@ -110,7 +131,9 @@ pub fn run(rest: &[String]) -> ExitCode { } }; - let snapshot = snapshot_local(Path::new(&args.path), args.slug.as_deref()); + let root = Path::new(&args.path); + let before = lock_verifier::checkout_revision(root); + let snapshot = snapshot_local(root, args.slug.as_deref()); let slug = snapshot.repo.as_str().to_string(); if !matches!(snapshot.status, ScanStatus::Scanned { .. }) { let reason = match &snapshot.status { @@ -132,7 +155,42 @@ pub fn run(rest: &[String]) -> ExitCode { // Probe the posture only when there is something to attribute — it is an // Administration-scoped read and must not be spent for nothing. let mut notes = snapshot.notes.clone(); - let allow_list = if runs.is_empty() { + let after = lock_verifier::checkout_revision(root); + let head_sha = match (&before, &after) { + (Ok(a), Ok(b)) if same_source_revision(Some(a), Some(b)) => Some(a.clone()), + _ => { + notes.push(format!("local source identity unavailable or changed: before={before:?}; after={after:?}; no source/settings move may be inferred")); + None + } + }; + let matching_run = runs.iter().any(|run| { + run.jobs == Some(0) && same_source_revision(run.head_sha.as_deref(), head_sha.as_deref()) + }); + let lock = match (&args.lock_verifier, &head_sha) { + (Some(binary), Some(head)) if matching_run => { + let workflows = snapshot + .files + .iter() + .map(|file| format!(".github/workflows/{}", file.path)) + .collect::>(); + Some(lock_verifier::verify( + Path::new(binary), + root, + head, + &workflows, + )) + } + (Some(_), _) => { + notes.push("native lock verifier not run: no observed zero-job candidate matches the established checkout revision".into()); + None + } + (None, _) => { + notes.push("native Actions lock not checked; supply --actions-lock-verifier with an independently verified official v0.2.0 binary for offline source diagnosis".into()); + None + } + }; + let source = SourceEvidence { head_sha, lock }; + let allow_list = if !matching_run { None } else { probe_allow_list(&slug, &mut notes) @@ -141,10 +199,12 @@ pub fn run(rest: &[String]) -> ExitCode { let report = if runs.is_empty() { ZeroJobReport { repo: slug, + checkout_head_sha: source.head_sha.clone(), + lock_verification: source.lock.clone(), runs: Vec::new(), notes: { notes.push(format!( - "no `STARTUP_FAILURE` run among the last {} run(s) — the zero-job class was \ + "no zero-job failure candidate among the last {} run(s) — the zero-job class was \ not observed in this window (that is not a claim that CI works)", args.limit )); @@ -159,6 +219,7 @@ pub fn run(rest: &[String]) -> ExitCode { &GhRest, allow_list.as_ref(), notes, + &source, ) }; @@ -207,9 +268,10 @@ fn probe_allow_list(slug: &str, notes: &mut Vec) -> Option { } } -/// Pure assembly: resolve canonical names for every coordinate the failing -/// workflows use, then attribute each run. No IO beyond `rest`, so the whole -/// report shape is unit-testable. +/// Assemble a report, resolving names for workflows of runs with zero jobs +/// and matching source revisions, then attributing each candidate. Resolution +/// failures are retained as notes; `source` supplies the checkout and optional +/// native lock evidence used for attribution. No IO occurs beyond `rest`. fn collect( slug: &str, files: &[WorkflowFile], @@ -217,9 +279,15 @@ fn collect( rest: &dyn RestTransport, allow_list: Option<&AllowList>, mut notes: Vec, + source: &SourceEvidence, ) -> ZeroJobReport { let mut repos: BTreeSet = BTreeSet::new(); for run in &runs { + if run.jobs != Some(0) + || !same_source_revision(run.head_sha.as_deref(), source.head_sha.as_deref()) + { + continue; + } if let Some(file) = files.iter().find(|f| f.path == run.workflow_file) { for edge in extract_edges(&RepoId::new(slug), file) { repos.insert(edge.to); @@ -251,14 +319,25 @@ fn collect( .into_iter() .map(|run| ZeroJobRunAttribution { run_id: run.id, + head_sha: run.head_sha.clone(), workflow: run.workflow_file.clone(), jobs: run.jobs, - attributions: attribute_run(slug, &run, files, &canonical, allow_list), + attributions: attribute_run( + slug, + &run, + files, + &canonical, + allow_list, + source.head_sha.as_deref(), + source.lock.as_ref(), + ), }) .collect(); ZeroJobReport { repo: slug.to_string(), + checkout_head_sha: source.head_sha.clone(), + lock_verification: source.lock.clone(), runs, notes, } @@ -277,18 +356,23 @@ fn exit_for(report: &ZeroJobReport) -> ExitCode { /// report, including unreadable job counts. fn narrate(r: &ZeroJobReport) -> String { let mut o = format!( - "zero-job: {} — {} `startup_failure` run(s) (jobs=0, no check run exists)\n", + "zero-job: {} — {} failed run candidate(s); job counts are reported individually\n", r.repo, r.runs.len() ); + if let Some(lock) = &r.lock_verification { + o.push_str(&narrate_lock(lock)); + } for run in &r.runs { let jobs = match run.jobs { Some(n) => format!("{n} job(s)"), None => "job count unreadable".to_string(), }; o.push_str(&format!( - "\nrun {} · {} ({jobs})\n", - run.run_id, run.workflow + "\nrun {} · {} ({jobs}) · source {}\n", + run.run_id, + run.workflow, + run.head_sha.as_deref().unwrap_or("unreadable") )); for a in &run.attributions { o.push_str(&format!( @@ -319,47 +403,87 @@ fn narrate(r: &ZeroJobReport) -> String { o } +/// Retain the optional verifier's own diagnostics independently of run output. +fn narrate_lock(lock: &squabble_core::startup::NativeLockVerification) -> String { + let mut output = format!( + "native lock: {:?} at {} · {} exit {:?}\n {}\n", + lock.status, lock.head_sha, lock.tool_version, lock.exit_code, lock.reason + ); + for line in lock.stderr.lines() { + output.push_str(&format!(" {line}\n")); + } + if let Ok(report) = serde_json::from_str::(&lock.stdout) { + if let Some(findings) = report.get("findings").and_then(|v| v.as_array()) { + for finding in findings { + if let Some(detail) = finding.get("detail").and_then(|v| v.as_str()) { + output.push_str(&format!(" {detail}\n")); + } + } + } + } + output +} + /// Live runs via `gh api --include` (REST). One listing call, plus one jobs -/// call per startup failure to record the zero-job evidence. +/// call per failed candidate to record the zero-job evidence. struct GhRuns; impl GhRuns { /// Request one page of recent runs with `limit` as `per_page`, retaining - /// startup failures. - /// Read each matching run's job count; a failed jobs request or invalid - /// count leaves `jobs` as `None`, and positive counts are retained too. + /// `startup_failure` and ordinary `failure` candidates. + /// An unreadable jobs request leaves `jobs` as `None`; malformed counts + /// fail the read. Ordinary failures with observed jobs are excluded. /// /// # Errors /// - /// Propagates transport and parsing errors from the run listing, and - /// returns an error for any non-success HTTP status on that listing. + /// Propagates transport and parsing errors from the run listing, returns an + /// error for a non-success listing status, and rejects malformed job counts + /// from successful jobs responses. fn startup_runs(&self, slug: &str, limit: usize) -> Result, String> { - let transport = GhRest; - let path = format!("repos/{slug}/actions/runs?per_page={limit}"); - let resp = transport.get(&path)?; - match resp.status { - 404 => { - return Err(format!( - "no Actions runs are visible for `{slug}` (HTTP 404) — check the slug and \ + fetch_startup_runs(&GhRest, slug, limit) + } +} + +/// Read one page of failed run candidates and their job counts through `transport`. +/// Ordinary failures with positive counts are excluded; startup failures with +/// positive counts remain as contradictory evidence. Job request errors and +/// non-success statuses leave the count unknown. +/// +/// Returns an error for a failed or non-successful run listing, invalid run +/// data, or malformed job evidence from a successful jobs response. +fn fetch_startup_runs( + transport: &dyn RestTransport, + slug: &str, + limit: usize, +) -> Result, String> { + let path = format!("repos/{slug}/actions/runs?per_page={limit}"); + let resp = transport.get(&path)?; + match resp.status { + 404 => { + return Err(format!( + "no Actions runs are visible for `{slug}` (HTTP 404) — check the slug and \ the token's Actions read scope" - )) - } - s if !(200..300).contains(&s) => return Err(format!("`{path}` returned HTTP {s}")), - _ => {} + )) } - let mut runs = parse_startup_runs(&resp.body)?; - for run in &mut runs { - let jobs_path = format!("repos/{slug}/actions/runs/{}/jobs?per_page=1", run.id); - if let Ok(jobs) = transport.get(&jobs_path) { - if (200..300).contains(&jobs.status) { - let count = parse_job_count(&jobs.body) - .map_err(|err| format!("invalid job evidence for `{jobs_path}`: {err}"))?; - run.jobs = Some(count); - } + s if !(200..300).contains(&s) => return Err(format!("`{path}` returned HTTP {s}")), + _ => {} + } + let mut runs = parse_startup_runs(&resp.body)?; + for run in &mut runs { + let jobs_path = format!("repos/{slug}/actions/runs/{}/jobs?per_page=1", run.id); + if let Ok(jobs) = transport.get(&jobs_path) { + if (200..300).contains(&jobs.status) { + let count = parse_job_count(&jobs.body) + .map_err(|err| format!("invalid job evidence for `{jobs_path}`: {err}"))?; + run.jobs = Some(count); } } - Ok(runs) } + // A regular failure with observed jobs belongs to execution-phase + // diagnosis. Unknown counts remain visible, and a startup_failure + // with positive jobs remains as contradictory evidence, never zero. + runs.retain(|run| run.conclusion != "failure" || !matches!(run.jobs, Some(n) if n > 0)); + Ok(runs) } #[cfg(test)] @@ -369,9 +493,159 @@ mod tests { use squabble_core::moves::Move; use squabble_core::startup::StartupFailureClass; use squabble_forge::rest::RestResponse; + use std::collections::BTreeMap; const SHA: &str = "8f2ee50841e216cd8c192eeb68953118190f105c"; + /// These fixtures represent an unmodified checkout at the observed SHA. + fn collect( + slug: &str, + files: &[WorkflowFile], + runs: Vec, + rest: &dyn RestTransport, + allow_list: Option<&AllowList>, + notes: Vec, + ) -> ZeroJobReport { + super::collect( + slug, + files, + runs, + rest, + allow_list, + notes, + &SourceEvidence { + head_sha: Some(SHA.into()), + lock: None, + }, + ) + } + + struct Responses(BTreeMap); + + impl RestTransport for Responses { + fn get(&self, path: &str) -> Result { + // Exercise the live transport's admission check too: the old + // reader generated query strings that this check rejected. + squabble_forge::rest::check_api_path(path)?; + self.0 + .get(path) + .cloned() + .ok_or_else(|| format!("unexpected path {path}")) + } + } + + fn recorded_runs(conclusion: &str, jobs_status: u16, jobs: serde_json::Value) -> Responses { + // Public run41880894104 is a real `failure` with jobs.total_count=0. + // Other conclusions/counts below are planted boundary controls. + let slug = "hyperpolymath/boj-server-cartridges"; + Responses(BTreeMap::from([ + ( + format!("repos/{slug}/actions/runs?per_page=30"), + RestResponse { + status: 200, + location: None, + body: json!({"workflow_runs": [{ + "id": 41880894104_u64, + "head_sha": "e9b571a48a9abe1429e9c271f1044e9c9decf574", + "conclusion": conclusion, + "path": ".github/workflows/governance.yml" + }]}) + .to_string(), + }, + ), + ( + format!("repos/{slug}/actions/runs/41880894104/jobs?per_page=1"), + RestResponse { + status: jobs_status, + location: None, + body: jobs.to_string(), + }, + ), + ])) + } + + #[test] + fn regular_failure_with_observed_zero_jobs_is_not_silently_dropped() { + let rest = recorded_runs("failure", 200, json!({"total_count": 0, "jobs": []})); + let runs = fetch_startup_runs(&rest, "hyperpolymath/boj-server-cartridges", 30).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].id, 41880894104); + assert_eq!(runs[0].jobs, Some(0)); + assert_eq!(runs[0].conclusion, "failure"); + assert_eq!( + runs[0].head_sha.as_deref(), + Some("e9b571a48a9abe1429e9c271f1044e9c9decf574") + ); + } + + #[test] + fn ordinary_job_failures_are_left_to_execution_phase_diagnosis() { + let rest = recorded_runs( + "failure", + 200, + json!({"total_count": 2, "jobs": [{"id": 1}]}), + ); + assert!( + fetch_startup_runs(&rest, "hyperpolymath/boj-server-cartridges", 30) + .unwrap() + .is_empty() + ); + } + + #[test] + fn missing_counts_remain_visible_and_cannot_propose_settings_changes() { + let rest = recorded_runs("failure", 403, json!({"message": "not accessible"})); + let runs = fetch_startup_runs(&rest, "hyperpolymath/boj-server-cartridges", 30).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].jobs, None); + let report = collect( + "hyperpolymath/boj-server-cartridges", + &[moved_workflow()], + runs, + &Rest(vec![]), + Some(&AllowList::default()), + Vec::new(), + ); + assert_eq!(exit_for(&report), ExitCode::from(BLOCKING_EXIT)); + assert!(report.runs[0].attributions[0].proposed_move().is_none()); + assert!(narrate(&report).contains("job count unreadable")); + assert!(!narrate(&report).contains("jobs=0")); + } + + #[test] + fn contradictory_job_responses_fail_instead_of_becoming_zero() { + let rest = recorded_runs( + "startup_failure", + 200, + json!({"total_count": 0, "jobs": [{"id": 1}]}), + ); + assert!( + fetch_startup_runs(&rest, "hyperpolymath/boj-server-cartridges", 30) + .unwrap_err() + .contains("more jobs") + ); + let rest = recorded_runs( + "startup_failure", + 200, + json!({"total_count": 2, "jobs": [{"id": 1}]}), + ); + let runs = fetch_startup_runs(&rest, "hyperpolymath/boj-server-cartridges", 30).unwrap(); + assert_eq!(runs[0].jobs, Some(2)); + let report = collect( + "hyperpolymath/boj-server-cartridges", + &[], + runs, + &Rest(vec![]), + None, + Vec::new(), + ); + assert_eq!(exit_for(&report), ExitCode::from(BLOCKING_EXIT)); + assert!(report.runs[0].attributions[0] + .evidence + .contains("contradicting")); + assert!(report.runs[0].attributions[0].proposed_move().is_none()); + } + fn wf(path: &str, text: &str) -> WorkflowFile { WorkflowFile { path: path.into(), @@ -382,6 +656,8 @@ mod tests { fn run(id: u64, file: &str) -> ZeroJobRun { ZeroJobRun { id, + conclusion: "startup_failure".into(), + head_sha: Some(SHA.into()), workflow_file: file.into(), workflow_name: None, jobs: Some(0), @@ -560,6 +836,8 @@ mod tests { fn no_runs_is_a_success_with_no_claim_about_ci() { let report = ZeroJobReport { repo: "hyperpolymath/hypatia".into(), + checkout_head_sha: None, + lock_verification: None, runs: Vec::new(), notes: vec!["no STARTUP_FAILURE run".into()], }; @@ -597,6 +875,23 @@ mod tests { assert!(parse_args(&[]).is_err()); assert!(parse_args(&["x".into(), "--limit".into(), "0".into()]).is_err()); assert!(parse_args(&["x".into(), "--nope".into()]).is_err()); + assert!(parse_args(&["x".into(), "--actions-lock-verifier".into()]).is_err()); + assert!(parse_args(&[ + "x".into(), + "--actions-lock-verifier".into(), + "relative".into() + ]) + .is_err()); + let a = parse_args(&[ + "x".into(), + "--actions-lock-verifier".into(), + "/verified/gh-actions-lock".into(), + ]) + .unwrap(); + assert_eq!( + a.lock_verifier.as_deref(), + Some("/verified/gh-actions-lock") + ); } #[test] diff --git a/crates/squabble-core/src/chains.rs b/crates/squabble-core/src/chains.rs index 29741bf..66ee856 100644 --- a/crates/squabble-core/src/chains.rs +++ b/crates/squabble-core/src/chains.rs @@ -21,7 +21,9 @@ //! `uses:` produces no check run at all, so the board reads green while the //! job never ran. *Blocking.* //! * [`Finding::MovedUpstream`] — a referenced owner/repo resolves to a -//! *different* canonical name (renamed or transferred). *Blocking*: the +//! *different* canonical name (renamed or transferred). *Blocking* when +//! confirmed in a requested subject or through exact pinned routes; an +//! unconfirmed followed revision remains a warning. The //! redirect that resolves the name is an API fact, not a repair — Actions does //! not follow redirects for `uses:`, so the reference fails as written, and //! for a reusable workflow it dies as a zero-job `startup_failure` that no PR @@ -276,6 +278,11 @@ impl RepoSnapshot { pub struct ChainGraph { pub scans: BTreeMap, pub edges: Vec, + /// Repositories explicitly requested by the operator. Followed snapshots + /// are not subjects: their edges require an exact pinned route from one. + /// Older graphs without this provenance cannot confirm moved edges. + #[serde(default)] + pub subjects: BTreeSet, /// Source caveats carried through to the report verbatim. #[serde(default)] pub notes: Vec, @@ -328,6 +335,45 @@ impl ChainGraph { self.edges.extend(edges); } + /// Record an explicitly requested subject, separately from a followed + /// upstream whose default branch may differ from the caller's pin. + pub fn record_subject(&mut self, repo: RepoId) { + self.subjects.insert(repo); + } + + fn confirmed_edges(&self) -> BTreeSet { + let mut confirmed: BTreeSet = self + .edges + .iter() + .filter(|edge| self.subjects.contains(&edge.from) && self.is_scanned(&edge.from)) + .cloned() + .collect(); + loop { + let additions: Vec = self + .edges + .iter() + .filter(|edge| !confirmed.contains(*edge) && self.is_scanned(&edge.from)) + .filter(|edge| { + confirmed.iter().any(|caller| { + caller.to == edge.from + && caller.reusable_file() == Some(edge.file.as_str()) + && match (&caller.pin, self.scanned_revision(&edge.from)) { + (Pin::Sha(pin), Some(revision)) => { + crate::startup::same_source_revision(Some(pin), Some(revision)) + } + _ => false, + } + }) + }) + .cloned() + .collect(); + if additions.is_empty() { + return confirmed; + } + confirmed.extend(additions); + } + } + fn is_scanned(&self, repo: &RepoId) -> bool { matches!(self.scans.get(repo), Some(ScanStatus::Scanned { .. })) } @@ -625,23 +671,21 @@ pub enum Finding { consumers: Vec, }, /// A referenced owner/repo resolves to a *different* canonical name. The - /// reference works only through a redirect, and a reusable coordinate - /// under a moved owner can fail at startup with zero jobs — invisible in - /// PR check lists. - /// - /// **Warning, deliberately.** A redirect that still works is a risk, not - /// an observed failure, and `chains` refuses to overclaim: the blocking - /// class is an *observed* zero-job run (`squabble zero-job`, exit 4), where - /// the failure is a fact rather than a prediction. Today's redirect is - /// tomorrow's lapsed redirect (or a re-registered old name), which is why - /// the move is named now rather than after CI goes quiet. + /// API's redirect resolves the name, but Actions does not redirect the + /// `uses:` reference. A confirmed reference blocks; a failed reusable coordinate can prevent + /// jobs from starting. Observed run evidence is reported separately by + /// `squabble zero-job`. MovedUpstream { /// The coordinate as written in the consumer. written: RepoId, /// The canonical `owner/repo` the resolver observed. canonical: RepoId, - /// Every referencing edge from a scanned consumer. + /// References in requested subjects or through fully pinned routes. consumers: Vec, + /// References observed only at an unconfirmed upstream revision. + /// They remain visible but cannot make this finding blocking. + #[serde(default)] + unconfirmed_consumers: Vec, }, Diamond { consumer: RepoId, @@ -659,14 +703,20 @@ pub enum Finding { } impl Finding { - /// Cycles and dead upstreams block; all other findings are warnings. + /// Cycles, dead upstreams and confirmed moved coordinates block; other findings warn. pub fn severity(&self) -> Severity { match self { Finding::Cycle { .. } | Finding::DeadUpstream { .. } => Severity::Blocking, - Finding::MovedUpstream { .. } - | Finding::Diamond { .. } - | Finding::PinSkew { .. } - | Finding::MutablePins { .. } => Severity::Warning, + Finding::MovedUpstream { consumers, .. } => { + if consumers.is_empty() { + Severity::Warning + } else { + Severity::Blocking + } + } + Finding::Diamond { .. } | Finding::PinSkew { .. } | Finding::MutablePins { .. } => { + Severity::Warning + } } } @@ -685,14 +735,24 @@ impl Finding { written, canonical, consumers, - } => format!( - "moved upstream `{written}` → canonical `{canonical}`: {} reference(s) still name \ - the old coordinate. Actions does not redirect `uses:`, so the reference fails as \ - written — for a reusable workflow, a zero-job `startup_failure` (\"workflow was \ - not found\") that no PR check list shows. Repoint it; the pin is carried \ - unchanged.", - consumers.len() - ), + unconfirmed_consumers, + } => { + let confirmed = if consumers.is_empty() { + "no reference is confirmed in a requested subject or its pinned dependencies" + .to_string() + } else { + format!("{} confirmed reference(s) still name the old coordinate. Actions does not redirect `uses:`; a moved reusable can cause a zero-job `startup_failure`. Review repointing while retaining the pin", consumers.len()) + }; + let unconfirmed = if unconfirmed_consumers.is_empty() { + String::new() + } else { + format!( + "; {} reference(s) were observed only at an unconfirmed upstream revision and require inspection at the caller's exact pin", + unconfirmed_consumers.len() + ) + }; + format!("moved upstream `{written}` → canonical `{canonical}`: {confirmed}{unconfirmed}") + } Finding::Diamond { consumer, target, @@ -793,7 +853,7 @@ impl ChainsReport { /// /// Mutable-pin and skew findings are reported only for edges whose consumer /// was actually scanned, so a frontier repo is never blamed on hearsay. -/// Moved-upstream warnings also require a scanned consumer. Diamond and skew +/// Moved-upstream findings also require a scanned consumer. Diamond and skew /// findings group targets by their recorded canonical names. pub fn analyse(graph: &ChainGraph, cost: Option) -> ChainsReport { let mut findings = Vec::new(); @@ -819,22 +879,28 @@ pub fn analyse(graph: &ChainGraph, cost: Option) -> ChainsReport { // under a moved owner dies as a zero-job `startup_failure` that no PR check // list shows. Only edges from scanned consumers count: a frontier repo is // never blamed on hearsay (same rule as the warnings below). - let mut moved: BTreeMap)> = BTreeMap::new(); + let confirmed_edges = graph.confirmed_edges(); + let mut moved: BTreeMap, Vec)> = BTreeMap::new(); for e in graph.edges.iter().filter(|e| graph.is_scanned(&e.from)) { if let Some(canonical) = graph.moved_to(&e.to) { - moved + let entry = moved .entry(e.to.clone()) - .or_insert_with(|| (canonical.clone(), Vec::new())) - .1 - .push(e.clone()); + .or_insert_with(|| (canonical.clone(), Vec::new(), Vec::new())); + if confirmed_edges.contains(e) { + entry.1.push(e.clone()); + } else { + entry.2.push(e.clone()); + } } } - for (written, (canonical, mut consumers)) in moved { + for (written, (canonical, mut consumers, mut unconfirmed_consumers)) in moved { consumers.sort(); + unconfirmed_consumers.sort(); findings.push(Finding::MovedUpstream { written, canonical, consumers, + unconfirmed_consumers, }); } @@ -1319,8 +1385,9 @@ mod tests { } #[test] - fn a_moved_upstream_is_flagged_as_a_warning_naming_canonical_and_written() { + fn a_moved_upstream_blocks_and_names_canonical_and_written_coordinates() { let mut g = ChainGraph::new(); + g.record_subject(r("me/app")); g.record_scan(r("me/app"), scanned(None)); g.record_canonical(r("old/stds"), r("hyperpolymath/standards")); g.add_edges([edge( @@ -1345,6 +1412,7 @@ mod tests { written, canonical, consumers, + .. } => { assert_eq!(written, &r("old/stds")); assert_eq!(canonical, &r("hyperpolymath/standards")); @@ -1382,6 +1450,134 @@ mod tests { assert!(rep.canonical.is_empty()); } + fn followed_move_graph( + revision: Option<&str>, + caller_pin: &str, + called_file: &str, + ) -> ChainGraph { + let mut graph = ChainGraph::new(); + graph.record_subject(r("me/app")); + graph.record_scan(r("me/app"), scanned(Some(SHA_A))); + graph.record_scan(r("me/shared"), scanned(revision)); + graph.record_canonical(r("old/tool"), r("new/tool")); + graph.add_edges([ + edge( + "me/app", + "ci.yml", + "me/shared", + EdgeKind::ReusableWorkflow, + Some(called_file), + caller_pin, + ), + action("me/shared", "old/tool", SHA_A), + ]); + graph + } + + #[test] + fn followed_move_requires_the_exact_revision_and_called_workflow() { + for (revision, pin, file, blocking) in [ + (Some(SHA_A), SHA_A, ".github/workflows/ci.yml", true), + (Some(SHA_B), SHA_A, ".github/workflows/ci.yml", false), + (None, SHA_A, ".github/workflows/ci.yml", false), + (Some(SHA_A), "main", ".github/workflows/ci.yml", false), + (Some(SHA_A), SHA_A, ".github/workflows/other.yml", false), + ] { + let report = analyse(&followed_move_graph(revision, pin, file), None); + let finding = report + .findings + .iter() + .find(|finding| matches!(finding, Finding::MovedUpstream { .. })) + .expect("retained move"); + assert_eq!( + finding.severity() == Severity::Blocking, + blocking, + "{revision:?} {pin} {file}" + ); + if let Finding::MovedUpstream { + consumers, + unconfirmed_consumers, + .. + } = finding + { + assert_eq!(consumers.len(), usize::from(blocking)); + assert_eq!(unconfirmed_consumers.len(), usize::from(!blocking)); + } + } + } + + #[test] + fn a_matching_inner_pin_cannot_qualify_an_unconfirmed_outer_route() { + let mut graph = followed_move_graph(Some(SHA_B), SHA_A, ".github/workflows/ci.yml"); + graph.edges.pop(); + graph.record_scan(r("me/inner"), scanned(Some(SHA_A))); + graph.add_edges([ + edge( + "me/shared", + "ci.yml", + "me/inner", + EdgeKind::ReusableWorkflow, + Some(".github/workflows/ci.yml"), + SHA_A, + ), + action("me/inner", "old/tool", SHA_A), + ]); + let report = analyse(&graph, None); + assert!(!report.has_blocking()); + assert!(report.findings.iter().any(|finding| + matches!(finding, Finding::MovedUpstream { consumers, unconfirmed_consumers, .. } + if consumers.is_empty() && unconfirmed_consumers.len() == 1))); + } + + #[test] + fn a_two_hop_pinned_route_confirms_a_moved_reference() { + let mut graph = ChainGraph::new(); + graph.record_subject(r("me/app")); + for repo in ["me/app", "me/shared", "me/inner"] { + graph.record_scan(r(repo), scanned(Some(SHA_A))); + } + graph.record_canonical(r("old/tool"), r("new/tool")); + graph.add_edges([ + edge( + "me/app", + "ci.yml", + "me/shared", + EdgeKind::ReusableWorkflow, + Some(".github/workflows/ci.yml"), + SHA_A, + ), + edge( + "me/shared", + ".github/workflows/ci.yml", + "me/inner", + EdgeKind::ReusableWorkflow, + Some(".github/workflows/ci.yml"), + SHA_A, + ), + action("me/inner", "old/tool", SHA_A), + ]); + let report = analyse(&graph, None); + assert!(report.has_blocking()); + assert!(report.findings.iter().any(|finding| + matches!(finding, Finding::MovedUpstream { consumers, unconfirmed_consumers, .. } + if consumers.len() == 1 && unconfirmed_consumers.is_empty()))); + } + + #[test] + fn older_graph_without_subject_provenance_retains_unconfirmed_moves() { + let mut graph = followed_move_graph(Some(SHA_A), SHA_A, ".github/workflows/ci.yml"); + graph.subjects.clear(); + let mut encoded = serde_json::to_value(&graph).unwrap(); + encoded.as_object_mut().unwrap().remove("subjects"); + let restored: ChainGraph = serde_json::from_value(encoded).unwrap(); + let report = analyse(&restored, None); + assert!(!report.has_blocking()); + assert!(report.findings.iter().any( + |finding| matches!(finding, Finding::MovedUpstream { unconfirmed_consumers, .. } + if unconfirmed_consumers.len() == 1) + )); + } + #[test] fn a_frontier_consumer_is_never_blamed_for_a_move() { let mut g = ChainGraph::new(); diff --git a/crates/squabble-core/src/execution.rs b/crates/squabble-core/src/execution.rs index d2f386f..608653c 100644 --- a/crates/squabble-core/src/execution.rs +++ b/crates/squabble-core/src/execution.rs @@ -123,7 +123,7 @@ impl PhaseSignatures { /// GitHub's own trailing cleanup steps — never a policy step. pub fn is_post(&self, name: &str) -> bool { let n = name.trim(); - POST_JOB_STEPS.iter().any(|s| *s == n) + POST_JOB_STEPS.contains(&n) } /// A step that is not preflight, not a prerequisite and not cleanup: the @@ -460,7 +460,8 @@ mod tests { } ); assert_eq!(c.handoff(), Handoff::Provisioning); - assert!(!c.explain().contains("finding")); + assert!(c.explain().contains("not a policy finding")); + assert!(!c.is_evidence_bearing_success()); } #[test] diff --git a/crates/squabble-core/src/startup.rs b/crates/squabble-core/src/startup.rs index 705611a..bf31348 100644 --- a/crates/squabble-core/src/startup.rs +++ b/crates/squabble-core/src/startup.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -//! Zero-job `startup_failure` attribution — the class that is invisible in PR +//! Zero-job failure attribution — the class that is invisible in PR //! check lists. //! //! A workflow that fails *before any job exists* creates no check run at all, @@ -27,6 +27,10 @@ //! [`crate::gate::CheckRun`] — it produces a diagnosis and sequencing advice, //! nothing else. //! +//! Native lock parser/coverage findings are separate source diagnoses. They +//! retain official verifier output at the matching source revision and never +//! mint a settings move, a CI pass or a qualified learning outcome. +//! //! # Why names, not pins //! //! A moved owner is a *name* problem. Resolving the pin (or trusting a tag) @@ -236,7 +240,7 @@ impl CanonicalResolution { // Attribution // --------------------------------------------------------------------------- -/// The two first-class zero-job classes, plus the honest non-answer. +/// Evidence-backed startup diagnoses, plus the honest non-answer. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum StartupFailureClass { @@ -248,6 +252,12 @@ pub enum StartupFailureClass { /// refuses the run at startup (jobs=0). Move: admit the `owner/repo@*` /// pattern the report names. AllowListAdmissionRequired, + /// The official native lock parser rejected the lock at the run's source + /// revision. This is a reproduced source blocker, not a settings failure. + InvalidActionsLock, + /// The official offline verifier found a workflow/lock coverage mismatch + /// in this run's workflow at the same source revision. + ActionsLockMismatch, /// A zero-job startup failure was observed but cannot be attributed from /// the facts available. No move is proposed (fail-closed). Unattributed, @@ -259,6 +269,8 @@ impl StartupFailureClass { match self { Self::MovedCoordinate => "moved coordinate", Self::AllowListAdmissionRequired => "allow-list admission required", + Self::InvalidActionsLock => "invalid native Actions lock", + Self::ActionsLockMismatch => "Actions lock/workflow mismatch", Self::Unattributed => "unattributed", } } @@ -292,8 +304,9 @@ pub struct ZeroJobAttribution { impl ZeroJobAttribution { /// The single legitimate move for this class, if one is proposable. /// - /// `None` for [`StartupFailureClass::Unattributed`], and for a - /// `MovedCoordinate` without a canonical target or a parseable coordinate. + /// `None` for lock diagnoses and [`StartupFailureClass::Unattributed`], + /// and for a `MovedCoordinate` without a canonical target or a parseable + /// coordinate. /// Admission also requires a recorded allow-list pattern. pub fn proposed_move(&self) -> Option { match self.class { @@ -319,7 +332,9 @@ impl ZeroJobAttribution { pattern, }) } - StartupFailureClass::Unattributed => None, + StartupFailureClass::InvalidActionsLock + | StartupFailureClass::ActionsLockMismatch + | StartupFailureClass::Unattributed => None, } } @@ -329,14 +344,16 @@ impl ZeroJobAttribution { /// This is the bridge between the zero-job report and the gate model: a /// diagnosed cause can explain a `Missing` requirement, but (as /// `squabble-core::gate` proves) no cause is a path to `Green`. `None` for - /// an unattributed run — an unknown cause is not invented. + /// lock diagnoses and unattributed runs. pub fn missing_cause(&self) -> Option { match self.class { StartupFailureClass::MovedCoordinate => Some(MissingCause::MovedUpstreamCoordinate), StartupFailureClass::AllowListAdmissionRequired => { Some(MissingCause::AllowListAdmissionRequired) } - StartupFailureClass::Unattributed => None, + StartupFailureClass::InvalidActionsLock + | StartupFailureClass::ActionsLockMismatch + | StartupFailureClass::Unattributed => None, } } @@ -577,11 +594,51 @@ pub fn attribute_zero_job( // Runs and reports // --------------------------------------------------------------------------- -/// One Actions run that concluded `STARTUP_FAILURE` — a run with no jobs, which -/// therefore cannot appear as a red check on any PR. +/// Outcome of the official `gh-actions-lock --verify-local --json` reader. +/// A successful local check is coverage evidence only, never a CI pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NativeLockStatus { + Verified, + Malformed, + CoverageMismatch, + Unverified, +} + +/// Unmodified output and source identity of an offline native lock probe. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeLockVerification { + pub head_sha: String, + pub verifier: String, + pub tool_version: String, + pub exit_code: Option, + pub status: NativeLockStatus, + pub stdout: String, + pub stderr: String, + pub reason: String, +} + +/// Compare two full 40-character hexadecimal commit IDs, ignoring ASCII case. +/// Missing, abbreviated or malformed IDs never match. +pub fn same_source_revision(observed: Option<&str>, checkout: Option<&str>) -> bool { + let is_sha = |s: &str| s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()); + matches!((observed, checkout), (Some(a), Some(b)) + if is_sha(a) && is_sha(b) && a.eq_ignore_ascii_case(b)) +} + +/// One failed Actions run to investigate for startup failure. GitHub also +/// reports ordinary `failure` for some runs with zero jobs; the separately +/// fetched job count, not the conclusion spelling, establishes that fact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ZeroJobRun { pub id: u64, + /// The observed conclusion, retained so a job-level `failure` can be + /// excluded without silently dropping contradictory `startup_failure`. + #[serde(default)] + pub conclusion: String, + /// The failed run's immutable source revision, when supplied by GitHub. + #[serde(default)] + pub head_sha: Option, /// Workflow file relative to `.github/workflows/`. pub workflow_file: String, /// The workflow's display name, when the API reports one. @@ -593,10 +650,12 @@ pub struct ZeroJobRun { pub jobs: Option, } -/// One zero-job run and every coordinate attribution made for its workflow. +/// One failed candidate and every attribution made from its available evidence. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ZeroJobRunAttribution { pub run_id: u64, + #[serde(default)] + pub head_sha: Option, pub workflow: String, #[serde(default)] pub jobs: Option, @@ -609,6 +668,11 @@ pub struct ZeroJobRunAttribution { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ZeroJobReport { pub repo: String, + /// Present only when the local workflow tree matches this commit. + #[serde(default)] + pub checkout_head_sha: Option, + #[serde(default)] + pub lock_verification: Option, #[serde(default)] pub runs: Vec, #[serde(default)] @@ -616,9 +680,8 @@ pub struct ZeroJobReport { } impl ZeroJobReport { - /// True when at least one zero-job run was observed. Always a finding, - /// whatever the attribution quality: an unrecorded run is still a run that - /// never reported. + /// True when a failed candidate remains in the report. Unknown or + /// contradictory job evidence still blocks; it does not establish zero. pub fn has_findings(&self) -> bool { !self.runs.is_empty() } @@ -1014,8 +1077,11 @@ mod tests { ); let report = ZeroJobReport { repo: CALLER.into(), + checkout_head_sha: Some(SHA.into()), + lock_verification: None, runs: vec![ZeroJobRunAttribution { run_id: 40_451_601_571, + head_sha: Some(SHA.into()), workflow: WF.into(), jobs: Some(0), attributions: vec![a], @@ -1029,10 +1095,31 @@ mod tests { assert_eq!(report, back); assert!(ZeroJobReport { repo: CALLER.into(), + checkout_head_sha: None, + lock_verification: None, runs: Vec::new(), notes: Vec::new(), } .runs .is_empty()); } + + #[test] + fn source_matching_requires_complete_commit_ids() { + assert!(same_source_revision( + Some(SHA), + Some(&SHA.to_ascii_uppercase()) + )); + for other in [ + None, + Some(""), + Some("main"), + Some(&SHA[..8]), + Some("0000000000000000000000000000000000000000"), + ] { + assert!(!same_source_revision(Some(SHA), other)); + assert!(!same_source_revision(other, Some(SHA))); + } + assert!(!same_source_revision(Some("main"), Some("main"))); + } } diff --git a/crates/squabble-fight/src/chains.rs b/crates/squabble-fight/src/chains.rs index ad8d6e8..71f8eb8 100644 --- a/crates/squabble-fight/src/chains.rs +++ b/crates/squabble-fight/src/chains.rs @@ -112,6 +112,9 @@ pub fn graph_from_snapshots<'a>( let mut g = ChainGraph::new(); for (snap, role) in snapshots { g.record_scan(snap.repo.clone(), snap.status.clone()); + if matches!(role, Role::Subject) { + g.record_subject(snap.repo.clone()); + } let canonical = snap.canonical_id(); if canonical != snap.repo { g.record_canonical(snap.repo.clone(), canonical); diff --git a/crates/squabble-fight/src/startup.rs b/crates/squabble-fight/src/startup.rs index 9311fcc..21ead9a 100644 --- a/crates/squabble-fight/src/startup.rs +++ b/crates/squabble-fight/src/startup.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -//! Host side of zero-job `startup_failure` attribution. +//! Host side of failed-run startup attribution. //! //! `squabble-core::startup` owns the taxonomy and the moves; this module does //! the estate-aware half: read a run's own workflow file, extract every @@ -10,7 +10,9 @@ //! Two rules keep the output honest: //! //! * **Only observed runs are attributed.** The caller supplies runs that -//! actually concluded `STARTUP_FAILURE`; this module never invents one. +//! actually failed and supplies their separately read job counts. A source +//! attribution also requires the failed run's complete commit ID to match +//! the unmodified checkout; this module never invents either fact. //! * **A run always reports something.** If no coordinate is moved or //! unadmitted, the run gets one [`StartupFailureClass::Unattributed`] entry //! naming the workflow — never silence, and never a fabricated cause. @@ -21,8 +23,8 @@ use squabble_core::chains::{Edge, RepoId, WorkflowFile}; use squabble_core::startup::{ - attribute_zero_job, AllowList, CanonicalResolution, StartupFailureClass, ZeroJobAttribution, - ZeroJobRun, + attribute_zero_job, same_source_revision, AllowList, CanonicalResolution, NativeLockStatus, + NativeLockVerification, StartupFailureClass, ZeroJobAttribution, ZeroJobRun, }; use std::collections::BTreeMap; @@ -77,28 +79,120 @@ pub fn attribute_workflow( out } -/// Attribute one zero-job run from the repo's already-read workflow files. +/// Attribute one failed run from the repo's already-read workflow files. +/// +/// Attribution requires an observed zero job count and a full run commit ID +/// matching `checkout_revision`, which the caller must establish from a +/// checkout with an unmodified workflow tree. Otherwise the result is one +/// `Unattributed` entry. +/// Optional `lock` evidence must match that revision: malformed locks and +/// workflow-specific coverage mismatches take precedence over coordinates, +/// while evidence from a different source revision leaves the run unattributed. +/// An unverified optional probe is retained beside independent coordinate +/// attribution. A verified lock, or a coverage mismatch for another workflow, +/// also permits coordinate attribution to continue. /// /// When the run's workflow file is not among `files` (an unreadable checkout, a /// workflow deleted since the run), the run still gets an honest /// `Unattributed` entry with the reason — absence of evidence is never /// evidence of a clean start. /// -/// When no coordinate is moved or unadmitted, the result is a single -/// `Unattributed` entry rather than one per coordinate: the run failed for a -/// reason outside this taxonomy, and the report should say that once. The +/// When coordinate attribution finds no moved or unadmitted coordinate, the +/// result is a single `Unattributed` entry rather than one per coordinate. The /// entry kept is the first by coordinate in lexicographic order. Attributed /// results are also sorted by coordinate. The caller supplies the observed -/// run; this function does not check its job count. +/// run; only an explicitly observed zero job count permits attribution. pub fn attribute_run( repo: &str, run: &ZeroJobRun, files: &[WorkflowFile], canonical: &BTreeMap, allow_list: Option<&AllowList>, + checkout_revision: Option<&str>, + lock: Option<&NativeLockVerification>, ) -> Vec { - let Some(file) = files.iter().find(|f| f.path == run.workflow_file) else { + if run.jobs != Some(0) { + let evidence = match run.jobs { + Some(count) => format!( + "run {} created {count} job(s), contradicting zero-job startup attribution; \ + inspect the job's execution evidence before proposing a source or settings change", + run.id + ), + None => format!( + "run {} has no readable job count, so zero-job startup attribution is unverified; \ + recover the run's job evidence before proposing a source or settings change", + run.id + ), + }; + return vec![unattributed(repo, &run.workflow_file, evidence)]; + } + if !same_source_revision(run.head_sha.as_deref(), checkout_revision) { return vec![unattributed( + repo, + &run.workflow_file, + format!( + "run {} source {:?} does not match an established, unmodified checkout {:?}; \ + inspect the run's exact source before proposing a source or settings change", + run.id, run.head_sha, checkout_revision + ), + )]; + } + if let Some(lock) = lock { + if !same_source_revision(Some(&lock.head_sha), checkout_revision) { + return vec![unattributed(repo, &run.workflow_file, + "native lock evidence belongs to a different source revision; no attribution or move inferred".into())]; + } + let class = match lock.status { + NativeLockStatus::Malformed => Some(StartupFailureClass::InvalidActionsLock), + NativeLockStatus::CoverageMismatch => { + let path = format!(".github/workflows/{}", run.workflow_file); + let affected = serde_json::from_str::(&lock.stdout) + .ok() + .and_then(|report| report.get("workflows").and_then(|v| v.as_array()).cloned()) + .is_some_and(|workflows| { + workflows.iter().any(|workflow| { + workflow.get("path").and_then(|v| v.as_str()) == Some(path.as_str()) + && workflow.get("valid").and_then(|v| v.as_bool()) == Some(false) + }) + }); + affected.then_some(StartupFailureClass::ActionsLockMismatch) + } + NativeLockStatus::Unverified => None, + NativeLockStatus::Verified => None, + }; + if let Some(class) = class { + let mut finding = unattributed( + repo, + &run.workflow_file, + format!( + "{} at source {} ({} exit {:?}); the complete official output is retained. \ + This reproduces a source defect, not proof of the sole GitHub startup cause.", + lock.reason, lock.head_sha, lock.tool_version, lock.exit_code + ), + ); + finding.class = class; + finding.sequencing = vec![ + "review a candidate produced by the repository's official lock regeneration chain; do not hand-edit actions.lock or widen Actions permissions".into(), + "verify that candidate against the exact workflows, then rerun the failed workflow; this diagnosis satisfies no required check".into(), + ]; + return vec![finding]; + } + } + // An optional probe cannot erase independently established coordinate + // evidence. Keep its failure alongside that evidence without inferring + // any lock repair, permission change or successful verification. + let retain_probe_failure = |mut findings: Vec| { + if let Some(probe) = lock.filter(|probe| probe.status == NativeLockStatus::Unverified) { + findings.push(unattributed( + repo, + &run.workflow_file, + format!("native lock verification is unverified: {}; no repair inferred from that result", probe.reason), + )); + } + findings + }; + let Some(file) = files.iter().find(|f| f.path == run.workflow_file) else { + return retain_probe_failure(vec![unattributed( repo, &run.workflow_file, format!( @@ -107,7 +201,7 @@ pub fn attribute_run( the run's commit before changing anything.", run.workflow_file ), - )]; + )]); }; let all = attribute_workflow(repo, &run.workflow_file, &file.text, canonical, allow_list); @@ -119,7 +213,7 @@ pub fn attribute_run( // coordinate or an unverified name is a fact the operator can act on, // and it must not be flattened into "everything resolved canonically". unexplained.sort_by(|a, b| a.coordinate.cmp(&b.coordinate)); - return match unexplained.into_iter().next() { + return retain_probe_failure(match unexplained.into_iter().next() { Some(a) => vec![a], None => vec![unattributed( repo, @@ -129,10 +223,10 @@ pub fn attribute_run( from the run's own workflow." .to_string(), )], - }; + }); } primary.sort_by(|a, b| a.coordinate.cmp(&b.coordinate)); - primary + retain_probe_failure(primary) } /// Build a workflow-level explanation without blaming a coordinate or @@ -152,13 +246,14 @@ fn unattributed(repo: &str, workflow: &str, evidence: String) -> ZeroJobAttribut } } -/// Parse a `GET /repos/{o}/{r}/actions/runs` response, keeping only runs that -/// concluded `STARTUP_FAILURE`. +/// Parse a `GET /repos/{o}/{r}/actions/runs` response, retaining failed run +/// candidates (`startup_failure` or `failure`) for a separate job-count read. +/// An ordinary failure is not a startup diagnosis by itself. /// /// `jobs` is left `None` here: the count comes from a second read and is the /// host's evidence, not something this parser may assume. -/// Matching is case-insensitive. Entries without an unsigned integer run ID -/// are skipped; a missing or non-string path becomes an empty filename, +/// Matching is case-insensitive. A failed entry without an unsigned integer +/// run ID is an error; a missing or non-string path becomes an empty filename, /// otherwise the ref suffix is dropped and only the final path component is /// kept. GitHub reports `path` either as `.github/workflows/ci.yml` or as /// `.github/workflows/ci.yml@refs/heads/main`; taking the last slash-separated @@ -177,13 +272,14 @@ pub fn parse_startup_runs(body: &str) -> Result, String> { .ok_or_else(|| "actions/runs response has no `workflow_runs` array".to_string())?; let mut out = Vec::new(); for r in runs { - if !r + let Some(conclusion) = r .get("conclusion") .and_then(serde_json::Value::as_str) - .is_some_and(|c| c.eq_ignore_ascii_case("startup_failure")) - { + .map(str::to_ascii_lowercase) + .filter(|c| matches!(c.as_str(), "startup_failure" | "failure")) + else { continue; - } + }; // A startup failure with no usable id cannot be reported, and dropping // it would let the command exit 0 saying it observed none. Fail closed. let id = r @@ -191,7 +287,7 @@ pub fn parse_startup_runs(body: &str) -> Result, String> { .and_then(serde_json::Value::as_u64) .ok_or_else(|| { format!( - "a `startup_failure` run has no unsigned integer `id`, so it cannot be \ + "a `{conclusion}` run has no unsigned integer `id`, so it cannot be \ reported: refusing to skip it and claim no failure was observed{}", r.get("path") .and_then(serde_json::Value::as_str) @@ -214,6 +310,11 @@ pub fn parse_startup_runs(body: &str) -> Result, String> { .map(str::to_string); out.push(ZeroJobRun { id, + conclusion, + head_sha: r + .get("head_sha") + .and_then(serde_json::Value::as_str) + .map(str::to_string), workflow_file, workflow_name, jobs: None, @@ -232,11 +333,14 @@ pub fn parse_startup_runs(body: &str) -> Result, String> { pub fn parse_job_count(body: &str) -> Result { let v: serde_json::Value = serde_json::from_str(body) .map_err(|e| format!("could not parse actions/jobs response: {e}"))?; - let count = v.get("total_count") + let count = v + .get("total_count") .and_then(serde_json::Value::as_u64) .ok_or_else(|| "actions/jobs response has no `total_count`".to_string())?; if let Some(jobs) = v.get("jobs") { - let jobs = jobs.as_array().ok_or("actions/jobs response has invalid `jobs`")?; + let jobs = jobs + .as_array() + .ok_or("actions/jobs response has invalid `jobs`")?; if jobs.len() as u64 > count { return Err("actions/jobs response lists more jobs than its total_count".to_string()); } @@ -253,6 +357,242 @@ mod tests { const CALLER: &str = "hyperpolymath/hypatia"; const SHA: &str = "8f2ee50841e216cd8c192eeb68953118190f105c"; + #[test] + fn missing_or_different_source_cannot_borrow_current_workflow_attribution() { + let files = [wf("ci.yml", WORKFLOW)]; + let names = canonical(&[( + "old/standards", + CanonicalResolution::Moved { + canonical: "new/standards".into(), + }, + )]); + for checkout in [ + None, + Some("main"), + Some(&SHA[..8]), + Some("0000000000000000000000000000000000000000"), + ] { + let out = attribute_run( + CALLER, + &run(42, "ci.yml"), + &files, + &names, + Some(&AllowList::default()), + checkout, + None, + ); + assert_eq!(out[0].class, StartupFailureClass::Unattributed); + assert!(out[0].proposed_move().is_none()); + assert!(out[0].required_allow_list_pattern.is_none()); + assert!(out[0].evidence.contains("source")); + } + let mut observed = run(42, "ci.yml"); + observed.head_sha = None; + assert_eq!( + attribute_run(CALLER, &observed, &files, &names, None, Some(SHA), None)[0].class, + StartupFailureClass::Unattributed + ); + } + + fn native_lock(status: NativeLockStatus) -> NativeLockVerification { + NativeLockVerification { + head_sha: SHA.into(), + verifier: "/verified/gh-actions-lock".into(), + tool_version: "actions-lock version v0.2.0".into(), + exit_code: Some(2), + status, + stdout: String::new(), + stderr: "recorded native parser error".into(), + reason: "native parser rejected this source lock".into(), + } + } + + #[test] + fn native_lock_defect_is_source_evidence_without_a_permission_move_or_green() { + let lock = native_lock(NativeLockStatus::Malformed); + let out = attribute_run( + CALLER, + &run(42, "ci.yml"), + &[], + &BTreeMap::new(), + Some(&AllowList::default()), + Some(SHA), + Some(&lock), + ); + assert_eq!(out[0].class, StartupFailureClass::InvalidActionsLock); + assert!(out[0].proposed_move().is_none()); + assert!(out[0].missing_cause().is_none()); + assert!(out[0].required_allow_list_pattern.is_none()); + assert!(out[0] + .evidence + .contains("not proof of the sole GitHub startup cause")); + assert!(out[0].sequencing[0].contains("do not hand-edit")); + + let mut stale = lock.clone(); + stale.head_sha = "0000000000000000000000000000000000000000".into(); + assert_eq!( + attribute_run( + CALLER, + &run(42, "ci.yml"), + &[], + &BTreeMap::new(), + None, + Some(SHA), + Some(&stale) + )[0] + .class, + StartupFailureClass::Unattributed + ); + let mut unknown = run(42, "ci.yml"); + unknown.jobs = None; + assert_eq!( + attribute_run( + CALLER, + &unknown, + &[], + &BTreeMap::new(), + None, + Some(SHA), + Some(&lock) + )[0] + .class, + StartupFailureClass::Unattributed + ); + } + + #[test] + fn coverage_mismatch_is_workflow_specific_and_probe_failure_is_not_clean() { + let mut lock = native_lock(NativeLockStatus::CoverageMismatch); + lock.exit_code = Some(1); + lock.stdout = serde_json::json!({"workflows": [ + {"path": ".github/workflows/ci.yml", "valid": false}, + {"path": ".github/workflows/other.yml", "valid": true} + ]}) + .to_string(); + let out = attribute_run( + CALLER, + &run(42, "ci.yml"), + &[], + &BTreeMap::new(), + None, + Some(SHA), + Some(&lock), + ); + assert_eq!(out[0].class, StartupFailureClass::ActionsLockMismatch); + assert!(out[0].proposed_move().is_none()); + let other = attribute_run( + CALLER, + &run(43, "other.yml"), + &[], + &BTreeMap::new(), + None, + Some(SHA), + Some(&lock), + ); + assert_eq!(other[0].class, StartupFailureClass::Unattributed); + lock.status = NativeLockStatus::Unverified; + assert_eq!( + attribute_run( + CALLER, + &run(42, "ci.yml"), + &[], + &BTreeMap::new(), + None, + Some(SHA), + Some(&lock) + )[0] + .class, + StartupFailureClass::Unattributed + ); + } + + #[test] + fn absent_or_positive_job_counts_cannot_propose_admission_or_repointing() { + let files = [wf( + "ci.yml", + &format!("jobs:\n scan:\n uses: old/standards/.github/workflows/ci.yml@{SHA}\n"), + )]; + let canonical = BTreeMap::from([( + RepoId::new("old/standards"), + CanonicalResolution::Moved { + canonical: "new/standards".into(), + }, + )]); + let allow = AllowList::default(); + for count in [None, Some(1), Some(3)] { + let mut observed = run(42, "ci.yml"); + observed.jobs = count; + let findings = attribute_run( + CALLER, + &observed, + &files, + &canonical, + Some(&allow), + Some(SHA), + None, + ); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].class, StartupFailureClass::Unattributed); + assert!(findings[0].proposed_move().is_none()); + assert!(findings[0].required_allow_list_pattern.is_none()); + assert!(findings[0].evidence.contains(if count.is_some() { + "contradicting" + } else { + "unverified" + })); + } + } + + #[test] + fn optional_probe_failure_preserves_independent_coordinate_attribution() { + let files = [wf( + "ci.yml", + &format!("jobs:\n scan:\n uses: old/standards/.github/workflows/ci.yml@{SHA}\n"), + )]; + let moved = BTreeMap::from([( + RepoId::new("old/standards"), + CanonicalResolution::Moved { + canonical: "new/standards".into(), + }, + )]); + let unchanged = BTreeMap::new(); + let allow = AllowList::default(); + for names in [&moved, &unchanged] { + let expected = attribute_run( + CALLER, + &run(42, "ci.yml"), + &files, + names, + Some(&allow), + Some(SHA), + None, + ); + for reason in [ + "verified binary is unavailable", + "unrelated untracked file prevents full-input qualification", + ] { + let mut probe = native_lock(NativeLockStatus::Unverified); + probe.reason = reason.into(); + let actual = attribute_run( + CALLER, + &run(42, "ci.yml"), + &files, + names, + Some(&allow), + Some(SHA), + Some(&probe), + ); + assert_eq!(&actual[..expected.len()], expected.as_slice()); + let retained = actual.last().unwrap(); + assert_eq!(actual.len(), expected.len() + 1); + assert_eq!(retained.class, StartupFailureClass::Unattributed); + assert!(retained.evidence.contains(reason)); + assert!(retained.proposed_move().is_none()); + assert!(retained.required_allow_list_pattern.is_none()); + } + } + } + fn wf(path: &str, text: &str) -> WorkflowFile { WorkflowFile { path: path.into(), @@ -263,6 +603,8 @@ mod tests { fn run(id: u64, file: &str) -> ZeroJobRun { ZeroJobRun { id, + conclusion: "startup_failure".into(), + head_sha: Some(SHA.into()), workflow_file: file.into(), workflow_name: None, jobs: Some(0), @@ -301,6 +643,8 @@ jobs: &files, &names, None, + Some(SHA), + None, ); assert_eq!(out.len(), 1, "{out:#?}"); let a = &out[0]; @@ -341,6 +685,8 @@ jobs: &files, &names, Some(&allow), + Some(SHA), + None, ); // The moved reusable wins, but the admission pattern is carried so the // sequencing is visible in one report. @@ -369,6 +715,8 @@ jobs: &files, &BTreeMap::new(), Some(&allow), + Some(SHA), + None, ); assert_eq!(out.len(), 1); assert_eq!( @@ -396,6 +744,8 @@ jobs: &files, &BTreeMap::new(), Some(&allow), + Some(SHA), + None, ); assert_eq!(out.len(), 1, "{out:#?}"); assert_eq!(out[0].class, StartupFailureClass::Unattributed); @@ -405,7 +755,15 @@ jobs: #[test] fn a_missing_workflow_file_is_unattributed_not_clean() { - let out = attribute_run(CALLER, &run(3, "deleted.yml"), &[], &BTreeMap::new(), None); + let out = attribute_run( + CALLER, + &run(3, "deleted.yml"), + &[], + &BTreeMap::new(), + None, + Some(SHA), + None, + ); assert_eq!(out.len(), 1); assert_eq!(out[0].class, StartupFailureClass::Unattributed); assert!(out[0].evidence.contains("not in the local checkout")); @@ -431,7 +789,7 @@ jobs: } #[test] - fn run_parsing_keeps_only_startup_failures() { + fn run_parsing_keeps_failed_candidates_without_inventing_job_counts() { let body = serde_json::json!({ "total_count": 3, "workflow_runs": [ @@ -443,11 +801,13 @@ jobs: }) .to_string(); let runs = parse_startup_runs(&body).expect("parse"); - assert_eq!(runs.len(), 2); + assert_eq!(runs.len(), 3); assert_eq!(runs[0].id, 11); assert_eq!(runs[0].workflow_file, "hypatia-scan.yml"); assert_eq!(runs[0].workflow_name.as_deref(), Some("Hypatia scan")); - assert_eq!(runs[1].workflow_file, "gov.yml"); + assert_eq!(runs[1].workflow_file, "ci.yml"); + assert_eq!(runs[1].conclusion, "failure"); + assert_eq!(runs[2].workflow_file, "gov.yml"); assert!(runs.iter().all(|r| r.jobs.is_none())); } @@ -488,8 +848,8 @@ jobs: "the error must name the run: {err}" ); - // A non-integer id is refused the same way, and a healthy run without an - // id is still ignored: it is not a startup failure. + // A non-integer id is refused the same way. A regular failure without + // an id cannot have its job count checked either, so it is not skipped. let quoted = serde_json::json!({ "workflow_runs": [{"id": "12", "conclusion": "startup_failure"}] }) @@ -499,7 +859,12 @@ jobs: "workflow_runs": [{"conclusion": "failure"}] }) .to_string(); - assert!(parse_startup_runs(&fine).expect("parse").is_empty()); + assert!(parse_startup_runs(&fine).is_err()); + let healthy = serde_json::json!({ + "workflow_runs": [{"conclusion": "success"}] + }) + .to_string(); + assert!(parse_startup_runs(&healthy).unwrap().is_empty()); } #[test] diff --git a/crates/squabble-forge/src/rest.rs b/crates/squabble-forge/src/rest.rs index b2d9fa6..b6152c1 100644 --- a/crates/squabble-forge/src/rest.rs +++ b/crates/squabble-forge/src/rest.rs @@ -70,12 +70,14 @@ pub trait RestTransport { const PATH_ALLOWED: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/=@+~:"; -/// Check an API path before it becomes an argument to `gh`. +/// Check an API path before it becomes an argument to `gh`. An optional query +/// must be exactly `per_page=N`, with decimal `N` from 1 to 100 inclusive. /// /// # Errors /// -/// Returns the reason the path was refused: empty, too long, an option-looking -/// leading `-`, a `..` segment, or any byte outside [`PATH_ALLOWED`]. +/// Returns the reason the path was refused: empty, over 512 bytes, an +/// option-looking leading `-`, a `..` segment, a byte outside [`PATH_ALLOWED`] +/// in the path, or an invalid query. pub fn check_api_path(path: &str) -> Result<(), String> { if path.is_empty() { return Err("empty API path".to_string()); @@ -86,6 +88,22 @@ pub fn check_api_path(path: &str) -> Result<(), String> { if path.starts_with('-') { return Err(format!("API path '{path}' looks like an option; refusing")); } + // The zero-job reader already requests bounded run/job pages. Validate + // that one query form separately; do not admit arbitrary query syntax or + // weaken the path/argv checks below. + let path = if let Some((base, query)) = path.split_once('?') { + let page_size = query + .strip_prefix("per_page=") + .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + .and_then(|n| n.parse::().ok()) + .filter(|n| (1..=100).contains(n)); + if base.is_empty() || page_size.is_none() { + return Err("API query must be exactly per_page=N with N from 1 to 100".into()); + } + base + } else { + path + }; for seg in path.split('/') { if seg == ".." { return Err(format!("API path '{path}' traverses upward; refusing")); @@ -628,6 +646,35 @@ mod tests { assert!(check_api_path("repos/a/b/commits/main").is_ok()); } + #[test] + fn bounded_pagination_used_by_zero_job_is_accepted() { + for page_size in [1, 30, 100] { + assert!(check_api_path(&format!( + "repos/hyperpolymath/cicd-squabbler/actions/runs?per_page={page_size}" + )) + .is_ok()); + } + assert!(check_api_path("repos/a/b/actions/runs/42/jobs?per_page=1").is_ok()); + } + + #[test] + fn pagination_does_not_admit_other_queries_or_unsafe_paths() { + for path in [ + "repos/a/b/actions/runs?per_page=0", + "repos/a/b/actions/runs?per_page=101", + "repos/a/b/actions/runs?per_page=30&other=1", + "repos/a/b/actions/runs?per_page=30?per_page=1", + "repos/a/b/actions/runs?per_page=+1", + "repos/a/b/actions/runs?per_page=1\n", + "repos/a/b/actions/runs?filter=all", + "repos/a/../b?per_page=1", + "--help?per_page=1", + "?per_page=1", + ] { + assert!(check_api_path(path).is_err(), "accepted {path:?}"); + } + } + #[test] fn an_option_looking_path_is_refused_before_it_reaches_argv() { let err = check_api_path("--help").expect_err("must be refused"); diff --git a/verification/squabble-creusot/why3find.json b/verification/squabble-creusot/why3find.json new file mode 100644 index 0000000..b198d8b --- /dev/null +++ b/verification/squabble-creusot/why3find.json @@ -0,0 +1,10 @@ +{ + "fast": 0.2, + "time": 1.0, + "depth": 6, + "packages": ["creusot"], + "provers": ["z3"], + "tactics": ["compute_specified", "split_vc"], + "drivers": [], + "warnoff": ["unused_variable", "axiom_abstract"] +}