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";