From b7a5511622f372f4e113b2ae8318932d9c3305e6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:25:41 +0000 Subject: [PATCH 1/7] fix: bind startup diagnosis to observed jobs and immutable source Read bounded Actions pages successfully, investigate ordinary failure runs with independent job counts, and refuse attribution from missing, contradictory or mismatched source evidence. Add an opt-in official offline native-lock verifier with complete pristine input closure and strict output consistency; retain parser, coverage and tool failures separately without settings changes or green CI claims. Complete the previously intended moved-coordinate blocking policy and repair inconsistent inherited fixtures. All404 workspace tests, all-target Clippy -D warnings, CLI build and six real official-binary boundary controls passed. No production workflows, lockfiles, settings or learning data changed. --- README.adoc | 67 ++- crates/squabble-cli/src/chains.rs | 71 ++- crates/squabble-cli/src/lock_verifier.rs | 533 +++++++++++++++++++++++ crates/squabble-cli/src/main.rs | 5 +- crates/squabble-cli/src/phase.rs | 4 +- crates/squabble-cli/src/zero_job.rs | 360 +++++++++++++-- crates/squabble-core/src/chains.rs | 31 +- crates/squabble-core/src/execution.rs | 5 +- crates/squabble-core/src/startup.rs | 105 ++++- crates/squabble-fight/src/startup.rs | 336 +++++++++++++- crates/squabble-forge/src/rest.rs | 45 ++ 11 files changed, 1446 insertions(+), 116 deletions(-) create mode 100644 crates/squabble-cli/src/lock_verifier.rs diff --git a/README.adoc b/README.adoc index ba2387d..e11e75b 100644 --- a/README.adoc +++ b/README.adoc @@ -49,11 +49,10 @@ 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. 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 +60,10 @@ 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. This blocks even when the repository API follows a redirect: Actions +does not redirect the `uses:` reference. `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 +76,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 +97,64 @@ 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. + +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..2f842a7 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::{ @@ -648,21 +648,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..53e1274 --- /dev/null +++ b/crates/squabble-cli/src/lock_verifier.rs @@ -0,0 +1,533 @@ +// 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"; + +/// Bind the workflow tree to a complete commit ID. Uncommitted or unreadable +/// workflow inputs cannot explain a historical run at that commit. +pub(crate) fn checkout_revision(root: &Path) -> Result { + source_revision(root, false) +} + +/// The official reader also follows same-repository action definitions. A +/// pristine full checkout closes that input set without a second action parser. +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) +} + +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. +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. +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..c83b35d 100644 --- a/crates/squabble-cli/src/phase.rs +++ b/crates/squabble-cli/src/phase.rs @@ -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..cd3f0a7 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,6 +58,12 @@ 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 @@ -63,11 +75,19 @@ fn parse_args(rest: &[String]) -> Result { 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() @@ -110,7 +130,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 +154,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 +198,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 +218,7 @@ pub fn run(rest: &[String]) -> ExitCode { &GhRest, allow_list.as_ref(), notes, + &source, ) }; @@ -217,9 +277,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 +317,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 +354,38 @@ 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(&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() { + o.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()) { + o.push_str(&format!(" {detail}\n")); + } + } + } + } + } 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!( @@ -320,46 +417,58 @@ fn narrate(r: &ZeroJobReport) -> String { } /// 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. 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) + } +} + +/// Transport seam used by the live reader and its recorded-response controls. +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 +478,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 +641,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 +821,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 +860,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..3bf0780 100644 --- a/crates/squabble-core/src/chains.rs +++ b/crates/squabble-core/src/chains.rs @@ -625,16 +625,10 @@ 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. This 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, @@ -659,14 +653,15 @@ pub enum Finding { } impl Finding { - /// Cycles and dead upstreams block; all other findings are warnings. + /// Cycles, dead upstreams and 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::Cycle { .. } + | Finding::DeadUpstream { .. } + | Finding::MovedUpstream { .. } => Severity::Blocking, + Finding::Diamond { .. } | Finding::PinSkew { .. } | Finding::MutablePins { .. } => { + Severity::Warning + } } } @@ -793,7 +788,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(); @@ -1319,7 +1314,7 @@ 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_scan(r("me/app"), scanned(None)); g.record_canonical(r("old/stds"), r("hyperpolymath/standards")); 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..dade03f 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", } } @@ -319,7 +331,9 @@ impl ZeroJobAttribution { pattern, }) } - StartupFailureClass::Unattributed => None, + StartupFailureClass::InvalidActionsLock + | StartupFailureClass::ActionsLockMismatch + | StartupFailureClass::Unattributed => None, } } @@ -336,7 +350,9 @@ impl ZeroJobAttribution { StartupFailureClass::AllowListAdmissionRequired => { Some(MissingCause::AllowListAdmissionRequired) } - StartupFailureClass::Unattributed => None, + StartupFailureClass::InvalidActionsLock + | StartupFailureClass::ActionsLockMismatch + | StartupFailureClass::Unattributed => None, } } @@ -577,11 +593,50 @@ 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, +} + +/// Only complete commit IDs may bind a local observation to a failed run. +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 +648,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 +666,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 +678,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 +1075,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 +1093,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/startup.rs b/crates/squabble-fight/src/startup.rs index 9311fcc..992028d 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; @@ -89,14 +91,81 @@ pub fn attribute_workflow( /// reason outside this taxonomy, and the report should say that once. 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 { + 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 => return vec![unattributed(repo, &run.workflow_file, + format!("native lock verification is unverified: {}; no repair inferred from that result", lock.reason))], + 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]; + } + } let Some(file) = files.iter().find(|f| f.path == run.workflow_file) else { return vec![unattributed( repo, @@ -152,13 +221,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 +247,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 +262,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 +285,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 +308,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 +332,192 @@ 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" + })); + } + } + fn wf(path: &str, text: &str) -> WorkflowFile { WorkflowFile { path: path.into(), @@ -263,6 +528,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 +568,8 @@ jobs: &files, &names, None, + Some(SHA), + None, ); assert_eq!(out.len(), 1, "{out:#?}"); let a = &out[0]; @@ -341,6 +610,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 +640,8 @@ jobs: &files, &BTreeMap::new(), Some(&allow), + Some(SHA), + None, ); assert_eq!(out.len(), 1); assert_eq!( @@ -396,6 +669,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 +680,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 +714,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 +726,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 +773,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 +784,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..bdc6eea 100644 --- a/crates/squabble-forge/src/rest.rs +++ b/crates/squabble-forge/src/rest.rs @@ -86,6 +86,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 +644,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"); From 79ad298326bca22dbd9905388f0906e5349e21e3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:43:26 +0000 Subject: [PATCH 2/7] docs(startup): clarify attribution, lock verification, and API validation contracts --- crates/squabble-cli/src/lock_verifier.rs | 24 +++++++++++++--- crates/squabble-cli/src/phase.rs | 4 +-- crates/squabble-cli/src/zero_job.rs | 35 +++++++++++++++--------- crates/squabble-core/src/startup.rs | 10 ++++--- crates/squabble-fight/src/startup.rs | 17 +++++++++--- crates/squabble-forge/src/rest.rs | 8 ++++-- 6 files changed, 68 insertions(+), 30 deletions(-) diff --git a/crates/squabble-cli/src/lock_verifier.rs b/crates/squabble-cli/src/lock_verifier.rs index 53e1274..e814f1b 100644 --- a/crates/squabble-cli/src/lock_verifier.rs +++ b/crates/squabble-cli/src/lock_verifier.rs @@ -11,14 +11,21 @@ use std::process::{Command, Output}; const SUPPORTED_VERSION: &str = "actions-lock version v0.2.0"; -/// Bind the workflow tree to a complete commit ID. Uncommitted or unreadable -/// workflow inputs cannot explain a historical run at that commit. +/// 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) } -/// The official reader also follows same-repository action definitions. A -/// pristine full checkout closes that input set without a second action parser. +/// 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") @@ -89,6 +96,8 @@ fn source_revision(root: &Path, full_tree: bool) -> Result { 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); @@ -107,6 +116,11 @@ fn command(binary: &Path, root: &Path) -> Command { /// 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, @@ -206,6 +220,8 @@ struct NativeFinding { /// 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, diff --git a/crates/squabble-cli/src/phase.rs b/crates/squabble-cli/src/phase.rs index c83b35d..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`. diff --git a/crates/squabble-cli/src/zero_job.rs b/crates/squabble-cli/src/zero_job.rs index cd3f0a7..e5fa102 100644 --- a/crates/squabble-cli/src/zero_job.rs +++ b/crates/squabble-cli/src/zero_job.rs @@ -67,8 +67,9 @@ struct SourceEvidence { } /// 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(), @@ -116,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, @@ -267,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], @@ -428,14 +430,21 @@ impl GhRuns { /// /// # 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> { fetch_startup_runs(&GhRest, slug, limit) } } -/// Transport seam used by the live reader and its recorded-response controls. +/// 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, diff --git a/crates/squabble-core/src/startup.rs b/crates/squabble-core/src/startup.rs index dade03f..bf31348 100644 --- a/crates/squabble-core/src/startup.rs +++ b/crates/squabble-core/src/startup.rs @@ -304,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 { @@ -343,7 +344,7 @@ 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), @@ -617,7 +618,8 @@ pub struct NativeLockVerification { pub reason: String, } -/// Only complete commit IDs may bind a local observation to a failed run. +/// 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)) diff --git a/crates/squabble-fight/src/startup.rs b/crates/squabble-fight/src/startup.rs index 992028d..4c2f347 100644 --- a/crates/squabble-fight/src/startup.rs +++ b/crates/squabble-fight/src/startup.rs @@ -79,16 +79,25 @@ 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 unverified or mismatched evidence leaves the run unattributed. +/// A verified lock, or a mismatch for another workflow, 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; only an explicitly observed zero job count permits attribution. diff --git a/crates/squabble-forge/src/rest.rs b/crates/squabble-forge/src/rest.rs index bdc6eea..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()); From e324a7596c2c00f9efe1f76472ecc14bcb47ceee Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:58:06 +0000 Subject: [PATCH 3/7] fix(chains): qualify moved dependencies through exact pinned routes Keep followed default-branch moves visible without blocking an unconfirmed consumer. Preserve independent startup coordinate evidence when an optional native-lock probe is unavailable. Pin repaired upstream governance and Creusot workflows while retaining the native lock recovery requirement. Preserve concurrent review documentation. --- .github/workflows/creusot.yml | 2 +- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/mirror.yml | 2 +- .github/workflows/rust-ci.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- .github/workflows/standards-pipeline.yml | 2 +- README.adoc | 14 +- crates/squabble-cli/src/chains.rs | 18 +- crates/squabble-cli/src/zero_job.rs | 38 +++-- crates/squabble-core/src/chains.rs | 201 ++++++++++++++++++++--- crates/squabble-fight/src/chains.rs | 3 + crates/squabble-fight/src/startup.rs | 92 +++++++++-- 14 files changed, 318 insertions(+), 64 deletions(-) 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 e11e75b..a9ecaae 100644 --- a/README.adoc +++ b/README.adoc @@ -50,7 +50,8 @@ squabble chains . ../standards gh:hyperpolymath/typed-wasm --follow 1 ---- * *Blocking* (exit 4): dependency cycles, dead upstreams, and moved upstream - coordinates. A deleted reusable workflow can fail before any job reports. + 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, and tag/branch pins. * It also prints an upstreams-first landing order and the longest chain. @@ -60,8 +61,12 @@ 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. This blocks even when the repository API follows a redirect: Actions -does not redirect the `uses:` reference. `squabble zero-job` separately reports +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 @@ -149,6 +154,9 @@ 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 diff --git a/crates/squabble-cli/src/chains.rs b/crates/squabble-cli/src/chains.rs index 2f842a7..76fcaf4 100644 --- a/crates/squabble-cli/src/chains.rs +++ b/crates/squabble-cli/src/chains.rs @@ -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, }) diff --git a/crates/squabble-cli/src/zero_job.rs b/crates/squabble-cli/src/zero_job.rs index e5fa102..5b2a742 100644 --- a/crates/squabble-cli/src/zero_job.rs +++ b/crates/squabble-cli/src/zero_job.rs @@ -361,22 +361,7 @@ fn narrate(r: &ZeroJobReport) -> String { r.runs.len() ); if let Some(lock) = &r.lock_verification { - o.push_str(&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() { - o.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()) { - o.push_str(&format!(" {detail}\n")); - } - } - } - } + o.push_str(&narrate_lock(lock)); } for run in &r.runs { let jobs = match run.jobs { @@ -418,6 +403,27 @@ 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 failed candidate to record the zero-job evidence. struct GhRuns; diff --git a/crates/squabble-core/src/chains.rs b/crates/squabble-core/src/chains.rs index 3bf0780..0de68d4 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 { .. })) } @@ -626,7 +672,7 @@ pub enum Finding { }, /// A referenced owner/repo resolves to a *different* canonical name. The /// API's redirect resolves the name, but Actions does not redirect the - /// `uses:` reference. This blocks; a failed reusable coordinate can prevent + /// `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 { @@ -634,8 +680,12 @@ pub enum Finding { 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, @@ -653,12 +703,17 @@ pub enum Finding { } impl Finding { - /// Cycles, dead upstreams and moved coordinates block; other findings warn. + /// Cycles, dead upstreams and confirmed moved coordinates block; other findings warn. pub fn severity(&self) -> Severity { match self { - Finding::Cycle { .. } - | Finding::DeadUpstream { .. } - | Finding::MovedUpstream { .. } => Severity::Blocking, + Finding::Cycle { .. } | Finding::DeadUpstream { .. } => Severity::Blocking, + Finding::MovedUpstream { consumers, .. } => { + if consumers.is_empty() { + Severity::Warning + } else { + Severity::Blocking + } + } Finding::Diamond { .. } | Finding::PinSkew { .. } | Finding::MutablePins { .. } => { Severity::Warning } @@ -680,14 +735,16 @@ 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()) + }; + format!("moved upstream `{written}` → canonical `{canonical}`: {confirmed}; {} reference(s) were observed only at an unconfirmed upstream revision and require inspection at the caller's exact pin", unconfirmed_consumers.len()) + } Finding::Diamond { consumer, target, @@ -814,22 +871,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, }); } @@ -1316,6 +1379,7 @@ mod tests { #[test] 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( @@ -1340,6 +1404,7 @@ mod tests { written, canonical, consumers, + .. } => { assert_eq!(written, &r("old/stds")); assert_eq!(canonical, &r("hyperpolymath/standards")); @@ -1377,6 +1442,100 @@ 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 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-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 4c2f347..21ead9a 100644 --- a/crates/squabble-fight/src/startup.rs +++ b/crates/squabble-fight/src/startup.rs @@ -87,9 +87,10 @@ pub fn attribute_workflow( /// `Unattributed` entry. /// Optional `lock` evidence must match that revision: malformed locks and /// workflow-specific coverage mismatches take precedence over coordinates, -/// while unverified or mismatched evidence leaves the run unattributed. -/// A verified lock, or a mismatch for another workflow, permits coordinate -/// attribution to continue. +/// 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 @@ -148,13 +149,15 @@ pub fn attribute_run( 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))); + .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 => return vec![unattributed(repo, &run.workflow_file, - format!("native lock verification is unverified: {}; no repair inferred from that result", lock.reason))], + NativeLockStatus::Unverified => None, NativeLockStatus::Verified => None, }; if let Some(class) = class { @@ -175,8 +178,21 @@ pub fn attribute_run( 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 vec![unattributed( + return retain_probe_failure(vec![unattributed( repo, &run.workflow_file, format!( @@ -185,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); @@ -197,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, @@ -207,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 @@ -527,6 +543,56 @@ mod tests { } } + #[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(), From eeec5edcd8f9c4f8fb39c3df1e5f0c201ff0aea8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:08:22 +0000 Subject: [PATCH 4/7] fix(ci): apply the official source-bound actions lock candidate Apply the unchanged candidate from cicd-suite run41902769910 for subject e324a7596c2c00f9efe1f76472ecc14bcb47ceee. Verify outer artifact and internal digests; retain canonical native regeneration and target hosted checks. --- .github/workflows/actions.lock | 58 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) 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' From 1fd9db9bb94d9ac8ed54f7a6ef4f8c51ff35a848 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:42:30 +0000 Subject: [PATCH 5/7] fix(proofs): configure Why3 in the standalone Creusot workspace Use the qualified Creusot/Z3 configuration at the verification crate's own workspace root so the caller's proof phase can run. Previous hosted controls rejected the false contract and proved the true contract, but caller execution stopped before proof because this file was absent. No proof-success claim is made by adding configuration. --- verification/squabble-creusot/why3find.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 verification/squabble-creusot/why3find.json 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"] +} From dcc794f2609354f68aff9cffa75c9ef9a22318f6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:01:31 +0000 Subject: [PATCH 6/7] Update crates/squabble-core/src/chains.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- crates/squabble-core/src/chains.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/squabble-core/src/chains.rs b/crates/squabble-core/src/chains.rs index 0de68d4..b5882bd 100644 --- a/crates/squabble-core/src/chains.rs +++ b/crates/squabble-core/src/chains.rs @@ -743,7 +743,15 @@ impl Finding { } 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()) }; - format!("moved upstream `{written}` → canonical `{canonical}`: {confirmed}; {} reference(s) were observed only at an unconfirmed upstream revision and require inspection at the caller's exact pin", unconfirmed_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, From 7744f6de48aaa096f7599588c3ee2daca4167299 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:02:02 +0000 Subject: [PATCH 7/7] Update crates/squabble-core/src/chains.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- crates/squabble-core/src/chains.rs | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/squabble-core/src/chains.rs b/crates/squabble-core/src/chains.rs index b5882bd..66ee856 100644 --- a/crates/squabble-core/src/chains.rs +++ b/crates/squabble-core/src/chains.rs @@ -1529,6 +1529,40 @@ mod tests { 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");