From 8d743dfdfb0c368ba70e4f7c66ff992660adb27e Mon Sep 17 00:00:00 2001 From: joshlf's Agent Date: Tue, 25 Aug 2026 13:05:26 +0000 Subject: [PATCH] [ci] Audit typed jobs into the required check Audit the all-jobs-succeed behavior established by the typed plan and semver integration. Require its exact top-level shape, externally configured display name, empty permissions, hosted runner, always-run condition, and minimum direct path from plan_ci, build_test, miri, semver, and the job dependency audit. Audit the exact ordered cancellation guard and require all five planner outputs to be present before final aggregation. Treat Miri and semver as the only optional jobs: each may be skipped exactly when its checked enable output is false and must succeed when enabled. Match the total skipped dependency count to those disabled jobs so no unrelated skip can pass. Reject extra privileged steps, and require exact environments, custom Bash, and the absolute jq run block. On Linux, execute that exact Bash and jq program against a truth table covering both optional jobs enabled and disabled. Reject enabled skips, disabled successes, unrelated skips, failures, cancellations, malformed results JSON, and invalid gates so the source audit and runtime meaning cannot drift independently. Normalize the aggregate YAML indentation and document that its display name must stay coordinated with the external branch-protection or ruleset setting. Tests: offline zc tests Tests: zc clippy with warnings denied Tests: ci/check_actions.sh Tests: cargo.sh ci audit Tests: ci/check_fmt.sh Tests: git diff --check gherrit-pr-id: Gnouzrlnq3bnxeg72jfe7d3xq6jymqqcf --- .github/workflows/ci.yml | 130 ++-- tools/zc/src/ci.rs | 20 +- tools/zc/src/planned_adapter/aggregate.rs | 825 ++++++++++++++++++++++ tools/zc/src/planned_adapter/mod.rs | 13 +- tools/zc/src/planned_adapter/source.rs | 27 + tools/zc/src/workflow_protocol.rs | 7 + 6 files changed, 950 insertions(+), 72 deletions(-) create mode 100644 tools/zc/src/planned_adapter/aggregate.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed2cdd38e9..5d95e229c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -926,62 +926,74 @@ jobs: # Used to signal to branch protections that all other jobs have succeeded. all-jobs-succeed: - # WARNING: This name is load-bearing! It's how GitHub's settings UI configures which jobs - # to block on. DO NOT change this name without updating the settings UI to match. - name: All checks succeeded (ci.yml) - # Run even when a dependency fails, is skipped, or is cancelled, then - # inspect every result explicitly. A skipped required check counts as - # success in branch protection, so this aggregation must fail closed. - if: ${{ always() }} - runs-on: ubuntu-latest - needs: [build_test, miri, semver, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_tools, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env, plan_ci] - steps: - - name: Reject workflow cancellation - if: ${{ cancelled() }} - run: exit 1 - # GitHub can omit a job output at promotion time if its secret scanner - # produces a false positive. Keep this comparison in expression space - # so the large JSON never enters a process environment. A disabled - # optional plan is still the nonempty JSON value {"include":[]}; each - # boolean gate must also be present and canonical before aggregation - # trusts it. - - name: Require published planner outputs - if: ${{ needs.plan_ci.result == 'success' && (needs.plan_ci.outputs.build_matrix == '' || needs.plan_ci.outputs.miri_matrix == '' || needs.plan_ci.outputs.semver_matrix == '' || (needs.plan_ci.outputs.miri_enabled != 'true' && needs.plan_ci.outputs.miri_enabled != 'false') || (needs.plan_ci.outputs.semver_enabled != 'true' && needs.plan_ci.outputs.semver_enabled != 'false')) }} - shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} - run: exit 1 - - - name: Require every dependency to succeed - # Pin the absolute interpreter and remove Bash startup controls so a - # job default, PATH shim, or exported function cannot turn this - # required-check assertion into a successful no-op. - shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} - env: - # Do not serialize the entire `needs` object here: job outputs may - # legitimately approach GitHub's configured output limit, while a - # Linux process has a much smaller per-environment-value limit. - # Results stay small regardless of matrix JSON size. Separate - # optional-job results identify the only dependencies which may be - # skipped, and couple each skip to its planner-derived gate. - RESULTS_JSON: ${{ toJSON(needs.*.result) }} - MIRI_ENABLED: ${{ needs.plan_ci.outputs.miri_enabled }} - MIRI_RESULT: ${{ needs.miri.result }} - SEMVER_ENABLED: ${{ needs.plan_ci.outputs.semver_enabled }} - SEMVER_RESULT: ${{ needs.semver.result }} - run: | - set -euo pipefail - /usr/bin/jq -e \ - --arg miri_enabled "$MIRI_ENABLED" \ - --arg miri "$MIRI_RESULT" \ - --arg semver_enabled "$SEMVER_ENABLED" \ - --arg semver "$SEMVER_RESULT" ' - def optional_job_ok($enabled; $result): - ($enabled == "false" and $result == "skipped") or - ($enabled == "true" and $result == "success"); - type == "array" and length > 0 and - optional_job_ok($miri_enabled; $miri) and - optional_job_ok($semver_enabled; $semver) and - ([.[] | select(. == "skipped")] | length) == - ([$miri_enabled, $semver_enabled] | - map(select(. == "false")) | length) and - all(.[]; . == "success" or . == "skipped") - ' <<< "$RESULTS_JSON" + # WARNING: The external branch-protection or ruleset setting expects this + # exact display name. Coordinate any rename with that setting. The source + # audit can enforce this spelling but cannot update repository settings. + name: All checks succeeded (ci.yml) + # Run even when a dependency fails, is skipped, or is cancelled, then + # inspect every result explicitly. A skipped required check counts as + # success in branch protection, so this aggregation must fail closed. + if: ${{ always() }} + runs-on: ubuntu-latest + # The conclusion gate needs no repository authority. Keep this explicit so + # a future workflow-level permission expansion cannot reach this job. + permissions: {} + # `planned_adapter/aggregate.rs` audits the minimum path from the planner, + # both typed executors, and the semver adapter through + # `check-job-dependencies`. That script remains the owner of every other job + # in this complete dependency inventory. + needs: [build_test, miri, semver, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_tools, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env, plan_ci] + steps: + - name: Reject workflow cancellation + if: ${{ cancelled() }} + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + run: exit 1 + + # GitHub can omit a job output at promotion time if its secret scanner + # produces a false positive. Keep this comparison in expression space + # so the large JSON never enters a process environment. A disabled + # optional plan is still the nonempty JSON value {"include":[]}; each + # boolean gate must also be present and canonical before aggregation + # trusts it. + - name: Require published planner outputs + if: ${{ needs.plan_ci.result == 'success' && (needs.plan_ci.outputs.build_matrix == '' || needs.plan_ci.outputs.miri_matrix == '' || needs.plan_ci.outputs.semver_matrix == '' || (needs.plan_ci.outputs.miri_enabled != 'true' && needs.plan_ci.outputs.miri_enabled != 'false') || (needs.plan_ci.outputs.semver_enabled != 'true' && needs.plan_ci.outputs.semver_enabled != 'false')) }} + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + run: exit 1 + + - name: Require every dependency to succeed + # Pin the absolute interpreter and remove Bash startup controls so a + # job default, PATH shim, or exported function cannot turn this + # required-check assertion into a successful no-op. + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + env: + # Do not serialize the entire `needs` object here: job outputs may + # legitimately approach GitHub's configured output limit, while a + # Linux process has a much smaller per-environment-value limit. + # Results stay small regardless of matrix JSON size. Separate + # optional-job results identify the only dependencies which may be + # skipped, and couple each skip to its planner-derived gate. + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + MIRI_ENABLED: ${{ needs.plan_ci.outputs.miri_enabled }} + MIRI_RESULT: ${{ needs.miri.result }} + SEMVER_ENABLED: ${{ needs.plan_ci.outputs.semver_enabled }} + SEMVER_RESULT: ${{ needs.semver.result }} + # The aggregate audit checks this hosted absolute jq path and the run + # block line-for-line. + run: | + set -euo pipefail + /usr/bin/jq -e \ + --arg miri_enabled "$MIRI_ENABLED" \ + --arg miri "$MIRI_RESULT" \ + --arg semver_enabled "$SEMVER_ENABLED" \ + --arg semver "$SEMVER_RESULT" ' + def optional_job_ok($enabled; $result): + ($enabled == "false" and $result == "skipped") or + ($enabled == "true" and $result == "success"); + type == "array" and length > 0 and + optional_job_ok($miri_enabled; $miri) and + optional_job_ok($semver_enabled; $semver) and + ([.[] | select(. == "skipped")] | length) == + ([$miri_enabled, $semver_enabled] | + map(select(. == "false")) | length) and + all(.[]; . == "success" or . == "skipped") + ' <<< "$RESULTS_JSON" diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index e57d58f512..4131c6a943 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -13,10 +13,11 @@ //! metadata and repository files, every workflow job has an exact reviewed //! role, the handwritten matrix jobs exactly publish and consume typed plans, //! the complete standalone semver job consumes its typed target matrix and -//! exactly implements policy, every independently recorded legacy baseline -//! parses canonically, and the typed execution model exactly reproduces that -//! legacy evidence. Planners therefore consume checked data rather than -//! remembering which validation passes must precede which lookups. +//! exactly implements policy, the required check exactly aggregates their +//! conclusions, every independently recorded legacy baseline parses +//! canonically, and the typed execution model exactly reproduces that legacy +//! evidence. Planners therefore consume checked data rather than remembering +//! which validation passes must precede which lookups. use std::{ collections::HashMap, @@ -98,11 +99,12 @@ impl CiInputs { audit_workflows(&repository_root, reviewed_workflow_jobs) .map_err(|error| LoadCiError::Workflow(Box::new(error)))?; // Job-ID inventory cannot prove that a planned job publishes or - // consumes its typed matrix through the complete checked CLI. Audit - // that bridge using the exact bytes retained by the inventory pass, - // rather than reopening a possibly replaced path. The image producer - // also consumes validated inventory so its preinstalled compiler pins - // cannot drift from the toolchains selected by the typed plan. + // consumes its typed matrix through the complete checked CLI, or that + // those conclusions reach the required check. Audit that bridge using + // the exact bytes retained by the inventory pass, rather than reopening + // a possibly replaced path. The image producer also consumes validated + // inventory so its preinstalled compiler pins cannot drift from the + // toolchains selected by the typed plan. let workflow_source = workflow_sources.source(WORKFLOW_PATH).ok_or_else(|| { LoadCiError::RequiredWorkflowMissing { path: WORKFLOW_PATH.to_owned() } })?; diff --git a/tools/zc/src/planned_adapter/aggregate.rs b/tools/zc/src/planned_adapter/aggregate.rs new file mode 100644 index 0000000000..3c7cd28041 --- /dev/null +++ b/tools/zc/src/planned_adapter/aggregate.rs @@ -0,0 +1,825 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Exact required-check portion of the planned-job workflow bridge. + +use std::collections::BTreeMap; + +use super::{ + source::{ + audit_exact_job_fields, audit_exact_scalar_field, audit_exact_step_sequence, + audit_singleton_job_contract, audit_step, audited_steps_block, find_job, + job_field_location, job_fields, parse_needs, unique_field, RunForm, StepExpectation, + }, + ViolationSink, +}; +use crate::workflow_protocol::{ + AGGREGATE_DISPLAY_NAME, AGGREGATE_JOB, AGGREGATE_JOB_CONDITION, AGGREGATE_STEP_NAME, BUILD_JOB, + BUILD_MATRIX_OUTPUT, CANCELLATION_STEP_NAME, CHECK_JOB_DEPENDENCIES_JOB, MIRI_ENABLED_OUTPUT, + MIRI_JOB, MIRI_MATRIX_OUTPUT, PLAN_JOB, PUBLISHED_OUTPUTS_STEP_NAME, SEMVER_ENABLED_OUTPUT, + SEMVER_JOB, SEMVER_MATRIX_OUTPUT, TRUSTED_SHELL, +}; + +const AGGREGATE_JOB_FIELDS: &[&str] = &["name", "if", "runs-on", "permissions", "needs", "steps"]; + +pub(super) fn audit(lines: &[&str], errors: &mut ViolationSink) { + let Some(job) = find_job(lines, AGGREGATE_JOB, errors) else { + return; + }; + let fields = job_fields(lines, job.clone(), AGGREGATE_JOB, errors); + audit_exact_job_fields(&fields, AGGREGATE_JOB, AGGREGATE_JOB_FIELDS, errors); + audit_exact_scalar_field(&fields, AGGREGATE_JOB, "name", AGGREGATE_DISPLAY_NAME, errors); + audit_exact_scalar_field(&fields, AGGREGATE_JOB, "if", AGGREGATE_JOB_CONDITION, errors); + audit_exact_scalar_field(&fields, AGGREGATE_JOB, "permissions", "{}", errors); + audit_minimum_dependencies(&fields, errors); + audit_singleton_job_contract(&fields, AGGREGATE_JOB, errors); + + let gate_scalars = BTreeMap::from([ + ("if".to_owned(), published_outputs_condition()), + ("shell".to_owned(), TRUSTED_SHELL.to_owned()), + ]); + let aggregate_scalars = BTreeMap::from([("shell".to_owned(), TRUSTED_SHELL.to_owned())]); + let aggregate_environment = BTreeMap::from([ + ("RESULTS_JSON".to_owned(), "${{ toJSON(needs.*.result) }}".to_owned()), + ( + "MIRI_ENABLED".to_owned(), + format!("${{{{ needs.{PLAN_JOB}.outputs.{MIRI_ENABLED_OUTPUT} }}}}"), + ), + ("MIRI_RESULT".to_owned(), format!("${{{{ needs.{MIRI_JOB}.result }}}}")), + ( + "SEMVER_ENABLED".to_owned(), + format!("${{{{ needs.{PLAN_JOB}.outputs.{SEMVER_ENABLED_OUTPUT} }}}}"), + ), + ("SEMVER_RESULT".to_owned(), format!("${{{{ needs.{SEMVER_JOB}.result }}}}")), + ]); + let cancellation_scalars = BTreeMap::from([ + ("if".to_owned(), "${{ cancelled() }}".to_owned()), + ("shell".to_owned(), TRUSTED_SHELL.to_owned()), + ]); + let cancellation_run = ["exit 1".to_owned()]; + let gate_run = ["exit 1".to_owned()]; + let aggregate_run = aggregate_run(); + if let Some(steps) = audited_steps_block(&fields, job, AGGREGATE_JOB, 6, errors) { + audit_exact_step_sequence( + lines, + &steps, + AGGREGATE_JOB, + &[CANCELLATION_STEP_NAME, PUBLISHED_OUTPUTS_STEP_NAME, AGGREGATE_STEP_NAME], + errors, + ); + audit_step( + lines, + &steps, + StepExpectation { + job: AGGREGATE_JOB, + name: CANCELLATION_STEP_NAME, + root_fields: &["if", "shell", "run"], + scalar_fields: &cancellation_scalars, + environment: &BTreeMap::new(), + run: &cancellation_run, + run_form: RunForm::Inline, + }, + errors, + ); + audit_step( + lines, + &steps, + StepExpectation { + job: AGGREGATE_JOB, + name: PUBLISHED_OUTPUTS_STEP_NAME, + root_fields: &["if", "shell", "run"], + scalar_fields: &gate_scalars, + environment: &BTreeMap::new(), + run: &gate_run, + run_form: RunForm::Inline, + }, + errors, + ); + audit_step( + lines, + &steps, + StepExpectation { + job: AGGREGATE_JOB, + name: AGGREGATE_STEP_NAME, + root_fields: &["shell", "env", "run"], + scalar_fields: &aggregate_scalars, + environment: &aggregate_environment, + run: &aggregate_run, + run_form: RunForm::Block, + }, + errors, + ); + } +} + +fn audit_minimum_dependencies(fields: &[super::source::Field<'_>], errors: &mut ViolationSink) { + let Some(needs) = unique_field(fields, "needs", AGGREGATE_JOB, errors) else { + return; + }; + let dependencies = match parse_needs(needs.value) { + Ok(dependencies) => dependencies, + Err(message) => { + errors.push(job_field_location(AGGREGATE_JOB, "needs"), message); + return; + } + }; + + // These are only the edges needed to prove that the planned-job workflow + // bridge reaches the required check. `check_job_dependencies.sh` remains + // the single owner of every other job in the complete dependency list. + for dependency in [PLAN_JOB, BUILD_JOB, MIRI_JOB, SEMVER_JOB, CHECK_JOB_DEPENDENCIES_JOB] { + if !dependencies.contains(dependency) { + errors.push( + job_field_location(AGGREGATE_JOB, "needs"), + format!( + "must depend directly on `{dependency}` as part of the minimum planned-job workflow bridge into the required check" + ), + ); + } + } +} + +fn published_outputs_condition() -> String { + format!( + "${{{{ needs.{PLAN_JOB}.result == 'success' && (needs.{PLAN_JOB}.outputs.{BUILD_MATRIX_OUTPUT} == '' || needs.{PLAN_JOB}.outputs.{MIRI_MATRIX_OUTPUT} == '' || needs.{PLAN_JOB}.outputs.{SEMVER_MATRIX_OUTPUT} == '' || (needs.{PLAN_JOB}.outputs.{MIRI_ENABLED_OUTPUT} != 'true' && needs.{PLAN_JOB}.outputs.{MIRI_ENABLED_OUTPUT} != 'false') || (needs.{PLAN_JOB}.outputs.{SEMVER_ENABLED_OUTPUT} != 'true' && needs.{PLAN_JOB}.outputs.{SEMVER_ENABLED_OUTPUT} != 'false')) }}}}" + ) +} + +fn aggregate_run() -> Vec { + vec![ + "set -euo pipefail".to_owned(), + "/usr/bin/jq -e \\".to_owned(), + " --arg miri_enabled \"$MIRI_ENABLED\" \\".to_owned(), + " --arg miri \"$MIRI_RESULT\" \\".to_owned(), + " --arg semver_enabled \"$SEMVER_ENABLED\" \\".to_owned(), + " --arg semver \"$SEMVER_RESULT\" '".to_owned(), + " def optional_job_ok($enabled; $result):".to_owned(), + " ($enabled == \"false\" and $result == \"skipped\") or".to_owned(), + " ($enabled == \"true\" and $result == \"success\");".to_owned(), + " type == \"array\" and length > 0 and".to_owned(), + " optional_job_ok($miri_enabled; $miri) and".to_owned(), + " optional_job_ok($semver_enabled; $semver) and".to_owned(), + " ([.[] | select(. == \"skipped\")] | length) ==".to_owned(), + " ([$miri_enabled, $semver_enabled] |".to_owned(), + " map(select(. == \"false\")) | length) and".to_owned(), + " all(.[]; . == \"success\" or . == \"skipped\")".to_owned(), + "' <<< \"$RESULTS_JSON\"".to_owned(), + ] +} + +#[cfg(test)] +mod tests { + use std::path::Path; + #[cfg(target_os = "linux")] + use std::process::{Command, Stdio}; + + use super::{aggregate_run, audit}; + use crate::{ + ci::POLICY_PATH, + inventory::RepositoryInventory, + planned_adapter::{ + audit_planned_adapter, + test_support::{assert_rejected, audit_feature, replace_in_job}, + }, + policy::Policy, + workflow::{ReviewedWorkflowJobs, WORKFLOW_REGISTRY_PATH}, + workflow_protocol::{ + AGGREGATE_DISPLAY_NAME, AGGREGATE_JOB, AGGREGATE_STEP_NAME, CANCELLATION_STEP_NAME, + CHECK_JOB_DEPENDENCIES_JOB, PLAN_JOB, PUBLISHED_OUTPUTS_STEP_NAME, SEMVER_JOB, + TRUSTED_SHELL, WORKFLOW_PATH, + }, + }; + + const CANONICAL_SOURCE: &str = r#"jobs: + all-jobs-succeed: + name: All checks succeeded (ci.yml) + if: ${{ always() }} + runs-on: ubuntu-latest + permissions: {} + needs: [build_test, miri, semver, check-job-dependencies, plan_ci] + steps: + - name: Reject workflow cancellation + if: ${{ cancelled() }} + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + run: exit 1 + - name: Require published planner outputs + if: ${{ needs.plan_ci.result == 'success' && (needs.plan_ci.outputs.build_matrix == '' || needs.plan_ci.outputs.miri_matrix == '' || needs.plan_ci.outputs.semver_matrix == '' || (needs.plan_ci.outputs.miri_enabled != 'true' && needs.plan_ci.outputs.miri_enabled != 'false') || (needs.plan_ci.outputs.semver_enabled != 'true' && needs.plan_ci.outputs.semver_enabled != 'false')) }} + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + run: exit 1 + - name: Require every dependency to succeed + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + env: + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + MIRI_ENABLED: ${{ needs.plan_ci.outputs.miri_enabled }} + MIRI_RESULT: ${{ needs.miri.result }} + SEMVER_ENABLED: ${{ needs.plan_ci.outputs.semver_enabled }} + SEMVER_RESULT: ${{ needs.semver.result }} + run: | + set -euo pipefail + /usr/bin/jq -e \ + --arg miri_enabled "$MIRI_ENABLED" \ + --arg miri "$MIRI_RESULT" \ + --arg semver_enabled "$SEMVER_ENABLED" \ + --arg semver "$SEMVER_RESULT" ' + def optional_job_ok($enabled; $result): + ($enabled == "false" and $result == "skipped") or + ($enabled == "true" and $result == "success"); + type == "array" and length > 0 and + optional_job_ok($miri_enabled; $miri) and + optional_job_ok($semver_enabled; $semver) and + ([.[] | select(. == "skipped")] | length) == + ([$miri_enabled, $semver_enabled] | + map(select(. == "false")) | length) and + all(.[]; . == "success" or . == "skipped") + ' <<< "$RESULTS_JSON" + next_job: + runs-on: ubuntu-latest +"#; + + fn audit_source(source: &str) -> Result<(), super::super::PlannedAdapterViolations> { + audit_feature(source, audit) + } + + fn rejected(label: &str, source: &str, expected: &str) { + assert_rejected(label, audit_source(source), expected); + } + + #[cfg(target_os = "linux")] + fn aggregate_accepts( + results: &str, + miri_enabled: &str, + miri_result: &str, + semver_enabled: &str, + semver_result: &str, + ) -> bool { + // Execute the same literal program which the source audit requires in + // Actions. The hosted aggregate is Linux-only and pins /usr/bin/jq, so + // keep this semantic test equally explicit instead of substituting a + // Rust interpretation which could drift from jq behavior. + let script = aggregate_run().join("\n"); + Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-p", "-euo", "pipefail", "-c", &script]) + .env_clear() + .env("RESULTS_JSON", results) + .env("MIRI_ENABLED", miri_enabled) + .env("MIRI_RESULT", miri_result) + .env("SEMVER_ENABLED", semver_enabled) + .env("SEMVER_RESULT", semver_result) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + } + + fn swap_first_two_step_blocks(source: &str) -> String { + let first = format!(" - name: {CANCELLATION_STEP_NAME}\n"); + let second = format!(" - name: {PUBLISHED_OUTPUTS_STEP_NAME}\n"); + let third = format!(" - name: {AGGREGATE_STEP_NAME}\n"); + let first_start = source.find(&first).unwrap(); + let second_start = source.find(&second).unwrap(); + let third_start = source.find(&third).unwrap(); + assert!(first_start < second_start && second_start < third_start); + format!( + "{}{}{}{}", + &source[..first_start], + &source[second_start..third_start], + &source[first_start..second_start], + &source[third_start..] + ) + } + + #[test] + fn accepts_the_literal_fixture_and_live_workflow() { + audit_source(CANONICAL_SOURCE).unwrap(); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap(); + let reviewed = ReviewedWorkflowJobs::read(root.join(WORKFLOW_REGISTRY_PATH)).unwrap(); + let policy = Policy::read(root.join(POLICY_PATH)).unwrap(); + let repository = RepositoryInventory::audit(&root, &policy).unwrap(); + let workflow = crate::repository_text::read(&root.join(WORKFLOW_PATH)).unwrap(); + audit_planned_adapter(&root, &workflow, &reviewed, &repository).unwrap(); + } + + #[test] + fn required_check_name_and_singleton_host_contract_are_exact() { + let cases = [ + ( + "display name", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + AGGREGATE_DISPLAY_NAME, + "Almost all checks succeeded", + ), + ".name", + ), + ( + "condition", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "if: ${{ always() }}", + "if: success()", + ), + ".if", + ), + ( + "runner", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "runs-on: ubuntu-latest", + "runs-on: self-hosted", + ), + ".runs-on", + ), + ( + "missing permissions", + replace_in_job(CANONICAL_SOURCE, AGGREGATE_JOB, " permissions: {}\n", ""), + ".permissions", + ), + ( + "expanded permissions", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " permissions: {}\n", + " permissions:\n contents: read\n", + ), + ".permissions", + ), + ( + "container", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n container: ignored.invalid/noop\n", + ), + ".container", + ), + ( + "strategy", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n strategy:\n matrix:\n include: []\n", + ), + ".strategy", + ), + ( + "continue on error", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n continue-on-error: true\n", + ), + ".continue-on-error", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn aggregate_rejects_every_unreviewed_top_level_job_field() { + let additions = [ + ("concurrency", " concurrency: one-at-a-time\n"), + ("environment", " environment: protected\n"), + ("env", " env:\n SURPRISE: value\n"), + ("services", " services: {}\n"), + ("timeout-minutes", " timeout-minutes: 1\n"), + ("defaults", " defaults: {}\n"), + ("outputs", " outputs: {}\n"), + ("uses", " uses: example.invalid/owner/workflow@main\n"), + ("with", " with: {}\n"), + ("secrets", " secrets: inherit\n"), + ]; + for (field, addition) in additions { + let source = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " runs-on: ubuntu-latest\n", + &format!(" runs-on: ubuntu-latest\n{addition}"), + ); + rejected(field, &source, &format!("all-jobs-succeed.{field}")); + } + } + + #[test] + fn aggregate_requires_the_minimum_planned_job_dependency_bridge() { + let canonical = "[build_test, miri, semver, check-job-dependencies, plan_ci]"; + let cases = [ + ("build_test", "[miri, semver, check-job-dependencies, plan_ci]"), + ("miri", "[build_test, semver, check-job-dependencies, plan_ci]"), + (SEMVER_JOB, "[build_test, miri, check-job-dependencies, plan_ci]"), + (CHECK_JOB_DEPENDENCIES_JOB, "[build_test, miri, semver, plan_ci]"), + (PLAN_JOB, "[build_test, miri, semver, check-job-dependencies]"), + ]; + for (dependency, replacement) in cases { + let source = replace_in_job(CANONICAL_SOURCE, AGGREGATE_JOB, canonical, replacement); + rejected(dependency, &source, "must depend directly"); + } + + let extra = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + canonical, + "[build_test, miri, semver, codegen, check-job-dependencies, plan_ci]", + ); + audit_source(&extra).unwrap(); + + let duplicate = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + canonical, + "[build_test, miri, miri, semver, check-job-dependencies, plan_ci]", + ); + rejected("duplicate dependency", &duplicate, "repeats job `miri`"); + } + + #[test] + fn published_output_gate_is_exact_and_uses_every_typed_output() { + let cases = [ + ( + "step name", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + PUBLISHED_OUTPUTS_STEP_NAME, + "Maybe require planner outputs", + ), + "canonical step declaration", + ), + ( + "build output", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "outputs.build_matrix == ''", + "outputs.build_matrix != ''", + ), + ".fields.if", + ), + ( + "Miri output", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "outputs.miri_matrix == ''", + "outputs.miri_matrix != ''", + ), + ".fields.if", + ), + ( + "Miri gate", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "outputs.miri_enabled != 'false'", + "outputs.miri_enabled == 'false'", + ), + ".fields.if", + ), + ( + "semver output", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "outputs.semver_matrix == ''", + "outputs.semver_matrix != ''", + ), + ".fields.if", + ), + ( + "semver gate", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "outputs.semver_enabled != 'false'", + "outputs.semver_enabled == 'false'", + ), + ".fields.if", + ), + ( + "successful no-op", + replace_in_job(CANONICAL_SOURCE, AGGREGATE_JOB, "run: exit 1", "run: exit 0"), + ".run", + ), + ( + "gate shell", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &format!("shell: {TRUSTED_SHELL}\n run: exit 1"), + "shell: bash\n run: exit 1", + ), + ".fields.shell", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn dependency_assertion_environment_and_absolute_jq_run_are_exact() { + let cases = [ + ( + "results", + "RESULTS_JSON: ${{ toJSON(needs.*.result) }}", + "RESULTS_JSON: ${{ toJSON(needs) }}", + ), + ( + "Miri enabled", + "MIRI_ENABLED: ${{ needs.plan_ci.outputs.miri_enabled }}", + "MIRI_ENABLED: ${{ needs.miri.outputs.miri_enabled }}", + ), + ( + "Miri result", + "MIRI_RESULT: ${{ needs.miri.result }}", + "MIRI_RESULT: ${{ needs.build_test.result }}", + ), + ( + "semver enabled", + "SEMVER_ENABLED: ${{ needs.plan_ci.outputs.semver_enabled }}", + "SEMVER_ENABLED: ${{ needs.semver.outputs.semver_enabled }}", + ), + ( + "semver result", + "SEMVER_RESULT: ${{ needs.semver.result }}", + "SEMVER_RESULT: ${{ needs.build_test.result }}", + ), + ("absolute jq", "/usr/bin/jq -e", "jq -e"), + ]; + for (label, from, to) in cases { + let source = replace_in_job(CANONICAL_SOURCE, AGGREGATE_JOB, from, to); + rejected(label, &source, if label == "absolute jq" { ".run" } else { ".env." }); + } + + let aggregate_header = + format!(" - name: {AGGREGATE_STEP_NAME}\n shell: {TRUSTED_SHELL}\n"); + let weakened = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &aggregate_header, + &format!(" - name: {AGGREGATE_STEP_NAME}\n shell: bash\n"), + ); + rejected("assertion shell", &weakened, ".fields.shell"); + + for line in aggregate_run() { + let changed = if line == "set -euo pipefail" { + "set -eo pipefail".to_owned() + } else if line.contains('"') { + line.replacen('"', "", 1) + } else { + format!("{line} and true") + }; + let source = replace_in_job(CANONICAL_SOURCE, AGGREGATE_JOB, &line, &changed); + rejected(&line, &source, ".run"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn dependency_assertion_accepts_exactly_planned_optional_skips() { + let accepted = [ + ( + "both enabled", + r#"["success","success","success"]"#, + "true", + "success", + "true", + "success", + ), + ( + "Miri disabled", + r#"["success","skipped","success"]"#, + "false", + "skipped", + "true", + "success", + ), + ( + "semver disabled", + r#"["success","success","skipped"]"#, + "true", + "success", + "false", + "skipped", + ), + ( + "both disabled", + r#"["success","skipped","skipped"]"#, + "false", + "skipped", + "false", + "skipped", + ), + ]; + for (name, results, miri_enabled, miri, semver_enabled, semver) in accepted { + assert!( + aggregate_accepts(results, miri_enabled, miri, semver_enabled, semver), + "rejected valid result combination: {name}" + ); + } + + let rejected = [ + ( + "enabled Miri skipped", + r#"["success","skipped","success"]"#, + "true", + "skipped", + "true", + "success", + ), + ( + "disabled Miri succeeded", + r#"["success","success","success"]"#, + "false", + "success", + "true", + "success", + ), + ( + "enabled semver skipped", + r#"["success","success","skipped"]"#, + "true", + "success", + "true", + "skipped", + ), + ( + "unrelated job skipped", + r#"["success","skipped","skipped"]"#, + "false", + "skipped", + "true", + "success", + ), + ( + "dependency failed", + r#"["success","failure","success"]"#, + "true", + "success", + "true", + "success", + ), + ( + "dependency cancelled", + r#"["success","cancelled","success"]"#, + "true", + "success", + "true", + "success", + ), + ("empty results", "[]", "true", "success", "true", "success"), + ("non-array results", "{}", "true", "success", "true", "success"), + ( + "invalid gate", + r#"["success","success","success"]"#, + "", + "success", + "true", + "success", + ), + ]; + for (name, results, miri_enabled, miri, semver_enabled, semver) in rejected { + assert!( + !aggregate_accepts(results, miri_enabled, miri, semver_enabled, semver), + "accepted invalid result combination: {name}" + ); + } + } + + #[test] + fn both_aggregate_shells_reject_startup_influence() { + let mut replacements = vec!["bash".to_owned(), TRUSTED_SHELL.replace(" -p ", " ")]; + for variable in ["BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"] { + replacements.push(TRUSTED_SHELL.replace(&format!("-u {variable} "), "")); + } + + let gate_shell = format!(" shell: {TRUSTED_SHELL}\n run: exit 1"); + let aggregate_header = + format!(" - name: {AGGREGATE_STEP_NAME}\n shell: {TRUSTED_SHELL}\n"); + for replacement in replacements { + let gate = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &gate_shell, + &format!(" shell: {replacement}\n run: exit 1"), + ); + rejected("published-output gate", &gate, ".fields.shell"); + + let assertion = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &aggregate_header, + &format!(" - name: {AGGREGATE_STEP_NAME}\n shell: {replacement}\n"), + ); + rejected("dependency assertion", &assertion, ".fields.shell"); + } + } + + #[test] + fn ordered_steps_and_cancellation_guard_are_exact() { + let cancellation = format!( + " - name: {CANCELLATION_STEP_NAME}\n if: ${{{{ cancelled() }}}}\n shell: {TRUSTED_SHELL}\n run: exit 1\n" + ); + let cases = [ + ( + "inserted privileged step", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &cancellation, + &format!( + " - name: Replace jq\n run: sudo install ./fake-jq /usr/bin/jq\n{cancellation}" + ), + ), + "steps must be exactly", + ), + ( + "reordered steps", + swap_first_two_step_blocks(CANONICAL_SOURCE), + "steps must be exactly", + ), + ( + "cancellation condition", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + "if: ${{ cancelled() }}", + "if: success()", + ), + ".fields.if", + ), + ( + "cancellation no-op", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &cancellation, + &format!( + " - name: {CANCELLATION_STEP_NAME}\n if: ${{{{ cancelled() }}}}\n run: exit 0\n" + ), + ), + ".run", + ), + ( + "conditional guard shell", + replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &cancellation, + &format!( + " - name: {CANCELLATION_STEP_NAME}\n if: ${{{{ cancelled() }}}}\n shell: bash\n run: exit 1\n" + ), + ), + ".fields.shell", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn aggregate_steps_are_bounded_by_one_steps_mapping() { + let duplicate_mapping = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + " steps:\n", + " steps: []\n steps:\n", + ); + rejected("duplicate steps", &duplicate_mapping, ".steps"); + + let duplicate_step = replace_in_job( + CANONICAL_SOURCE, + AGGREGATE_JOB, + &format!(" - name: {AGGREGATE_STEP_NAME}\n"), + &format!( + " - name: {AGGREGATE_STEP_NAME}\n run: echo unrelated\n - name: {AGGREGATE_STEP_NAME}\n" + ), + ); + rejected("duplicate assertion", &duplicate_step, "inside `all-jobs-succeed.steps`"); + + let same_name_elsewhere = CANONICAL_SOURCE.replace( + " next_job:\n", + &format!( + " next_job:\n steps:\n - name: {AGGREGATE_STEP_NAME}\n run: echo unrelated\n" + ), + ); + audit_source(&same_name_elsewhere).unwrap(); + } +} diff --git a/tools/zc/src/planned_adapter/mod.rs b/tools/zc/src/planned_adapter/mod.rs index 8e8794cb71..b31c34077d 100644 --- a/tools/zc/src/planned_adapter/mod.rs +++ b/tools/zc/src/planned_adapter/mod.rs @@ -18,10 +18,13 @@ //! a real Docker invocation to the typed executor. Repository-owned actions, //! the Dockerfile, and the image context are mutable too, so this boundary //! resolves every source inside the checkout and requires its complete contents -//! to match an independent reviewed snapshot. A missing output, changed matrix -//! expression, substituted setup step, modified checkout, no-op interpreter, -//! conditional step, or dropped selector could otherwise silently reduce -//! coverage while the Rust plan and job-ID inventory remained valid. +//! to match an independent reviewed snapshot. The required-check aggregate +//! must then depend on the planner, all typed-plan consumers, and the remaining +//! dependency-inventory audit while enforcing every optional planned +//! conclusion. A missing output, changed matrix expression, substituted setup +//! step, modified checkout, no-op interpreter, conditional step, or dropped +//! selector could otherwise silently reduce coverage while the Rust plan and +//! job-ID inventory remained valid. //! //! This module is deliberately not a YAML or GitHub Actions interpreter. It //! recognizes the canonical source forms which carry the planned-job workflow @@ -39,6 +42,7 @@ use thiserror::Error; use crate::{inventory::RepositoryInventory, workflow::ReviewedWorkflowJobs}; +mod aggregate; mod image; mod matrix; mod planner; @@ -95,6 +99,7 @@ fn audit_source( planner::audit(&lines, &mut errors); image::audit(&lines, &mut errors); matrix::audit(&lines, reviewed_planned_jobs, &mut errors); + aggregate::audit(&lines, &mut errors); if errors.is_empty() { Ok(()) diff --git a/tools/zc/src/planned_adapter/source.rs b/tools/zc/src/planned_adapter/source.rs index 2560019be1..2e510d0d80 100644 --- a/tools/zc/src/planned_adapter/source.rs +++ b/tools/zc/src/planned_adapter/source.rs @@ -570,6 +570,33 @@ fn is_block_scalar_header(line: &str) -> bool { true } +pub(super) fn audit_exact_step_sequence( + lines: &[&str], + steps: &StepsBlock, + job: &str, + expected_names: &[&str], + errors: &mut ViolationSink, +) { + let actual = lines + .iter() + .enumerate() + .filter(|(index, line)| { + steps.range.contains(index) + && !line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == steps.marker_indent + }) + .map(|(_, line)| line[steps.marker_indent..].to_owned()) + .collect::>(); + let expected = expected_names.iter().map(|name| format!("- name: {name}")).collect::>(); + if actual != expected { + errors.push( + job_field_location(job, "steps"), + format!("steps must be exactly {expected:?} in order, found {actual:?}"), + ); + } +} + pub(super) fn audit_step( lines: &[&str], steps: &StepsBlock, diff --git a/tools/zc/src/workflow_protocol.rs b/tools/zc/src/workflow_protocol.rs index 5b68a5dc54..54464b7b5d 100644 --- a/tools/zc/src/workflow_protocol.rs +++ b/tools/zc/src/workflow_protocol.rs @@ -19,6 +19,8 @@ pub(crate) const BUILD_JOB: &str = "build_test"; pub(crate) const MIRI_JOB: &str = "miri"; pub(crate) const IMAGE_JOB: &str = "build_docker_env"; pub(crate) const SEMVER_JOB: &str = "semver"; +pub(crate) const AGGREGATE_JOB: &str = "all-jobs-succeed"; +pub(crate) const CHECK_JOB_DEPENDENCIES_JOB: &str = "check-job-dependencies"; // These are the workflow's only permitted YAML anchors. The build matrix owns // one exact definition of each and Miri owns one exact alias of each. Keep this @@ -48,6 +50,11 @@ pub(crate) const PLAN_STEP_ID: &str = "plan"; pub(crate) const BUILD_STEP_NAME: &str = "Execute checked build cell"; pub(crate) const MIRI_STEP_NAME: &str = "Execute checked Miri cell"; pub(crate) const SEMVER_STEP_NAME: &str = "Check semver compatibility"; +pub(crate) const CANCELLATION_STEP_NAME: &str = "Reject workflow cancellation"; +pub(crate) const PUBLISHED_OUTPUTS_STEP_NAME: &str = "Require published planner outputs"; +pub(crate) const AGGREGATE_STEP_NAME: &str = "Require every dependency to succeed"; +pub(crate) const AGGREGATE_DISPLAY_NAME: &str = "All checks succeeded (ci.yml)"; +pub(crate) const AGGREGATE_JOB_CONDITION: &str = "${{ always() }}"; pub(crate) const GITHUB_PLAN_COMMAND: &str = "github-plan"; pub(crate) const EXECUTE_BUILD_CELL_COMMAND: &str = "execute-build-cell";