diff --git a/.github/actions/upload-file-artifact/action.yml b/.github/actions/upload-file-artifact/action.yml index 8e98d18359..2741acda04 100644 --- a/.github/actions/upload-file-artifact/action.yml +++ b/.github/actions/upload-file-artifact/action.yml @@ -1,5 +1,5 @@ name: Upload file artifact -description: Publish one already-compressed file for jobs in this workflow run +description: Publish one exact file for jobs in this workflow run inputs: name: @@ -8,6 +8,10 @@ inputs: path: description: Exact path of the nonempty file to publish required: true + retention-days: + description: Days to retain the artifact, from 1 through 90 + required: false + default: "1" outputs: artifact-id: @@ -25,6 +29,7 @@ runs: env: ARTIFACT_NAME: ${{ inputs.name }} ARTIFACT_PATH: ${{ inputs.path }} + RETENTION_DAYS: ${{ inputs.retention-days }} run: | set -euo pipefail @@ -45,15 +50,19 @@ runs: echo "Artifact is missing or empty: $ARTIFACT_PATH" >&2 exit 1 fi + if [[ ! "$RETENTION_DAYS" =~ ^([1-9]|[1-8][0-9]|90)$ ]]; then + echo "Artifact retention must be an integer from 1 through 90: $RETENTION_DAYS" >&2 + exit 1 + fi artifact_size=$(stat --format=%s "$ARTIFACT_PATH") echo "Uploading $ARTIFACT_NAME ($artifact_size bytes)" | tee -a "$GITHUB_STEP_SUMMARY" - # Both current callers produce tar archives whose contents are already - # compressed (Docker's image layers use gzip; Anneal uses zstd). Version 7's - # direct-file mode avoids a redundant ZIP on upload and extraction on every - # consumer. Keep this coordinated with download-artifact v8 in - # `../download-artifact-with-retry/action.yml`, which understands direct + # The large tar callers are already compressed (Docker's image layers use + # gzip; Anneal uses zstd), while the CI plan caller needs its exact JSON + # bytes preserved for review. Version 7's direct-file mode handles both + # without a redundant ZIP. Keep this coordinated with download-artifact v8 + # in `../download-artifact-with-retry/action.yml`, which understands direct # artifacts and verifies their service-provided digest. - name: Upload artifact id: upload @@ -62,7 +71,7 @@ runs: name: ${{ inputs.name }} path: ${{ inputs.path }} if-no-files-found: error - retention-days: 1 + retention-days: ${{ inputs.retention-days }} archive: false # Artifacts are immutable. Delete an artifact with the same validated # name first so "Re-run all jobs" can republish it under a new ID. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cee70f0857..ffb5fcb4f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,54 @@ env: CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN: 1 jobs: + # Build the typed plan in parallel with the Docker image. This job is + # deliberately unprivileged: it may select ordinary test work, but runner, + # action, permission, and command authority remain in this workflow. + # + # The first rollout only publishes the matrices and review artifact. The + # existing handwritten matrices remain authoritative until later commits + # consume these outputs after shadow validation on real workflow runs. + plan_ci: + name: Plan ordinary CI work + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + build_matrix: ${{ steps.plan.outputs.build_matrix }} + miri_matrix: ${{ steps.plan.outputs.miri_matrix }} + defaults: + run: + working-directory: zerocopy + env: + # upload-file-artifact requires its name to equal the path basename. + # Keep this one value coordinated with the planner invocation and upload + # below rather than repeating a load-bearing artifact name. + CI_PLAN_ARTIFACT: ci-plan.json + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate inputs and project the plan + id: plan + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + ./cargo.sh ci github-plan \ + --event "$EVENT_NAME" \ + --github-output "$GITHUB_OUTPUT" \ + --artifact "$RUNNER_TEMP/$CI_PLAN_ARTIFACT" + + - name: Upload detailed plan for review + uses: ./.github/actions/upload-file-artifact + with: + name: ${{ env.CI_PLAN_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.CI_PLAN_ARTIFACT }} + # This JSON is small and intended for PR review, unlike the large + # one-day fan-out archives used only by jobs in the same run. + retention-days: 14 + build_test: runs-on: ubuntu-latest # We pre-compute a Docker image containing most dependencies. Each job in @@ -1058,25 +1106,40 @@ 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-all-toolchains-tested, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env] + 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-all-toolchains-tested, 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 PR's empty + # Miri plan is still the nonempty JSON value {"include":[]}. + - name: Require published planner outputs + if: ${{ needs.plan_ci.result == 'success' && (needs.plan_ci.outputs.build_matrix == '' || needs.plan_ci.outputs.miri_matrix == '') }} + run: exit 1 + - name: Require every dependency to succeed env: EVENT_NAME: ${{ github.event_name }} - NEEDS_JSON: ${{ toJSON(needs) }} + # Do not serialize the entire `needs` object here: job outputs may + # legitimately approach GitHub's configured output limit, while a + # Linux process has a much smaller per-environment-value limit. + # Results stay small regardless of matrix JSON size. Keep the + # separate Miri result so the one intentional PR skip is still + # identified rather than accepting an arbitrary skipped job. + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + MIRI_RESULT: ${{ needs.miri.result }} run: | set -euo pipefail - jq -e --arg event "$EVENT_NAME" ' - type == "object" and length > 0 and - all( - to_entries[]; - if .key == "miri" and $event == "pull_request" - then .value.result == "skipped" - else .value.result == "success" - end - ) - ' <<< "$NEEDS_JSON" + jq -e --arg event "$EVENT_NAME" --arg miri "$MIRI_RESULT" ' + type == "array" and length > 0 and + if $event == "pull_request" + then $miri == "skipped" and + ([.[] | select(. == "skipped")] | length) == 1 and + all(.[]; . == "success" or . == "skipped") + else $miri == "success" and + all(.[]; . == "success") + end + ' <<< "$RESULTS_JSON" diff --git a/ci/workflow-jobs.tsv b/ci/workflow-jobs.tsv index e7a016bde1..8b9111bf38 100644 --- a/ci/workflow-jobs.tsv +++ b/ci/workflow-jobs.tsv @@ -45,6 +45,7 @@ workflow job role .github/workflows/ci.yml coverage static-ci .github/workflows/ci.yml kani static-ci .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 zizmor security .github/workflows/dependency-review.yml dependency-review security