diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58808b22ff..ed2cdd38e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,9 @@ env: RUSTUP_MAX_RETRIES: "10" # These two values are the producer/consumer protocol for the matrix's CI # image. `build_docker_env` must export this tag into this archive, each - # consumer must load that archive, and the Docker shell wrapper must run this - # tag. Keep the protocol centralized here rather than duplicating literals in - # the upload/download actions. + # consumer must load that archive, and each direct Docker bridge must run + # this tag. Keep the protocol centralized here rather than duplicating + # literals in the upload/download actions. ZC_CI_IMAGE: zerocopy-ci:local ZC_CI_IMAGE_ARCHIVE: zerocopy-ci.tar RUSTFLAGS: -Dwarnings @@ -59,26 +59,29 @@ jobs: # deliberately unprivileged: it may select ordinary test work, but runner, # action, permission, and command authority remain in this workflow. # - # The ordinary build and Miri jobs consume these matrices directly. The - # 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/workflow_protocol.rs`, the two - # `fromJSON` expressions below, and the Miri eligibility consumers below. + # The ordinary build, Miri, and semver jobs consume these matrices directly. + # The jobs consume only checked selectors from each matrix cell. The typed + # executors reconstruct ordinary build and Miri commands without interpreting + # matrix data as shell text; the separately audited semver adapter accepts + # only its target selector. Keep the output names coordinated with + # `tools/zc/src/workflow_protocol.rs`, the three `fromJSON` expressions below, + # and both optional-job eligibility consumers below. # The producer fields are checked by the planned-job workflow audit in # `tools/zc/src/planned_adapter/planner.rs`. plan_ci: - name: Plan ordinary CI work + name: Plan CI work runs-on: ubuntu-latest permissions: contents: read outputs: build_matrix: ${{ steps.plan.outputs.build_matrix }} miri_matrix: ${{ steps.plan.outputs.miri_matrix }} - # This gate is derived from the projected Miri matrix, not independently - # from the event name. Keep it coordinated with `workflow_protocol.rs`, - # the Miri job condition, and the required-check aggregation. miri_enabled: ${{ steps.plan.outputs.miri_enabled }} + semver_matrix: ${{ steps.plan.outputs.semver_matrix }} + # These gates are derived from their projected matrices, not independently + # from the event name. Keep them coordinated with `workflow_protocol.rs`, + # the optional job conditions, and the required-check aggregation. + semver_enabled: ${{ steps.plan.outputs.semver_enabled }} env: # upload-file-artifact requires its name to equal the path basename. # Keep this one value coordinated with the planner invocation and upload @@ -130,10 +133,6 @@ jobs: needs: [build_docker_env, plan_ci] permissions: contents: read - defaults: - run: - shell: /tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes build matrix commands through the prebuilt Docker image) - working-directory: zerocopy strategy: # By default, this is set to `true`, which means that a single CI job @@ -151,14 +150,13 @@ jobs: name: Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }}) steps: + # This defines the checkout step shared with Miri below. The planned-matrix + # audit checks its complete significant source and corresponding Miri + # alias. Keep the anchor, action identity, and inputs coordinated with + # `tools/zc/src/planned_adapter/matrix.rs`. - &matrix_checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # `Prepare cargo-semver-checks` reads the pull request head commit by - # its explicit SHA. Keep this depth large enough to include that parent - # of GitHub's synthetic pull request merge commit. A missing object - # makes `git log` fail rather than checking a different message. - fetch-depth: 2 persist-credentials: false # The producer exposes the ID assigned by upload-artifact, rather than an @@ -173,11 +171,11 @@ jobs: path: ${{ runner.temp }} expected-file: ${{ env.ZC_CI_IMAGE_ARCHIVE }} - # This step runs before /tmp/docker-shell.sh exists, so its explicit Bash - # shell is load-bearing. The Docker exporter is single-platform; the - # producer and every consumer intentionally use the same ubuntu-latest - # runner architecture. If CI gains another architecture, build and select a - # distinct artifact ID for it rather than silently sharing this archive. + # This host setup step uses an explicit shell. The Docker exporter is + # single-platform; the producer and every consumer intentionally use the + # same ubuntu-latest runner architecture. If CI gains another architecture, + # build and select a distinct artifact ID for it rather than silently + # sharing this archive. - &load_ci_image name: Load prebuilt Docker image shell: bash @@ -196,42 +194,6 @@ jobs: docker image inspect "$IMAGE_NAME" >/dev/null docker run --rm "$IMAGE_NAME" true - # This wrapper remains necessary for the semver preparation later in this - # job. The typed executor below deliberately does not use it. The matrix - # audit checks this complete step because it runs before that executor and - # therefore must not acquire any unreviewed authority over the checkout. - - name: Create Docker Shell Wrapper - shell: bash - # Keep the Docker steps' Cargo cache separate from the host Cargo home. - # The container runs as root, while later host-side actions run as the - # runner user and need to write their own Cargo registry cache. - run: | - set -eo pipefail - - mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git - - cat << 'EOF' > /tmp/docker-shell.sh - #!/bin/bash - # Boot an ephemeral container for the step, mounting the workspace and - # temp dirs. Explicitly forward GitHub Actions internal state and matrix - # environment variables. - docker run --rm -i \ - --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 ZC_TOOLCHAIN -e PR_HEAD_SHA \ - -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 \ - "$ZC_CI_IMAGE" bash -c "git config --global --add safe.directory '*' && exec bash -e -o pipefail \"\$1\"" -- "$1" - EOF - chmod +x /tmp/docker-shell.sh - # Setup includes repository-owned code, so checking only the executor's # source would not prove which checkout that executor invokes. Run this # exact host-side gate after every setup step and share it with Miri below. @@ -320,10 +282,9 @@ 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. 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 + # the typed argv and environment, and executes the complete cell. The + # complete Docker bridge below is explicit so the audit sees how the + # container is actually launched. 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 @@ -331,7 +292,10 @@ jobs: # terminator forces the inherited image value to be parsed as an image. The # planned-job workflow audit in `planned_adapter/matrix.rs` checks the step # fields exactly and this run block line-for-line. Keep protocol spellings - # coordinated through `tools/zc/src/workflow_protocol.rs`. + # coordinated through `tools/zc/src/workflow_protocol.rs`. Do not forward + # GITHUB_ENV or GITHUB_PATH into the container: the typed executor has no + # cross-step environment protocol, and it must remain the terminal step in + # this job. `planned_adapter/matrix.rs` audits that exact order. - 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 @@ -347,13 +311,12 @@ jobs: -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 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=*" \ @@ -370,93 +333,115 @@ jobs: --feature-profile "$FEATURE_PROFILE" \ --target "$TARGET" - # The semver check must remain a literal `uses` step, so it cannot run - # inside the typed executor. This workflow-owned preparation resolves the - # one action input which GitHub expressions cannot compute and handles the - # documented skip marker. Keep its selector condition coordinated with the - # action condition and the semver policy in `ci/zc.toml`; a later audit - # replaces this manual cross-file contract with a checked one. + # Semver needs a literal `uses` step, so it cannot run inside the typed + # executor. Give it a fresh runner instead of sequencing it with ordinary + # builds. This isolates the action in both directions: build setup cannot + # alter semver's checkout or process environment, while code compiled by the + # semver action cannot leave checkout or runner state for an ordinary build. + # It also lets semver start as soon as planning finishes, in parallel with + # Docker image production and the ordinary build fan-out. + # + # `plan.rs` selects semver work from checked policy and coverage; + # `github.rs` projects only the target selectors below; and + # `semver_adapter.rs` audits this complete job header and exact three-step + # sequence. Keep those files, `workflow_protocol.rs`, the planner outputs + # above, and the required-check aggregation below coordinated with this job. + semver: + if: needs.plan_ci.outputs.semver_enabled == 'true' + runs-on: ubuntu-latest + needs: [plan_ci] + permissions: + contents: read + strategy: + fail-fast: false + # The planner emits a complete `include` object. Target selection and + # waivers belong to `ci/zc.toml`; do not reproduce them as handwritten + # axes, exclusions, or step conditions here. + matrix: ${{ fromJSON(needs.plan_ci.outputs.semver_matrix) }} + name: Semver (${{ matrix.target }}) + steps: + # Unlike the ordinary matrix checkout, this needs enough history to read + # the pull request head commit behind GitHub's synthetic merge commit. The + # semver audit owns this complete step; do not reuse the matrix anchor. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + + # This preparation handles only the documented commit-message skip marker + # and the vendored-source workaround. Its step-local output prevents an + # ambient variable or unrelated step from suppressing the action. Keep its + # shell, directory, environment, and run block coordinated exactly with + # `SemverAdapterSpec` in `tools/zc/src/semver_adapter.rs`. - name: Prepare cargo-semver-checks + id: prepare_semver + 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 }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - ZC_TOOLCHAIN="$(./cargo.sh --version "$TOOLCHAIN")" - printf "Resolved the '%s' toolchain to %s\n" \ - "$TOOLCHAIN" "$ZC_TOOLCHAIN" | tee -a "$GITHUB_STEP_SUMMARY" - printf 'ZC_TOOLCHAIN=%s\n' "$ZC_TOOLCHAIN" >> "$GITHUB_ENV" - # Pull request jobs check the head commit rather than GitHub's synthetic # merge commit. `PR_HEAD_SHA`, the depth-2 checkout above, and this # lookup are one contract: if checkout stops fetching that object, # `git log` fails instead of silently inspecting another message. if [[ "$GITHUB_EVENT_NAME" == 'pull_request' ]]; then - MESSAGE="$(git log -1 --pretty=%B "$PR_HEAD_SHA")" + MESSAGE="$(/usr/bin/git log -1 --pretty=%B "$PR_HEAD_SHA")" MESSAGE_SOURCE='pull request head commit message' else - MESSAGE="$(git log -1 --pretty=%B HEAD)" + MESSAGE="$(/usr/bin/git log -1 --pretty=%B HEAD)" MESSAGE_SOURCE='commit message' fi - if grep -Eq \ + if /usr/bin/grep -Eq \ '^[[:space:]]*SKIP_CARGO_SEMVER_CHECKS=1[[:space:]]*$' \ <<< "$MESSAGE"; then printf "Found 'SKIP_CARGO_SEMVER_CHECKS=1' in the %s; " \ - "$MESSAGE_SOURCE" | tee -a "$GITHUB_STEP_SUMMARY" + "$MESSAGE_SOURCE" | /usr/bin/tee -a "$GITHUB_STEP_SUMMARY" printf 'skipping cargo-semver-checks.\n' | \ - tee -a "$GITHUB_STEP_SUMMARY" - printf 'ZC_SKIP_CARGO_SEMVER_CHECKS=1\n' >> "$GITHUB_ENV" + /usr/bin/tee -a "$GITHUB_STEP_SUMMARY" + printf 'run=false\n' >> "$GITHUB_OUTPUT" else # FIXME(#2906): cargo-semver-checks fetches the latest Zerocopy from # crates.io, but the vendored-source configuration cannot resolve - # that package. This exact file removal affects only a semver matrix - # cell. Switch to --baseline-rev before removing this workaround. - rm .cargo/config.toml + # that package. This exact file removal affects only this isolated + # checkout. Switch to --baseline-rev before removing the workaround. + /usr/bin/rm .cargo/config.toml + printf 'run=true\n' >> "$GITHUB_OUTPUT" fi - # TODO(#1565): Run on wasm32-unknown-unknown. - if: | - matrix.crate == 'zerocopy' && - matrix.feature_profile == 'stable' && - matrix.toolchain == 'stable' && - matrix.target != 'wasm32-unknown-unknown' - - # Check semver compatibility with the most recently-published version on - # crates.io. We do this in the matrix rather than in its own job so that it - # gets run on different targets. Some of our API is target-specific (e.g., - # SIMD type impls), and so we need to run on each target. + + # Check compatibility with the most recently published Zerocopy version. + # The planner selects each supported non-waived target because some public + # API is target-specific. Keep every literal and the action pin coordinated + # with `SemverAdapterSpec`; changing any one makes `ci audit` fail. - name: Check semver compatibility uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2.9 env: - # `zerocopy_unstable_ptr` exposes unstable API, so it should not be - # included in semver checks. + # `zerocopy_unstable_ptr` exposes unstable API. Preserve warning denial + # without inheriting that workflow-wide rustdoc cfg into this action. RUSTDOCFLAGS: -Dwarnings + RUSTFLAGS: -Dwarnings with: - # Don't semver check zerocopy-derive; as a proc macro, it doesn't have - # an API that cargo-semver-checks can understand. + # The proc-macro crate has no API cargo-semver-checks can understand. package: zerocopy - # Test on the stable toolchain, and thus don't test nightly features. - # We previously tested on the nightly toolchain, but this caused problems - # [1] because cargo-semver-checks only promises compatibility with the - # latest stable toolchain. Testing on the stable toolchain is more - # reliable, and doesn't require us to give up anything - we wouldn't want - # to test nightly-only features anyway, as we don't make stability - # guarantees regarding these features. - # - # [1] See, for example: https://github.com/google/zerocopy/actions/runs/9466417300/job/26078264384?pr=1413 + # Use only explicitly named stable features. The action supports the + # latest stable compiler, while nightly-only APIs carry no stability + # guarantee. feature-group: only-explicit-features features: __internal_use_only_features_that_work_on_stable manifest-path: zerocopy/Cargo.toml - rust-toolchain: ${{ env.ZC_TOOLCHAIN }} + # The action's automatic baseline-rustdoc cache key does not include + # `rust-target`. Give concurrent targets distinct prefixes so they do + # not race while reading and writing one cache entry. + prefix-key: ${{ matrix.target }} + # Keep this exact version coordinated with + # `zerocopy/Cargo.toml`'s `package.metadata.ci.pinned-stable` and + # `SemverAdapterSpec`. A partial stable-toolchain roll fails locally. + rust-toolchain: 1.93.1 rust-target: ${{ matrix.target }} - # TODO(#1565): Run on wasm32-unknown-unknown. if: | - matrix.crate == 'zerocopy' && - matrix.feature_profile == 'stable' && - matrix.toolchain == 'stable' && - matrix.target != 'wasm32-unknown-unknown' && - env.ZC_SKIP_CARGO_SEMVER_CHECKS != '1' + steps.prepare_semver.outputs.run == 'true' # 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 @@ -499,6 +484,9 @@ jobs: # fail-closed boundary as the ordinary build bridge. The matrix audit # checks the fields exactly and this run block line-for-line. Keep the # Miri-model and command spellings in `workflow_protocol.rs` coordinated. + # Keep the same GITHUB_ENV/GITHUB_PATH exclusion as the build bridge so a + # future step cannot inherit environment changes written by this container + # without first extending the typed audit. - 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 @@ -515,13 +503,12 @@ jobs: -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 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=*" \ @@ -947,19 +934,19 @@ jobs: # success in branch protection, so this aggregation must fail closed. if: ${{ always() }} runs-on: ubuntu-latest - needs: [build_test, miri, 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] + 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 Miri - # plan is still the nonempty JSON value {"include":[]}; the boolean gate - # must also be present and canonical before aggregation trusts it. + # 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.miri_enabled != 'true' && needs.plan_ci.outputs.miri_enabled != 'false')) }} + 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 @@ -972,22 +959,29 @@ jobs: # 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 a planner-disabled Miri job is identified - # rather than accepting an arbitrary skipped dependency. + # 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 enabled "$MIRI_ENABLED" --arg miri "$MIRI_RESULT" ' + /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 - if $enabled == "false" - then $miri == "skipped" and - ([.[] | select(. == "skipped")] | length) == 1 and - all(.[]; . == "success" or . == "skipped") - else $enabled == "true" and - $miri == "success" and - all(.[]; . == "success") - end + 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/ci/workflow-jobs.tsv b/ci/workflow-jobs.tsv index 9d2b2207ba..94f31d32ae 100644 --- a/ci/workflow-jobs.tsv +++ b/ci/workflow-jobs.tsv @@ -46,6 +46,7 @@ workflow job role .github/workflows/ci.yml miri planned .github/workflows/ci.yml plan_ci static-ci .github/workflows/ci.yml run-git-hooks static-ci +.github/workflows/ci.yml semver planned .github/workflows/ci.yml zizmor security .github/workflows/dependency-review.yml dependency-review security .github/workflows/docs.yml build documentation diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index c2de3eeeb3..e57d58f512 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -12,10 +12,11 @@ //! [`CiInputs`] until the policy is valid, its references agree with live Cargo //! metadata and repository files, every workflow job has an exact reviewed //! role, the handwritten matrix jobs exactly publish and consume typed plans, -//! 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. +//! 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. use std::{ collections::HashMap, @@ -32,6 +33,7 @@ use crate::{ planned_adapter::{audit_planned_adapter, PlannedAdapterAuditError}, policy::{Baselines, Policy, ReadPolicyError}, repository_file::{self, OpenRepositoryFileError, OpenedRepositoryFile}, + semver_adapter::{audit_semver_adapter, SemverAdapterAuditError}, workflow::{ audit_workflows, ReviewedWorkflowJobs, WorkflowAuditError, WorkflowRegistryError, WORKFLOW_REGISTRY_PATH, @@ -108,6 +110,15 @@ impl CiInputs { .map_err(LoadCiError::Inventory)?; audit_planned_adapter(&repository_root, workflow_source, &workflow_jobs, &repository) .map_err(LoadCiError::PlannedAdapter)?; + // `audit_workflows` deliberately recognizes jobs, not arbitrary YAML + // steps. GitHub requires the semver action reference to remain literal, + // so check the complete standalone job only after policy and Cargo + // inventory are trustworthy. The preceding planned-adapter audit has + // already established exact planner publication and reviewed ownership; + // this focused audit checks semver's target-only matrix consumer, fresh + // runner boundary, checkout, preparation, and literal action. + audit_semver_adapter(workflow_source, &policy, &repository) + .map_err(LoadCiError::SemverAdapter)?; let baseline_files = OpenLegacyBaselineFiles::open(&repository_root, policy.baselines())?; let paths = baseline_files.paths(); // Policy validation rejects two fields with the same lexical path. @@ -354,6 +365,9 @@ pub enum LoadCiError { /// The planned-job workflow bridge did not publish or execute plans exactly. #[error(transparent)] PlannedAdapter(PlannedAdapterAuditError), + /// The standalone literal semver job did not implement policy. + #[error(transparent)] + SemverAdapter(SemverAdapterAuditError), /// The frozen legacy evidence was unreadable or noncanonical. #[error(transparent)] Baseline(BaselineError), diff --git a/tools/zc/src/cli.rs b/tools/zc/src/cli.rs index 4a17a7939d..adbf69ed90 100644 --- a/tools/zc/src/cli.rs +++ b/tools/zc/src/cli.rs @@ -37,7 +37,7 @@ use crate::{ github::{GitHubProjection, ProjectionError, ProjectionWriteError}, plan::{ BuildPlanCell, ExecutionMode, FeatureSelection, MiriPlanCell, Plan, PlanError, - PlanExplanation, + PlanExplanation, SemverPlanCell, }, workflow_protocol::{ CELL_FEATURE_PROFILE_OPTION, CELL_MIRI_MODEL_OPTION, CELL_PACKAGE_OPTION, @@ -376,11 +376,12 @@ fn audit(inputs: &CiInputs, output: &mut impl Write) -> Result<(), CliError> { for plan in plans { writeln!( output, - "{}: {} coverage; {} build cells; {} Miri cells", + "{}: {} coverage; {} build cells; {} Miri cells; {} semver cells", plan.event(), plan.class(), plan.builds().len(), plan.miri().len(), + plan.semver().len(), )?; } Ok(()) @@ -392,12 +393,16 @@ fn print_plan(inputs: &CiInputs, event: &str, output: &mut impl Write) -> Result writeln!(output, "coverage: {}", plan.class())?; writeln!(output, "build cells: {}", plan.builds().len())?; writeln!(output, "Miri cells: {}", plan.miri().len())?; + writeln!(output, "semver cells: {}", plan.semver().len())?; for cell in plan.builds() { print_build_cell(output, cell)?; } for cell in plan.miri() { print_miri_cell(output, cell)?; } + for cell in plan.semver() { + print_semver_cell(output, cell)?; + } Ok(()) } @@ -441,6 +446,20 @@ fn print_miri_cell(output: &mut impl Write, cell: &MiriPlanCell) -> io::Result<( writeln!(output, "]") } +fn print_semver_cell(output: &mut impl Write, cell: &SemverPlanCell) -> io::Result<()> { + write!( + output, + "semver: package={} manifest={} toolchain={} version={} profile={} features=", + cell.package().id(), + cell.package().manifest().display(), + cell.toolchain().id(), + cell.toolchain().version(), + cell.features().profile(), + )?; + print_feature_selection(output, cell.features().selection())?; + writeln!(output, " target={}", cell.target().triple()) +} + fn print_feature_selection( output: &mut impl Write, selection: &FeatureSelection, @@ -506,9 +525,6 @@ fn print_execution_report( for step in report.executed_steps() { writeln!(output, "executed: {step}")?; } - for step in report.workflow_owned_steps() { - writeln!(output, "skipped workflow-owned step: {step}")?; - } Ok(()) } @@ -662,7 +678,10 @@ mod tests { use super::{run, CliError, Command}; use crate::{ execution::{BuildCellSelector, MiriCellSelector}, - workflow_protocol::{BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT}, + workflow_protocol::{ + BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, SEMVER_ENABLED_OUTPUT, + SEMVER_MATRIX_OUTPUT, + }, }; fn strings(args: &[&str]) -> Vec { @@ -887,10 +906,10 @@ mod tests { String::from_utf8(output).unwrap(), concat!( "CI audit passed\n", - "merge_group: full coverage; 182 build cells; 64 Miri cells\n", - "pull_request: reduced coverage; 60 build cells; 0 Miri cells\n", - "push: full coverage; 182 build cells; 64 Miri cells\n", - "workflow_dispatch: full coverage; 182 build cells; 64 Miri cells\n", + "merge_group: full coverage; 182 build cells; 64 Miri cells; 9 semver cells\n", + "pull_request: reduced coverage; 60 build cells; 0 Miri cells; 3 semver cells\n", + "push: full coverage; 182 build cells; 64 Miri cells; 9 semver cells\n", + "workflow_dispatch: full coverage; 182 build cells; 64 Miri cells; 9 semver cells\n", ) ); } @@ -901,10 +920,11 @@ mod tests { run(repository_root(), strings(&["plan", "--event", "pull_request"]), &mut output).unwrap(); let output = String::from_utf8(output).unwrap(); assert!(output.starts_with( - "event: pull_request\ncoverage: reduced\nbuild cells: 60\nMiri cells: 0\n" + "event: pull_request\ncoverage: reduced\nbuild cells: 60\nMiri cells: 0\nsemver cells: 3\n" )); assert_eq!(output.lines().filter(|line| line.starts_with("build: ")).count(), 60); assert!(!output.lines().any(|line| line.starts_with("miri: "))); + assert_eq!(output.lines().filter(|line| line.starts_with("semver: ")).count(), 3); } #[test] @@ -974,7 +994,9 @@ mod tests { let job_outputs = fs::read_to_string(github_output).unwrap(); 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"))); + assert!(job_outputs.contains(&format!("{MIRI_ENABLED_OUTPUT}=false\n"))); + assert!(job_outputs.contains(&format!("{SEMVER_MATRIX_OUTPUT}={{\"include\":["))); + assert!(job_outputs.ends_with(&format!("{SEMVER_ENABLED_OUTPUT}=true\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/execution.rs b/tools/zc/src/execution.rs index 4f52db2c36..d752fc1e51 100644 --- a/tools/zc/src/execution.rs +++ b/tools/zc/src/execution.rs @@ -10,17 +10,18 @@ //! //! [`Plan`](crate::plan::Plan) decides matrix membership. This module takes the //! next deliberately separate step: it expands each selected cell into the -//! ordinary Cargo or Miri operations which that cell means. The local executor -//! can run one explicitly selected cell, but it does not inspect workflow YAML -//! or make any decision about runners, permissions, secrets, actions, or -//! publication. Those remain visible workflow authority in +//! ordinary Cargo, Miri, or semver operations which that cell means. The local +//! executor can run one explicitly selected cell, but it does not inspect +//! workflow YAML or make any decision about runners, permissions, secrets, +//! actions, or publication. Those remain visible workflow authority in //! `.github/workflows/ci.yml`. //! //! The operation builders below are the single semantic source for both parity //! checking and local execution. `ci.yml` passes complete cell selectors back //! to this module instead of reproducing Cargo or Miri commands. The semver //! action remains an explicit workflow-owned exception because GitHub requires -//! a literal `uses` value. The independent files under `ci/baselines/` are +//! a literal `uses` value, but its target membership and typed operation are +//! modeled here. The independent files under `ci/baselines/` are //! comparison evidence only: this module never reads a baseline row to //! construct proposed behavior. In //! particular, the legacy comparison covers the repository state named by the @@ -54,12 +55,16 @@ use crate::{ ci::CiInputs, plan::{ BuildPlanCell, EventClass, ExecutionMode, FeatureSelection, MiriPlanCell, Plan, PlanError, + SemverPlanCell, }, policy::{Policy, ToolchainSource}, + semver_adapter::{ + SemverAdapterSpec, SEMVER_CACHE_PREFIX_INPUT, SEMVER_FEATURE_GROUP, + SEMVER_MATRIX_TARGET_EXPRESSION, SEMVER_TARGET_INPUT, + }, + workflow_protocol::{BUILD_JOB, MIRI_JOB, SEMVER_JOB, SEMVER_STEP_NAME}, }; -const BUILD_JOB: &str = "build_test"; -const MIRI_JOB: &str = "miri"; const MATRIX_WORKING_DIRECTORY: &str = "zerocopy"; // Keep the platform-neutral command model and its frozen evidence in terms of // the public Unix wrapper. Only the host boundary below translates that exact @@ -88,13 +93,6 @@ const BASE_RUSTDOCFLAGS: &str = "-Dwarnings --cfg=zerocopy_unstable_ptr"; const NIGHTLY_RUSTFLAGS: &str = "-Zrandomize-layout"; const NIGHTLY_MIRIFLAGS: &str = "-Zmiri-strict-provenance -Zmiri-backtrace=full"; -// GitHub requires `uses` to remain visible in workflow YAML. This typed value -// freezes the action identity used by the command model, while the workflow -// audit and ordinary code review continue to own the actual action authority. -// Keep it coordinated with the "Check semver compatibility" step in `ci.yml`. -const SEMVER_ACTION: &str = - "obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239"; - /// A deterministic failure proving that proposed execution behavior differs /// from independently captured legacy evidence. #[derive(Clone, Debug, Eq, PartialEq)] @@ -230,23 +228,17 @@ impl MiriCellSelector { #[derive(Clone, Debug, Eq, PartialEq)] pub struct CellExecutionReport { executed_steps: Vec, - workflow_owned_steps: Vec, } impl CellExecutionReport { fn new() -> Self { - Self { executed_steps: Vec::new(), workflow_owned_steps: Vec::new() } + Self { executed_steps: Vec::new() } } /// Returns modeled process steps which completed successfully, in order. pub fn executed_steps(&self) -> &[String] { &self.executed_steps } - - /// Returns selected steps which remain owned by GitHub Actions. - pub fn workflow_owned_steps(&self) -> &[String] { - &self.workflow_owned_steps - } } /// A deterministic selection, model, process, or Miri-setup failure. @@ -384,10 +376,9 @@ pub enum CellExecutionError { /// Executes the modeled commands for one exact selected ordinary build cell. /// -/// The semver action is intentionally not executed: GitHub requires its -/// literal `uses` identity and security-relevant condition to remain in the -/// workflow. If the selected cell includes that action, the returned report -/// names it as workflow-owned instead of silently treating it as completed. +/// Semver is deliberately absent from this executor. It has an independent +/// plan and workflow job so repository-controlled build code cannot share a +/// runner with the compatibility check in either direction. pub fn execute_build_cell( inputs: &CiInputs, selector: &BuildCellSelector, @@ -555,12 +546,9 @@ fn execute_build_cell_with( )?; let semantics = BuildCellSemantics::from_plan(cell, inputs.policy()) .map_err(|message| CellExecutionError::Model { message })?; - let operations = build_operations( - inputs.policy(), - inputs.repository().zerocopy_docs_rs_rustdoc_args(), - &semantics, - ) - .map_err(|message| CellExecutionError::Model { message })?; + let operations = + build_operations(inputs.repository().zerocopy_docs_rs_rustdoc_args(), &semantics) + .map_err(|message| CellExecutionError::Model { message })?; let mut report = CellExecutionReport::new(); for operation in operations { @@ -578,14 +566,6 @@ fn execute_build_cell_with( run_process(host, repository_root, &operation.command, argv)?; report.executed_steps.push(operation.command.step.clone()); } - CommandPayload::ActionInputs { .. } - if operation.kind == MatrixOperationKind::CargoSemverCheck => - { - // The action identity, condition, and permission boundary must - // stay literal in ci.yml. This explicit report is coupled to - // that audited adapter until GitHub supports dynamic `uses`. - report.workflow_owned_steps.push(operation.command.step.clone()); - } CommandPayload::ActionInputs { .. } => { return Err(CellExecutionError::UnsupportedPayload { step: operation.command.step.clone(), @@ -921,13 +901,14 @@ impl MatrixOperationKind { Self::CargoClippyTests => "Clippy tests", Self::CargoClippyLibrary => "Clippy", Self::CargoDoc => "Cargo doc", - Self::CargoSemverCheck => "Check semver compatibility", + Self::CargoSemverCheck => SEMVER_STEP_NAME, Self::MiriTest => "Run tests under Miri", } } fn job(self) -> &'static str { match self { + Self::CargoSemverCheck => SEMVER_JOB, Self::MiriTest => MIRI_JOB, _ => BUILD_JOB, } @@ -1007,9 +988,7 @@ struct MatrixOperation { #[derive(Clone, Debug)] struct BuildCellSemantics { package: String, - manifest: String, toolchain: String, - toolchain_version: String, pinned_nightly: bool, feature_profile: String, features: FeatureSelection, @@ -1021,9 +1000,7 @@ impl BuildCellSemantics { fn from_plan(cell: &BuildPlanCell, policy: &Policy) -> Result { Ok(Self { package: cell.package().id().to_owned(), - manifest: path_text(cell.package().manifest())?, toolchain: cell.toolchain().id().to_owned(), - toolchain_version: cell.toolchain().version().to_owned(), pinned_nightly: is_pinned_nightly(policy, cell.toolchain().id())?, feature_profile: cell.features().profile().to_owned(), features: cell.features().selection().clone(), @@ -1033,6 +1010,31 @@ impl BuildCellSemantics { } } +#[derive(Clone, Debug)] +struct SemverCellSemantics { + package: String, + manifest: String, + toolchain: String, + toolchain_version: String, + feature_profile: String, + features: FeatureSelection, + target: String, +} + +impl SemverCellSemantics { + fn from_plan(cell: &SemverPlanCell) -> Result { + Ok(Self { + package: cell.package().id().to_owned(), + manifest: path_text(cell.package().manifest())?, + toolchain: cell.toolchain().id().to_owned(), + toolchain_version: cell.toolchain().version().to_owned(), + feature_profile: cell.features().profile().to_owned(), + features: cell.features().selection().clone(), + target: cell.target().triple().to_owned(), + }) + } +} + #[derive(Clone, Debug)] struct MiriCellSemantics { package: String, @@ -1111,6 +1113,7 @@ trait ModelMutation { fn mutate_docs_rs_rustdoc_args(&mut self, _arguments: &mut Vec) {} fn mutate_build_cell(&mut self, _class: EventClass, _cell: &mut BuildCellSemantics) {} fn mutate_miri_cell(&mut self, _class: EventClass, _cell: &mut MiriCellSemantics) {} + fn mutate_semver_cell(&mut self, _class: EventClass, _cell: &mut SemverCellSemantics) {} fn mutate_operation(&mut self, _class: EventClass, _operation: &mut MatrixOperation) {} } @@ -1122,6 +1125,14 @@ fn derive_execution( inputs: &CiInputs, mutation: &mut impl ModelMutation, ) -> Result { + // GitHub requires the semver action invocation to remain literal workflow + // YAML. Build its typed specification once here as well as auditing that + // YAML at the `CiInputs` boundary. The independently planned semver cells + // and the live adapter therefore cannot acquire independent action + // identities, input sets, or explicit environment values. + let semver_adapter = + SemverAdapterSpec::from_checked_inputs(inputs.policy(), inputs.repository()) + .map_err(|error| format!("semver adapter: {error}"))?; let mut docs_rs_rustdoc_args = inputs.repository().zerocopy_docs_rs_rustdoc_args().to_vec(); mutation.mutate_docs_rs_rustdoc_args(&mut docs_rs_rustdoc_args); let reduced = plan_for_class(inputs, EventClass::Reduced)?; @@ -1133,7 +1144,7 @@ fn derive_execution( for cell in plan.builds() { let mut cell = BuildCellSemantics::from_plan(cell, inputs.policy())?; mutation.mutate_build_cell(class, &mut cell); - for mut operation in build_operations(inputs.policy(), &docs_rs_rustdoc_args, &cell)? { + for mut operation in build_operations(&docs_rs_rustdoc_args, &cell)? { mutation.mutate_operation(class, &mut operation); validate_operation(&operation)?; if !operation.applicable { @@ -1145,6 +1156,28 @@ fn derive_execution( } } } + for cell in plan.semver() { + let mut cell = SemverCellSemantics::from_plan(cell)?; + mutation.mutate_semver_cell(class, &mut cell); + let mut operation = semver_operation(&semver_adapter, &cell)?; + mutation.mutate_operation(class, &mut operation); + validate_operation(&operation)?; + if !operation.applicable { + continue; + } + + // The frozen evidence describes the historical workflow in which + // this exact operation ran inside `build_test`. Normalize only its + // source job at the comparison boundary: the live typed operation + // above must continue to validate as belonging to the isolated + // semver job, while every other logical and command field remains + // subject to byte-for-byte legacy parity. + let operation = operation_for_frozen_legacy_evidence(operation); + record_logical(&mut logical, class, &operation.logical)?; + if class == EventClass::Full { + collect_golden(&mut commands, operation)?; + } + } for cell in plan.miri() { let mut cell = MiriCellSemantics::from_plan(cell, inputs.policy())?; mutation.mutate_miri_cell(class, &mut cell); @@ -1259,6 +1292,34 @@ fn validate_operation(operation: &MatrixOperation) -> Result<(), String> { Ok(()) } +/// Adapts a validated live operation to the source labels in frozen evidence. +/// +/// The baseline source commit ran semver as a step of `build_test`, whereas +/// the live control plane now gives it a separate runner. It also lacked the +/// target-specific cache prefix now required to keep concurrent action runs +/// from sharing one baseline-rustdoc cache. Keeping both adaptations here, +/// immediately before evidence collection, preserves exact comparison of +/// every historical field while the live workflow audits own these deliberate +/// post-baseline changes. Callers must validate the live operation first: the +/// adapted operation intentionally no longer passes live [`validate_operation`]. +fn operation_for_frozen_legacy_evidence(mut operation: MatrixOperation) -> MatrixOperation { + if operation.kind == MatrixOperationKind::CargoSemverCheck { + debug_assert_eq!(operation.logical.job, SEMVER_JOB); + debug_assert_eq!(operation.command.job, SEMVER_JOB); + operation.logical.job = BUILD_JOB.to_owned(); + operation.command.job = BUILD_JOB.to_owned(); + let CommandPayload::ActionInputs { inputs, .. } = &mut operation.command.payload else { + unreachable!("validated semver operations always use action inputs"); + }; + let Some(JsonValue::Object(with)) = inputs.get_mut("with") else { + unreachable!("validated semver action inputs always contain `with`"); + }; + let removed = with.remove(SEMVER_CACHE_PREFIX_INPUT); + debug_assert!(matches!(removed, Some(JsonValue::String(_)))); + } + operation +} + fn matrix_logical_spec( kind: MatrixOperationKind, package: &str, @@ -1297,7 +1358,6 @@ fn matrix_command( } fn build_operations( - policy: &Policy, docs_rs_rustdoc_args: &[String], cell: &BuildCellSemantics, ) -> Result, String> { @@ -1368,9 +1428,6 @@ fn build_operations( } operations.push(docs_operation(docs_rs_rustdoc_args, cell)); - if semver_applies(policy, cell)? { - operations.push(semver_operation(cell)?); - } Ok(operations) } @@ -1478,19 +1535,10 @@ fn docs_operation(docs_rs_rustdoc_args: &[String], cell: &BuildCellSemantics) -> } } -fn semver_applies(policy: &Policy, cell: &BuildCellSemantics) -> Result { - let semver = policy.semver(); - let targets = policy - .target_sets() - .get(semver.target_set().as_str()) - .ok_or_else(|| format!("semver target set `{}` is absent", semver.target_set()))?; - Ok(cell.package == semver.package().as_str() - && cell.toolchain == semver.toolchain().as_str() - && cell.feature_profile == semver.profile().as_str() - && targets.iter().any(|target| target.as_str() == cell.target)) -} - -fn semver_operation(cell: &BuildCellSemantics) -> Result { +fn semver_operation( + adapter: &SemverAdapterSpec, + cell: &SemverCellSemantics, +) -> Result { let kind = MatrixOperationKind::CargoSemverCheck; let stable_feature = match &cell.features { FeatureSelection::StableAggregate { feature } => feature.clone(), @@ -1501,16 +1549,45 @@ fn semver_operation(cell: &BuildCellSemantics) -> Result Result Option<&'static str> { .then_some("nightly-docs") } -fn semver_golden(cell: &BuildCellSemantics) -> Option<&'static str> { +fn semver_golden(cell: &SemverCellSemantics) -> Option<&'static str> { (cell.target == "x86_64-unknown-linux-gnu").then_some("semver") } @@ -2258,15 +2332,21 @@ mod tests { use super::{ audit_execution, checked_miri_thread_count, compare_execution, derive_execution, execute_build_cell_with, execute_miri_cell_with, miri_thread_count, - miri_wrapper_invocation, parse_nproc_thread_count, substitute_dynamic, - system_command_for_platform, unique_match, BuildCellSelector, BuildCellSemantics, - CapturedProcessOutcome, CellExecutionError, CommandSpec, EventClass, ExecutionHost, - ExecutionMode, FeatureSelection, HostPlatform, MatrixOperation, MatrixOperationKind, - MiriCellSelector, ModelMutation, ProcessInvocation, ProcessOutcome, WorkingDirectory, - AARCH64_TARGET, CARGO_WRAPPER, EXECUTION_CONTEXT_ENV, MIRI_JOB, - MIRI_REPOSITORY_ROOT_CONTEXT, MIRI_THREAD_PLACEHOLDER, NPROC_STEP, WINDOWS_CARGO_WRAPPER, + miri_wrapper_invocation, operation_for_frozen_legacy_evidence, parse_nproc_thread_count, + semver_operation, substitute_dynamic, system_command_for_platform, unique_match, + BuildCellSelector, BuildCellSemantics, CapturedProcessOutcome, CellExecutionError, + CommandSpec, EventClass, ExecutionHost, ExecutionMode, FeatureSelection, HostPlatform, + MatrixOperation, MatrixOperationKind, MiriCellSelector, ModelMutation, ProcessInvocation, + ProcessOutcome, SemverCellSemantics, WorkingDirectory, AARCH64_TARGET, BUILD_JOB, + CARGO_WRAPPER, EXECUTION_CONTEXT_ENV, MIRI_JOB, MIRI_REPOSITORY_ROOT_CONTEXT, + MIRI_THREAD_PLACEHOLDER, NPROC_STEP, SEMVER_JOB, WINDOWS_CARGO_WRAPPER, + }; + use crate::{ + baseline::{CommandPayload, JsonValue}, + ci::CiInputs, + plan::Plan, + semver_adapter::{SemverAdapterSpec, SEMVER_CACHE_PREFIX_INPUT}, }; - use crate::{baseline::CommandPayload, ci::CiInputs}; fn inputs() -> &'static CiInputs { static INPUTS: OnceLock = OnceLock::new(); @@ -2454,7 +2534,6 @@ mod tests { let report = execute_build_cell_with(inputs(), &selector, &mut host).unwrap(); assert_eq!(report.executed_steps, ["Test native target", "Cargo doc"]); - assert_eq!(report.workflow_owned_steps, ["Check semver compatibility"]); assert_eq!(host.invocations.len(), 2); assert_eq!( host.invocations[0].argv, @@ -2883,6 +2962,68 @@ mod tests { audit_execution(inputs()).unwrap(); } + #[test] + fn semver_normalizes_only_its_two_deliberate_post_baseline_changes() { + let plan = Plan::create(inputs(), "push").unwrap(); + let cell = plan + .semver() + .iter() + .find(|cell| cell.target().triple() == "x86_64-unknown-linux-gnu") + .unwrap(); + let cell = SemverCellSemantics::from_plan(cell).unwrap(); + let adapter = + SemverAdapterSpec::from_checked_inputs(inputs().policy(), inputs().repository()) + .unwrap(); + let operation = semver_operation(&adapter, &cell).unwrap(); + + assert_eq!(operation.logical.job, SEMVER_JOB); + assert_eq!(operation.command.job, SEMVER_JOB); + super::validate_operation(&operation).unwrap(); + + let live = operation.clone(); + let mut expected_historical_payload = live.command.payload.clone(); + let CommandPayload::ActionInputs { inputs, .. } = &mut expected_historical_payload else { + panic!("semver operation must use action inputs"); + }; + let Some(JsonValue::Object(with)) = inputs.get_mut("with") else { + panic!("semver action inputs must contain `with`"); + }; + assert_eq!( + with.remove(SEMVER_CACHE_PREFIX_INPUT), + Some(JsonValue::String(cell.target.clone())) + ); + + let evidence = operation_for_frozen_legacy_evidence(operation); + assert_eq!(evidence.logical.job, BUILD_JOB); + assert_eq!(evidence.command.job, BUILD_JOB); + assert_eq!(evidence.logical.key, live.logical.key); + assert_eq!(evidence.logical.condition, live.logical.condition); + assert_eq!(evidence.logical.step, live.logical.step); + assert_eq!(evidence.command.step, live.command.step); + assert_eq!(evidence.command.working_directory, live.command.working_directory); + assert_eq!(evidence.command.environment, live.command.environment); + assert_eq!(evidence.command.payload, expected_historical_payload); + } + + struct InvalidSemverFeatures(bool); + + impl ModelMutation for InvalidSemverFeatures { + fn mutate_semver_cell(&mut self, class: EventClass, cell: &mut SemverCellSemantics) { + if !self.0 && class == EventClass::Full && cell.target == "x86_64-unknown-linux-gnu" { + cell.features = FeatureSelection::All; + self.0 = true; + } + } + } + + #[test] + fn semver_rejects_feature_semantics_which_the_action_cannot_represent() { + let mut mutation = InvalidSemverFeatures(false); + let diagnostic = model_error_with(&mut mutation); + assert!(mutation.0, "the test must mutate its intended semver cell"); + assert!(diagnostic.contains("requires stable-aggregate features")); + } + struct NonGoldenArgvMutation(bool); impl ModelMutation for NonGoldenArgvMutation { diff --git a/tools/zc/src/github.rs b/tools/zc/src/github.rs index 25babb99ec..b8dd0ff2ca 100644 --- a/tools/zc/src/github.rs +++ b/tools/zc/src/github.rs @@ -9,11 +9,11 @@ //! A narrow, deterministic bridge from a checked CI plan to GitHub Actions. //! //! Planning and workflow authority deliberately remain separate. The planner -//! decides which ordinary build and Miri cells belong to an event. This module -//! serializes only those selectors and their documented execution meaning. It -//! cannot choose runner labels, permissions, secrets, environments, actions, -//! or shell commands; those choices remain visible in hand-written workflow -//! YAML. +//! decides which ordinary build, Miri, and semver cells belong to an event. +//! This module serializes only those selectors and their documented execution +//! meaning. It cannot choose runner labels, permissions, secrets, environments, +//! actions, or shell commands; those choices remain visible in hand-written +//! workflow YAML. //! //! There are two deliberately different JSON forms: //! @@ -41,9 +41,12 @@ use crate::{ ci::CiInputs, plan::{ BuildPlanCell, CellDecision, DecisionReason, EventClass, ExecutionMode, FeatureSelection, - MiriPlanCell, PlanError, PlanExplanation, + MiriPlanCell, PlanError, PlanExplanation, SemverPlanCell, + }, + workflow_protocol::{ + BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, SEMVER_ENABLED_OUTPUT, + SEMVER_MATRIX_OUTPUT, }, - workflow_protocol::{BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT}, }; /// The artifact schema emitted by this version of `zc`. @@ -51,7 +54,7 @@ use crate::{ /// Increment this before making an incompatible change to the pretty JSON /// document. The compact matrix is a separate contract coordinated directly /// with `.github/workflows/ci.yml`. -pub const PROJECTION_SCHEMA_VERSION: u32 = 1; +pub const PROJECTION_SCHEMA_VERSION: u32 = 2; /// JSON ready for GitHub Actions plus a detailed review artifact. #[derive(Clone, Debug, Eq, PartialEq)] @@ -59,6 +62,8 @@ pub struct GitHubProjection { build_matrix_json: String, miri_matrix_json: String, miri_enabled: bool, + semver_matrix_json: String, + semver_enabled: bool, artifact: Vec, output_records: Vec, output_utf16_bytes: u64, @@ -95,6 +100,16 @@ impl GitHubProjection { self.miri_enabled } + /// Returns the compact semver `include` matrix. + pub fn semver_matrix_json(&self) -> &str { + &self.semver_matrix_json + } + + /// Returns whether the projected semver matrix contains any selected cells. + pub fn semver_enabled(&self) -> bool { + self.semver_enabled + } + /// Returns deterministic, pretty JSON suitable for a workflow artifact. pub fn artifact_bytes(&self) -> &[u8] { &self.artifact @@ -105,12 +120,12 @@ impl GitHubProjection { self.output_utf16_bytes } - /// Appends the three checked `name=value` records to `GITHUB_OUTPUT`. + /// Appends the five checked `name=value` records to `GITHUB_OUTPUT`. /// /// The caller supplies the path rather than this library reading ambient - /// 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. + /// environment state. Names are fixed constants, gates are Rust booleans, + /// 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, @@ -203,7 +218,7 @@ pub enum ProjectionError { "GitHub output records require {actual} UTF-16 bytes, above limits.max_job_output_utf16_bytes ({maximum})" )] JobOutputTooLarge { - /// The exact estimate for both `name=value` records and newlines. + /// The exact estimate for all `name=value` records and newlines. actual: u64, /// The configured maximum. maximum: u64, @@ -285,13 +300,15 @@ struct CompactMatrix { include: Vec, } -// These transport structs deliberately repeat only the selectors accepted by -// `execute-build-cell` and `execute-miri-cell`. The executors load the checked -// 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. +// These transport structs deliberately repeat only selectors consumed by the +// handwritten workflow adapters. The build and Miri executors reload checked +// repository inputs and resolve their remaining behavior. Semver has one +// policy-owned package/toolchain/profile tuple whose exact static action inputs +// are audited separately, so only its varying target crosses this boundary. +// Putting derived details in these compact matrices 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")] @@ -311,6 +328,11 @@ struct CompactMiriCell<'a> { miri_model: &'a str, } +#[derive(Serialize)] +struct CompactSemverCell<'a> { + target: &'a str, +} + #[derive(Serialize)] struct ArtifactDocument<'a> { schema_version: u32, @@ -320,6 +342,7 @@ struct ArtifactDocument<'a> { counts: ArtifactCounts, build_cells: Vec>, miri_cells: Vec>, + semver_cells: Vec>, } #[derive(Clone, Copy, Serialize)] @@ -334,6 +357,7 @@ struct ArtifactCounts { total: DecisionCounts, build: DecisionCounts, miri: DecisionCounts, + semver: DecisionCounts, } #[derive(Clone, Copy, Serialize)] @@ -361,6 +385,15 @@ struct ArtifactMiriCell<'a> { decision: ArtifactDecision, } +#[derive(Serialize)] +struct ArtifactSemverCell<'a> { + package: ArtifactPackage<'a>, + toolchain: ArtifactToolchain<'a>, + features: ArtifactFeatures<'a>, + target: ArtifactSemverTarget<'a>, + decision: ArtifactDecision, +} + #[derive(Serialize)] struct ArtifactPackage<'a> { id: &'a str, @@ -408,6 +441,11 @@ struct ArtifactMiriTarget<'a> { execution: ArtifactMiriExecution, } +#[derive(Serialize)] +struct ArtifactSemverTarget<'a> { + triple: &'a str, +} + #[derive(Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] enum ArtifactMiriExecution { @@ -435,6 +473,9 @@ enum ArtifactDecisionCode { ReducedEventExcludesIneligibleTarget, MiriEventCategoryMatches, MiriEventCategoryDoesNotMatch, + FullEventIncludesSemver, + ReducedEventIncludesEligibleSemverTarget, + ReducedEventExcludesIneligibleSemverTarget, } fn project( @@ -450,6 +491,11 @@ fn project( .collect::>(); let selected_miri = explanation.miri().iter().filter(|cell| cell.decision().is_included()).collect::>(); + let selected_semver = explanation + .semver() + .iter() + .filter(|cell| cell.decision().is_included()) + .collect::>(); // Sharding is deterministic and ready to reuse, but the current workflow // exposes one output and one consumer job for each matrix. Fail here until @@ -458,7 +504,9 @@ 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 selected_semver = one_workflow_shard("semver matrix", &selected_semver, max_matrix_cells)?; let miri_enabled = !selected_miri.is_empty(); + let semver_enabled = !selected_semver.is_empty(); let compact_builds = CompactMatrix { include: selected_builds @@ -472,8 +520,15 @@ fn project( .map(|explained| compact_miri_cell(explained.cell())) .collect(), }; + let compact_semver = CompactMatrix { + include: selected_semver + .iter() + .map(|explained| compact_semver_cell(explained.cell())) + .collect(), + }; let build_matrix_json = compact_json("ordinary build matrix", &compact_builds)?; let miri_matrix_json = compact_json("Miri matrix", &compact_miri)?; + let semver_matrix_json = compact_json("semver matrix", &compact_semver)?; let artifact = artifact_document(explanation, policy_schema_version)?; let mut artifact = serde_json::to_vec_pretty(&artifact) @@ -481,7 +536,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{MIRI_ENABLED_OUTPUT}={miri_enabled}\n" + "{BUILD_MATRIX_OUTPUT}={build_matrix_json}\n{MIRI_MATRIX_OUTPUT}={miri_matrix_json}\n{MIRI_ENABLED_OUTPUT}={miri_enabled}\n{SEMVER_MATRIX_OUTPUT}={semver_matrix_json}\n{SEMVER_ENABLED_OUTPUT}={semver_enabled}\n" ); let output_utf16_bytes = utf16_bytes(&output_records); if output_utf16_bytes > max_job_output_utf16_bytes { @@ -495,6 +550,8 @@ fn project( build_matrix_json, miri_matrix_json, miri_enabled, + semver_matrix_json, + semver_enabled, artifact, output_records: output_records.into_bytes(), output_utf16_bytes, @@ -527,19 +584,25 @@ fn compact_miri_cell(cell: &MiriPlanCell) -> CompactMiriCell<'_> { } } +fn compact_semver_cell(cell: &SemverPlanCell) -> CompactSemverCell<'_> { + CompactSemverCell { target: cell.target().triple() } +} + fn artifact_document<'a>( explanation: &'a PlanExplanation, policy_schema_version: u32, ) -> Result, ProjectionError> { let build_counts = decision_counts(explanation.builds().iter().map(|cell| cell.decision())); let miri_counts = decision_counts(explanation.miri().iter().map(|cell| cell.decision())); + let semver_counts = decision_counts(explanation.semver().iter().map(|cell| cell.decision())); let counts = ArtifactCounts { total: DecisionCounts { - selected: build_counts.selected + miri_counts.selected, - excluded: build_counts.excluded + miri_counts.excluded, + selected: build_counts.selected + miri_counts.selected + semver_counts.selected, + excluded: build_counts.excluded + miri_counts.excluded + semver_counts.excluded, }, build: build_counts, miri: miri_counts, + semver: semver_counts, }; let build_cells = explanation @@ -586,6 +649,23 @@ fn artifact_document<'a>( }) }) .collect::, ProjectionError>>()?; + let semver_cells = explanation + .semver() + .iter() + .map(|explained| { + let cell = explained.cell(); + Ok(ArtifactSemverCell { + package: artifact_package(cell.package().id(), cell.package().manifest())?, + toolchain: ArtifactToolchain { + id: cell.toolchain().id(), + version: cell.toolchain().version(), + }, + features: artifact_features(cell.features().profile(), cell.features().selection()), + target: ArtifactSemverTarget { triple: cell.target().triple() }, + decision: artifact_decision(explained.decision()), + }) + }) + .collect::, ProjectionError>>()?; Ok(ArtifactDocument { schema_version: PROJECTION_SCHEMA_VERSION, @@ -598,6 +678,7 @@ fn artifact_document<'a>( counts, build_cells, miri_cells, + semver_cells, }) } @@ -636,6 +717,13 @@ fn artifact_decision(decision: CellDecision) -> ArtifactDecision { DecisionReason::MiriEventCategoryDoesNotMatch => { ArtifactDecisionCode::MiriEventCategoryDoesNotMatch } + DecisionReason::FullEventIncludesSemver => ArtifactDecisionCode::FullEventIncludesSemver, + DecisionReason::ReducedEventIncludesEligibleSemverTarget => { + ArtifactDecisionCode::ReducedEventIncludesEligibleSemverTarget + } + DecisionReason::ReducedEventExcludesIneligibleSemverTarget => { + ArtifactDecisionCode::ReducedEventExcludesIneligibleSemverTarget + } }; ArtifactDecision { selected: decision.is_included(), @@ -794,13 +882,16 @@ mod tests { use super::{ one_workflow_shard, project, shard_cells, slash_normalized_path, utf16_bytes, - CompactBuildCell, CompactMiriCell, GitHubProjection, ProjectionError, ProjectionWriteError, - PROJECTION_SCHEMA_VERSION, + CompactBuildCell, CompactMiriCell, CompactSemverCell, GitHubProjection, ProjectionError, + ProjectionWriteError, PROJECTION_SCHEMA_VERSION, }; use crate::{ ci::CiInputs, plan::{Plan, PlanExplanation}, - workflow_protocol::{BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT}, + workflow_protocol::{ + BUILD_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, SEMVER_ENABLED_OUTPUT, + SEMVER_MATRIX_OUTPUT, + }, }; fn inputs() -> &'static CiInputs { @@ -817,11 +908,11 @@ mod tests { #[test] fn projects_all_current_events() { - for (event, builds, miri) in [ - ("pull_request", 60, 0), - ("merge_group", 182, 64), - ("push", 182, 64), - ("workflow_dispatch", 182, 64), + for (event, builds, miri, semver) in [ + ("pull_request", 60, 0, 3), + ("merge_group", 182, 64, 9), + ("push", 182, 64, 9), + ("workflow_dispatch", 182, 64, 9), ] { let projection = GitHubProjection::create(inputs(), event).unwrap(); assert_eq!( @@ -833,6 +924,11 @@ mod tests { miri ); assert_eq!(projection.miri_enabled(), miri != 0); + assert_eq!( + parse(projection.semver_matrix_json())["include"].as_array().unwrap().len(), + semver + ); + assert_eq!(projection.semver_enabled(), semver != 0); let artifact: Value = serde_json::from_slice(projection.artifact_bytes()).unwrap(); assert_eq!(artifact["schema_version"], PROJECTION_SCHEMA_VERSION); @@ -840,6 +936,12 @@ mod tests { assert_eq!(artifact["event"], event); assert_eq!(artifact["counts"]["build"]["selected"], builds); assert_eq!(artifact["counts"]["miri"]["selected"], miri); + assert_eq!(artifact["counts"]["semver"]["selected"], semver); + assert_eq!(artifact["counts"]["total"]["selected"], builds + miri + semver); + assert_eq!( + artifact["counts"]["total"]["excluded"], + (182 - builds) + (64 - miri) + (9 - semver) + ); } } @@ -874,6 +976,11 @@ mod tests { }) }) .collect::>(); + let expected_semver = plan + .semver() + .iter() + .map(|cell| json!({ "target": cell.target().triple() })) + .collect::>(); assert_eq!( parse(projection.build_matrix_json())["include"], @@ -883,6 +990,10 @@ mod tests { parse(projection.miri_matrix_json())["include"], Value::Array(expected_miri) ); + assert_eq!( + parse(projection.semver_matrix_json())["include"], + Value::Array(expected_semver) + ); } } @@ -910,6 +1021,9 @@ mod tests { serde_json::to_string(&miri).unwrap(), r#"{"crate":"package","toolchain":"toolchain","feature_profile":"profile","target":"target","miri_model":"model"}"# ); + + let semver = CompactSemverCell { target: "target" }; + assert_eq!(serde_json::to_string(&semver).unwrap(), r#"{"target":"target"}"#); } #[test] @@ -920,6 +1034,8 @@ 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.semver_matrix_json(), second.semver_matrix_json()); + assert_eq!(first.semver_enabled(), second.semver_enabled()); assert_eq!(first.artifact_bytes(), second.artifact_bytes()); assert!(first.artifact_bytes().ends_with(b"\n")); } @@ -940,6 +1056,26 @@ mod tests { assert!(projection.miri_enabled()); } + #[test] + fn semver_projection_tracks_event_specific_targets() { + let reduced = GitHubProjection::create(inputs(), "pull_request").unwrap(); + let full = GitHubProjection::create(inputs(), "merge_group").unwrap(); + + assert_eq!( + parse(reduced.semver_matrix_json()), + json!({ + "include": [ + {"target": "i686-unknown-linux-gnu"}, + {"target": "x86_64-pc-windows-msvc"}, + {"target": "x86_64-unknown-linux-gnu"}, + ] + }) + ); + assert_eq!(parse(full.semver_matrix_json())["include"].as_array().unwrap().len(), 9); + assert!(reduced.semver_enabled()); + assert!(full.semver_enabled()); + } + #[test] fn sharding_has_an_exact_256_cell_boundary() { let cells = (0..257).collect::>(); @@ -975,20 +1111,22 @@ mod tests { let projection = GitHubProjection::create(inputs(), "pull_request").unwrap(); let records = format!( - "{BUILD_MATRIX_OUTPUT}={}\n{MIRI_MATRIX_OUTPUT}={}\n{MIRI_ENABLED_OUTPUT}={}\n", + "{BUILD_MATRIX_OUTPUT}={}\n{MIRI_MATRIX_OUTPUT}={}\n{MIRI_ENABLED_OUTPUT}={}\n{SEMVER_MATRIX_OUTPUT}={}\n{SEMVER_ENABLED_OUTPUT}={}\n", projection.build_matrix_json(), projection.miri_matrix_json(), projection.miri_enabled(), + projection.semver_matrix_json(), + projection.semver_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_832); + assert_eq!(projection.output_utf16_bytes(), 15_148); assert_eq!( GitHubProjection::create(inputs(), "merge_group").unwrap().output_utf16_bytes(), - 60_834 + 61_622 ); } @@ -1022,10 +1160,12 @@ mod tests { let projection = GitHubProjection::create(inputs(), "merge_group").unwrap(); let artifact: Value = serde_json::from_slice(projection.artifact_bytes()).unwrap(); - for cell in artifact["build_cells"].as_array().unwrap() { - let manifest = cell["package"]["manifest"].as_str().unwrap(); - assert!(!manifest.contains('\\')); - assert!(!Path::new(manifest).is_absolute()); + for group in ["build_cells", "miri_cells", "semver_cells"] { + for cell in artifact[group].as_array().unwrap() { + let manifest = cell["package"]["manifest"].as_str().unwrap(); + assert!(!manifest.contains('\\')); + assert!(!Path::new(manifest).is_absolute()); + } } } @@ -1060,10 +1200,21 @@ mod tests { assert_eq!(tree["target"]["execution"], "miri_interpreted"); assert_eq!(tree["model"]["flags"], json!(["-Zmiri-tree-borrows"])); + let semver = artifact["semver_cells"] + .as_array() + .unwrap() + .iter() + .find(|cell| cell["target"]["triple"] == "x86_64-unknown-linux-gnu") + .unwrap(); + assert_eq!(semver["toolchain"]["id"], "stable"); + assert_eq!(semver["features"]["selection"]["kind"], "stable_aggregate"); + assert_eq!(semver["decision"]["code"], "full_event_includes_semver"); + let mut keys = BTreeSet::new(); collect_keys(&artifact, &mut keys); collect_keys(&parse(projection.build_matrix_json()), &mut keys); collect_keys(&parse(projection.miri_matrix_json()), &mut keys); + collect_keys(&parse(projection.semver_matrix_json()), &mut keys); for forbidden in [ "permissions", "secrets", @@ -1124,7 +1275,12 @@ mod tests { assert!( output.contains(&format!("{MIRI_MATRIX_OUTPUT}={}\n", projection.miri_matrix_json())) ); - assert!(output.ends_with(&format!("{MIRI_ENABLED_OUTPUT}={}\n", projection.miri_enabled()))); + assert!(output.contains(&format!("{MIRI_ENABLED_OUTPUT}={}\n", projection.miri_enabled()))); + assert!(output + .contains(&format!("{SEMVER_MATRIX_OUTPUT}={}\n", projection.semver_matrix_json()))); + assert!( + output.ends_with(&format!("{SEMVER_ENABLED_OUTPUT}={}\n", projection.semver_enabled())) + ); assert_eq!(fs::read(artifact).unwrap(), projection.artifact_bytes()); assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); diff --git a/tools/zc/src/lib.rs b/tools/zc/src/lib.rs index 69ec2c1964..671e5a6318 100644 --- a/tools/zc/src/lib.rs +++ b/tools/zc/src/lib.rs @@ -21,5 +21,6 @@ pub mod planned_adapter; pub mod policy; mod repository_file; mod repository_text; +pub mod semver_adapter; pub mod workflow; mod workflow_protocol; diff --git a/tools/zc/src/plan.rs b/tools/zc/src/plan.rs index 1646678371..c56ce948b9 100644 --- a/tools/zc/src/plan.rs +++ b/tools/zc/src/plan.rs @@ -13,8 +13,8 @@ //! the frozen legacy files have each passed their owning validator. Planning //! therefore performs no file-system access and has no ambient inputs. //! -//! This module selects ordinary build and Miri matrix members; it does not -//! prove how GitHub executes them. In particular, a plan never contains +//! This module selects ordinary build, Miri, and semver matrix members; it does +//! not prove how GitHub executes them. In particular, a plan never contains //! permissions, secrets, runner labels, action references, publication //! choices, or shell commands. Keep those security-sensitive concerns in the //! small hand-written workflows. @@ -46,7 +46,7 @@ use crate::{ baseline::{BaselineId, BuildCell, LegacyBaselines, MiriCell, SetDifference}, ci::CiInputs, inventory::RepositoryInventory, - policy::{EventCategory, FeatureProfile, Id, Policy, Scope, TargetMode}, + policy::{EventCategory, FeatureProfile, Id, Policy, Scope, Semver, TargetMode}, }; /// Whether the event receives reduced or full policy coverage. @@ -241,6 +241,23 @@ impl MiriTargetSelector { } } +/// A target whose public API is checked for semver compatibility. +/// +/// This deliberately does not expose [`ExecutionMode`]. The semver action +/// examines target-specific public API; it does not execute the ordinary +/// build behavior associated with the same target. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct SemverTargetSelector { + triple: String, +} + +impl SemverTargetSelector { + /// Returns the Rust target triple whose public API must be checked. + pub fn triple(&self) -> &str { + &self.triple + } +} + /// A Miri borrow model selected for work. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct MiriModelSelector { @@ -328,6 +345,37 @@ impl MiriPlanCell { } } +/// One selected semver matrix member with typed semantic intent. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct SemverPlanCell { + package: PackageSelector, + toolchain: ToolchainSelector, + features: FeatureSelector, + target: SemverTargetSelector, +} + +impl SemverPlanCell { + /// Returns the package selector. + pub fn package(&self) -> &PackageSelector { + &self.package + } + + /// Returns the exact toolchain selector. + pub fn toolchain(&self) -> &ToolchainSelector { + &self.toolchain + } + + /// Returns the semantic feature selector. + pub fn features(&self) -> &FeatureSelector { + &self.features + } + + /// Returns the target whose public API must be checked. + pub fn target(&self) -> &SemverTargetSelector { + &self.target + } +} + /// Why the shared evaluator included or excluded one cell. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecisionReason { @@ -341,6 +389,14 @@ pub enum DecisionReason { MiriEventCategoryMatches, /// This event category does not match the category configured for Miri. MiriEventCategoryDoesNotMatch, + /// Full events include every configured semver target. + FullEventIncludesSemver, + /// A reduced event includes this semver target because ordinary policy + /// marks the corresponding target eligible for reduced events. + ReducedEventIncludesEligibleSemverTarget, + /// A reduced event excludes this semver target because ordinary policy + /// does not mark the corresponding target eligible for reduced events. + ReducedEventExcludesIneligibleSemverTarget, } impl fmt::Display for DecisionReason { @@ -360,6 +416,15 @@ impl fmt::Display for DecisionReason { Self::MiriEventCategoryDoesNotMatch => { formatter.write_str("excluded because Miri runs in the other event category") } + Self::FullEventIncludesSemver => { + formatter.write_str("included because full events run every semver cell") + } + Self::ReducedEventIncludesEligibleSemverTarget => formatter.write_str( + "included because policy marks the semver target eligible for reduced events", + ), + Self::ReducedEventExcludesIneligibleSemverTarget => formatter.write_str( + "excluded because policy does not mark the semver target eligible for reduced events", + ), } } } @@ -445,6 +510,39 @@ impl ExplainedMiriCell { } } +/// One explained semver candidate. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExplainedSemverCell { + cell: SemverPlanCell, + decision: CellDecision, +} + +impl ExplainedSemverCell { + /// Returns the fully resolved candidate. + pub fn cell(&self) -> &SemverPlanCell { + &self.cell + } + + /// Returns whether and why the candidate is selected. + pub fn decision(&self) -> CellDecision { + self.decision + } +} + +impl fmt::Display for ExplainedSemverCell { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "semver {}/{}/{}/{}: {}", + self.cell.package.id, + self.cell.toolchain.id, + self.cell.features.profile, + self.cell.target.triple, + self.decision + ) + } +} + impl fmt::Display for ExplainedMiriCell { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -467,6 +565,7 @@ pub struct PlanExplanation { class: EventClass, builds: Vec, miri: Vec, + semver: Vec, } impl PlanExplanation { @@ -494,6 +593,11 @@ impl PlanExplanation { pub fn miri(&self) -> &[ExplainedMiriCell] { &self.miri } + + /// Returns all semver candidates in deterministic order. + pub fn semver(&self) -> &[ExplainedSemverCell] { + &self.semver + } } impl fmt::Display for PlanExplanation { @@ -505,6 +609,9 @@ impl fmt::Display for PlanExplanation { for cell in &self.miri { writeln!(formatter, "- {cell}")?; } + for cell in &self.semver { + writeln!(formatter, "- {cell}")?; + } Ok(()) } } @@ -516,6 +623,7 @@ pub struct Plan { class: EventClass, builds: Vec, miri: Vec, + semver: Vec, } impl Plan { @@ -543,6 +651,11 @@ impl Plan { pub fn miri(&self) -> &[MiriPlanCell] { &self.miri } + + /// Returns semver cells in deterministic order. + pub fn semver(&self) -> &[SemverPlanCell] { + &self.semver + } } #[derive(Clone, Debug)] @@ -551,12 +664,19 @@ struct BuildCandidate { reduced_eligible: bool, } +#[derive(Clone, Debug)] +struct SemverCandidate { + cell: SemverPlanCell, + reduced_eligible: bool, +} + #[derive(Clone, Debug)] struct EvaluatedPlan { event: String, class: EventClass, builds: Vec, miri: Vec, + semver: Vec, } impl EvaluatedPlan { @@ -566,6 +686,7 @@ impl EvaluatedPlan { let class = classify_event(policy, event)?; let build_candidates = enumerate_build_candidates(policy, inputs.repository())?; let miri_candidates = enumerate_miri_candidates(policy, inputs.repository())?; + let semver_candidates = enumerate_semver_candidates(policy, &build_candidates)?; validate_legacy_membership(policy, inputs.legacy(), &build_candidates, &miri_candidates)?; @@ -582,12 +703,20 @@ impl EvaluatedPlan { .cloned() .map(|cell| ExplainedMiriCell { cell, decision: evaluate_miri(class, miri_class) }) .collect::>(); + let semver = semver_candidates + .values() + .map(|candidate| ExplainedSemverCell { + cell: candidate.cell.clone(), + decision: evaluate_semver(class, candidate.reduced_eligible), + }) + .collect::>(); let selected = builds.iter().filter(|cell| cell.decision.is_included()).count() - + miri.iter().filter(|cell| cell.decision.is_included()).count(); + + miri.iter().filter(|cell| cell.decision.is_included()).count() + + semver.iter().filter(|cell| cell.decision.is_included()).count(); enforce_plan_limit(selected, policy.limits().max_plan_cells())?; - Ok(Self { event: event.to_owned(), class, builds, miri }) + Ok(Self { event: event.to_owned(), class, builds, miri, semver }) } fn into_plan(self) -> Plan { @@ -601,7 +730,12 @@ impl EvaluatedPlan { .into_iter() .filter_map(|candidate| candidate.decision.is_included().then_some(candidate.cell)) .collect(); - Plan { event: self.event, class: self.class, builds, miri } + let semver = self + .semver + .into_iter() + .filter_map(|candidate| candidate.decision.is_included().then_some(candidate.cell)) + .collect(); + Plan { event: self.event, class: self.class, builds, miri, semver } } fn into_explanation(self) -> PlanExplanation { @@ -610,6 +744,7 @@ impl EvaluatedPlan { class: self.class, builds: self.builds, miri: self.miri, + semver: self.semver, } } } @@ -745,6 +880,98 @@ fn enumerate_miri_candidates( Ok(cells) } +/// Derives semver work from the corresponding ordinary-build candidates. +/// +/// Semver has its own matrix and runner, but its event-specific membership is +/// deliberately the same as the package/toolchain/profile slice which used to +/// host it. A full event selects every configured semver target; a reduced +/// event inherits the target's ordinary reduced-event eligibility. Looking up +/// every configured target in `builds` also makes the policy invariant that +/// semver coverage is backed by ordinary build coverage fail closed here. +fn enumerate_semver_candidates( + policy: &Policy, + builds: &BTreeMap, +) -> Result, PlanError> { + let semver = policy.semver(); + let targets = policy.target_sets().get(semver.target_set().as_str()).ok_or_else(|| { + PlanError::MissingValidatedInput { + location: format!("target_sets.{}", semver.target_set().as_str()), + } + })?; + let matching_builds = index_semver_build_coverage(semver, builds); + let mut cells = BTreeMap::new(); + for target in targets { + let matches = matching_builds.get(target.as_str()).map(Vec::as_slice).unwrap_or_default(); + let candidate = match matches { + [candidate] => *candidate, + [] => { + return Err(PlanError::MissingValidatedInput { + location: format!( + "semver build coverage for {}/{}/{}/{}", + semver.package(), + semver.toolchain(), + semver.profile(), + target, + ), + }); + } + candidates => { + return Err(PlanError::DuplicateCell { + kind: "semver build coverage", + selector: format!( + "{}/{}/{}/{} ({})", + semver.package(), + semver.toolchain(), + semver.profile(), + target, + candidates.len(), + ), + }); + } + }; + let cell = SemverPlanCell { + package: candidate.cell.package.clone(), + toolchain: candidate.cell.toolchain.clone(), + features: candidate.cell.features.clone(), + target: SemverTargetSelector { triple: target.as_str().to_owned() }, + }; + let semver_candidate = + SemverCandidate { cell: cell.clone(), reduced_eligible: candidate.reduced_eligible }; + if cells.insert(cell.clone(), semver_candidate).is_some() { + return Err(PlanError::DuplicateCell { + kind: "semver", + selector: format_semver_selector(&cell), + }); + } + } + Ok(cells) +} + +/// Indexes the ordinary-build slice which can provide semver coverage. +/// +/// Keep this scan outside the configured-target loop above. The number of +/// ordinary scopes and the number of semver targets have independent bounds; +/// rescanning every expanded build for every target would make otherwise valid +/// high-cardinality policy take quadratic time. Retaining every match rather +/// than overwriting one preserves the duplicate diagnostic at the checked +/// planning boundary. +fn index_semver_build_coverage<'a>( + semver: &Semver, + builds: &'a BTreeMap, +) -> BTreeMap<&'a str, Vec<&'a BuildCandidate>> { + let mut by_target: BTreeMap<&str, Vec<&BuildCandidate>> = BTreeMap::new(); + for candidate in builds.values() { + let cell = &candidate.cell; + if cell.package.id() == semver.package().as_str() + && cell.toolchain.id() == semver.toolchain().as_str() + && cell.features.profile() == semver.profile().as_str() + { + by_target.entry(cell.target.triple()).or_default().push(candidate); + } + } + by_target +} + fn target_set<'a>(policy: &'a Policy, scope: &Scope) -> Result<&'a BTreeSet, PlanError> { policy.target_sets().get(scope.target_set().as_str()).ok_or_else(|| { PlanError::MissingValidatedInput { @@ -821,6 +1048,18 @@ fn evaluate_miri(actual: EventClass, configured: EventClass) -> CellDecision { } } +fn evaluate_semver(class: EventClass, reduced_eligible: bool) -> CellDecision { + match (class, reduced_eligible) { + (EventClass::Full, _) => CellDecision::Included(DecisionReason::FullEventIncludesSemver), + (EventClass::Reduced, true) => { + CellDecision::Included(DecisionReason::ReducedEventIncludesEligibleSemverTarget) + } + (EventClass::Reduced, false) => { + CellDecision::Excluded(DecisionReason::ReducedEventExcludesIneligibleSemverTarget) + } + } +} + fn validate_legacy_membership( policy: &Policy, legacy: &LegacyBaselines, @@ -934,6 +1173,13 @@ fn format_miri_selector(cell: &MiriPlanCell) -> String { ) } +fn format_semver_selector(cell: &SemverPlanCell) -> String { + format!( + "{}/{}/{}/{}", + cell.package.id, cell.toolchain.id, cell.features.profile, cell.target.triple + ) +} + fn escape_control_characters(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for character in value.chars() { @@ -1029,13 +1275,17 @@ impl PlanError { #[cfg(test)] mod tests { - use std::{path::Path, sync::OnceLock}; + use std::{collections::BTreeMap, path::Path, sync::OnceLock}; use super::{ - enforce_plan_limit, CellDecision, DecisionReason, EventClass, ExecutionMode, - FeatureSelection, Plan, PlanError, PlanExplanation, + enforce_plan_limit, enumerate_semver_candidates, index_semver_build_coverage, + BuildCandidate, BuildPlanCell, CellDecision, DecisionReason, EventClass, ExecutionMode, + FeatureSelection, FeatureSelector, PackageSelector, Plan, PlanError, PlanExplanation, + TargetSelector, ToolchainSelector, }; - use crate::ci::CiInputs; + use crate::{ci::CiInputs, policy::Policy}; + + const REPOSITORY_POLICY: &str = include_str!("../../../ci/zc.toml"); fn inputs() -> &'static CiInputs { static INPUTS: OnceLock = OnceLock::new(); @@ -1045,6 +1295,44 @@ mod tests { }) } + fn build_candidate( + package: &str, + toolchain: &str, + profile: &str, + target: String, + reduced_eligible: bool, + ) -> BuildCandidate { + let selection = if profile == "stable" { + FeatureSelection::StableAggregate { + feature: "__internal_use_only_features_that_work_on_stable".to_owned(), + } + } else { + FeatureSelection::Default + }; + BuildCandidate { + cell: BuildPlanCell { + package: PackageSelector { + id: package.to_owned(), + manifest: format!("{package}/Cargo.toml").into(), + }, + toolchain: ToolchainSelector { + id: toolchain.to_owned(), + version: "synthetic-version".to_owned(), + }, + features: FeatureSelector { profile: profile.to_owned(), selection }, + target: TargetSelector { triple: target, mode: ExecutionMode::Native }, + }, + reduced_eligible, + } + } + + fn insert_build_candidate( + builds: &mut BTreeMap, + candidate: BuildCandidate, + ) { + assert!(builds.insert(candidate.cell.clone(), candidate).is_none()); + } + #[test] fn plans_each_exact_legacy_event_class() { for event in ["pull_request", "merge_group", "push", "workflow_dispatch"] { @@ -1053,10 +1341,12 @@ mod tests { assert_eq!(plan.class(), EventClass::Reduced); assert_eq!(plan.builds().len(), 60); assert!(plan.miri().is_empty()); + assert_eq!(plan.semver().len(), 3); } else { assert_eq!(plan.class(), EventClass::Full); assert_eq!(plan.builds().len(), 182); assert_eq!(plan.miri().len(), 64); + assert_eq!(plan.semver().len(), 9); } } } @@ -1092,6 +1382,8 @@ mod tests { assert_eq!(reduced.miri().len(), inputs().legacy().miri_reduced().len()); assert_eq!(full.builds().len(), inputs().legacy().build_full().len()); assert_eq!(full.miri().len(), inputs().legacy().miri_full().len()); + assert_eq!(reduced.semver().len(), 3); + assert_eq!(full.semver().len(), 9); assert_eq!(full, Plan::create(inputs(), "merge_group").unwrap()); } @@ -1143,6 +1435,124 @@ mod tests { assert_eq!(full.miri().len(), 64); } + #[test] + fn semver_preserves_the_build_slices_event_membership() { + let reduced = PlanExplanation::create(inputs(), "pull_request").unwrap(); + let full = PlanExplanation::create(inputs(), "push").unwrap(); + + let reduced_selected = reduced + .semver() + .iter() + .filter(|cell| cell.decision().is_included()) + .map(|cell| cell.cell().target().triple()) + .collect::>(); + assert_eq!( + reduced_selected, + ["i686-unknown-linux-gnu", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu",] + ); + assert_eq!(full.semver().len(), 9); + assert!(full.semver().iter().all(|cell| cell.decision().is_included())); + + let reduced_arm = reduced + .semver() + .iter() + .find(|cell| cell.cell().target().triple() == "arm-unknown-linux-gnueabi") + .unwrap(); + assert_eq!( + reduced_arm.decision(), + CellDecision::Excluded(DecisionReason::ReducedEventExcludesIneligibleSemverTarget) + ); + } + + #[test] + fn semver_build_coverage_indexes_high_cardinality_input_once() { + const MATCHING_CANDIDATES: usize = 20_000; + + let policy = Policy::parse(REPOSITORY_POLICY).unwrap(); + let semver = policy.semver(); + let mut builds = BTreeMap::new(); + for index in 0..MATCHING_CANDIDATES { + let target = format!("synthetic-target-{index:05}"); + insert_build_candidate( + &mut builds, + build_candidate( + semver.package().as_str(), + semver.toolchain().as_str(), + semver.profile().as_str(), + target.clone(), + index % 2 == 0, + ), + ); + // A large unrelated slice proves that indexing filters while it + // makes its one pass rather than materializing all ordinary work. + insert_build_candidate( + &mut builds, + build_candidate( + "unrelated-package", + semver.toolchain().as_str(), + semver.profile().as_str(), + target, + false, + ), + ); + } + + let by_target = index_semver_build_coverage(semver, &builds); + assert_eq!(by_target.len(), MATCHING_CANDIDATES); + assert!(by_target.values().all(|candidates| candidates.len() == 1)); + assert!(by_target["synthetic-target-00000"][0].reduced_eligible); + assert!(!by_target["synthetic-target-19999"][0].reduced_eligible); + } + + #[test] + fn semver_index_uses_distinct_policy_toolchain_and_profile_ids() { + const TOOLCHAIN_DECLARATION: &str = "id = \"stable\"\nsource = \"pinned-stable\""; + const RENAMED_TOOLCHAIN_DECLARATION: &str = + "id = \"semver-stable\"\nsource = \"pinned-stable\""; + const SEMVER_SELECTION: &str = concat!( + "toolchain = \"stable\"\n", + "profile = \"stable\"\n", + "target_set = \"semver\"", + ); + const RENAMED_SEMVER_SELECTION: &str = concat!( + "toolchain = \"semver-stable\"\n", + "profile = \"stable\"\n", + "target_set = \"semver\"", + ); + + assert_eq!(REPOSITORY_POLICY.matches(TOOLCHAIN_DECLARATION).count(), 1); + let source = + REPOSITORY_POLICY.replacen(TOOLCHAIN_DECLARATION, RENAMED_TOOLCHAIN_DECLARATION, 1); + assert_eq!(source.matches(SEMVER_SELECTION).count(), 1); + let source = source.replacen(SEMVER_SELECTION, RENAMED_SEMVER_SELECTION, 1); + let policy = Policy::parse(&source).unwrap(); + let semver = policy.semver(); + assert_ne!(semver.toolchain().as_str(), semver.profile().as_str()); + + let targets = policy.target_sets().get(semver.target_set().as_str()).unwrap(); + let mut builds = BTreeMap::new(); + for target in targets { + let reduced_eligible = policy.targets()[target.as_str()].pr_eligible(); + insert_build_candidate( + &mut builds, + build_candidate( + semver.package().as_str(), + semver.toolchain().as_str(), + semver.profile().as_str(), + target.as_str().to_owned(), + reduced_eligible, + ), + ); + } + + let candidates = enumerate_semver_candidates(&policy, &builds).unwrap(); + assert_eq!(candidates.len(), targets.len()); + assert!(candidates.values().all(|candidate| { + candidate.cell.toolchain.id() == "semver-stable" + && candidate.cell.features.profile() == "stable" + })); + } + #[test] fn miri_interprets_tests_for_an_ordinary_cross_target() { let plan = Plan::create(inputs(), "merge_group").unwrap(); @@ -1187,6 +1597,13 @@ mod tests { ); assert!(included.to_string().contains("included because")); assert!(excluded.to_string().contains("excluded because")); + let semver = explanation + .semver() + .iter() + .find(|cell| cell.cell().target().triple() == "arm-unknown-linux-gnueabi") + .unwrap(); + assert!(semver.to_string().starts_with("semver ")); + assert!(semver.to_string().contains("excluded because")); let rendered = explanation.to_string(); assert!(rendered.contains("event `pull_request` has reduced coverage")); assert!(rendered.contains("included because")); diff --git a/tools/zc/src/planned_adapter/matrix.rs b/tools/zc/src/planned_adapter/matrix.rs index 72b4940ea9..6d3de059e3 100644 --- a/tools/zc/src/planned_adapter/matrix.rs +++ b/tools/zc/src/planned_adapter/matrix.rs @@ -18,8 +18,7 @@ use super::{ audit_exact_job_fields, audit_exact_scalar_field, audit_host_job_contract, audit_read_permissions, audit_step, audit_unique_run_mentions, audited_steps_block, compare_map, escape_control_characters, exact_step_lines, find_job, job_field_location, - job_fields, nested_fields, nested_mapping, parse_needs, unique_field, RunForm, - StepExpectation, + job_fields, nested_mapping, parse_needs, unique_field, RunForm, StepExpectation, }, ViolationSink, }; @@ -36,8 +35,8 @@ use crate::{ CELL_TARGET_OPTION, CELL_TOOLCHAIN_OPTION, CI_EVENT_OPTION, DOCKER_ENTRYPOINT_ARGUMENT, DOCKER_OPTION_TERMINATOR, EXECUTE_BUILD_CELL_COMMAND, EXECUTE_MIRI_CELL_COMMAND, HOST_DOCKER_RUN, IMAGE_JOB, MATRIX_STEP_ANCHORS, MIRI_ENABLED_OUTPUT, MIRI_JOB, - MIRI_MATRIX_OUTPUT, MIRI_STEP_NAME, PLAN_JOB, REPOSITORY_WORKING_DIRECTORY, TRUSTED_SHELL, - WORKFLOW_PATH, + MIRI_MATRIX_OUTPUT, MIRI_STEP_NAME, PLAN_JOB, REPOSITORY_WORKING_DIRECTORY, SEMVER_JOB, + TRUSTED_SHELL, WORKFLOW_PATH, }, }; @@ -65,13 +64,6 @@ struct MatrixJobExpectation { selectors: &'static [SelectorExpectation], forwarded_selector_environment: &'static str, condition: JobConditionExpectation, - run_defaults: Option, -} - -#[derive(Clone, Copy)] -struct RunDefaultsExpectation { - shell: &'static str, - working_directory: &'static str, } const DOWNLOAD_ACTION_PATH: &str = ".github/actions/download-artifact-with-retry/action.yml"; @@ -90,29 +82,30 @@ const REVIEWED_SOURCES: &[ReviewedSource] = &[ReviewedSource { snapshot_path: DOWNLOAD_ACTION_SNAPSHOT_PATH, expected: DOWNLOAD_ACTION_EXPECTED_SOURCE, }]; -const BUILD_JOB_FIELDS: &[&str] = - &["runs-on", "needs", "permissions", "defaults", "strategy", "name", "steps"]; +const BUILD_JOB_FIELDS: &[&str] = &["runs-on", "needs", "permissions", "strategy", "name", "steps"]; const MIRI_JOB_FIELDS: &[&str] = &["if", "runs-on", "needs", "permissions", "strategy", "name", "steps"]; -const BUILD_DEFAULT_SHELL: &str = "/tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes build matrix commands through the prebuilt Docker image)"; const BUILD_DISPLAY_NAME: &str = "Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }})"; const MIRI_DISPLAY_NAME: &str = "Miri (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }})"; -// The build job owns the setup definitions used by both typed matrix jobs. -// Their exact source is part of the execution boundary: a preceding step can -// otherwise alter the checkout, selected image, or process environment before -// an exactly audited executor runs. Keep these definitions coordinated with -// the corresponding steps and anchors in `.github/workflows/ci.yml`. +// The build job defines these four reusable setup steps, and the Miri job +// consumes the corresponding aliases. Their exact source is part of the typed +// execution boundary: changing checkout identity, artifact selection, or the +// host commands which load the image and verify the checkout can make an +// otherwise-correct executor run against different code or a different +// container. // -// YAML comments outside a run block may change freely. Comments inside a run -// block are shell input, so `exact_step_lines` retains them and the constants -// below include them. The repository-owned downloader receives a separate -// source audit because its local `uses` path cannot pin its implementation. +// Keep these definitions synchronized with `&matrix_checkout`, +// `&download_ci_image`, `&load_ci_image`, and `&verify_matrix_checkout` in +// `.github/workflows/ci.yml`. YAML comments may change without changing +// behavior; comments inside a block scalar are shell content and remain part +// of the exact comparison. The repository-owned download action receives an +// additional containment and complete-source audit below because its `uses` +// line cannot pin its implementation independently of this checkout. const CHECKOUT_STEP: &[&str] = &[ " - &matrix_checkout", " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", " with:", - " fetch-depth: 2", " persist-credentials: false", ]; fn download_image_step() -> Vec { @@ -140,34 +133,6 @@ const LOAD_IMAGE_STEP: &[&str] = &[ " docker image inspect \"$IMAGE_NAME\" >/dev/null", " docker run --rm \"$IMAGE_NAME\" true", ]; -const CREATE_DOCKER_SHELL_STEP: &[&str] = &[ - " - name: Create Docker Shell Wrapper", - " shell: bash", - " run: |", - " set -eo pipefail", - " mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git", - " cat << 'EOF' > /tmp/docker-shell.sh", - " #!/bin/bash", - " # Boot an ephemeral container for the step, mounting the workspace and", - " # temp dirs. Explicitly forward GitHub Actions internal state and matrix", - " # environment variables.", - " docker run --rm -i \\", - " --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 ZC_TOOLCHAIN -e PR_HEAD_SHA \\", - " -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 \\", - " \"$ZC_CI_IMAGE\" bash -c \"git config --global --add safe.directory '*' && exec bash -e -o pipefail \\\"\\$1\\\"\" -- \"$1\"", - " EOF", - " chmod +x /tmp/docker-shell.sh", -]; const VERIFY_CHECKOUT_STEP: &[&str] = &[ " - &verify_matrix_checkout", " name: Verify matrix checkout is unchanged", @@ -251,11 +216,8 @@ const BUILD_STEP_MARKERS: &[&str] = &[ "- &matrix_checkout", "- &download_ci_image", "- &load_ci_image", - "- name: Create Docker Shell Wrapper", "- &verify_matrix_checkout", "- name: Execute checked build cell", - "- name: Prepare cargo-semver-checks", - "- name: Check semver compatibility", ]; const MIRI_STEP_MARKERS: &[&str] = &[ "- *matrix_checkout", @@ -326,10 +288,6 @@ const BUILD_EXPECTATION: MatrixJobExpectation = MatrixJobExpectation { selectors: &BUILD_SELECTORS, forwarded_selector_environment: " -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", condition: JobConditionExpectation::Absent, - run_defaults: Some(RunDefaultsExpectation { - shell: BUILD_DEFAULT_SHELL, - working_directory: REPOSITORY_WORKING_DIRECTORY, - }), }; const MIRI_EXPECTATION: MatrixJobExpectation = MatrixJobExpectation { @@ -343,7 +301,6 @@ const MIRI_EXPECTATION: MatrixJobExpectation = MatrixJobExpectation { forwarded_selector_environment: " -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \\", condition: JobConditionExpectation::MiriEnabled, - run_defaults: None, }; pub(super) fn audit( @@ -408,6 +365,7 @@ fn audit_download_action(repository_root: &Path) -> Result<(), PlannedAdapterAud audit_reviewed_sources(repository_root, REVIEWED_SOURCES) } +/// Reads the fixed local action through the shared reviewed-source boundary. #[cfg(test)] fn read_download_action(repository_root: &Path) -> Result { read_reviewed_source(repository_root, DOWNLOAD_ACTION_PATH).map(|(_, source, _)| source) @@ -440,9 +398,13 @@ fn is_identifier_byte(byte: u8) -> bool { } fn audit_reviewed_roles(reviewed: &BTreeSet<(String, String)>, errors: &mut ViolationSink) { - let expected = [BUILD_EXPECTATION, MIRI_EXPECTATION] + // The matrix module directly audits build and Miri below. The standalone + // semver adapter has its own exact job audit, but its role still belongs in + // this single ownership equality check so adding or dropping `planned` in + // the registry cannot fall between the two focused modules. + let expected = [BUILD_JOB, MIRI_JOB, SEMVER_JOB] .into_iter() - .map(|spec| (WORKFLOW_PATH.to_owned(), spec.job_name.to_owned())) + .map(|job| (WORKFLOW_PATH.to_owned(), job.to_owned())) .collect::>(); for (workflow, job) in expected.difference(reviewed) { errors.push( @@ -471,9 +433,6 @@ fn audit_matrix_job( audit_condition(&fields, expected, errors); audit_host_job_contract(&fields, expected.job_name, errors); audit_read_permissions(lines, job.end, &fields, expected.job_name, errors); - if let Some(defaults) = expected.run_defaults { - audit_run_defaults(lines, job.end, &fields, expected.job_name, defaults, errors); - } audit_strategy(lines, job.end, &fields, expected, errors); if let Some(steps) = audited_steps_block(&fields, job, expected.job_name, 4, errors) { @@ -505,18 +464,16 @@ fn audit_matrix_step_contract( ); } - // `build_test` owns the definitions and Miri consumes exact aliases. The - // terminal executors have richer field-by-field audits below. The two - // semver steps follow the build executor and cannot affect it; their exact - // behavior remains outside this matrix-execution boundary until the - // standalone semver audit replaces them later in the stack. + // `build_test` owns the definitions and Miri must consume those exact + // aliases. Comparing complete significant steps prevents an extra field, + // alternate action, or setup command from hiding behind a familiar anchor + // name. The terminal executor has its richer field-by-field audit below. let owned = |step: &[&str]| step.iter().map(|line| (*line).to_owned()).collect(); let expected_setup: Vec> = if expected.job_name == BUILD_JOB { vec![ owned(CHECKOUT_STEP), download_image_step(), owned(LOAD_IMAGE_STEP), - owned(CREATE_DOCKER_SHELL_STEP), owned(VERIFY_CHECKOUT_STEP), ] } else { @@ -568,42 +525,6 @@ fn audit_needs(fields: &[super::source::Field<'_>], job: &str, errors: &mut Viol } } -fn audit_run_defaults( - lines: &[&str], - job_end: usize, - fields: &[super::source::Field<'_>], - job: &str, - expected: RunDefaultsExpectation, - errors: &mut ViolationSink, -) { - let Some(defaults) = unique_field(fields, "defaults", job, errors) else { - return; - }; - let defaults_job = format!("{job}.defaults"); - let Some(default_fields) = nested_fields(lines, defaults, job_end, &defaults_job, errors) - else { - return; - }; - audit_exact_job_fields(&default_fields, &defaults_job, &["run"], errors); - - let Some(run) = unique_field(&default_fields, "run", &defaults_job, errors) else { - return; - }; - let run_job = format!("{defaults_job}.run"); - let Some(run_fields) = nested_fields(lines, run, job_end, &run_job, errors) else { - return; - }; - audit_exact_job_fields(&run_fields, &run_job, &["shell", "working-directory"], errors); - audit_exact_scalar_field(&run_fields, &run_job, "shell", expected.shell, errors); - audit_exact_scalar_field( - &run_fields, - &run_job, - "working-directory", - expected.working_directory, - errors, - ); -} - fn audit_condition( fields: &[super::source::Field<'_>], expected: MatrixJobExpectation, @@ -719,13 +640,20 @@ fn executor_run(expected: MatrixJobExpectation) -> Vec { " -v /home/runner/work:/home/runner/work \\".to_owned(), " -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \\".to_owned(), " -v /home/runner/.docker-cargo/git:/root/.cargo/git \\".to_owned(), - " -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\".to_owned(), + // GITHUB_ENV and GITHUB_PATH are intentionally absent. The container + // must not use GitHub's cross-step file-command channels to alter the + // host environment. The exact sequence above keeps the executor + // terminal today; any future later step would first require extending + // this audit deliberately. Keep this line coordinated with both matrix + // job run blocks in `.github/workflows/ci.yml` and the local-action + // source audit in this module. Summary, output, and workspace paths are + // step-scoped data channels which the checked executor may still use. + " -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\".to_owned(), " -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \\".to_owned(), expected.forwarded_selector_environment.to_owned(), " -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \\".to_owned(), " -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \\".to_owned(), " -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \\".to_owned(), - " -e ZC_SKIP_CARGO_SEMVER_CHECKS \\".to_owned(), " -e GIT_CONFIG_COUNT=1 \\".to_owned(), " -e GIT_CONFIG_KEY_0=safe.directory \\".to_owned(), " -e \"GIT_CONFIG_VALUE_0=*\" \\".to_owned(), @@ -770,8 +698,7 @@ mod tests { use super::{ audit, audit_download_action, audit_download_action_source, download_image_step, - executor_run, read_download_action, BUILD_DEFAULT_SHELL, BUILD_DISPLAY_NAME, - BUILD_EXPECTATION, CHECKOUT_STEP, CREATE_DOCKER_SHELL_STEP, + executor_run, read_download_action, BUILD_DISPLAY_NAME, BUILD_EXPECTATION, CHECKOUT_STEP, DOWNLOAD_ACTION_EXPECTED_SOURCE, DOWNLOAD_ACTION_PATH, DOWNLOAD_ACTION_SNAPSHOT_PATH, LOAD_IMAGE_STEP, MIRI_DISPLAY_NAME, MIRI_EXPECTATION, TRUSTED_SHELL_LINE, VERIFY_CHECKOUT_STEP, @@ -788,7 +715,7 @@ mod tests { policy::Policy, workflow::{ReviewedWorkflowJobs, WORKFLOW_REGISTRY_PATH}, workflow_protocol::{ - BUILD_JOB, EXECUTE_BUILD_CELL_COMMAND, EXECUTE_MIRI_CELL_COMMAND, MIRI_JOB, + BUILD_JOB, EXECUTE_BUILD_CELL_COMMAND, EXECUTE_MIRI_CELL_COMMAND, MIRI_JOB, SEMVER_JOB, TRUSTED_SHELL, WORKFLOW_PATH, }, }; @@ -799,10 +726,6 @@ mod tests { needs: [build_docker_env, plan_ci] permissions: contents: read - defaults: - run: - shell: /tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes build matrix commands through the prebuilt Docker image) - working-directory: zerocopy strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan_ci.outputs.build_matrix) }} @@ -811,7 +734,6 @@ mod tests { - &matrix_checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 2 persist-credentials: false - &download_ci_image name: Download prebuilt Docker image @@ -832,32 +754,6 @@ mod tests { docker load --input "$IMAGE_ARCHIVE" docker image inspect "$IMAGE_NAME" >/dev/null docker run --rm "$IMAGE_NAME" true - - name: Create Docker Shell Wrapper - shell: bash - run: | - set -eo pipefail - mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git - cat << 'EOF' > /tmp/docker-shell.sh - #!/bin/bash - # Boot an ephemeral container for the step, mounting the workspace and - # temp dirs. Explicitly forward GitHub Actions internal state and matrix - # environment variables. - docker run --rm -i \ - --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 ZC_TOOLCHAIN -e PR_HEAD_SHA \ - -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 \ - "$ZC_CI_IMAGE" bash -c "git config --global --add safe.directory '*' && exec bash -e -o pipefail \"\$1\"" -- "$1" - EOF - chmod +x /tmp/docker-shell.sh - &verify_matrix_checkout name: Verify matrix checkout is unchanged shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} @@ -947,13 +843,12 @@ mod tests { -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 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=*" \ @@ -969,10 +864,6 @@ mod tests { --toolchain "$TOOLCHAIN" \ --feature-profile "$FEATURE_PROFILE" \ --target "$TARGET" - - name: Prepare cargo-semver-checks - run: echo audited separately later - - name: Check semver compatibility - run: echo audited separately later miri: if: needs.plan_ci.outputs.miri_enabled == 'true' runs-on: ubuntu-latest @@ -1004,13 +895,12 @@ mod tests { -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 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=*" \ @@ -1183,14 +1073,13 @@ mod tests { } #[test] - fn matrix_setup_definitions_are_exact() { + fn matrix_setup_definitions_are_exact_and_yaml_comments_remain_free() { assert_eq!(TRUSTED_SHELL_LINE, format!(" shell: {TRUSTED_SHELL}")); let owned = |step: &[&str]| step.iter().map(|line| (*line).to_owned()).collect(); for (step_name, step) in [ ("checkout", owned(CHECKOUT_STEP)), ("download", download_image_step()), ("load", owned(LOAD_IMAGE_STEP)), - ("Docker shell", owned(CREATE_DOCKER_SHELL_STEP)), ("checkout verification", owned(VERIFY_CHECKOUT_STEP)), ] { for line in &step { @@ -1199,22 +1088,26 @@ mod tests { rejected(&format!("{step_name}: {line}"), &source, "exact canonical contract"); } } - } - #[test] - fn prerequisite_checkout_overwrites_are_rejected() { - let overwrite_from_wrapper = replace_in_step( + let comments = replace_in_job( CANONICAL_SOURCE, - CREATE_DOCKER_SHELL_STEP[0], - " set -eo pipefail\n", - " set -eo pipefail\n printf malicious > zerocopy/cargo.sh\n", + BUILD_JOB, + " - &download_ci_image\n", + " - &download_ci_image\n # The source audit intentionally ignores documentation.\n", ); - rejected( - "wrapper overwrites cargo.sh", - &overwrite_from_wrapper, - "exact canonical contract", + audit_canonical(&comments).unwrap(); + + let scalar_comment = replace_in_step( + CANONICAL_SOURCE, + LOAD_IMAGE_STEP[0], + " set -euo pipefail\n", + " set -euo pipefail\n # ${{ github.event.pull_request.title }}\n", ); + rejected("run scalar comment", &scalar_comment, "exact canonical contract"); + } + #[test] + fn prerequisite_checkout_overwrites_are_rejected() { let inserted_overwrite = replace_in_job( CANONICAL_SOURCE, BUILD_JOB, @@ -1314,6 +1207,15 @@ mod tests { " - name: Unexpected setup\n run: true\n - &matrix_checkout\n", ), ), + ( + "build anchor renamed", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " - &matrix_checkout\n", + " - &wrong_anchor\n", + ), + ), ( "Miri setup reordered", replace_in_job( @@ -1323,6 +1225,15 @@ mod tests { " - *download_ci_image\n - *matrix_checkout\n", ), ), + ( + "Miri alias renamed", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " - *download_ci_image\n", + " - *other_download\n", + ), + ), ( "Miri alias extended", replace_in_job( @@ -1332,6 +1243,15 @@ mod tests { " - *verify_matrix_checkout\n if: success()\n", ), ), + ( + "Miri setup inserted", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " - *matrix_checkout\n", + " - *matrix_checkout\n - name: Unexpected setup\n run: true\n", + ), + ), ( "step after Miri executor", replace_in_job( @@ -1418,6 +1338,20 @@ mod tests { } } + #[test] + fn local_download_action_read_normalizes_crlf_but_rejects_bare_cr() { + let repository = TemporaryRepository::new("line-endings"); + repository.write_action(&DOWNLOAD_ACTION_EXPECTED_SOURCE.replace('\n', "\r\n")); + let source = read_download_action(&repository.root).unwrap(); + assert_eq!(source, DOWNLOAD_ACTION_EXPECTED_SOURCE); + audit_download_action_source(&source).unwrap(); + + repository.write_action("runs:\r using: composite\n"); + let error = read_download_action(&repository.root).unwrap_err(); + assert!(matches!(error, super::super::PlannedAdapterAuditError::ReadReviewedSource { .. })); + assert!(error.to_string().contains("bare carriage return"), "{error}"); + } + #[test] fn local_download_action_path_is_contained_and_regular() { let missing = TemporaryRepository::new("missing"); @@ -1454,21 +1388,38 @@ mod tests { #[cfg(unix)] #[test] - fn local_download_action_rejects_a_symlink() { + fn local_download_action_rejects_file_and_ancestor_symlinks() { use std::os::unix::fs::symlink; - let repository = TemporaryRepository::new("symlink"); - let outside = repository.directory.join("outside/action.yml"); - fs::create_dir_all(outside.parent().unwrap()).unwrap(); - fs::write(&outside, "runs:\n using: composite\n steps: []\n").unwrap(); - fs::create_dir_all(repository.action_path().parent().unwrap()).unwrap(); - symlink(&outside, repository.action_path()).unwrap(); + for escape in ["file", "ancestor"] { + let repository = TemporaryRepository::new(escape); + let outside = repository.directory.join("outside"); + fs::create_dir_all(&outside).unwrap(); + let outside_action = if escape == "file" { + outside.join("action.yml") + } else { + outside.join("download-artifact-with-retry/action.yml") + }; + fs::create_dir_all(outside_action.parent().unwrap()).unwrap(); + fs::write(&outside_action, "runs:\n using: composite\n steps: []\n").unwrap(); - let error = read_download_action(&repository.root).unwrap_err(); - assert!(matches!( - error, - super::super::PlannedAdapterAuditError::ReviewedSourceSymlink { .. } - )); + if escape == "file" { + fs::create_dir_all(repository.action_path().parent().unwrap()).unwrap(); + symlink(&outside_action, repository.action_path()).unwrap(); + } else { + fs::create_dir_all(repository.root.join(".github")).unwrap(); + symlink(&outside, repository.root.join(".github/actions")).unwrap(); + } + + let error = read_download_action(&repository.root).unwrap_err(); + assert!( + matches!( + error, + super::super::PlannedAdapterAuditError::ReviewedSourceSymlink { .. } + ), + "{escape}: {error:?}" + ); + } } #[cfg(unix)] @@ -1514,7 +1465,7 @@ mod tests { } #[test] - fn reviewed_planned_roles_equal_the_two_audited_jobs() { + fn reviewed_planned_roles_equal_all_three_typed_plan_consumers() { let mut missing = canonical_planned_jobs(); missing.remove(&(WORKFLOW_PATH.to_owned(), BUILD_JOB.to_owned())); assert_rejected( @@ -1523,6 +1474,14 @@ mod tests { "must have the reviewed `planned` role", ); + let mut missing = canonical_planned_jobs(); + missing.remove(&(WORKFLOW_PATH.to_owned(), SEMVER_JOB.to_owned())); + assert_rejected( + "missing standalone semver role", + audit_source(CANONICAL_SOURCE, &missing), + "must have the reviewed `planned` role", + ); + let mut extra = canonical_planned_jobs(); extra.insert((WORKFLOW_PATH.to_owned(), "surprise".to_owned())); assert_rejected( @@ -1760,44 +1719,14 @@ mod tests { "miri.permissions.id-token", ), ( - "default shell", - replace_in_job( - CANONICAL_SOURCE, - BUILD_JOB, - BUILD_DEFAULT_SHELL, - "/tmp/other-shell.sh {0}", - ), - "build_test.defaults.run.shell", - ), - ( - "default working directory", - replace_in_job( - CANONICAL_SOURCE, - BUILD_JOB, - " working-directory: zerocopy", - " working-directory: .", - ), - "build_test.defaults.run.working-directory", - ), - ( - "extra run default", + "build defaults", replace_in_job( CANONICAL_SOURCE, BUILD_JOB, - " working-directory: zerocopy\n", - " working-directory: zerocopy\n timeout-minutes: 1\n", - ), - "build_test.defaults.run.timeout-minutes", - ), - ( - "scalar defaults", - replace_in_job( - CANONICAL_SOURCE, - BUILD_JOB, - " defaults:\n", - " defaults: {}\n", + " strategy:\n", + " defaults: {}\n strategy:\n", ), - "canonical nested mapping", + "build_test.defaults", ), ( "Miri defaults", @@ -1897,6 +1826,18 @@ mod tests { "-e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", "Miri forwarding", ), + ( + BUILD_JOB, + "-e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\", + "-e GITHUB_ENV -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\", + "host environment file command", + ), + ( + MIRI_JOB, + "-e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\", + "-e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\", + "host PATH file command", + ), (BUILD_JOB, EXECUTE_BUILD_CELL_COMMAND, "wrong-build-command", "build command"), (MIRI_JOB, EXECUTE_MIRI_CELL_COMMAND, "wrong-miri-command", "Miri command"), ]; diff --git a/tools/zc/src/planned_adapter/mod.rs b/tools/zc/src/planned_adapter/mod.rs index 8a4ce2937c..8e8794cb71 100644 --- a/tools/zc/src/planned_adapter/mod.rs +++ b/tools/zc/src/planned_adapter/mod.rs @@ -13,15 +13,15 @@ //! The plan producer and its ordinary build and Miri consumers form a smaller //! handwritten boundary. The producer must publish exact outputs through one //! unconditional singleton job. Each planned matrix job must consume the -//! matching output, run only its exact setup sequence, prove that setup left -//! the checkout unchanged, and pass every selector through a real Docker -//! invocation to the typed executor. The local artifact action is mutable -//! repository code, so this boundary also resolves it inside the checkout and -//! requires its complete source to match an independent reviewed snapshot. A -//! missing output, substituted setup step, changed matrix expression, no-op -//! interpreter, conditional step, or dropped selector could otherwise -//! silently reduce coverage while the Rust plan and job-ID inventory remained -//! valid. +//! matching output, run only its exact checkout and image setup, prove that +//! setup left the expected checkout unchanged, and pass every selector through +//! 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. //! //! This module is deliberately not a YAML or GitHub Actions interpreter. It //! recognizes the canonical source forms which carry the planned-job workflow diff --git a/tools/zc/src/planned_adapter/planner.rs b/tools/zc/src/planned_adapter/planner.rs index 2fc7644c61..d90b44f841 100644 --- a/tools/zc/src/planned_adapter/planner.rs +++ b/tools/zc/src/planned_adapter/planner.rs @@ -23,11 +23,12 @@ use super::{ use crate::workflow_protocol::{ BUILD_MATRIX_OUTPUT, CI_EVENT_OPTION, GITHUB_OUTPUT_OPTION, GITHUB_PLAN_COMMAND, MIRI_ENABLED_OUTPUT, MIRI_MATRIX_OUTPUT, PLANNER_PATH, PLAN_ARTIFACT_OPTION, PLAN_JOB, - PLAN_STEP_ID, PLAN_STEP_NAME, REPOSITORY_WORKING_DIRECTORY, TRUSTED_SHELL, WORKFLOW_PATH, + PLAN_STEP_ID, PLAN_STEP_NAME, REPOSITORY_WORKING_DIRECTORY, SEMVER_ENABLED_OUTPUT, + SEMVER_MATRIX_OUTPUT, TRUSTED_SHELL, WORKFLOW_PATH, }; const PLAN_JOB_FIELDS: &[&str] = &["name", "runs-on", "permissions", "outputs", "env", "steps"]; -const PLAN_DISPLAY_NAME: &str = "Plan ordinary CI work"; +const PLAN_DISPLAY_NAME: &str = "Plan CI work"; const PLAN_ARTIFACT_ENVIRONMENT: &str = "CI_PLAN_ARTIFACT"; const PLAN_ARTIFACT_NAME: &str = "ci-plan.json"; // GitHub merges the workflow-level environment into every job. Require one @@ -181,10 +182,16 @@ fn audit_outputs( } let actual = nested_mapping(lines, outputs, job_end, PLAN_JOB, errors); - let expected = [BUILD_MATRIX_OUTPUT, MIRI_MATRIX_OUTPUT, MIRI_ENABLED_OUTPUT] - .into_iter() - .map(|output| (output.to_owned(), plan_output_expression(output))) - .collect::>(); + let expected = [ + BUILD_MATRIX_OUTPUT, + MIRI_MATRIX_OUTPUT, + MIRI_ENABLED_OUTPUT, + SEMVER_MATRIX_OUTPUT, + SEMVER_ENABLED_OUTPUT, + ] + .into_iter() + .map(|output| (output.to_owned(), plan_output_expression(output))) + .collect::>(); compare_map(job_field_location(PLAN_JOB, "outputs"), &expected, &actual, errors); } @@ -322,7 +329,7 @@ env: CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN: 1 jobs: plan_ci: - name: Plan ordinary CI work + name: Plan CI work runs-on: ubuntu-latest permissions: contents: read @@ -330,6 +337,8 @@ jobs: build_matrix: ${{ steps.plan.outputs.build_matrix }} miri_matrix: ${{ steps.plan.outputs.miri_matrix }} miri_enabled: ${{ steps.plan.outputs.miri_enabled }} + semver_matrix: ${{ steps.plan.outputs.semver_matrix }} + semver_enabled: ${{ steps.plan.outputs.semver_enabled }} env: CI_PLAN_ARTIFACT: ci-plan.json steps: @@ -438,8 +447,8 @@ jobs: "extra output", replace_once( CANONICAL_SOURCE, - " miri_enabled: ${{ steps.plan.outputs.miri_enabled }}\n", - " miri_enabled: ${{ steps.plan.outputs.miri_enabled }}\n surprise: ${{ steps.plan.outputs.surprise }}\n", + " semver_enabled: ${{ steps.plan.outputs.semver_enabled }}\n", + " semver_enabled: ${{ steps.plan.outputs.semver_enabled }}\n surprise: ${{ steps.plan.outputs.surprise }}\n", ), "plan_ci.outputs.surprise", ), @@ -456,14 +465,14 @@ jobs: #[test] fn producer_is_an_unconditional_host_singleton_with_one_steps_mapping() { - let header = " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n"; + let header = " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n"; let cases = [ ( "changed runner", replace_once( CANONICAL_SOURCE, header, - " plan_ci:\n name: Plan ordinary CI work\n runs-on: self-hosted\n", + " plan_ci:\n name: Plan CI work\n runs-on: self-hosted\n", ), ".runs-on", ), @@ -472,7 +481,7 @@ jobs: replace_once( CANONICAL_SOURCE, header, - " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n container: ignored.invalid/noop\n", + " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n container: ignored.invalid/noop\n", ), ".container", ), @@ -481,7 +490,7 @@ jobs: replace_once( CANONICAL_SOURCE, header, - " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n strategy:\n matrix:\n include: []\n", + " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n strategy:\n matrix:\n include: []\n", ), ".strategy", ), @@ -490,7 +499,7 @@ jobs: replace_once( CANONICAL_SOURCE, header, - " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n if: github.ref == 'refs/heads/main'\n", + " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n if: github.ref == 'refs/heads/main'\n", ), ".if", ), @@ -499,7 +508,7 @@ jobs: replace_once( CANONICAL_SOURCE, header, - " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n continue-on-error: true\n", + " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n continue-on-error: true\n", ), ".continue-on-error", ), @@ -525,7 +534,7 @@ jobs: #[test] fn producer_top_level_fields_permissions_and_environment_are_exact() { - let header = " plan_ci:\n name: Plan ordinary CI work\n runs-on: ubuntu-latest\n"; + let header = " plan_ci:\n name: Plan CI work\n runs-on: ubuntu-latest\n"; let additions = [ ("needs", " needs: build_docker_env\n"), ("concurrency", " concurrency: one-at-a-time\n"), @@ -545,15 +554,15 @@ jobs: let cases = [ ( "missing name", - replace_once(CANONICAL_SOURCE, " name: Plan ordinary CI work\n", ""), + replace_once(CANONICAL_SOURCE, " name: Plan CI work\n", ""), "plan_ci.name", ), ( "changed name", replace_once( CANONICAL_SOURCE, - "name: Plan ordinary CI work", - "name: Maybe plan ordinary CI work", + "name: Plan CI work", + "name: Maybe plan CI work", ), "plan_ci.name", ), @@ -1058,8 +1067,8 @@ jobs: fn scalar_fields_reject_indented_continuations_but_mappings_remain_valid() { let source = replace_once( CANONICAL_SOURCE, - " name: Plan ordinary CI work\n runs-on: ubuntu-latest\n", - " name: Plan ordinary CI work\n runs-on: ubuntu-latest\n accidentally-nested\n", + " name: Plan CI work\n runs-on: ubuntu-latest\n", + " name: Plan CI work\n runs-on: ubuntu-latest\n accidentally-nested\n", ); rejected("scalar continuation", &source, "indented scalar continuation"); audit_source(CANONICAL_SOURCE).unwrap(); diff --git a/tools/zc/src/planned_adapter/source.rs b/tools/zc/src/planned_adapter/source.rs index b3525ca669..2560019be1 100644 --- a/tools/zc/src/planned_adapter/source.rs +++ b/tools/zc/src/planned_adapter/source.rs @@ -415,24 +415,6 @@ pub(super) fn audit_read_permissions( ); } -pub(super) fn nested_fields<'a>( - lines: &'a [&'a str], - parent: &Field<'_>, - block_end: usize, - job: &str, - errors: &mut ViolationSink, -) -> Option>> { - if !parent.value.is_empty() { - errors.push( - job_field_location(job, parent.key), - format!("{} must use the canonical nested mapping form", parent.key), - ); - return None; - } - let end = nested_block_end(lines, parent, block_end); - Some(job_fields_at_indent(lines, parent.line..end, job, parent.indent + 2, errors)) -} - pub(super) fn nested_mapping( lines: &[&str], parent: &Field<'_>, diff --git a/tools/zc/src/planned_adapter/test_support.rs b/tools/zc/src/planned_adapter/test_support.rs index c762763359..6da6a6fba0 100644 --- a/tools/zc/src/planned_adapter/test_support.rs +++ b/tools/zc/src/planned_adapter/test_support.rs @@ -11,7 +11,7 @@ use std::collections::BTreeSet; use super::{source::canonical_workflow_lines, PlannedAdapterViolations, ViolationSink}; -use crate::workflow_protocol::{BUILD_JOB, MIRI_JOB, WORKFLOW_PATH}; +use crate::workflow_protocol::{BUILD_JOB, MIRI_JOB, SEMVER_JOB, WORKFLOW_PATH}; pub(super) fn audit_feature( source: &str, @@ -63,7 +63,7 @@ pub(super) fn replace_in_job(source: &str, job: &str, from: &str, to: &str) -> S } pub(super) fn canonical_planned_jobs() -> BTreeSet<(String, String)> { - [BUILD_JOB, MIRI_JOB] + [BUILD_JOB, MIRI_JOB, SEMVER_JOB] .into_iter() .map(|job| (WORKFLOW_PATH.to_owned(), job.to_owned())) .collect() diff --git a/tools/zc/src/policy.rs b/tools/zc/src/policy.rs index 95fad98190..0eed76d6a6 100644 --- a/tools/zc/src/policy.rs +++ b/tools/zc/src/policy.rs @@ -1619,9 +1619,32 @@ impl Validator { EventCategory::Reduced => (miri_matrix_cell_count, 0), EventCategory::Full => (0, miri_matrix_cell_count), }; - let reduced_event_cell_count = - reduced_build_cell_count.saturating_add(reduced_miri_cell_count); - let full_event_cell_count = full_build_cell_count.saturating_add(full_miri_cell_count); + // Semver has its own matrix, but its target membership is the matching + // ordinary-build slice: full events select the complete configured set, + // while reduced events keep only targets marked `pr_eligible`. Keep + // this preflight coordinated with `plan::enumerate_semver_candidates`. + // Counting it here ensures a limit which can admit only the two Cargo + // matrices fails during policy validation rather than later planning. + let full_semver_cell_count = target_sets + .get(&semver.target_set) + .map_or(0, |members| u64::try_from(members.len()).unwrap_or(u64::MAX)); + let reduced_semver_cell_count = target_sets.get(&semver.target_set).map_or(0, |members| { + u64::try_from( + members + .iter() + .filter(|target| { + targets.get(*target).is_some_and(|target| target.pr_eligible()) + }) + .count(), + ) + .unwrap_or(u64::MAX) + }); + let reduced_event_cell_count = reduced_build_cell_count + .saturating_add(reduced_miri_cell_count) + .saturating_add(reduced_semver_cell_count); + let full_event_cell_count = full_build_cell_count + .saturating_add(full_miri_cell_count) + .saturating_add(full_semver_cell_count); self.check_plan_size("reduced-event", reduced_event_cell_count, limits); self.check_plan_size("full-event", full_event_cell_count, limits); @@ -2626,8 +2649,8 @@ mod tests { mutate(REPOSITORY_POLICY, "max_matrix_cells = 256", "max_matrix_cells = 181"); assert_eq!(Policy::parse(&matrix_limit).unwrap().limits().max_matrix_cells(), 181); - let plan_limit = mutate(REPOSITORY_POLICY, "max_plan_cells = 4096", "max_plan_cells = 245"); - assert_invalid_contains(&plan_limit, &["full-event plan expands to 246 cells"]); + let plan_limit = mutate(REPOSITORY_POLICY, "max_plan_cells = 4096", "max_plan_cells = 254"); + assert_invalid_contains(&plan_limit, &["full-event plan expands to 255 cells"]); } #[test] diff --git a/tools/zc/src/semver_adapter.rs b/tools/zc/src/semver_adapter.rs new file mode 100644 index 0000000000..ec91fab7ad --- /dev/null +++ b/tools/zc/src/semver_adapter.rs @@ -0,0 +1,1926 @@ +// 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. + +//! A deliberately narrow audit of the semver GitHub Actions adapter. +//! +//! GitHub requires an action reference to remain literal workflow YAML. The +//! typed planner therefore cannot execute `cargo-semver-checks` itself or emit +//! the `uses` field dynamically. That narrow handwritten boundary is +//! load-bearing: changing its preparation, action revision, explicit +//! environment, inputs, or condition can silently make the typed semver policy +//! mean something else. +//! +//! This module does not pretend to parse arbitrary GitHub Actions YAML. The +//! general workflow inventory in [`crate::workflow`] proves filenames and job +//! IDs, while the planner projects exact event-specific semver targets. Here we +//! recognize the complete canonical semver job and its preparation/action pair, +//! construct the static action specification from checked policy and repository +//! inventory, and reject unfamiliar or additional syntax. A broader workflow +//! shape must receive a deliberately reviewed parser change. +//! +//! Keep the following files coordinated: +//! +//! * `ci/zc.toml` owns the semver package, toolchain, profile, target set, and +//! waivers. +//! * `zerocopy/Cargo.toml` owns the stable aggregate feature and package path +//! discovered through Cargo metadata. +//! * `.github/workflows/ci.yml` contains the literal action adapter audited +//! here. +//! * `plan.rs` derives semver work from the same checked coverage cells as the +//! ordinary build plan. `github.rs` projects only each selected target, and +//! this module requires the standalone job to consume that exact matrix. +//! * The planner part of that audit checks the complete workflow-level +//! environment. This module rejects a job-level environment and requires the +//! semver pair immediately after pinned checkout on a fresh runner, isolated +//! from repository-owned setup and the ordinary build. Keep the audit order +//! in `CiInputs::load` intact when changing that division of responsibility. +//! * `workflow_protocol.rs` owns the workflow path, job and output names shared +//! with the producer. Keep this module's deliberately narrow job-range +//! grammar coordinated with `planned_adapter/source.rs`. +//! * The workflow's `Prepare cargo-semver-checks` step implements the reviewed +//! commit-message escape hatch and vendored-source workaround. Its exact +//! shell, selector, and step-scoped output are audited here alongside the +//! action which consumes that output. Do not replace the output with an +//! ambient environment variable: another step or scope could then suppress +//! semver checks. +//! * `execution.rs` models the same action as legacy command behavior. It must +//! consume the constants and typed values exported here rather than growing +//! an independent copy. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, + ops::Range, + path::Path, +}; + +use thiserror::Error; + +use crate::{ + inventory::RepositoryInventory, + policy::{FeatureProfile, Policy}, + workflow_protocol::{ + PLAN_JOB, REPOSITORY_WORKING_DIRECTORY, SEMVER_ENABLED_OUTPUT, SEMVER_JOB, + SEMVER_MATRIX_OUTPUT, SEMVER_STEP_NAME, TRUSTED_SHELL, WORKFLOW_PATH, + }, +}; + +/// The pinned action identity shared with the typed execution model. +pub(crate) const SEMVER_ACTION: &str = + "obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239"; + +/// The action's explicit feature-selection mode. +pub(crate) const SEMVER_FEATURE_GROUP: &str = "only-explicit-features"; + +/// Action input which separates target-specific baseline-rustdoc caches. +pub(crate) const SEMVER_CACHE_PREFIX_INPUT: &str = "prefix-key"; + +/// Action input which selects the target whose public API is checked. +pub(crate) const SEMVER_TARGET_INPUT: &str = "rust-target"; + +/// The one dynamic value accepted by both target-specific action inputs. +pub(crate) const SEMVER_MATRIX_TARGET_EXPRESSION: &str = "${{ matrix.target }}"; + +/// Warnings remain errors, while unstable public API stays hidden from semver. +pub(crate) const SEMVER_WARNING_FLAGS: &str = "-Dwarnings"; + +const PREPARE_STEP_NAME: &str = "Prepare cargo-semver-checks"; +const PREPARE_STEP_MARKER: &str = " - name: Prepare cargo-semver-checks"; +const PREPARE_STEP_ID: &str = "prepare_semver"; +const PREPARE_OUTPUT_CONDITION: &str = "steps.prepare_semver.outputs.run == 'true'"; +const LEGACY_SKIP_ENVIRONMENT: &str = "ZC_SKIP_CARGO_SEMVER_CHECKS"; +const SEMVER_ACTION_MARKER: &str = "cargo-semver-checks-action@"; +const PREPARE_ROOT_FIELD_ORDER: [&str; 5] = ["id", "shell", "working-directory", "env", "run"]; +const ROOT_FIELD_ORDER: [&str; 4] = ["uses", "env", "with", "if"]; + +// The preparation reads the pull request head commit, so checkout identity and +// history depth are part of the adapter's behavior. Nothing except this pinned +// external action may run first on the fresh semver runner. +const CHECKOUT_STEP: &[&str] = &[ + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", + " with:", + " fetch-depth: 2", + " persist-credentials: false", +]; + +const PREPARE_RUN: &[&str] = &[ + "set -euo pipefail", + "# Pull request jobs check the head commit rather than GitHub's synthetic", + "# merge commit. `PR_HEAD_SHA`, the depth-2 checkout above, and this", + "# lookup are one contract: if checkout stops fetching that object,", + "# `git log` fails instead of silently inspecting another message.", + "if [[ \"$GITHUB_EVENT_NAME\" == 'pull_request' ]]; then", + " MESSAGE=\"$(/usr/bin/git log -1 --pretty=%B \"$PR_HEAD_SHA\")\"", + " MESSAGE_SOURCE='pull request head commit message'", + "else", + " MESSAGE=\"$(/usr/bin/git log -1 --pretty=%B HEAD)\"", + " MESSAGE_SOURCE='commit message'", + "fi", + "if /usr/bin/grep -Eq \\", + " '^[[:space:]]*SKIP_CARGO_SEMVER_CHECKS=1[[:space:]]*$' \\", + " <<< \"$MESSAGE\"; then", + " printf \"Found 'SKIP_CARGO_SEMVER_CHECKS=1' in the %s; \" \\", + " \"$MESSAGE_SOURCE\" | /usr/bin/tee -a \"$GITHUB_STEP_SUMMARY\"", + " printf 'skipping cargo-semver-checks.\\n' | \\", + " /usr/bin/tee -a \"$GITHUB_STEP_SUMMARY\"", + " printf 'run=false\\n' >> \"$GITHUB_OUTPUT\"", + "else", + " # FIXME(#2906): cargo-semver-checks fetches the latest Zerocopy from", + " # crates.io, but the vendored-source configuration cannot resolve", + " # that package. This exact file removal affects only this isolated", + " # checkout. Switch to --baseline-rev before removing the workaround.", + " /usr/bin/rm .cargo/config.toml", + " printf 'run=true\\n' >> \"$GITHUB_OUTPUT\"", + "fi", +]; + +/// All semantic values expected in the handwritten adapter. +/// +/// This type is crate-visible so the typed executor can share this model. Its +/// fields remain private to prevent another module from constructing an +/// unchecked, nearly-identical adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SemverAdapterSpec { + preparation_environment: BTreeMap, + preparation_run: Vec, + action: &'static str, + environment: BTreeMap, + inputs: BTreeMap, + condition: Vec, +} + +impl SemverAdapterSpec { + /// Derives the adapter from inputs which have already passed their owning + /// validators. + pub(crate) fn from_checked_inputs( + policy: &Policy, + repository: &RepositoryInventory, + ) -> Result { + let mut errors = ViolationSink::default(); + let semver = policy.semver(); + + let profile = policy.features().profiles().get(semver.profile().as_str()); + if profile != Some(&FeatureProfile::StableAggregate) { + errors.push( + "semver.profile", + format!( + "profile `{}` must select the stable aggregate feature for the GitHub adapter", + semver.profile() + ), + ); + } + + let manifest = match repository.policy_packages().get(semver.package().as_str()) { + Some(package) => match slash_path(package.cargo().manifest()) { + Ok(path) => Some(path), + Err(message) => { + errors.push("semver.package", message); + None + } + }, + None => { + errors.push( + "semver.package", + format!( + "checked inventory has no package `{}` for the semver adapter", + semver.package() + ), + ); + None + } + }; + + let toolchain_version = + match repository.toolchain_versions().get(semver.toolchain().as_str()) { + Some(version) => Some(version.clone()), + None => { + errors.push( + "semver.toolchain", + format!( + "checked inventory has no version for semver toolchain `{}`", + semver.toolchain() + ), + ); + None + } + }; + + // These values are written as unquoted YAML scalars by the deliberately + // narrow source adapter below. Refuse repository or policy values for + // which textual equality would not imply YAML scalar equality. + validate_plain_string_scalar("semver.package", semver.package().as_str(), &mut errors); + validate_plain_string_scalar("semver.profile", semver.profile().as_str(), &mut errors); + validate_plain_string_scalar("semver.toolchain", semver.toolchain().as_str(), &mut errors); + validate_plain_string_scalar( + "features.stable_feature_root", + policy.features().stable_feature_root().as_str(), + &mut errors, + ); + if let Some(manifest) = manifest.as_deref() { + validate_plain_string_scalar("semver.package.manifest", manifest, &mut errors); + } + if let Some(toolchain_version) = toolchain_version.as_deref() { + validate_exact_rust_version_scalar( + "semver.toolchain.version", + toolchain_version, + &mut errors, + ); + } + if !errors.is_empty() { + return Err(errors.finish()); + } + let manifest = manifest.expect("a valid checked package must have a UTF-8 manifest path"); + let toolchain_version = + toolchain_version.expect("a valid checked semver toolchain must have a version"); + let environment = BTreeMap::from([ + ("RUSTDOCFLAGS".to_owned(), SEMVER_WARNING_FLAGS.to_owned()), + ("RUSTFLAGS".to_owned(), SEMVER_WARNING_FLAGS.to_owned()), + ]); + let inputs = BTreeMap::from([ + ("feature-group".to_owned(), SEMVER_FEATURE_GROUP.to_owned()), + ("features".to_owned(), policy.features().stable_feature_root().as_str().to_owned()), + ("manifest-path".to_owned(), manifest), + ("package".to_owned(), semver.package().as_str().to_owned()), + (SEMVER_CACHE_PREFIX_INPUT.to_owned(), SEMVER_MATRIX_TARGET_EXPRESSION.to_owned()), + (SEMVER_TARGET_INPUT.to_owned(), SEMVER_MATRIX_TARGET_EXPRESSION.to_owned()), + ("rust-toolchain".to_owned(), toolchain_version), + ]); + + let preparation_environment = BTreeMap::from([( + "PR_HEAD_SHA".to_owned(), + "${{ github.event.pull_request.head.sha }}".to_owned(), + )]); + let preparation_run = PREPARE_RUN.iter().map(|line| (*line).to_owned()).collect(); + let condition = vec![PREPARE_OUTPUT_CONDITION.to_owned()]; + + Ok(Self { + preparation_environment, + preparation_run, + action: SEMVER_ACTION, + environment, + inputs, + condition, + }) + } + + /// Returns the preparation step's complete explicit environment. + fn preparation_environment(&self) -> &BTreeMap { + &self.preparation_environment + } + + /// Returns every behavior-bearing shell line in the preparation step. + fn preparation_run(&self) -> &[String] { + &self.preparation_run + } + + /// Returns the exact pinned `uses` identity. + pub(crate) fn action(&self) -> &str { + self.action + } + + /// Returns the action step's complete explicit environment. + pub(crate) fn environment(&self) -> &BTreeMap { + &self.environment + } + + /// Returns the complete `with` mapping, including GitHub expressions. + pub(crate) fn inputs(&self) -> &BTreeMap { + &self.inputs + } + + /// Returns the exact lines in the multiline GitHub condition. + pub(crate) fn condition(&self) -> &[String] { + &self.condition + } +} + +/// Checks the literal workflow adapter against its typed specification. +/// +/// `source` must be the exact text retained by the workflow inventory. Taking +/// source rather than a path makes the job inventory and this behavioral audit +/// inseparable even if an editor replaces the workflow pathname during load. +pub(crate) fn audit_semver_adapter( + source: &str, + policy: &Policy, + repository: &RepositoryInventory, +) -> Result<(), SemverAdapterAuditError> { + let expected = SemverAdapterSpec::from_checked_inputs(policy, repository)?; + audit_source(&expected, source)?; + Ok(()) +} + +fn audit_source(expected: &SemverAdapterSpec, source: &str) -> Result<(), SemverAdapterViolations> { + let mut errors = ViolationSink::default(); + if source.contains(LEGACY_SKIP_ENVIRONMENT) { + errors.push( + adapter_location("preparation"), + format!( + "legacy ambient skip channel `{LEGACY_SKIP_ENVIRONMENT}` must not appear; use the audited `{PREPARE_STEP_ID}` step output" + ), + ); + } + + let preparation = ParsedPreparation::parse(source)?; + let actual = ParsedAdapter::parse(source)?; + audit_semver_job_contract(source, preparation.marker, actual.marker, &mut errors); + if preparation.marker >= actual.marker { + errors.push( + adapter_location("order"), + format!("`{PREPARE_STEP_NAME}` must precede `{SEMVER_STEP_NAME}`"), + ); + } + compare_map( + "preparation.env", + expected.preparation_environment(), + &preparation.environment, + &mut errors, + ); + if expected.preparation_run() != preparation.run { + errors.push( + adapter_location("preparation.run"), + format!( + "run block must contain the exact reviewed shell lines {:?}, found {:?}", + expected.preparation_run(), + preparation.run + ), + ); + } + if !preparation.condition.is_empty() { + errors.push( + adapter_location("preparation.if"), + format!( + "preparation must run for every selected semver cell, found condition {:?}", + preparation.condition, + ), + ); + } + compare_value("uses", expected.action(), &actual.action, &mut errors); + compare_map("env", expected.environment(), &actual.environment, &mut errors); + compare_map("with", expected.inputs(), &actual.inputs, &mut errors); + if expected.condition() != actual.condition { + errors.push( + adapter_location("if"), + format!( + "condition must be the exact typed expression {:?}, found {:?}", + expected.condition(), + actual.condition + ), + ); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.finish()) + } +} + +/// Audits the complete handwritten host and matrix boundary for semver. +/// +/// The planner audit owns the complete workflow-level environment. This exact +/// job header rejects a job environment, runner or permission drift, and any +/// matrix source other than the typed semver projection. The three-step +/// sequence ensures that only pinned checkout runs before the exact preparation +/// and action on this fresh runner. +fn audit_semver_job_contract( + source: &str, + preparation_marker: usize, + adapter_marker: usize, + errors: &mut ViolationSink, +) { + let lines = source.lines().collect::>(); + let Some(job) = canonical_job_range(&lines, SEMVER_JOB, errors) else { + return; + }; + let steps = canonical_step_blocks(&lines, job.clone()); + let header_end = steps.first().map_or(job.end, |step| step.start); + let actual_header = significant_lines(&lines[job.start..header_end]) + .into_iter() + .map(str::to_owned) + .collect::>(); + let expected_header = semver_job_header(); + if actual_header != expected_header { + errors.push( + adapter_location("job"), + format!( + "`{SEMVER_JOB}` header must be exactly {expected_header:?}, found {actual_header:?}" + ), + ); + } + + // YAML mapping order is not semantic. A job-level field written after the + // final step still applies to the complete job, even though the focused + // step parsers stop when indentation returns to the job level. Require all + // such fields to precede the first sequence item, where the exact header + // comparison above sees them. In particular, this prevents a trailing + // `continue-on-error` or `env` field from weakening the voting job while + // remaining outside the audited header. + if let Some(first_step) = steps.first() { + for (index, line) in + lines.iter().enumerate().take(job.end).skip(first_step.start).filter(|(_, line)| { + !line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == 4 + && !line[4..].starts_with("- ") + }) + { + errors.push( + adapter_location("job"), + format!( + "job-level declaration after `steps` at line {} is outside the canonical header: `{}`", + index + 1, + escape_control_characters(&line[4..]), + ), + ); + } + } + + let actual_markers = + steps.iter().map(|step| lines[step.start][4..].to_owned()).collect::>(); + let expected_markers = [ + "- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1".to_owned(), + format!("- name: {PREPARE_STEP_NAME}"), + format!("- name: {SEMVER_STEP_NAME}"), + ]; + if actual_markers != expected_markers { + errors.push( + adapter_location("steps"), + format!( + "`{SEMVER_JOB}` steps must be exactly {expected_markers:?} in order, found {actual_markers:?}" + ), + ); + } + + let checkout_markers = + steps.iter().filter(|step| lines[step.start] == CHECKOUT_STEP[0]).collect::>(); + match checkout_markers.as_slice() { + [step] => { + let actual = significant_lines(&lines[step.start..step.end]); + if actual != CHECKOUT_STEP { + errors.push( + adapter_location("checkout"), + "checkout step must match the exact canonical contract", + ); + } + } + _ => errors.push( + adapter_location("checkout"), + format!( + "expected exactly one canonical checkout step, found {}", + checkout_markers.len() + ), + ), + } + + if steps.len() == 3 + && !(steps[0].start < preparation_marker + && preparation_marker < adapter_marker + && adapter_marker == steps[2].start) + { + errors.push( + adapter_location("order"), + "checkout, preparation, and action must be the job's exact step order", + ); + } + + // GITHUB_ENV and GITHUB_PATH are write-only file-command channels whose + // effects GitHub injects into later steps. The exact, two-step prefix above + // prevents new producers before semver, and this independent token check + // makes the intended absence explicit. GITHUB_OUTPUT remains the reviewed + // preparation's step-local skip channel; GITHUB_STEP_SUMMARY does not + // mutate a later process environment and remains available for diagnostics. + for (index, line) in lines + .iter() + .enumerate() + .take(adapter_marker) + .skip(job.start) + .filter(|(_, line)| !line.trim_start().starts_with('#')) + { + for channel in ["GITHUB_ENV", "GITHUB_PATH"] { + if token_mentions(line, channel) != 0 { + errors.push( + adapter_line_location(index + 1), + format!( + "`{channel}` must not be referenced before the semver action; cross-step environment producers are outside the audited adapter" + ), + ); + } + } + } +} + +fn semver_job_header() -> Vec { + vec![ + format!(" {SEMVER_JOB}:"), + format!(" if: needs.{PLAN_JOB}.outputs.{SEMVER_ENABLED_OUTPUT} == 'true'"), + " runs-on: ubuntu-latest".to_owned(), + format!(" needs: [{PLAN_JOB}]"), + " permissions:".to_owned(), + " contents: read".to_owned(), + " strategy:".to_owned(), + " fail-fast: false".to_owned(), + format!( + " matrix: ${{{{ fromJSON(needs.{PLAN_JOB}.outputs.{SEMVER_MATRIX_OUTPUT}) }}}}" + ), + " name: Semver (${{ matrix.target }})".to_owned(), + " steps:".to_owned(), + ] +} + +fn slash_path(path: &Path) -> Result { + let Some(path) = path.to_str() else { + return Err(format!("manifest path `{path:?}` is not UTF-8")); + }; + Ok(path.replace('\\', "/")) +} + +fn validate_plain_string_scalar(location: &str, value: &str, errors: &mut ViolationSink) { + let mut characters = value.chars(); + let safe_start = characters + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_'); + let safe_tail = characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/' | '+') + }); + let reserved = matches!( + value.to_ascii_lowercase().as_str(), + "null" | "true" | "false" | "y" | "n" | "yes" | "no" | "on" | "off" + ); + if !safe_start || !safe_tail || reserved { + errors.push( + location, + format!( + "value `{}` is not safe as a canonical unquoted YAML scalar", + escape_control_characters(value) + ), + ); + } +} + +fn validate_exact_rust_version_scalar(location: &str, value: &str, errors: &mut ViolationSink) { + // Repository inventory has already required an exact Rust version. Repeat + // the narrow lexical property needed at this adapter boundary: three + // nonempty decimal components cannot be a YAML integer, float, boolean, + // null, or date. Anything broader must first gain canonical YAML quoting. + let components = value.split('.').collect::>(); + if components.len() != 3 + || components.iter().any(|component| { + component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit()) + }) + { + errors.push( + location, + format!( + "value `{}` is not safe as an unquoted exact Rust version", + escape_control_characters(value) + ), + ); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedPreparation { + marker: usize, + environment: BTreeMap, + run: Vec, + condition: Vec, +} + +impl ParsedPreparation { + fn parse(source: &str) -> Result { + let mut errors = ViolationSink::default(); + if source.contains('\r') { + errors.push(adapter_location("source"), "workflow must use canonical LF line endings"); + } + + let lines = source.lines().collect::>(); + let semver_job = canonical_job_range(&lines, SEMVER_JOB, &mut errors); + let markers = lines + .iter() + .enumerate() + .filter_map(|(index, line)| (*line == PREPARE_STEP_MARKER).then_some(index)) + .collect::>(); + if markers.len() != 1 { + errors.push( + preparation_location("name"), + format!( + "expected exactly one canonical `{PREPARE_STEP_MARKER}` declaration, found {}", + markers.len() + ), + ); + return Err(errors.finish()); + } + + let marker = markers[0]; + let Some(semver_job) = semver_job else { + return Err(errors.finish()); + }; + if !semver_job.contains(&marker) { + errors.push( + preparation_location("name"), + format!("canonical preparation step must be inside the `{SEMVER_JOB}` job"), + ); + return Err(errors.finish()); + } + + let start = marker + 1; + let end = canonical_step_end(&lines, start, semver_job.end); + let mut section = PreparationSection::Root; + let mut root_fields = Vec::new(); + let mut id = None; + let mut shell = None; + let mut working_directory = None; + let mut environment = BTreeMap::new(); + let mut run = Vec::new(); + let mut condition = Vec::new(); + + for (index, line) in lines.iter().enumerate().take(end).skip(start) { + let line_number = index + 1; + if section == PreparationSection::Condition { + if indentation(line) != 8 || line.trim().is_empty() { + errors.push( + adapter_line_location(line_number), + "condition content must be one nonempty line at exactly eight spaces", + ); + } else { + // An indented `#` is scalar content under `if: |`, not a + // YAML comment. Preserve it so it cannot disappear from + // the expression compared below. + condition.push(line[8..].to_owned()); + } + continue; + } + if line.trim().is_empty() { + if section == PreparationSection::Run + && run.last().is_some_and(|line: &String| line.ends_with('\\')) + { + errors.push( + adapter_line_location(line_number), + "blank line must not interrupt a continued shell command", + ); + } + continue; + } + // Outside a block scalar this is a YAML comment. Beneath `run: |` + // it is shell-script content, and Actions expands `${{ ... }}` + // before invoking Bash. Preserve the latter in the exact run + // comparison so an apparently commented expression cannot inject + // commands. A scalar comment after `\` is likewise compared and + // rejected rather than being silently discarded. + if line.trim_start().starts_with('#') && section != PreparationSection::Run { + continue; + } + if line.trim_start().starts_with('#') + && run.last().is_some_and(|line: &String| line.ends_with('\\')) + { + errors.push( + adapter_line_location(line_number), + "comment line must not interrupt a continued shell command", + ); + } + if line.trim_end() != *line { + errors.push( + adapter_line_location(line_number), + "semantic preparation lines must not have trailing whitespace", + ); + continue; + } + + let indent = indentation(line); + if indent == 6 { + let declaration = &line[6..]; + match declaration { + "env:" => { + root_fields.push("env"); + section = PreparationSection::Environment; + } + "run: |" => { + root_fields.push("run"); + section = PreparationSection::Run; + } + "if: |" => { + root_fields.push("if"); + section = PreparationSection::Condition; + } + _ if declaration.starts_with("id: ") => { + root_fields.push("id"); + section = PreparationSection::Root; + if id.replace(declaration["id: ".len()..].to_owned()).is_some() { + errors.push( + adapter_line_location(line_number), + "preparation repeats its `id` field", + ); + } + } + _ if declaration.starts_with("shell: ") => { + root_fields.push("shell"); + section = PreparationSection::Root; + if shell.replace(declaration["shell: ".len()..].to_owned()).is_some() { + errors.push( + adapter_line_location(line_number), + "preparation repeats its `shell` field", + ); + } + } + _ if declaration.starts_with("working-directory: ") => { + root_fields.push("working-directory"); + section = PreparationSection::Root; + if working_directory + .replace(declaration["working-directory: ".len()..].to_owned()) + .is_some() + { + errors.push( + adapter_line_location(line_number), + "preparation repeats its `working-directory` field", + ); + } + } + _ => errors.push( + adapter_line_location(line_number), + format!( + "unsupported preparation root field `{}`; expected only id, shell, working-directory, env, run, and if", + escape_control_characters(declaration) + ), + ), + } + continue; + } + + match section { + PreparationSection::Environment if indent == 8 => insert_mapping( + "preparation.env", + &line[8..], + line_number, + &mut environment, + &mut errors, + ), + PreparationSection::Run if indent >= 8 => run.push(line[8..].to_owned()), + PreparationSection::Root + | PreparationSection::Environment + | PreparationSection::Run + | PreparationSection::Condition => errors.push( + adapter_line_location(line_number), + format!( + "unsupported preparation indentation in `{}`", + escape_control_characters(line) + ), + ), + } + } + + if root_fields != PREPARE_ROOT_FIELD_ORDER { + errors.push( + preparation_location("shape"), + format!( + "root fields must appear exactly as {PREPARE_ROOT_FIELD_ORDER:?}, found {root_fields:?}" + ), + ); + } + match id { + Some(actual) => compare_value("preparation.id", PREPARE_STEP_ID, &actual, &mut errors), + None => errors.push(preparation_location("id"), "preparation has no `id` value"), + } + match shell { + Some(actual) => { + compare_value("preparation.shell", TRUSTED_SHELL, &actual, &mut errors); + } + None => errors.push(preparation_location("shell"), "preparation has no `shell` value"), + } + match working_directory { + Some(actual) => compare_value( + "preparation.working-directory", + REPOSITORY_WORKING_DIRECTORY, + &actual, + &mut errors, + ), + None => errors.push( + preparation_location("working-directory"), + "preparation has no `working-directory` value", + ), + } + + let id_mentions = source.matches(&format!("id: {PREPARE_STEP_ID}")).count(); + if id_mentions != 1 { + errors.push( + preparation_location("id"), + format!( + "expected exactly one `id: {PREPARE_STEP_ID}` occurrence in the workflow, found {id_mentions}" + ), + ); + } + let output_mentions = source.matches(PREPARE_OUTPUT_CONDITION).count(); + if output_mentions != 1 { + errors.push( + adapter_location("if"), + format!( + "expected exactly one `{PREPARE_OUTPUT_CONDITION}` occurrence in the workflow, found {output_mentions}" + ), + ); + } + let id_token_mentions = lines + .iter() + .filter(|line| !line.trim_start().starts_with('#')) + .map(|line| line.matches(PREPARE_STEP_ID).count()) + .sum::(); + if id_token_mentions != 2 { + errors.push( + preparation_location("id"), + format!( + "expected `{PREPARE_STEP_ID}` only in its ID and audited consumer, found {id_token_mentions} occurrences" + ), + ); + } + + if errors.is_empty() { + Ok(Self { marker, environment, run, condition }) + } else { + Err(errors.finish()) + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PreparationSection { + Root, + Environment, + Run, + Condition, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedAdapter { + marker: usize, + action: String, + environment: BTreeMap, + inputs: BTreeMap, + condition: Vec, +} + +impl ParsedAdapter { + fn parse(source: &str) -> Result { + let mut errors = ViolationSink::default(); + if source.contains('\r') { + errors.push(adapter_location("source"), "workflow must use canonical LF line endings"); + } + + let lines = source.lines().collect::>(); + let semver_job = canonical_job_range(&lines, SEMVER_JOB, &mut errors); + let action_mentions = source.match_indices(SEMVER_ACTION_MARKER).count(); + if action_mentions != 1 { + errors.push( + adapter_location("uses"), + format!( + "expected exactly one `{SEMVER_ACTION_MARKER}` occurrence in the workflow, found {action_mentions}" + ), + ); + } + let semver_step_marker = format!(" - name: {SEMVER_STEP_NAME}"); + let markers = lines + .iter() + .enumerate() + .filter_map(|(index, line)| (*line == semver_step_marker).then_some(index)) + .collect::>(); + if markers.len() != 1 { + errors.push( + adapter_location("name"), + format!( + "expected exactly one canonical `{semver_step_marker}` declaration, found {}", + markers.len() + ), + ); + return Err(errors.finish()); + } + + let marker = markers[0]; + let Some(semver_job) = semver_job else { + return Err(errors.finish()); + }; + if !semver_job.contains(&marker) { + errors.push( + adapter_location("name"), + format!("canonical semver step must be inside the `{SEMVER_JOB}` job"), + ); + return Err(errors.finish()); + } + + let start = marker + 1; + let end = canonical_step_end(&lines, start, semver_job.end); + + let mut section = Section::Root; + let mut root_fields = Vec::new(); + let mut action = None; + let mut environment = BTreeMap::new(); + let mut inputs = BTreeMap::new(); + let mut condition = Vec::new(); + + for (index, line) in lines.iter().enumerate().take(end).skip(start) { + let line_number = index + 1; + if section == Section::Condition { + if indentation(line) != 8 || line.trim().is_empty() { + errors.push( + adapter_line_location(line_number), + "condition content must be one nonempty line at exactly eight spaces", + ); + } else { + condition.push(line[8..].to_owned()); + } + continue; + } + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if line.trim_end() != *line { + errors.push( + adapter_line_location(line_number), + "semantic adapter lines must not have trailing whitespace", + ); + continue; + } + + match indentation(line) { + 6 => { + let declaration = &line[6..]; + match declaration { + "env:" => { + root_fields.push("env"); + section = Section::Environment; + } + "with:" => { + root_fields.push("with"); + section = Section::Inputs; + } + "if: |" => { + root_fields.push("if"); + section = Section::Condition; + } + _ if declaration.starts_with("uses: ") => { + root_fields.push("uses"); + section = Section::Root; + let value = &declaration["uses: ".len()..]; + let value = match value.split_once(" # ") { + Some((value, comment)) if !comment.is_empty() => value, + Some((_value, _comment)) => { + errors.push( + adapter_line_location(line_number), + "inline action comment must not be empty", + ); + continue; + } + None => value, + }; + if action.replace(value.to_owned()).is_some() { + errors.push( + adapter_line_location(line_number), + "adapter repeats its `uses` field", + ); + } + } + _ => errors.push( + adapter_line_location(line_number), + format!( + "unsupported root field `{}`; expected only uses, env, with, and if", + escape_control_characters(declaration) + ), + ), + } + } + 8 => match section { + Section::Environment => insert_mapping( + "env", + &line[8..], + line_number, + &mut environment, + &mut errors, + ), + Section::Inputs => { + insert_mapping("with", &line[8..], line_number, &mut inputs, &mut errors) + } + Section::Root | Section::Condition => errors.push( + adapter_line_location(line_number), + "mapping entry appears outside `env` or `with`", + ), + }, + _ => errors.push( + adapter_line_location(line_number), + format!("unsupported indentation in `{}`", escape_control_characters(line)), + ), + } + } + + if root_fields != ROOT_FIELD_ORDER { + errors.push( + adapter_location("shape"), + format!( + "root fields must appear exactly as {ROOT_FIELD_ORDER:?}, found {root_fields:?}" + ), + ); + } + let Some(action) = action else { + errors.push(adapter_location("uses"), "adapter has no `uses` value"); + return Err(errors.finish()); + }; + if errors.is_empty() { + Ok(Self { marker, action, environment, inputs, condition }) + } else { + Err(errors.finish()) + } + } +} + +/// Finds one canonical top-level job without accepting general YAML syntax. +/// +/// This deliberately duplicates the small job-range grammar in +/// `planned_adapter/source.rs`: an exact two-space job declaration ends at the +/// next semantic two-space declaration. Keep the two implementations +/// coordinated. Sharing the planned adapter's private error-sink-aware helper +/// would couple otherwise independent focused audits more tightly than this +/// grammar warrants. +fn canonical_job_range( + lines: &[&str], + job: &str, + errors: &mut ViolationSink, +) -> Option> { + let marker = format!(" {job}:"); + let starts = lines + .iter() + .enumerate() + .filter_map(|(index, line)| (*line == marker).then_some(index)) + .collect::>(); + if starts.len() != 1 { + errors.push( + format!("{WORKFLOW_PATH}:{job}"), + format!("expected exactly one canonical job declaration, found {}", starts.len()), + ); + return None; + } + let start = starts[0]; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find_map(|(index, line)| { + (!line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == 2) + .then_some(index) + }) + .unwrap_or(lines.len()); + Some(start..end) +} + +fn canonical_step_end(lines: &[&str], start: usize, job_end: usize) -> usize { + let mut end = job_end; + for (index, line) in lines.iter().enumerate().take(job_end).skip(start) { + if !line.trim().is_empty() && !line.trim_start().starts_with('#') && indentation(line) <= 4 + { + end = index; + break; + } + } + // Blank lines and sibling-level comments between this step and the next + // sequence item are not part of the adapter. Strip only a trailing run: a + // blank or comment followed by another semantic field remains inside the + // block and is rejected, so whitespace cannot hide an unsupported field. + while end > start { + let line = lines[end - 1]; + if line.trim().is_empty() || (line.trim_start().starts_with('#') && indentation(line) <= 6) + { + end -= 1; + } else { + break; + } + } + end +} + +/// Returns the canonical top-level sequence items in one job's `steps` block. +/// +/// The focused parsers require four-space sequence markers. Treating every +/// such marker as a step makes an unfamiliar anchor, alias, or named step +/// visible to the exact sequence comparison instead of silently skipping it. +fn canonical_step_blocks(lines: &[&str], job: Range) -> Vec> { + let starts = lines + .iter() + .enumerate() + .take(job.end) + .skip(job.start + 1) + .filter_map(|(index, line)| { + (!line.trim().is_empty() + && !line.trim_start().starts_with('#') + && indentation(line) == 4 + && line[4..].starts_with("- ")) + .then_some(index) + }) + .collect::>(); + starts + .iter() + .enumerate() + .map(|(position, start)| { + let end = starts.get(position + 1).copied().unwrap_or(job.end); + *start..end + }) + .collect() +} + +fn significant_lines<'a>(lines: &'a [&'a str]) -> Vec<&'a str> { + lines + .iter() + .filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#')) + .copied() + .collect() +} + +fn token_mentions(text: &str, token: &str) -> usize { + text.match_indices(token) + .filter(|(start, _)| { + let end = start + token.len(); + let before = text[..*start].bytes().next_back(); + let after = text[end..].bytes().next(); + !before.is_some_and(is_identifier_byte) && !after.is_some_and(is_identifier_byte) + }) + .count() +} + +fn is_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Section { + Root, + Environment, + Inputs, + Condition, +} + +fn insert_mapping( + section: &str, + declaration: &str, + line: usize, + values: &mut BTreeMap, + errors: &mut ViolationSink, +) { + let Some((key, value)) = declaration.split_once(": ") else { + errors.push( + adapter_line_location(line), + format!("`{section}` entries must have canonical `key: value` form"), + ); + return; + }; + if key.is_empty() + || value.is_empty() + || key.chars().any(char::is_control) + || value.chars().any(char::is_control) + { + errors.push( + adapter_line_location(line), + format!("`{section}` entry contains an empty value or control character"), + ); + return; + } + if values.insert(key.to_owned(), value.to_owned()).is_some() { + errors.push( + adapter_line_location(line), + format!("`{section}` repeats key `{}`", escape_control_characters(key)), + ); + } +} + +fn compare_value(location: &str, expected: &str, actual: &str, errors: &mut ViolationSink) { + if expected != actual { + errors.push( + adapter_location(location), + format!( + "expected `{}`, found `{}`", + escape_control_characters(expected), + escape_control_characters(actual) + ), + ); + } +} + +fn compare_map( + section: &str, + expected: &BTreeMap, + actual: &BTreeMap, + errors: &mut ViolationSink, +) { + for (key, value) in expected { + match actual.get(key) { + Some(actual) => compare_value(&format!("{section}.{key}"), value, actual, errors), + None => errors + .push(adapter_location(&format!("{section}.{key}")), "required field is absent"), + } + } + for key in actual.keys() { + if !expected.contains_key(key) { + errors.push( + adapter_location(&format!("{section}.{key}")), + "field is not part of the typed semver adapter", + ); + } + } +} + +fn indentation(line: &str) -> usize { + line.bytes().take_while(|byte| *byte == b' ').count() +} + +fn adapter_location(field: &str) -> String { + format!("{WORKFLOW_PATH}:{SEMVER_STEP_NAME}.{field}") +} + +fn preparation_location(field: &str) -> String { + format!("{WORKFLOW_PATH}:{PREPARE_STEP_NAME}.{field}") +} + +fn adapter_line_location(line: usize) -> String { + format!("{WORKFLOW_PATH}:{line}") +} + +fn escape_control_characters(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +/// A failure reading or validating the live semver adapter. +#[derive(Debug, Error)] +pub enum SemverAdapterAuditError { + /// Typed policy or live adapter semantics were invalid. + #[error(transparent)] + Invalid(#[from] SemverAdapterViolations), +} + +/// Deterministically ordered semver-adapter violations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SemverAdapterViolations(Vec); + +impl SemverAdapterViolations { + /// Returns every violation in location and message order. + pub fn violations(&self) -> &[SemverAdapterViolation] { + &self.0 + } +} + +impl fmt::Display for SemverAdapterViolations { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(formatter, "semver GitHub adapter has {} violation(s):", self.0.len())?; + for error in &self.0 { + writeln!(formatter, "- {}: {}", error.location, error.message)?; + } + Ok(()) + } +} + +impl Error for SemverAdapterViolations {} + +/// One actionable mismatch in typed policy or live workflow source. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct SemverAdapterViolation { + location: String, + message: String, +} + +impl SemverAdapterViolation { + /// Returns the policy field or workflow location which must be repaired. + pub fn location(&self) -> &str { + &self.location + } + + /// Returns a plain-language repair diagnostic. + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Default)] +struct ViolationSink(BTreeSet); + +impl ViolationSink { + fn push(&mut self, location: impl Into, message: impl Into) { + self.0.insert(SemverAdapterViolation { + location: escape_control_characters(&location.into()), + message: escape_control_characters(&message.into()), + }); + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn finish(self) -> SemverAdapterViolations { + SemverAdapterViolations(self.0.into_iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use std::{path::Path, sync::OnceLock}; + + use super::{ + audit_semver_adapter, audit_source, validate_exact_rust_version_scalar, + validate_plain_string_scalar, SemverAdapterSpec, ViolationSink, PREPARE_OUTPUT_CONDITION, + PREPARE_STEP_ID, SEMVER_ACTION, SEMVER_CACHE_PREFIX_INPUT, SEMVER_FEATURE_GROUP, + SEMVER_MATRIX_TARGET_EXPRESSION, SEMVER_STEP_NAME, SEMVER_TARGET_INPUT, + SEMVER_WARNING_FLAGS, TRUSTED_SHELL, WORKFLOW_PATH, + }; + use crate::{inventory::RepositoryInventory, policy::Policy}; + + fn checked_inputs() -> &'static (Policy, RepositoryInventory) { + static INPUTS: OnceLock<(Policy, RepositoryInventory)> = OnceLock::new(); + INPUTS.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let policy = Policy::read(root.join("ci/zc.toml")).unwrap(); + let repository = RepositoryInventory::audit(&root, &policy).unwrap(); + (policy, repository) + }) + } + + fn spec() -> SemverAdapterSpec { + let (policy, repository) = checked_inputs(); + SemverAdapterSpec::from_checked_inputs(policy, repository).unwrap() + } + + fn canonical_adapter() -> String { + let spec = spec(); + let toolchain_version = spec.inputs()["rust-toolchain"].clone(); + let preparation_run = spec.preparation_run().join("\n "); + format!( + r#"jobs: + semver: + if: needs.plan_ci.outputs.semver_enabled == 'true' + runs-on: ubuntu-latest + needs: [plan_ci] + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{{{ fromJSON(needs.plan_ci.outputs.semver_matrix) }}}} + name: Semver (${{{{ matrix.target }}}}) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + - name: Prepare cargo-semver-checks + id: {PREPARE_STEP_ID} + shell: {TRUSTED_SHELL} + working-directory: zerocopy + env: + PR_HEAD_SHA: ${{{{ github.event.pull_request.head.sha }}}} + run: | + {preparation_run} + - name: {SEMVER_STEP_NAME} + uses: {SEMVER_ACTION} # v2.9 + env: + # Unstable API is not semver checked. + RUSTDOCFLAGS: {SEMVER_WARNING_FLAGS} + RUSTFLAGS: {SEMVER_WARNING_FLAGS} + with: + package: zerocopy + feature-group: {SEMVER_FEATURE_GROUP} + features: __internal_use_only_features_that_work_on_stable + manifest-path: zerocopy/Cargo.toml + prefix-key: ${{{{ matrix.target }}}} + rust-toolchain: {toolchain_version} + rust-target: ${{{{ matrix.target }}}} + if: | + {PREPARE_OUTPUT_CONDITION} + next_job: + runs-on: ubuntu-latest +"# + ) + } + + #[test] + fn derives_every_current_adapter_value_from_checked_inputs() { + let spec = spec(); + assert_eq!(spec.action(), SEMVER_ACTION); + assert_eq!( + spec.environment(), + &[ + ("RUSTDOCFLAGS".to_owned(), "-Dwarnings".to_owned()), + ("RUSTFLAGS".to_owned(), "-Dwarnings".to_owned()), + ] + .into_iter() + .collect() + ); + assert_eq!(spec.inputs()["package"], "zerocopy"); + assert_eq!(spec.inputs()["manifest-path"], "zerocopy/Cargo.toml"); + assert_eq!(spec.inputs()["features"], "__internal_use_only_features_that_work_on_stable"); + assert_eq!(spec.inputs()[SEMVER_CACHE_PREFIX_INPUT], SEMVER_MATRIX_TARGET_EXPRESSION); + assert_eq!(spec.inputs()[SEMVER_TARGET_INPUT], SEMVER_MATRIX_TARGET_EXPRESSION); + let (policy, repository) = checked_inputs(); + assert_eq!( + spec.inputs()["rust-toolchain"], + repository.toolchain_versions()[policy.semver().toolchain().as_str()] + ); + assert_eq!(spec.inputs()["rust-toolchain"], "1.93.1"); + assert_eq!( + spec.preparation_environment(), + &[("PR_HEAD_SHA".to_owned(), "${{ github.event.pull_request.head.sha }}".to_owned(),)] + .into_iter() + .collect() + ); + assert_eq!(spec.condition(), [PREPARE_OUTPUT_CONDITION]); + } + + #[test] + fn accepts_the_canonical_adapter_and_the_live_workflow() { + let spec = spec(); + audit_source(&spec, &canonical_adapter()).unwrap(); + + let (policy, repository) = checked_inputs(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap(); + let source = crate::repository_text::read(&root.join(WORKFLOW_PATH)).unwrap(); + audit_semver_adapter(&source, policy, repository).unwrap(); + } + + #[test] + fn job_and_checkout_contracts_are_exact() { + let original = canonical_adapter(); + let cases = [ + ( + "enable gate", + original + .replace("if: needs.plan_ci.outputs.semver_enabled == 'true'", "if: success()"), + ".job", + ), + ("runner", original.replace("runs-on: ubuntu-latest", "runs-on: self-hosted"), ".job"), + ( + "dependency", + original.replace("needs: [plan_ci]", "needs: [build_test, plan_ci]"), + ".job", + ), + ("permission", original.replace("contents: read", "contents: write"), ".job"), + ( + "matrix source", + original.replace( + "needs.plan_ci.outputs.semver_matrix", + "needs.plan_ci.outputs.build_matrix", + ), + ".job", + ), + ( + "job environment", + original.replace( + " strategy:\n", + " env:\n RUSTC_WRAPPER: /tmp/wrapper\n strategy:\n", + ), + ".job", + ), + ( + "trailing continue on error", + original.replace(" next_job:\n", " continue-on-error: true\n next_job:\n"), + ".job", + ), + ( + "trailing job environment", + original.replace( + " next_job:\n", + " env:\n NODE_OPTIONS: --require=/tmp/interceptor.js\n next_job:\n", + ), + ".job", + ), + ( + "trailing duplicate steps", + original.replace(" next_job:\n", " steps: []\n next_job:\n"), + ".job", + ), + ( + "checkout identity", + original.replacen( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/checkout@0000000000000000000000000000000000000000", + 1, + ), + ".checkout", + ), + ( + "checkout depth", + original.replacen("fetch-depth: 2", "fetch-depth: 1", 1), + ".checkout", + ), + ( + "checkout credentials", + original.replacen("persist-credentials: false", "persist-credentials: true", 1), + ".checkout", + ), + ( + "checkout ref override", + original.replacen( + " fetch-depth: 2\n", + " fetch-depth: 2\n ref: main\n", + 1, + ), + ".checkout", + ), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn no_repository_code_can_run_before_the_semver_action() { + let original = canonical_adapter(); + let preparation = " - name: Prepare cargo-semver-checks\n"; + let cases = [ + ( + "added producer step", + original.replacen( + preparation, + &format!( + " - name: Unreviewed setup\n run: echo setup\n{preparation}" + ), + 1, + ), + ".steps", + ), + ( + "GITHUB_ENV producer", + original.replacen( + " set -euo pipefail\n", + " set -euo pipefail\n printf 'RUSTC_WRAPPER=other\\n' >> \"$GITHUB_ENV\"\n", + 1, + ), + "GITHUB_ENV", + ), + ( + "GITHUB_PATH producer", + original.replacen( + " set -euo pipefail\n", + " set -euo pipefail\n printf '/tmp/bin\\n' >> \"$GITHUB_PATH\"\n", + 1, + ), + "GITHUB_PATH", + ), + ( + "Actions expression in shell comment", + original.replacen( + " # Pull request jobs check the head commit rather than GitHub's synthetic\n", + " # ${{ github.event.pull_request.title }}\n", + 1, + ), + ".preparation.run", + ), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn rejects_every_behavior_bearing_mutation() { + let original = canonical_adapter(); + let pinned_toolchain = spec().inputs()["rust-toolchain"].clone(); + let cases = [ + ( + "action identity", + original.replace( + SEMVER_ACTION, + "obi1kenobi/cargo-semver-checks-action@0000000000000000000000000000000000000000", + ), + ".uses", + ), + ( + "rustdoc warning environment", + original.replacen("RUSTDOCFLAGS: -Dwarnings", "RUSTDOCFLAGS: -Awarnings", 1), + ".env.RUSTDOCFLAGS", + ), + ( + "rust warning environment", + original.replacen("RUSTFLAGS: -Dwarnings", "RUSTFLAGS: -Awarnings", 1), + ".env.RUSTFLAGS", + ), + ( + "package input", + original.replace("package: zerocopy", "package: zerocopy-derive"), + ".with.package", + ), + ( + "feature group", + original.replace( + "feature-group: only-explicit-features", + "feature-group: all-features", + ), + ".with.feature-group", + ), + ( + "feature root", + original.replace( + "features: __internal_use_only_features_that_work_on_stable", + "features: derive", + ), + ".with.features", + ), + ( + "manifest", + original.replace("manifest-path: zerocopy/Cargo.toml", "manifest-path: Cargo.toml"), + ".with.manifest-path", + ), + ( + "cache prefix expression", + original.replace( + "prefix-key: ${{ matrix.target }}", + "prefix-key: one-cache-for-every-target", + ), + ".with.prefix-key", + ), + ( + "target expression", + original.replace( + "rust-target: ${{ matrix.target }}", + "rust-target: x86_64-unknown-linux-gnu", + ), + ".with.rust-target", + ), + ( + "toolchain literal", + original.replace( + &format!("rust-toolchain: {pinned_toolchain}"), + "rust-toolchain: ${{ env.ZC_TOOLCHAIN }}", + ), + ".with.rust-toolchain", + ), + ( + "skip condition", + original.replace( + PREPARE_OUTPUT_CONDITION, + "steps.prepare_semver.outputs.run != 'true'", + ), + ".if", + ), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn rejects_every_skip_producer_escape() { + let original = canonical_adapter(); + let preparation = original.find(" - name: Prepare cargo-semver-checks\n").unwrap(); + let action = original.find(" - name: Check semver compatibility\n").unwrap(); + let next_job = original.find(" next_job:\n").unwrap(); + let reordered = format!( + "{}{}{}{}", + &original[..preparation], + &original[action..next_job], + &original[preparation..action], + &original[next_job..] + ); + let cases = [ + ( + "unconditional decision", + original.replacen( + " if /usr/bin/grep -Eq \\\n", + " if true; then\n", + 1, + ), + ".preparation.run", + ), + ( + "generated shell wrapper", + original.replacen( + &format!("shell: {TRUSTED_SHELL}"), + "shell: /tmp/docker-shell.sh {0}", + 1, + ), + ".preparation.shell", + ), + ( + "wrong preparation directory", + original.replacen("working-directory: zerocopy", "working-directory: .", 1), + ".preparation.working-directory", + ), + ( + "changed producer output", + original.replacen("printf 'run=false\\n'", "printf 'run=true\\n'", 1), + ".preparation.run", + ), + ( + "comment interrupts continued command", + original.replacen( + " if /usr/bin/grep -Eq \\\n", + " if /usr/bin/grep -Eq \\\n # This changes shell continuation.\n", + 1, + ), + "continued shell command", + ), + ( + "legacy workflow environment", + original.replacen("jobs:\n", "env:\n ZC_SKIP_CARGO_SEMVER_CHECKS: 1\njobs:\n", 1), + "legacy ambient skip channel", + ), + ( + "duplicate preparation ID", + original.replacen( + " - name: Check semver compatibility\n", + " id: prepare_semver\n - name: Check semver compatibility\n", + 1, + ), + "prepare_semver", + ), + ("reordered steps", reordered, ".order"), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn treats_indented_condition_comments_as_scalar_content() { + let original = canonical_adapter(); + let cases = [ + ( + "leading action comment", + original.replacen( + &format!(" if: |\n {PREPARE_OUTPUT_CONDITION}"), + &format!( + " if: |\n # changed expression\n {PREPARE_OUTPUT_CONDITION}" + ), + 1, + ), + ".if", + ), + ( + "trailing action comment", + original.replacen( + &format!(" {PREPARE_OUTPUT_CONDITION}\n next_job:"), + &format!( + " {PREPARE_OUTPUT_CONDITION}\n # changed expression\n next_job:" + ), + 1, + ), + ".if", + ), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn accepts_only_yaml_plain_safe_generated_scalars() { + for value in ["zerocopy", "stable", "zerocopy/Cargo.toml", "_feature"] { + let mut errors = ViolationSink::default(); + validate_plain_string_scalar("test", value, &mut errors); + assert!(errors.is_empty(), "{value:?} should be safe"); + } + for value in [ + "path with space", + "path#comment", + "true", + "y", + "n", + "123", + "0123", + "1e3", + "2026-08-25", + "'quoted'", + "-leading", + ] { + let mut errors = ViolationSink::default(); + validate_plain_string_scalar("test", value, &mut errors); + assert!(!errors.is_empty(), "{value:?} should be rejected"); + } + + let mut errors = ViolationSink::default(); + validate_exact_rust_version_scalar("test", "1.93.1", &mut errors); + assert!(errors.is_empty()); + for value in ["123", "1.93", "1.93.1-beta", "2026-08-25"] { + let mut errors = ViolationSink::default(); + validate_exact_rust_version_scalar("test", value, &mut errors); + assert!(!errors.is_empty(), "{value:?} should be rejected"); + } + } + + #[test] + fn rejects_an_unchanged_adapter_moved_out_of_semver() { + let moved = canonical_adapter() + .replace(" semver:\n", " semver:\n steps: []\n another_job:\n"); + + let error = audit_source(&spec(), &moved).unwrap_err().to_string(); + + assert!(error.contains("must be inside the `semver` job"), "{error}"); + } + + #[test] + fn rejects_ambiguous_or_extended_step_shapes() { + let original = canonical_adapter(); + let cases = [ + ("duplicate step", format!("{original}\n{original}"), "expected exactly one"), + ( + "extra root field", + original.replace( + &format!(" uses: {SEMVER_ACTION} # v2.9\n env:\n"), + &format!( + " uses: {SEMVER_ACTION} # v2.9\n timeout-minutes: 5\n env:\n" + ), + ), + "unsupported root field", + ), + ( + "extra root field after a blank", + original.replace( + " next_job:", + "\n continue-on-error: true\n next_job:", + ), + "condition content", + ), + ( + "second action under another step name", + original.replace( + " next_job:", + &format!( + " - name: A misleading second adapter\n uses: {SEMVER_ACTION}\n next_job:" + ), + ), + "occurrence", + ), + ( + "extra input", + original.replace( + " package: zerocopy\n", + " package: zerocopy\n extra: value\n", + ), + "not part of the typed", + ), + ( + "duplicate input", + original.replace( + " package: zerocopy\n", + " package: zerocopy\n package: zerocopy\n", + ), + "repeats key", + ), + ( + "YAML merge", + original.replace( + " env:\n # Unstable API is not semver checked.\n", + " env:\n <<: *shared\n # Unstable API is not semver checked.\n", + ), + "not part of the typed", + ), + ( + "quoted uses key", + original.replace(" uses:", " \"uses\":"), + "unsupported root field", + ), + ( + "reordered root fields", + original.replace( + &format!(" uses: {SEMVER_ACTION} # v2.9\n env:\n"), + &format!(" env:\n uses: {SEMVER_ACTION} # v2.9\n"), + ), + "root fields must appear exactly", + ), + ( + "condition content disguised as a comment", + original.replace( + &format!(" {PREPARE_OUTPUT_CONDITION}"), + &format!(" # {PREPARE_OUTPUT_CONDITION}"), + ), + PREPARE_STEP_ID, + ), + ("CRLF source", original.replace('\n', "\r\n"), "canonical LF"), + ]; + + for (case, source, expected) in cases { + let error = audit_source(&spec(), &source).unwrap_err(); + assert!(error.to_string().contains(expected), "{case}: {error}"); + } + } + + #[test] + fn diagnostics_escape_control_characters() { + let source = canonical_adapter() + .replace(SEMVER_ACTION, "obi1kenobi/cargo-semver-checks-action@bad\u{1b}revision"); + let diagnostic = audit_source(&spec(), &source).unwrap_err().to_string(); + assert!(diagnostic.contains("\\u{1b}")); + assert!(!diagnostic.contains('\u{1b}')); + } +} diff --git a/tools/zc/src/workflow_protocol.rs b/tools/zc/src/workflow_protocol.rs index 6483f68f96..5b68a5dc54 100644 --- a/tools/zc/src/workflow_protocol.rs +++ b/tools/zc/src/workflow_protocol.rs @@ -18,6 +18,7 @@ pub(crate) const PLAN_JOB: &str = "plan_ci"; 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"; // 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 @@ -46,6 +47,7 @@ pub(crate) const PLAN_STEP_NAME: &str = "Validate inputs and project the plan"; 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 GITHUB_PLAN_COMMAND: &str = "github-plan"; pub(crate) const EXECUTE_BUILD_CELL_COMMAND: &str = "execute-build-cell"; @@ -63,6 +65,8 @@ pub(crate) const CELL_MIRI_MODEL_OPTION: &str = "--miri-model"; pub(crate) const BUILD_MATRIX_OUTPUT: &str = "build_matrix"; pub(crate) const MIRI_MATRIX_OUTPUT: &str = "miri_matrix"; pub(crate) const MIRI_ENABLED_OUTPUT: &str = "miri_enabled"; +pub(crate) const SEMVER_MATRIX_OUTPUT: &str = "semver_matrix"; +pub(crate) const SEMVER_ENABLED_OUTPUT: &str = "semver_enabled"; pub(crate) const HOST_RUNNER: &str = "ubuntu-latest"; pub(crate) const REPOSITORY_WORKING_DIRECTORY: &str = "zerocopy";