From 1145c68251e3a66bbf6a60155e3252848ecdeafd Mon Sep 17 00:00:00 2001 From: joshlf's Agent Date: Tue, 25 Aug 2026 11:40:08 +0000 Subject: [PATCH] [ci] Make typed workflow bridges fail closed Derive Miri job eligibility from the projected matrix instead of repeating the event policy in workflow YAML. Publish the same gate to the required-check aggregate so policy changes cannot select work that the workflow silently skips. Run the planner with an absolute privileged Bash, neutralized startup controls, and a command-local fixed PATH. Invoke an explicit child Bash so the cargo wrapper cannot select an ambient interpreter. Run typed cells with absolute Docker, a fixed /bin/bash entrypoint, privileged no-startup arguments, and an option terminator. Use absolute jq in the final gate. These bridges now fail if PATH, image startup state, or Bash startup state attempts to replace their commands. This changes transport and eligibility wiring, not selected coverage: pull requests still run 60 build and zero Miri cells. Full events still run 182 build cells and 64 Miri cells. Tests: offline zc tests Tests: ci/check_actions.sh Tests: cargo fmt --check Tests: git diff --check gherrit-pr-id: Gg5onzlaf6nsoqoubbjg43tdiuxvvjrbc --- .github/workflows/ci.yml | 149 +++++++++++++++++++++++++++++---------- tools/zc/src/cli.rs | 12 ++-- tools/zc/src/github.rs | 56 ++++++++++++--- 3 files changed, 166 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1fe570752..4335e457c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,9 @@ name: Build & Tests - main pull_request: merge_group: - # Useful for manually testing changes to this workflow. Because we check for - # the merge queue by doing `github.event_name != 'pull_request'` rather than - # `github.event_name == 'merge_group'`, triggering via workflow dispatch has - # the effect of running all jobs, including those which would normally only be - # run in the merge queue. + # Useful for manually testing changes to this workflow. The typed planner + # classifies this as a full event, so its plan enables all ordinary CI work, + # including Miri. workflow_dispatch: permissions: @@ -61,8 +59,8 @@ jobs: # jobs consume only the checked selectors from each matrix cell and pass them # back to `cargo-zerocopy`, which reconstructs and executes the complete # command without interpreting matrix data as shell text. Keep the output - # names coordinated with `tools/zc/src/github.rs` and the two `fromJSON` - # expressions below. + # names coordinated with `tools/zc/src/github.rs`, the two `fromJSON` + # expressions below, and the Miri eligibility consumers below. plan_ci: name: Plan ordinary CI work runs-on: ubuntu-latest @@ -71,9 +69,10 @@ jobs: outputs: build_matrix: ${{ steps.plan.outputs.build_matrix }} miri_matrix: ${{ steps.plan.outputs.miri_matrix }} - defaults: - run: - working-directory: zerocopy + # This gate is derived from the projected Miri matrix, not independently + # from the event name. Keep it coordinated with `tools/zc/src/github.rs`, + # the Miri job condition, and the required-check aggregation. + miri_enabled: ${{ steps.plan.outputs.miri_enabled }} env: # upload-file-artifact requires its name to equal the path basename. # Keep this one value coordinated with the planner invocation and upload @@ -86,11 +85,21 @@ jobs: - name: Validate inputs and project the plan id: plan + # The absolute custom shell removes Bash startup-control variables and + # enables privileged mode so inherited startup files, shell options, + # and exported functions cannot turn this step 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} + working-directory: zerocopy env: EVENT_NAME: ${{ github.event_name }} + # The runner applies GITHUB_PATH after merging step env, so assign the + # fixed path directly to the child command. Do not rely on cargo.sh's + # /usr/bin/env bash shebang. Privileged mode also prevents an imported + # function from replacing a builtin when this child Bash starts. run: | set -euo pipefail - ./cargo.sh ci github-plan \ + PATH=/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin \ + /bin/bash --noprofile --norc -p ./cargo.sh ci github-plan \ --event "$EVENT_NAME" \ --github-output "$GITHUB_OUTPUT" \ --artifact "$RUNNER_TEMP/$CI_PLAN_ARTIFACT" @@ -213,10 +222,19 @@ jobs: # The matrix values are data, not shell fragments. `cargo-zerocopy` # validates these selectors against the plan for this event, reconstructs - # the typed argv and environment, and executes the complete cell. Keep this - # invocation coordinated with `tools/zc/src/cli.rs`; adding a command here - # would bypass the checked execution model. + # the typed argv and environment, and executes the complete cell. This step + # deliberately does not use the job's generated Docker shell: auditing its + # path would not prove that the generated wrapper still invokes Docker. + # Instead, the complete bridge below is explicit. The absolute Docker path + # relies on the hosted Ubuntu runner contract and prevents a PATH shim from + # returning success without starting a container. The entrypoint override + # and Bash startup options prevent image startup behavior or exported + # functions from intercepting the typed executor argv. The option + # terminator forces the inherited image value to be parsed as an image. + # Keep this command and its options coordinated with `tools/zc/src/cli.rs`. - name: Execute checked build cell + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + working-directory: zerocopy env: TOOLCHAIN: ${{ matrix.toolchain }} CRATE: ${{ matrix.crate }} @@ -224,7 +242,28 @@ jobs: TARGET: ${{ matrix.target }} run: | set -euo pipefail - ./cargo.sh ci execute-build-cell \ + /usr/bin/docker run --rm \ + --workdir "$PWD" \ + -v /home/runner/work:/home/runner/work \ + -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \ + -v /home/runner/.docker-cargo/git:/root/.cargo/git \ + -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \ + -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \ + -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \ + -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \ + -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \ + -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \ + -e ZC_SKIP_CARGO_SEMVER_CHECKS \ + -e GIT_CONFIG_COUNT=1 \ + -e GIT_CONFIG_KEY_0=safe.directory \ + -e "GIT_CONFIG_VALUE_0=*" \ + --entrypoint /bin/bash \ + -- \ + "$ZC_CI_IMAGE" \ + --noprofile \ + --norc \ + -p \ + ./cargo.sh ci execute-build-cell \ --event "$GITHUB_EVENT_NAME" \ --package "$CRATE" \ --toolchain "$TOOLCHAIN" \ @@ -319,24 +358,26 @@ jobs: matrix.target != 'wasm32-unknown-unknown' && env.ZC_SKIP_CARGO_SEMVER_CHECKS != '1' - # Miri runs only on full events because it is much more expensive than the - # ordinary build matrix. Each borrow model is its own matrix cell so the two - # models and all eligible target/profile combinations can run concurrently. + # Today's policy selects Miri only for full events because it is much more + # expensive than the ordinary build matrix. Each borrow model is its own + # matrix cell so the models and all eligible target/profile combinations can + # run concurrently. The job itself follows the plan rather than repeating + # that current policy. miri: - if: github.event_name != 'pull_request' + # The planner derives this output solely from whether it selected any Miri + # cells. Do not duplicate today's PR/full-event policy here: future policy + # changes must automatically enable or skip this job with the matrix they + # produce. + if: needs.plan_ci.outputs.miri_enabled == 'true' runs-on: ubuntu-latest needs: [build_docker_env, plan_ci] permissions: contents: read - defaults: - run: - shell: /tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes Miri through the prebuilt Docker image) - working-directory: zerocopy strategy: fail-fast: false - # Pull requests skip this job before matrix expansion. Full events consume - # exactly the Miri cells selected by the same checked plan used by the - # executor below. + # A planner-disabled job skips before matrix expansion. When enabled, it + # consumes exactly the Miri cells selected by the same checked plan used + # by the executor below. matrix: ${{ fromJSON(needs.plan_ci.outputs.miri_matrix) }} name: Miri (${{ matrix.crate }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }}) @@ -345,12 +386,18 @@ jobs: - *matrix_checkout - *download_ci_image - *load_ci_image - - *create_docker_shell # As with ordinary cells, the workflow passes only selectors. Model flags, # feature arguments, the Cargo configuration transaction, and target # workarounds belong to the typed executor rather than this shell adapter. + # This explicit Docker bridge does not rely on the generated job shell + # actually executing its input. Its absolute Docker path, entrypoint + # override, Bash startup options, and option terminator provide the same + # fail-closed boundary as the ordinary build bridge. Keep the separate + # Miri-model selector coordinated with `tools/zc/src/cli.rs`. - name: Execute checked Miri cell + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + working-directory: zerocopy env: TOOLCHAIN: ${{ matrix.toolchain }} CRATE: ${{ matrix.crate }} @@ -359,7 +406,28 @@ jobs: MIRI_MODEL: ${{ matrix.miri_model }} run: | set -euo pipefail - ./cargo.sh ci execute-miri-cell \ + /usr/bin/docker run --rm \ + --workdir "$PWD" \ + -v /home/runner/work:/home/runner/work \ + -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \ + -v /home/runner/.docker-cargo/git:/root/.cargo/git \ + -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \ + -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \ + -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \ + -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \ + -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \ + -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \ + -e ZC_SKIP_CARGO_SEMVER_CHECKS \ + -e GIT_CONFIG_COUNT=1 \ + -e GIT_CONFIG_KEY_0=safe.directory \ + -e "GIT_CONFIG_VALUE_0=*" \ + --entrypoint /bin/bash \ + -- \ + "$ZC_CI_IMAGE" \ + --noprofile \ + --norc \ + -p \ + ./cargo.sh ci execute-miri-cell \ --event "$GITHUB_EVENT_NAME" \ --package "$CRATE" \ --toolchain "$TOOLCHAIN" \ @@ -768,32 +836,39 @@ jobs: # 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 PR's empty - # Miri plan is still the nonempty JSON value {"include":[]}. + # so the large JSON never enters a process environment. A disabled Miri + # plan is still the nonempty JSON value {"include":[]}; the 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 == '') }} + if: ${{ needs.plan_ci.result == 'success' && (needs.plan_ci.outputs.build_matrix == '' || needs.plan_ci.outputs.miri_matrix == '' || (needs.plan_ci.outputs.miri_enabled != 'true' && needs.plan_ci.outputs.miri_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: - EVENT_NAME: ${{ github.event_name }} # 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. Keep the - # separate Miri result so the one intentional PR skip is still - # identified rather than accepting an arbitrary skipped job. + # separate Miri result so a planner-disabled Miri job is identified + # rather than accepting an arbitrary skipped dependency. RESULTS_JSON: ${{ toJSON(needs.*.result) }} + MIRI_ENABLED: ${{ needs.plan_ci.outputs.miri_enabled }} MIRI_RESULT: ${{ needs.miri.result }} run: | set -euo pipefail - jq -e --arg event "$EVENT_NAME" --arg miri "$MIRI_RESULT" ' + /usr/bin/jq -e --arg enabled "$MIRI_ENABLED" --arg miri "$MIRI_RESULT" ' type == "array" and length > 0 and - if $event == "pull_request" + if $enabled == "false" then $miri == "skipped" and ([.[] | select(. == "skipped")] | length) == 1 and all(.[]; . == "success" or . == "skipped") - else $miri == "success" and + else $enabled == "true" and + $miri == "success" and all(.[]; . == "success") end ' <<< "$RESULTS_JSON" diff --git a/tools/zc/src/cli.rs b/tools/zc/src/cli.rs index 72ebfa309f..bca4d67228 100644 --- a/tools/zc/src/cli.rs +++ b/tools/zc/src/cli.rs @@ -654,7 +654,10 @@ mod tests { }; use super::{run, CliError, Command}; - use crate::execution::{BuildCellSelector, MiriCellSelector}; + use crate::{ + execution::{BuildCellSelector, MiriCellSelector}, + github::{BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT}, + }; fn strings(args: &[&str]) -> Vec { args.iter().map(|arg| (*arg).to_owned()).collect() @@ -939,7 +942,7 @@ mod tests { } #[test] - fn github_plan_publishes_both_outputs_from_one_checked_projection() { + fn github_plan_publishes_all_outputs_from_one_checked_projection() { static NEXT: AtomicU64 = AtomicU64::new(0); let unique = NEXT.fetch_add(1, Ordering::Relaxed); let directory = @@ -963,8 +966,9 @@ mod tests { .unwrap(); let job_outputs = fs::read_to_string(github_output).unwrap(); - assert!(job_outputs.starts_with("build_matrix={\"include\":[")); - assert!(job_outputs.ends_with("miri_matrix={\"include\":[]}\n")); + assert!(job_outputs.starts_with(&format!("{BUILD_MATRIX_OUTPUT}={{\"include\":["))); + assert!(job_outputs.contains(&format!("{MIRI_MATRIX_OUTPUT}={{\"include\":[]}}\n"))); + assert!(job_outputs.ends_with(&format!("{MIRI_ENABLED_OUTPUT}=false\n"))); let artifact_json: serde_json::Value = serde_json::from_slice(&fs::read(artifact).unwrap()).unwrap(); assert_eq!(artifact_json["event"], "pull_request"); diff --git a/tools/zc/src/github.rs b/tools/zc/src/github.rs index 8187aa9d2f..59411e4880 100644 --- a/tools/zc/src/github.rs +++ b/tools/zc/src/github.rs @@ -53,16 +53,29 @@ use crate::{ pub const PROJECTION_SCHEMA_VERSION: u32 = 1; /// The fixed output name consumed by the ordinary build job. +/// +/// Keep the producer and consumer expressions in the workflow coordinated +/// with this value. pub const BUILD_MATRIX_OUTPUT: &str = "build_matrix"; /// The fixed output name consumed by the Miri job. +/// +/// This has the same producer/consumer contract as +/// [`BUILD_MATRIX_OUTPUT`]. pub const MIRI_MATRIX_OUTPUT: &str = "miri_matrix"; +/// The fixed job gate derived from whether the Miri matrix is nonempty. +/// +/// The workflow must not independently classify events when deciding whether +/// to run or require Miri. +pub const MIRI_ENABLED_OUTPUT: &str = "miri_enabled"; + /// JSON ready for GitHub Actions plus a detailed review artifact. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitHubProjection { build_matrix_json: String, miri_matrix_json: String, + miri_enabled: bool, artifact: Vec, output_records: Vec, output_utf16_bytes: u64, @@ -94,21 +107,27 @@ impl GitHubProjection { &self.miri_matrix_json } + /// Returns whether the projected Miri matrix contains any selected cells. + pub fn miri_enabled(&self) -> bool { + self.miri_enabled + } + /// Returns deterministic, pretty JSON suitable for a workflow artifact. pub fn artifact_bytes(&self) -> &[u8] { &self.artifact } - /// Returns GitHub's UTF-16 size estimate for both output records. + /// Returns GitHub's UTF-16 size estimate for all output records. pub fn output_utf16_bytes(&self) -> u64 { self.output_utf16_bytes } - /// Appends the two checked `name=value` records to `GITHUB_OUTPUT`. + /// Appends the three checked `name=value` records to `GITHUB_OUTPUT`. /// /// The caller supplies the path rather than this library reading ambient - /// environment state. Names are fixed constants and compact JSON never - /// contains a literal newline, so plan data cannot inject another output. + /// environment state. Names are fixed constants, the gate is a Rust + /// boolean, and compact JSON never contains a literal newline, so plan data + /// cannot inject another output. pub fn append_to_github_output( &self, github_output: impl AsRef, @@ -288,6 +307,8 @@ struct CompactMatrix { // repository inputs and resolve feature arguments, target behavior, and Miri // flags themselves. Putting those derived details in the compact matrix would // create a second execution contract which could drift from that resolution. +// Keep the handwritten Actions jobs and byte-for-byte compact-schema tests +// below coordinated with any deliberate transport change. #[derive(Serialize)] struct CompactBuildCell<'a> { #[serde(rename = "crate")] @@ -454,6 +475,7 @@ fn project( let selected_builds = one_workflow_shard("ordinary build matrix", &selected_builds, max_matrix_cells)?; let selected_miri = one_workflow_shard("Miri matrix", &selected_miri, max_matrix_cells)?; + let miri_enabled = !selected_miri.is_empty(); let compact_builds = CompactMatrix { include: selected_builds @@ -476,7 +498,7 @@ fn project( artifact.push(b'\n'); let output_records = format!( - "{BUILD_MATRIX_OUTPUT}={build_matrix_json}\n{MIRI_MATRIX_OUTPUT}={miri_matrix_json}\n" + "{BUILD_MATRIX_OUTPUT}={build_matrix_json}\n{MIRI_MATRIX_OUTPUT}={miri_matrix_json}\n{MIRI_ENABLED_OUTPUT}={miri_enabled}\n" ); let output_utf16_bytes = utf16_bytes(&output_records); if output_utf16_bytes > max_job_output_utf16_bytes { @@ -489,6 +511,7 @@ fn project( Ok(GitHubProjection { build_matrix_json, miri_matrix_json, + miri_enabled, artifact, output_records: output_records.into_bytes(), output_utf16_bytes, @@ -789,7 +812,7 @@ mod tests { use super::{ one_workflow_shard, project, shard_cells, slash_normalized_path, utf16_bytes, CompactBuildCell, CompactMiriCell, GitHubProjection, ProjectionError, ProjectionWriteError, - BUILD_MATRIX_OUTPUT, MIRI_MATRIX_OUTPUT, PROJECTION_SCHEMA_VERSION, + BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, PROJECTION_SCHEMA_VERSION, }; use crate::{ ci::CiInputs, @@ -825,6 +848,7 @@ mod tests { parse(projection.miri_matrix_json())["include"].as_array().unwrap().len(), miri ); + assert_eq!(projection.miri_enabled(), miri != 0); let artifact: Value = serde_json::from_slice(projection.artifact_bytes()).unwrap(); assert_eq!(artifact["schema_version"], PROJECTION_SCHEMA_VERSION); @@ -911,6 +935,7 @@ mod tests { assert_eq!(first.build_matrix_json(), second.build_matrix_json()); assert_eq!(first.miri_matrix_json(), second.miri_matrix_json()); + assert_eq!(first.miri_enabled(), second.miri_enabled()); assert_eq!(first.artifact_bytes(), second.artifact_bytes()); assert!(first.artifact_bytes().ends_with(b"\n")); } @@ -920,6 +945,15 @@ mod tests { let projection = GitHubProjection::create(inputs(), "pull_request").unwrap(); assert_eq!(projection.miri_matrix_json(), r#"{"include":[]}"#); + assert!(!projection.miri_enabled()); + } + + #[test] + fn a_nonempty_miri_matrix_enables_its_consumer() { + let projection = GitHubProjection::create(inputs(), "merge_group").unwrap(); + + assert_ne!(projection.miri_matrix_json(), r#"{"include":[]}"#); + assert!(projection.miri_enabled()); } #[test] @@ -957,19 +991,20 @@ mod tests { let projection = GitHubProjection::create(inputs(), "pull_request").unwrap(); let records = format!( - "{BUILD_MATRIX_OUTPUT}={}\n{MIRI_MATRIX_OUTPUT}={}\n", + "{BUILD_MATRIX_OUTPUT}={}\n{MIRI_MATRIX_OUTPUT}={}\n{MIRI_ENABLED_OUTPUT}={}\n", projection.build_matrix_json(), projection.miri_matrix_json(), + projection.miri_enabled(), ); assert_eq!(projection.output_utf16_bytes(), utf16_bytes(&records)); // These sizes make growth in the compact workflow contract visible. // A deliberate coverage change can update them, but adding derived // execution details to every cell should not pass unnoticed. - assert_eq!(projection.output_utf16_bytes(), 14_794); + assert_eq!(projection.output_utf16_bytes(), 14_832); assert_eq!( GitHubProjection::create(inputs(), "merge_group").unwrap().output_utf16_bytes(), - 60_798 + 60_834 ); } @@ -1103,8 +1138,9 @@ mod tests { output.contains(&format!("{BUILD_MATRIX_OUTPUT}={}\n", projection.build_matrix_json())) ); assert!( - output.ends_with(&format!("{MIRI_MATRIX_OUTPUT}={}\n", projection.miri_matrix_json())) + output.contains(&format!("{MIRI_MATRIX_OUTPUT}={}\n", projection.miri_matrix_json())) ); + assert!(output.ends_with(&format!("{MIRI_ENABLED_OUTPUT}={}\n", projection.miri_enabled()))); assert_eq!(fs::read(artifact).unwrap(), projection.artifact_bytes()); assert_eq!(fs::read_dir(&directory).unwrap().count(), 2);