diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb14b9a..c20ed3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - name: install the repository Rust toolchain run: rustup toolchain install "$RUST_VERSION" --profile minimal --no-self-update - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 @@ -63,7 +64,10 @@ jobs: | tar xz -C /tmp ./typos echo /tmp >> "$GITHUB_PATH" - name: run the ordinary quality gate - run: npx --yes --package="@biomejs/biome@2.6.2" -- just check + env: + CI_BASE_REVISION: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + CI_HEAD_REVISION: ${{ github.sha }} + run: npx --yes --package="@biomejs/biome@2.6.2" -- just check-changes conventional-commits: name: conventional commits diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4fcccfd..a318711 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,4 @@ -# CodeQL security analysis (Advanced Setup). Scans the Rust codec source and the -# repository's own GitHub Actions workflows, uploading results to Security > Code -# scanning. Advanced (not Default) setup so the language set, query suite, and -# SHA-pinned action versions live in-repo under the same review as the code — -# matching this repo's "pin every action" convention. -# -# Languages: -# - rust the codec itself (build-mode: none — the only mode the Rust analysis -# supports; it resolves the cargo workspace without a traced build). -# - actions the workflows under .github/workflows (catches injection / unsafe -# checkout / token-scope issues in CI YAML). +# Analyze affected Rust and GitHub Actions source. name: CodeQL on: @@ -16,33 +6,57 @@ on: branches: [main] pull_request: branches: [main] - schedule: - # Weekly re-scan (Mon 03:27 UTC) so newly-published CodeQL queries run against - # unchanged code, not only on new commits. - - cron: "27 3 * * 1" permissions: - contents: read + contents: read # check out the source + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_VERSION: 1.99.1 jobs: + plan: + name: select analysis + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.selection.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - name: install the repository Rust toolchain + run: rustup toolchain install "$RUST_VERSION" --profile minimal --no-self-update + - name: select affected analysis languages + id: selection + shell: bash + env: + CI_BASE_REVISION: ${{ github.event.pull_request.base.sha }} + CI_HEAD_REVISION: ${{ github.sha }} + run: | + matrix=$(cargo run --locked --quiet -p webpkit-ci-select --bin ci-select -- codeql-matrix) + printf 'matrix=%s\n' "$matrix" >> "$GITHUB_OUTPUT" + analyze: name: analyze (${{ matrix.language }}) + needs: [plan] runs-on: ubuntu-latest timeout-minutes: 30 permissions: security-events: write # upload the SARIF results to code scanning - actions: read - contents: read + actions: read # inspect workflow metadata + contents: read # check out the source strategy: fail-fast: false - matrix: - include: - - language: rust - build-mode: none - - language: actions - build-mode: none + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: initialize CodeQL uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 795433b..559038e 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -490,7 +490,7 @@ jobs: - name: prove the production policy module env: POLICY_TOOL: ${{ steps.policy.outputs.executable }} - REQUIRED_HARNESSES: '["production::proofs::commit_identity_is_exact","production::proofs::candidate_selection_preserves_source","production::proofs::ordinary_summary_requires_complete_work","production::proofs::publication_requires_complete_same_source_qualification"]' + REQUIRED_HARNESSES: '["production::proofs::commit_identity_is_exact","production::proofs::candidate_selection_preserves_source","production::proofs::ordinary_summary_requires_complete_work","production::proofs::publication_requires_complete_same_source_qualification","production::proofs::affected_work_routes_recognized_workflow_boundaries","production::proofs::affected_work_routes_recognized_rust_and_action_inputs","production::proofs::affected_work_unknown_and_empty_inputs_keep_full_selection"]' COUNTEREXAMPLE: production::proofs::reject_incomplete_qualification_probe run: '"$POLICY_TOOL" prove-source --source xtask/src/ci.rs --require-json "$REQUIRED_HARNESSES" --counterexample "$COUNTEREXAMPLE"' - name: verify the candidate source after proofs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98370ed..1fe8695 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,13 +11,21 @@ Use Git Bash for Just recipes on Windows. ## Development -Run `mise x -- just check` before pushing. +Run `mise x -- just check-changes` before pushing. The pre-push hook runs the same command. -The gate checks formatting, Clippy, dependencies, spelling, workflows, workspace tests and doctests, documentation, the image feature, and the host `no_std` build. +The selector compares a clean checkout with `origin/main`, or the event's base commit in CI. +A nonempty change confined to workflow YAML runs Actionlint, YAML formatting, spelling, and workflow contract tests. +Changes to `.github/workflows/ci.yml` and uncertain comparisons run the complete gate. +Use `just check` to run the complete gate directly. +The complete gate checks formatting, Clippy, dependencies, spelling, workflows, workspace tests and doctests, documentation, the image feature, and the host `no_std` build. Workspace checks explicitly enable `webpkit/image` and `webpkit/rayon`. Workspace tests include conformance ledgers, stable fuzz seeds, image conversions, and the Rayon paths. The revision corpus replay shares acceptance and round-trip assertions with its release-only fuzz target. Pull requests and merge groups run one Linux quality job instead of repeating those suites in separate jobs. +CodeQL selects Rust or Actions analysis for recognized changes and analyzes both for unknown or mixed changes. +A change to the CodeQL workflow analyzes both languages. +Main-branch analysis runs both languages to maintain the pull-request comparison baseline without a scheduled scan. +Read [affected-work selection](docs/adr/affected-work-selection.md) for the comparison boundary and fallback behavior. Use `just lint`, `just test`, or `just doc` for a narrower development check. The supported Rust floor is 1.88. diff --git a/Cargo.lock b/Cargo.lock index ace3ecb..095bb19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1622,6 +1622,14 @@ dependencies = [ "webpkit-ci-core", ] +[[package]] +name = "webpkit-ci-select" +version = "0.1.0" +dependencies = [ + "tempfile", + "webpkit-ci-core", +] + [[package]] name = "webpkit-cli" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4b762b0..667fc3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/webpkit-samples", "xtask", "xtask/ci-core", + "xtask/ci-select", "xtask/fuzz", ] default-members = ["crates/webpkit"] diff --git a/Justfile b/Justfile index cce85db..8887031 100644 --- a/Justfile +++ b/Justfile @@ -113,7 +113,7 @@ typos-fix: typos --write-changes actionlint: - actionlint .github/workflows/*.yml + actionlint # Build rustdoc under `RUSTDOCFLAGS=-D warnings` (mirrors CI's doc gate). doc: @@ -127,13 +127,24 @@ lint: just typos just actionlint -# The ordinary PR and merge-group gate, kept to checks that are useful on every change. +# The complete source gate, also required for release qualification. check: just lint just test just doc just no-std-host +# Workflow syntax and production workflow contracts. +check-workflows: + just actionlint + yamlfmt --lint .github/workflows + just typos + cargo test --locked -p xtask --test ci_workflow + +# Select work from a clean checkout and a complete Git comparison. +check-changes: + cargo run --locked --quiet -p webpkit-ci-select --bin ci-select -- check + # ----- conformance / verification (webp) ----- # Regenerate golden fixtures from libwebp cwebp/dwebp (needs cwebp/dwebp on PATH). diff --git a/docs/adr/affected-work-selection.md b/docs/adr/affected-work-selection.md new file mode 100644 index 0000000..cd319a1 --- /dev/null +++ b/docs/adr/affected-work-selection.md @@ -0,0 +1,62 @@ +--- +date: 2026-10-28 +--- + +# Affected-work selection + +## Status + +Proposed. + +## Context + +The ordinary quality gate runs codec tests, documentation, and compilation even when a change only edits workflow YAML. +CodeQL also runs both Rust and Actions analysis regardless of the affected source. +A failed or incomplete path comparison must preserve full verification. +Local hooks and hosted jobs need the same selection rules. + +## Decision + +Keep `just check` as the complete source gate and release qualification unchanged. +Use `just check-changes` for the local pre-push hook and ordinary hosted quality job. +The `ci-select` binary in the `webpkit-ci-select` package validates the Git comparison and calls the dependency-free production decision in `xtask/src/ci.rs`. +The `webpkit-ci-core` package stays independent of the standard library and external dependencies. +The quality gate and CodeQL language set are separate typed decisions. + +Select `just check-workflows` only for a complete, nonempty set of recognized workflow YAML paths. +The workflow gate runs Actionlint, workflow YAML formatting, spelling, and the source-bound workflow contract tests. +The ordinary CI workflow always selects the complete gate because its configuration controls compilation and tests. +Other Rust, action-definition, unknown, or mixed changes select the complete gate. + +Select Rust analysis for recognized Rust workspace inputs and Actions analysis for workflow YAML or nested action metadata. +Changes to the ordinary CI workflow, CodeQL workflow, unknown inputs, or mixed language inputs analyze both languages. +Every successful analysis plan contains at least one language. +Run both languages on main to publish the pull-request comparison baseline. +A successful later main run covers both categories when it cancels an earlier run. +Remove the weekly scan and cancel superseded runs of the same ref. +Renovate updates to the pinned CodeQL action change the scanner workflow and run both analyses. + +Bind an explicit expected head to the checkout's complete commit identity before selection. +A contradictory or malformed expected identity is an error. +Default local comparisons use `origin/main`. +An unavailable or empty base, failed Git comparison, dirty checkout, empty difference, malformed path, or undecodable path selects the complete gate and both analyses. +Use `--no-renames` so a move contributes both the old and new paths. +Adding an unknown path can never reduce verification. +Dispatch and selection failures remain failures rather than successful skips. + +Source-bound Kani harnesses cover the production classification and conservative fallback. +Symbolic names use eight safe ASCII bytes, with up to four preceding paths selected from the recognized categories. +Malformed-path cases cover seven explicit shapes. +Real Git fixtures cover comparison and dispatch behavior outside the verifier's semantics. +Workflow contract tests bind local and hosted commands to the selector and preserve strict ordinary summaries and release qualification. +The proof inventory runs at the release gate. +An unrun proof supplies no verified release result. + +## Consequences + +Workflow maintenance avoids unrelated codec tests and Rust analysis when the comparison proves their absence. +Uncertain changes run the complete gate. +The selector trusts successful Git command results, the checkout filesystem, and the configured Rust and Just executables. +Local checks cover the selector and adapters. +GitHub event delivery, action behavior, and remote publication require separate evidence. +New source categories require explicit classification and corresponding boundary tests before they can reduce work. diff --git a/lefthook.yml b/lefthook.yml index c96ebfe..72f4221 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -66,4 +66,4 @@ pre-push: commands: check: tags: check - run: just check + run: just check-changes diff --git a/xtask/ci-core/src/lib.rs b/xtask/ci-core/src/lib.rs index c90713e..1f7d88a 100644 --- a/xtask/ci-core/src/lib.rs +++ b/xtask/ci-core/src/lib.rs @@ -6,8 +6,9 @@ mod decisions; pub use decisions::{ - Association, Candidate, Event, JobReport, Outcome, Qualified, Refusal, Repository, Revision, - Role, SourceFacts, candidate, final_summary, ordinary_summary, qualify, source, + AffectedWork, Association, Candidate, CodeqlScope, Event, JobReport, Outcome, Qualified, + QualityWork, Refusal, Repository, Revision, Role, SourceFacts, affected_work, candidate, + final_summary, ordinary_summary, qualify, source, }; #[cfg(test)] diff --git a/xtask/ci-select/Cargo.toml b/xtask/ci-select/Cargo.toml new file mode 100644 index 0000000..958c5d3 --- /dev/null +++ b/xtask/ci-select/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "webpkit-ci-select" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Checked Git and command adapter for webpkit verification selection." +publish = false + +[[bin]] +name = "ci-select" +path = "src/main.rs" + +[dependencies] +webpkit-ci-core = { path = "../ci-core" } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/xtask/ci-select/src/main.rs b/xtask/ci-select/src/main.rs new file mode 100644 index 0000000..853de53 --- /dev/null +++ b/xtask/ci-select/src/main.rs @@ -0,0 +1,287 @@ +//! Select and run conservative ordinary quality work for this checkout. + +use std::env; +use std::io::{self, Write}; +use std::path::Path; +use std::process::{Command, Output}; +use webpkit_ci_core::{AffectedWork, CodeqlScope, QualityWork, Revision, affected_work}; + +const DEFAULT_BASE: &str = "origin/main"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Operation { + Check, + CodeqlMatrix, +} + +struct Arguments { + operation: Operation, + base: Option, + head: Option, +} + +struct Selection { + work: AffectedWork, + reason: &'static str, +} + +fn main() -> Result<(), String> { + run() +} + +fn run() -> Result<(), String> { + let arguments = arguments()?; + let root = env::current_dir().map_err(|_| "current directory is unavailable".to_owned())?; + let selection = select(&root, &arguments)?; + report_selection(&selection)?; + match arguments.operation { + Operation::Check => run_quality_gate(&root, selection.work.quality), + Operation::CodeqlMatrix => write_matrix(selection.work.codeql), + } +} + +fn arguments() -> Result { + let mut values = env::args_os().skip(1); + let operation = match values + .next() + .and_then(|value| value.into_string().ok()) + .as_deref() + { + Some("check") => Operation::Check, + Some("codeql-matrix") => Operation::CodeqlMatrix, + _ => return Err("expected `check` or `codeql-matrix`".to_owned()), + }; + let mut base = None; + let mut head = None; + while let Some(option) = values.next() { + let option = option + .into_string() + .map_err(|_| "selector option is not valid Unicode".to_owned())?; + let value = values + .next() + .ok_or_else(|| format!("{option} requires a value"))? + .into_string() + .map_err(|_| format!("{option} value is not valid Unicode"))?; + match option.as_str() { + "--base" if base.is_none() => base = Some(value), + "--head" if head.is_none() => head = Some(value), + "--base" | "--head" => return Err(format!("{option} was supplied more than once")), + _ => return Err(format!("unsupported option `{option}`")), + } + } + Ok(Arguments { + operation, + base: configured(base, "CI_BASE_REVISION")?, + head: configured(head, "CI_HEAD_REVISION")?, + }) +} + +fn configured(explicit: Option, name: &str) -> Result, String> { + if explicit.is_some() { + return Ok(explicit); + } + env::var(name).map(Some).or_else(|error| match error { + env::VarError::NotPresent => Ok(None), + env::VarError::NotUnicode(_) => Err(format!("{name} is not valid Unicode")), + }) +} + +fn select(root: &Path, arguments: &Arguments) -> Result { + let expected_head = arguments + .head + .as_deref() + .map(|head| { + Revision::parse(head.as_bytes()).map_err(|_| { + "configured head must be a full 40-character hexadecimal SHA".to_owned() + }) + }) + .transpose()?; + let Ok(actual_head) = resolve_commit(root, "HEAD") else { + return Ok(full("checkout identity unavailable")); + }; + if expected_head.is_some_and(|expected| expected != actual_head) { + return Err("configured head does not match the checked-out commit".to_owned()); + } + + let base = arguments.base.as_deref().unwrap_or(DEFAULT_BASE); + if base.is_empty() { + return Ok(full("base revision is empty")); + } + let Ok(base) = resolve_commit(root, base) else { + return Ok(full("base revision could not be resolved")); + }; + + match git( + root, + [ + "--no-optional-locks", + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ], + ) { + Ok(status) if status.status.success() && status.stdout.is_empty() => {}, + Ok(status) if status.status.success() => return Ok(full("checkout has uncommitted paths")), + _ => return Ok(full("checkout status could not be inspected")), + } + + let base_text = String::from_utf8_lossy(base.bytes()).into_owned(); + let head_text = String::from_utf8_lossy(actual_head.bytes()).into_owned(); + let diff = match git( + root, + [ + "--no-optional-locks", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--name-only", + "-z", + base_text.as_str(), + head_text.as_str(), + "--", + ], + ) { + Ok(diff) if diff.status.success() => diff, + _ => return Ok(full("path comparison failed")), + }; + let Some(paths) = changed_paths(&diff.stdout) else { + return Ok(full("path comparison was empty or malformed")); + }; + let references = paths.iter().map(String::as_str).collect::>(); + Ok(Selection { + work: affected_work(&references), + reason: "complete path comparison", + }) +} + +fn resolve_commit(root: &Path, reference: &str) -> Result { + let expression = format!("{reference}^{{commit}}"); + let output = git( + root, + [ + "--no-optional-locks", + "rev-parse", + "--verify", + "--end-of-options", + expression.as_str(), + ], + ) + .map_err(|_| ())?; + if !output.status.success() { + return Err(()); + } + let text = std::str::from_utf8(&output.stdout).map_err(|_| ())?.trim(); + Revision::parse(text.as_bytes()).map_err(|_| ()) +} + +fn git(root: &Path, arguments: [&str; N]) -> io::Result { + Command::new("git") + .current_dir(root) + .args(arguments) + .output() +} + +fn changed_paths(output: &[u8]) -> Option> { + if output.is_empty() || output.last() != Some(&0) { + return None; + } + let mut paths = Vec::new(); + for path in output[..output.len() - 1].split(|byte| *byte == 0) { + if path.is_empty() { + return None; + } + paths.push(std::str::from_utf8(path).ok()?.to_owned()); + } + Some(paths) +} + +const fn full(reason: &'static str) -> Selection { + Selection { + work: AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, + }, + reason, + } +} + +fn report_selection(selection: &Selection) -> Result<(), String> { + let quality = match selection.work.quality { + QualityWork::Workflows => "workflow-only", + QualityWork::Full => "full", + }; + let codeql = match selection.work.codeql { + CodeqlScope::Rust => "rust", + CodeqlScope::Actions => "actions", + CodeqlScope::Both => "both", + }; + writeln!( + io::stderr().lock(), + "ci-select: quality={quality}; codeql={codeql}; reason={}", + selection.reason + ) + .map_err(|_| "could not report the selected work".to_owned()) +} + +fn run_quality_gate(root: &Path, quality: QualityWork) -> Result<(), String> { + let recipe = match quality { + QualityWork::Workflows => "check-workflows", + QualityWork::Full => "check", + }; + let status = Command::new("just") + .current_dir(root) + .arg(recipe) + .status() + .map_err(|_| format!("could not start `just {recipe}`"))?; + if status.success() { + Ok(()) + } else { + Err(format!("`just {recipe}` failed with {status}")) + } +} + +fn write_matrix(scope: CodeqlScope) -> Result<(), String> { + let json = match scope { + CodeqlScope::Rust => "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"}]}\n", + CodeqlScope::Actions => { + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}\n" + }, + CodeqlScope::Both => { + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}\n" + }, + }; + io::stdout() + .lock() + .write_all(json.as_bytes()) + .map_err(|_| "could not write the CodeQL matrix".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::changed_paths; + + #[test] + fn path_transport_rejects_empty_incomplete_and_undecodable_output() { + for output in [ + &b""[..], + &b".github/workflows/release.yml"[..], + &b".github/workflows/release.yml\0\0"[..], + &b"\xff\0"[..], + ] { + assert!(changed_paths(output).is_none()); + } + } + + #[test] + fn path_transport_preserves_every_nul_separated_path() { + assert_eq!( + changed_paths(b".github/workflows/release.yml\0notes/file with spaces.md\0"), + Some(vec![ + ".github/workflows/release.yml".to_owned(), + "notes/file with spaces.md".to_owned(), + ]) + ); + } +} diff --git a/xtask/ci-select/tests/affected_work.rs b/xtask/ci-select/tests/affected_work.rs new file mode 100644 index 0000000..eb3d40b --- /dev/null +++ b/xtask/ci-select/tests/affected_work.rs @@ -0,0 +1,634 @@ +//! Checked Git and path-selection boundaries for ordinary verification. + +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; +use webpkit_ci_core::{AffectedWork, CodeqlScope, QualityWork, affected_work}; + +const WORKFLOW_ONLY: AffectedWork = AffectedWork { + quality: QualityWork::Workflows, + codeql: CodeqlScope::Actions, +}; +const FULL_RUST: AffectedWork = AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Rust, +}; +const FULL_ACTIONS: AffectedWork = AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Actions, +}; +const WORKFLOW_BOTH: AffectedWork = AffectedWork { + quality: QualityWork::Workflows, + codeql: CodeqlScope::Both, +}; +const FULL_BOTH: AffectedWork = AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, +}; + +#[test] +fn release_workflow_uses_the_small_gate_and_actions_codeql() { + assert_eq!( + affected_work(&[".github/workflows/release-plz.yml"]), + WORKFLOW_ONLY + ); +} + +#[test] +fn ordinary_quality_workflow_requires_full_quality_and_both_codeql_languages() { + assert_eq!(affected_work(&[".github/workflows/ci.yml"]), FULL_BOTH); +} + +#[test] +fn codeql_workflow_uses_workflow_checks_and_both_codeql_languages() { + assert_eq!( + affected_work(&[".github/workflows/codeql.yml"]), + WORKFLOW_BOTH + ); +} + +#[test] +fn rust_source_uses_full_quality_and_rust_codeql() { + assert_eq!(affected_work(&["crates/webpkit/src/lib.rs"]), FULL_RUST); +} + +#[test] +fn action_definition_uses_full_quality_and_actions_codeql() { + assert_eq!( + affected_work(&[".github/actions/setup/action.yml"]), + FULL_ACTIONS + ); +} + +#[test] +fn unknown_action_metadata_and_nested_files_fail_safe_to_both_languages() { + for path in [ + ".github/actions/setup/script.js", + ".github/actions/action.yml", + ".github/actions/setup/nested/metadata.yml", + "crates/webpkit/src/upper.RS", + ] { + assert_eq!(affected_work(&[path]), FULL_BOTH, "path: {path}"); + } +} + +#[test] +fn mixed_workflows_and_rust_use_full_quality_and_both_codeql_languages() { + assert_eq!( + affected_work(&[ + ".github/workflows/release-plz.yml", + "crates/webpkit/src/lib.rs" + ]), + FULL_BOTH + ); +} + +#[test] +fn empty_unknown_and_malformed_path_sets_fail_safe_to_full() { + for paths in [ + &[][..], + &["README.md"][..], + &["../.github/workflows/release-plz.yml"][..], + &[".github\\workflows\\release-plz.yml"][..], + &["/absolute/path.yml"][..], + &[".github/workflows/.yml"][..], + &[""][..], + ] { + assert_eq!(affected_work(paths), FULL_BOTH); + } +} + +#[test] +fn adding_any_unclassified_path_disables_the_workflow_only_gate() { + assert_eq!( + affected_work(&[".github/workflows/release-plz.yml", "docs/ci.md"]), + FULL_BOTH + ); +} + +struct RepositoryFixture { + directory: TempDir, + base: String, +} + +impl RepositoryFixture { + fn new() -> io::Result { + let directory = TempDir::new()?; + let root = directory.path(); + git(root, ["init", "--quiet"])?; + git(root, ["config", "user.name", "Affected-work fixture"])?; + git( + root, + ["config", "user.email", "affected-work@example.invalid"], + )?; + fs::create_dir_all(root.join(".fixture-hooks"))?; + git(root, ["config", "core.hooksPath", ".fixture-hooks"])?; + git(root, ["config", "commit.gpgsign", "false"])?; + git(root, ["config", "core.fsmonitor", "false"])?; + write( + root, + "Justfile", + "check:\n @echo full-gate\n\ncheck-workflows:\n @echo workflow-gate\n", + )?; + write(root, ".github/workflows/release-plz.yml", "name: fixture\n")?; + write(root, ".github/workflows/ci.yml", "name: ordinary\n")?; + write(root, ".github/actions/setup/action.yml", "name: setup\n")?; + write(root, "notes/rename-input.txt", "rename fixture\n")?; + git(root, ["add", "--all"])?; + git( + root, + [ + "commit", + "--quiet", + "-m", + "test: initialize selector fixture", + ], + )?; + let base = output(&git(root, ["rev-parse", "HEAD"])?)?; + git( + root, + ["update-ref", "refs/remotes/origin/main", base.as_str()], + )?; + Ok(Self { directory, base }) + } + + fn root(&self) -> &Path { + self.directory.path() + } + + fn change_and_commit(&self, path: &str, contents: &str) -> io::Result { + write(self.root(), path, contents)?; + git(self.root(), ["add", "--all"])?; + git( + self.root(), + ["commit", "--quiet", "-m", "test: change selector fixture"], + )?; + output(&git(self.root(), ["rev-parse", "HEAD"])?) + } +} + +fn write(root: &Path, path: &str, contents: &str) -> io::Result<()> { + let destination = root.join(path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::write(destination, contents) +} + +fn git(root: &Path, arguments: [&str; N]) -> io::Result { + let mut command = Command::new("git"); + isolate_git_environment(&mut command); + let output = command.current_dir(root).args(arguments).output()?; + if !output.status.success() { + return Err(io::Error::other(format!( + "Git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(output) +} + +fn isolate_git_environment(command: &mut Command) { + let inherited = std::env::vars_os().map(|(name, _)| name); + let configured = command.get_envs().map(|(name, _)| name.to_os_string()); + let names = inherited + .chain(configured) + .filter(is_git_environment_name) + .collect::>(); + for name in names { + command.env_remove(name); + } +} + +fn is_git_environment_name(name: &OsString) -> bool { + name.to_string_lossy() + .to_ascii_uppercase() + .starts_with("GIT_") +} + +fn output(result: &Output) -> io::Result { + String::from_utf8(result.stdout.clone()) + .map(|stdout| stdout.trim().to_owned()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn cli(root: &Path, arguments: &[&str]) -> io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_ci-select")); + isolate_git_environment(&mut command); + command + .current_dir(root) + .args(arguments) + .env_remove("CI_BASE_REVISION") + .env_remove("CI_HEAD_REVISION") + .output() +} + +fn cli_with_environment( + root: &Path, + arguments: &[&str], + base: &str, + head: &str, +) -> io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_ci-select")); + isolate_git_environment(&mut command); + command + .current_dir(root) + .args(arguments) + .env("CI_BASE_REVISION", base) + .env("CI_HEAD_REVISION", head) + .output() +} + +fn cli_with_injected_git_config(root: &Path, arguments: &[&str]) -> io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_ci-select")); + command + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "core.worktree") + .env("GIT_CONFIG_VALUE_0", "nonexistent-fixture-worktree"); + isolate_git_environment(&mut command); + command + .current_dir(root) + .args(arguments) + .env_remove("CI_BASE_REVISION") + .env_remove("CI_HEAD_REVISION") + .output() +} + +#[test] +fn codeql_matrix_is_only_json_and_uses_the_complete_workflow_diff() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit( + ".github/workflows/release-plz.yml", + "name: changed fixture\n", + )?; + let result = cli( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + )?; + assert!( + result.status.success(), + "selector failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + output(&result)?, + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn codeql_matrix_uses_configured_base_and_head_when_flags_are_absent() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit( + ".github/workflows/release-plz.yml", + "name: changed fixture\n", + )?; + let result = cli_with_environment(fixture.root(), &["codeql-matrix"], &fixture.base, &head)?; + assert!(result.status.success()); + assert_eq!( + output(&result)?, + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn selector_fixture_removes_all_injected_git_configuration_before_running() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit( + ".github/workflows/release-plz.yml", + "name: changed fixture\n", + )?; + let result = cli_with_injected_git_config( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + )?; + assert!(result.status.success()); + assert_eq!( + output(&result)?, + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn explicit_flags_override_environment_and_invalid_recipe_status_fails() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit( + ".github/workflows/release-plz.yml", + "name: changed fixture\n", + )?; + let selected = cli_with_environment( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + "missing-base", + &"b".repeat(40), + )?; + assert!(selected.status.success()); + assert_eq!( + output(&selected)?, + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + + write( + fixture.root(), + "Justfile", + "check:\n @exit 7\n\ncheck-workflows:\n @echo workflow-gate\n", + )?; + write(fixture.root(), "xtask/src/failure.rs", "// fixture\n")?; + git(fixture.root(), ["add", "--all"])?; + git( + fixture.root(), + [ + "commit", + "--quiet", + "-m", + "test: fail the selected quality recipe", + ], + )?; + let failing_head = output(&git(fixture.root(), ["rev-parse", "HEAD"])?)?; + let failed = cli( + fixture.root(), + &["check", "--base", &fixture.base, "--head", &failing_head], + )?; + assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stderr).contains("`just check` failed")); + Ok(()) +} + +#[test] +fn check_dispatches_workflow_only_changes_to_the_workflow_gate() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit( + ".github/workflows/release-plz.yml", + "name: changed fixture\n", + )?; + let result = cli( + fixture.root(), + &["check", "--base", &fixture.base, "--head", &head], + )?; + assert!( + result.status.success(), + "selector failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(output(&result)?, "workflow-gate"); + Ok(()) +} + +#[test] +fn ci_workflow_change_dispatches_full_quality_and_both_codeql_languages() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = fixture.change_and_commit(".github/workflows/ci.yml", "name: changed ordinary\n")?; + let result = cli( + fixture.root(), + &["check", "--base", &fixture.base, "--head", &head], + )?; + assert!( + result.status.success(), + "selector failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(output(&result)?, "full-gate"); + assert!(String::from_utf8_lossy(&result.stderr).contains("quality=full; codeql=both")); + Ok(()) +} + +#[test] +fn dirty_checkout_and_unresolvable_base_use_full_work_and_both_languages() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + write(fixture.root(), "untracked file.md", "uncommitted\n")?; + let dirty = cli(fixture.root(), &["codeql-matrix", "--base", &fixture.base])?; + assert!(dirty.status.success()); + assert_eq!( + output(&dirty)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + + fs::remove_file(fixture.root().join("untracked file.md"))?; + let unresolved = cli(fixture.root(), &["codeql-matrix", "--base", "missing-base"])?; + assert!(unresolved.status.success()); + assert_eq!( + output(&unresolved)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn explicitly_empty_base_uses_full_work_and_both_languages() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let result = cli(fixture.root(), &["codeql-matrix", "--base", ""])?; + assert!(result.status.success()); + assert_eq!( + output(&result)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn changed_rust_input_selects_rust_and_empty_diff_selects_both() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let head = + fixture.change_and_commit("crates/webpkit/src/selector.rs", "pub fn fixture() {}\n")?; + let rust = cli( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + )?; + assert!(rust.status.success()); + assert_eq!( + output(&rust)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"}]}" + ); + + let empty = cli( + fixture.root(), + &["codeql-matrix", "--base", &head, "--head", &head], + )?; + assert!(empty.status.success()); + assert_eq!( + output(&empty)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + Ok(()) +} + +#[test] +fn configured_head_must_match_the_fixture_checkout() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let result = cli( + fixture.root(), + &[ + "codeql-matrix", + "--base", + &fixture.base, + "--head", + &"b".repeat(40), + ], + )?; + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("does not match")); + Ok(()) +} + +#[test] +fn configured_head_must_be_a_full_sha() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + for head in ["", "abcd", "g000000000000000000000000000000000000000"] { + let result = cli( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", head], + )?; + assert!(!result.status.success(), "head: {head:?}"); + assert!(String::from_utf8_lossy(&result.stderr).contains("full 40-character")); + } + Ok(()) +} + +#[test] +fn renamed_paths_are_compared_without_rename_detection() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + let source = fixture.root().join("notes/rename-input.txt"); + let destination = fixture.root().join(".github/workflows/deploy.yml"); + let destination_parent = destination.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "workflow destination has no parent", + ) + })?; + fs::create_dir_all(destination_parent)?; + fs::rename(source, destination)?; + git(fixture.root(), ["add", "--all"])?; + git( + fixture.root(), + ["commit", "--quiet", "-m", "test: rename selector fixture"], + )?; + let head = output(&git(fixture.root(), ["rev-parse", "HEAD"])?)?; + let result = cli( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + )?; + assert!(result.status.success()); + assert_eq!( + output(&result)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + let quality = cli( + fixture.root(), + &["check", "--base", &fixture.base, "--head", &head], + )?; + assert!(quality.status.success()); + assert_eq!(output(&quality)?, "full-gate"); + Ok(()) +} + +#[test] +fn deleting_a_workflow_keeps_workflow_only_quality_and_actions_codeql() -> io::Result<()> { + let fixture = RepositoryFixture::new()?; + fs::remove_file(fixture.root().join(".github/workflows/release-plz.yml"))?; + git(fixture.root(), ["add", "--all"])?; + git( + fixture.root(), + [ + "commit", + "--quiet", + "-m", + "test: delete selector workflow fixture", + ], + )?; + let head = output(&git(fixture.root(), ["rev-parse", "HEAD"])?)?; + let matrix = cli( + fixture.root(), + &["codeql-matrix", "--base", &fixture.base, "--head", &head], + )?; + assert!(matrix.status.success()); + assert_eq!( + output(&matrix)?, + "{\"include\":[{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + let quality = cli( + fixture.root(), + &["check", "--base", &fixture.base, "--head", &head], + )?; + assert!(quality.status.success()); + assert_eq!(output(&quality)?, "workflow-gate"); + Ok(()) +} + +#[test] +fn codeql_workflow_and_mixed_source_changes_use_real_git_diffs() -> io::Result<()> { + let workflow = RepositoryFixture::new()?; + let workflow_head = + workflow.change_and_commit(".github/workflows/codeql.yml", "name: CodeQL\n")?; + let workflow_matrix = cli( + workflow.root(), + &[ + "codeql-matrix", + "--base", + &workflow.base, + "--head", + &workflow_head, + ], + )?; + assert!(workflow_matrix.status.success()); + assert_eq!( + output(&workflow_matrix)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + let workflow_quality = cli( + workflow.root(), + &["check", "--base", &workflow.base, "--head", &workflow_head], + )?; + assert!(workflow_quality.status.success()); + assert_eq!(output(&workflow_quality)?, "workflow-gate"); + + let mixed = RepositoryFixture::new()?; + write( + mixed.root(), + ".github/workflows/release-plz.yml", + "name: mixed\n", + )?; + write( + mixed.root(), + "crates/webpkit/src/mixed.rs", + "pub fn mixed() {}\n", + )?; + git(mixed.root(), ["add", "--all"])?; + git( + mixed.root(), + [ + "commit", + "--quiet", + "-m", + "test: mix workflow and Rust inputs", + ], + )?; + let mixed_head = output(&git(mixed.root(), ["rev-parse", "HEAD"])?)?; + let mixed_matrix = cli( + mixed.root(), + &[ + "codeql-matrix", + "--base", + &mixed.base, + "--head", + &mixed_head, + ], + )?; + assert!(mixed_matrix.status.success()); + assert_eq!( + output(&mixed_matrix)?, + "{\"include\":[{\"language\":\"rust\",\"build-mode\":\"none\"},{\"language\":\"actions\",\"build-mode\":\"none\"}]}" + ); + let mixed_quality = cli( + mixed.root(), + &["check", "--base", &mixed.base, "--head", &mixed_head], + )?; + assert!(mixed_quality.status.success()); + assert_eq!(output(&mixed_quality)?, "full-gate"); + Ok(()) +} diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 19760b3..7343985 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -19,6 +19,146 @@ pub enum Refusal { Qualification, } +/// The ordinary quality work required by the changed source paths. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QualityWork { + /// Run only the source-bound workflow checks. + Workflows, + /// Run the complete ordinary quality gate. + Full, +} + +/// The `CodeQL` languages selected by the changed source paths. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CodeqlScope { + /// Analyze Rust only. + Rust, + /// Analyze GitHub Actions only. + Actions, + /// Analyze both languages. + Both, +} + +/// Independent ordinary-quality and `CodeQL` work selected for a complete path set. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AffectedWork { + /// The ordinary quality gate to run. + pub quality: QualityWork, + /// The `CodeQL` languages to analyze. + pub codeql: CodeqlScope, +} + +/// Select affected work from a complete set of repository-relative Git paths. +/// +/// Workflow YAML other than the ordinary CI definition selects workflow-only quality. +/// The `CodeQL` workflow selects both analysis languages. +/// Unknown, empty, invalid, and mixed path sets select full quality and both analyses. +#[must_use] +pub fn affected_work(paths: &[&str]) -> AffectedWork { + if paths.is_empty() { + return full_both(); + } + + let mut workflows_only = true; + let mut rust = false; + let mut actions = false; + for path in paths { + if !valid_repository_path(path) { + return full_both(); + } + if let Some(name) = workflow_yaml(path) { + actions = true; + if name == "ci.yml" || name == "ci.yaml" { + workflows_only = false; + rust = true; + } + if name == "codeql.yml" || name == "codeql.yaml" { + rust = true; + } + continue; + } + workflows_only = false; + if action_definition(path) { + actions = true; + continue; + } + if rust_input(path) { + rust = true; + } else { + return full_both(); + } + } + + AffectedWork { + quality: if workflows_only { + QualityWork::Workflows + } else { + QualityWork::Full + }, + codeql: match (rust, actions) { + (true, true) => CodeqlScope::Both, + (true, false) => CodeqlScope::Rust, + (false, true) => CodeqlScope::Actions, + (false, false) => return full_both(), + }, + } +} + +const fn full_both() -> AffectedWork { + AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, + } +} + +fn valid_repository_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains(':') + && !path.bytes().any(|byte| byte.is_ascii_control()) + && path + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") +} + +fn workflow_yaml(path: &str) -> Option<&str> { + let name = path.strip_prefix(".github/workflows/")?; + if name.contains('/') { + return None; + } + let stem = name + .strip_suffix(".yml") + .or_else(|| name.strip_suffix(".yaml"))?; + (!stem.is_empty()).then_some(name) +} + +fn action_definition(path: &str) -> bool { + let Some(name) = path.strip_prefix(".github/actions/") else { + return false; + }; + name.contains('/') && (name.ends_with("/action.yml") || name.ends_with("/action.yaml")) +} + +fn rust_input(path: &str) -> bool { + matches!( + path, + "Cargo.toml" + | "Cargo.lock" + | "rust-toolchain" + | "rust-toolchain.toml" + | "rustfmt.toml" + | "clippy.toml" + | ".cargo/config" + | ".cargo/config.toml" + | "build.rs" + ) || ((path.starts_with("crates/") || path.starts_with("xtask/")) + && (path + .rsplit_once('.') + .is_some_and(|(_, extension)| extension == "rs") + || path.ends_with("/Cargo.toml"))) +} + /// A complete, normalized Git SHA-1 identity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Revision([u8; Self::LENGTH]); @@ -325,10 +465,221 @@ pub fn qualify(revision: Revision, reports: &[JobReport]) -> Result ".github/workflows/release-plz.yml", + 1 => ".github/workflows/ci.yml", + 2 => ".github/workflows/codeql.yml", + 3 => "crates/webpkit/src/lib.rs", + _ => ".github/actions/setup/action.yml", + }; + } + let suffix: [u8; 8] = kani::any(); + for byte in suffix { + kani::assume(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + } + let prefix = b"notes/"; + let mut unknown_bytes = [0u8; b"notes/".len() + 8]; + unknown_bytes[..prefix.len()].copy_from_slice(prefix); + unknown_bytes[prefix.len()..].copy_from_slice(&suffix); + let unknown = core::str::from_utf8(&unknown_bytes).expect("bounded ASCII path is UTF-8"); + assert_eq!( + affected_work(&[unknown]), + AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, + } + ); + paths[prefix_count] = unknown; + assert_eq!( + affected_work(&paths[..prefix_count + 1]), + AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, + } + ); + kani::cover!(prefix_count == categories.len()); + + // A symbolic choice ranges over independently stated malformed path shapes. + let malformed_kind: u8 = kani::any(); + kani::assume(malformed_kind < 7); + let malformed = match malformed_kind { + 0 => "", + 1 => "../notes/file.txt", + 2 => "/notes/file.txt", + 3 => "notes//file.txt", + 4 => "notes\\file.txt", + 5 => "notes/file:name.txt", + _ => "notes/file\nname.txt", + }; + assert_eq!( + affected_work(&[malformed]), + AffectedWork { + quality: QualityWork::Full, + codeql: CodeqlScope::Both, + } + ); + } + #[kani::proof] #[kani::unwind(42)] fn commit_identity_is_exact() { diff --git a/xtask/tests/ci_workflow.rs b/xtask/tests/ci_workflow.rs index ee83f6e..8ceba30 100644 --- a/xtask/tests/ci_workflow.rs +++ b/xtask/tests/ci_workflow.rs @@ -297,11 +297,45 @@ fn ordinary_contract(source: &Yaml) -> Result<()> { "ordinary work inventory must remain lean" ); let jobs = &source["jobs"]; + let quality = &jobs["quality"]; + let steps = quality["steps"] + .as_vec() + .context("ordinary quality steps")?; + let dispatch = steps + .iter() + .find(|step| { + step["run"] + .as_str() + .is_some_and(|run| run.ends_with("-- just check-changes")) + }) + .context("ordinary checked dispatch")?; ensure!( - runs(&jobs["quality"]) - .iter() - .any(|run| run.ends_with("-- just check")), - "ordinary quality must execute the complete project gate" + dispatch["env"]["CI_BASE_REVISION"].as_str() + == Some( + "${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" + ) + && dispatch["env"]["CI_HEAD_REVISION"].as_str() == Some("${{ github.sha }}"), + "ordinary dispatch must compare the checked event source" + ); + ensure!( + dispatch["if"].is_badvalue() + && dispatch["continue-on-error"].is_badvalue() + && quality["if"].is_badvalue() + && quality["continue-on-error"].is_badvalue(), + "ordinary dispatch cannot skip work or ignore its failure" + ); + let checkout = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .is_some_and(|uses| uses.starts_with("actions/checkout@")) + }) + .context("ordinary source checkout")?; + ensure!( + checkout["with"]["fetch-depth"].as_i64() == Some(0) + && checkout["with"]["persist-credentials"].as_bool() == Some(false), + "ordinary selection needs comparison history without retained credentials" ); let required = &jobs["ci-required"]; ensure!( @@ -344,6 +378,226 @@ fn ordinary_contract(source: &Yaml) -> Result<()> { Ok(()) } +fn codeql_contract(source: &Yaml) -> Result<()> { + let jobs = &source["jobs"]; + ensure!( + jobs.as_hash().context("CodeQL jobs")?.len() == 2, + "CodeQL requires one planner and one analysis matrix" + ); + let plan = &jobs["plan"]; + ensure!( + plan["outputs"]["matrix"].as_str() == Some("${{ steps.selection.outputs.matrix }}") + && plan["if"].is_badvalue() + && plan["continue-on-error"].is_badvalue(), + "CodeQL must expose an unconditional checked selection" + ); + let steps = plan["steps"].as_vec().context("CodeQL planner steps")?; + let selection = steps + .iter() + .find(|step| step["id"].as_str() == Some("selection")) + .context("CodeQL selection producer")?; + ensure!( + selection["env"]["CI_BASE_REVISION"].as_str() + == Some("${{ github.event.pull_request.base.sha }}") + && selection["env"]["CI_HEAD_REVISION"].as_str() == Some("${{ github.sha }}"), + "CodeQL must bind PR changes and select both languages for the main baseline" + ); + let commands = selection["run"] + .as_str() + .context("CodeQL planner command")?; + let commands = commands.lines().map(str::trim).collect::>(); + ensure!( + commands + == [ + "matrix=$(cargo run --locked --quiet -p webpkit-ci-select --bin ci-select -- codeql-matrix)", + "printf 'matrix=%s\\n' \"$matrix\" >> \"$GITHUB_OUTPUT\"", + ] + && selection["shell"].as_str() == Some("bash") + && selection["if"].is_badvalue() + && selection["continue-on-error"].is_badvalue(), + "CodeQL must propagate selector failures before publishing its matrix" + ); + let checkout = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .is_some_and(|uses| uses.starts_with("actions/checkout@")) + }) + .context("CodeQL planner checkout")?; + ensure!( + checkout["with"]["fetch-depth"].as_i64() == Some(0) + && checkout["with"]["persist-credentials"].as_bool() == Some(false), + "CodeQL selection needs comparison history without retained credentials" + ); + let analyze = &jobs["analyze"]; + ensure!( + analyze["needs"].as_vec() == Some(&vec![Yaml::String("plan".to_owned())]) + && analyze["strategy"]["matrix"].as_str() + == Some("${{ fromJSON(needs.plan.outputs.matrix) }}") + && analyze["if"].is_badvalue() + && analyze["continue-on-error"].is_badvalue(), + "CodeQL analysis must consume the successful planner matrix" + ); + let steps = analyze["steps"].as_vec().context("CodeQL analysis steps")?; + let init = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .is_some_and(|uses| uses.starts_with("github/codeql-action/init@")) + }) + .context("CodeQL initialization")?; + ensure!( + init["with"]["languages"].as_str() == Some("${{ matrix.language }}") + && init["with"]["build-mode"].as_str() == Some("${{ matrix.build-mode }}") + && init["with"]["queries"].as_str() == Some("security-extended") + && init["if"].is_badvalue() + && init["continue-on-error"].is_badvalue(), + "CodeQL must analyze the selected language with the security suite" + ); + let upload = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .is_some_and(|uses| uses.starts_with("github/codeql-action/analyze@")) + }) + .context("CodeQL result upload")?; + ensure!( + upload["with"]["category"].as_str() == Some("/language:${{ matrix.language }}") + && upload["if"].is_badvalue() + && upload["continue-on-error"].is_badvalue(), + "CodeQL must publish each successful language analysis" + ); + ensure!( + source["on"]["schedule"].is_badvalue() + && source["on"]["push"]["branches"].as_vec() + == Some(&vec![Yaml::String("main".to_owned())]) + && !source["on"]["pull_request"].is_badvalue(), + "CodeQL must check changes and publish the main baseline without a schedule" + ); + Ok(()) +} + +#[test] +fn codeql_uses_the_checked_source_selection() { + codeql_contract(&workflow("codeql.yml").unwrap()).unwrap(); +} + +#[test] +fn disconnected_or_failure_masking_codeql_selection_is_rejected() { + let original = workflow("codeql.yml").unwrap(); + codeql_contract(&original).unwrap(); + let mut changed = original.clone(); + changed["jobs"]["analyze"]["strategy"]["matrix"] = Yaml::String("{}".to_owned()); + assert!(codeql_contract(&changed).is_err()); + let mut changed = original.clone(); + let Yaml::Array(steps) = &mut changed["jobs"]["plan"]["steps"] else { + unreachable!() + }; + let selection = steps + .iter_mut() + .find(|step| step["id"].as_str() == Some("selection")) + .unwrap(); + selection["run"] = Yaml::String("printf 'matrix={}\\n' >> \"$GITHUB_OUTPUT\"".to_owned()); + assert!(codeql_contract(&changed).is_err()); + let mut changed = original; + let Yaml::Array(steps) = &mut changed["jobs"]["plan"]["steps"] else { + unreachable!() + }; + let selection = steps + .iter_mut() + .find(|step| step["id"].as_str() == Some("selection")) + .unwrap(); + selection["env"]["CI_HEAD_REVISION"] = Yaml::String("main".to_owned()); + assert!(codeql_contract(&changed).is_err()); + let mut changed = workflow("codeql.yml").unwrap(); + let Yaml::Array(steps) = &mut changed["jobs"]["plan"]["steps"] else { + unreachable!() + }; + let selection = steps + .iter_mut() + .find(|step| step["id"].as_str() == Some("selection")) + .unwrap(); + selection["env"]["CI_BASE_REVISION"] = + Yaml::String("${{ github.event.pull_request.base.sha || github.event.before }}".to_owned()); + assert!(codeql_contract(&changed).is_err()); + for job in ["plan", "analyze"] { + let mut changed = workflow("codeql.yml").unwrap(); + let Yaml::Hash(job) = &mut changed["jobs"][job] else { + unreachable!() + }; + job.insert( + Yaml::String("continue-on-error".to_owned()), + Yaml::Boolean(true), + ); + assert!(codeql_contract(&changed).is_err()); + } +} + +fn changed_gate_contract(source: &str, hooks: &Yaml) -> Result<()> { + ensure!( + source.contains("\nactionlint:\n actionlint\n"), + "workflow lint must discover both supported YAML extensions" + ); + let (_, tail) = source + .split_once("\ncheck-workflows:\n") + .context("workflow check recipe")?; + let commands = tail + .lines() + .take_while(|line| line.is_empty() || line.starts_with(' ')) + .map(str::trim) + .collect::>(); + for command in [ + "just actionlint", + "yamlfmt --lint .github/workflows", + "just typos", + "cargo test --locked -p xtask --test ci_workflow", + ] { + ensure!(commands.contains(&command), "workflow gate lacks {command}"); + } + let (_, tail) = source + .split_once("\ncheck-changes:\n") + .context("checked selection recipe")?; + ensure!( + tail.lines().next() + == Some(" cargo run --locked --quiet -p webpkit-ci-select --bin ci-select -- check"), + "local selection must run the production adapter" + ); + ensure!( + hooks["pre-push"]["commands"]["check"]["run"].as_str() == Some("just check-changes"), + "pre-push must share the hosted selection entry point" + ); + Ok(()) +} + +#[test] +fn local_selection_retains_workflow_contracts_and_the_full_gate() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let source = fs::read_to_string(root.join("Justfile")) + .unwrap() + .replace("\r\n", "\n"); + let hooks = fs::read_to_string(root.join("lefthook.yml")).unwrap(); + let hooks = YamlLoader::load_from_str(&hooks).unwrap().remove(0); + just_check_contract(&source).unwrap(); + changed_gate_contract(&source, &hooks).unwrap(); + let missing_contracts = source.replacen( + " cargo test --locked -p xtask --test ci_workflow\n", + "", + 1, + ); + assert!(changed_gate_contract(&missing_contracts, &hooks).is_err()); + let disconnected = source.replacen("--bin ci-select -- check", "--bin ci-select -- help", 1); + assert!(changed_gate_contract(&disconnected, &hooks).is_err()); + let missing_yaml = source.replacen( + "\nactionlint:\n actionlint\n", + "\nactionlint:\n actionlint .github/workflows/*.yml\n", + 1, + ); + assert!(changed_gate_contract(&missing_yaml, &hooks).is_err()); +} + fn just_check_contract(source: &str) -> Result<()> { let (_, tail) = source .split_once("\ncheck:\n") @@ -434,7 +688,7 @@ fn deleting_ordinary_tests_or_weakening_required_is_rejected() { steps.retain(|step| { !step["run"] .as_str() - .is_some_and(|run| run.ends_with("-- just check")) + .is_some_and(|run| run.ends_with("-- just check-changes")) }); assert!(ordinary_contract(&changed).is_err()); let source = fs::read_to_string( @@ -568,6 +822,9 @@ fn proof_role_binds_the_exact_canonical_source_and_complete_inventory() { "production::proofs::candidate_selection_preserves_source", "production::proofs::ordinary_summary_requires_complete_work", "production::proofs::publication_requires_complete_same_source_qualification", + "production::proofs::affected_work_routes_recognized_workflow_boundaries", + "production::proofs::affected_work_routes_recognized_rust_and_action_inputs", + "production::proofs::affected_work_unknown_and_empty_inputs_keep_full_selection", ]; let names: Vec = serde_json::from_str(invocation["env"]["REQUIRED_HARNESSES"].as_str().unwrap()).unwrap();