From b8847ca7fc2826f0b72279682050a7fafe347382 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 11:38:50 +0200 Subject: [PATCH 1/4] fix(ci): keep required status gate trusted --- .github/workflows/ci.yaml | 53 ++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a644164..7d41846e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1642,9 +1642,9 @@ jobs: # that does `uses: ./`; # (1b) every reusable workflow (workflow_call) is exercised by a job # that does `uses: ./.github/workflows/.yaml`; and - # (2) ci-required-checks.needs and the `job-results` input of its - # ./aggregate-job-checks step name the SAME set of jobs — a job - # in `needs:` but missing from `job-results` has its failure + # (2) ci-required-checks.needs and its trusted inline JOB_RESULTS + # environment value name the SAME set of jobs — a job in + # `needs:` but missing from `JOB_RESULTS` has its failure # silently ignored (the single required check stays green). # yq is preinstalled on the GitHub-hosted ubuntu-latest runner image. ci=".github/workflows/ci.yaml" @@ -1671,12 +1671,12 @@ jobs: fi done - # (2) ci-required-checks: needs <-> job-results parity + # (2) ci-required-checks: needs <-> inline JOB_RESULTS parity needs="$(yq -r '.jobs.ci-required-checks.needs[]' "$ci" | sort -u)" results="$( yq -r '.jobs.ci-required-checks.steps[] - | select(.uses == "./aggregate-job-checks") - | .with["job-results"]' "$ci" \ + | select(.name == "📊 Summarize workflow result") + | .env.JOB_RESULTS' "$ci" \ | grep -oE 'needs\.[a-z0-9-]+\.result' \ | sed -E 's/needs\.(.*)\.result/\1/' | sort -u )" @@ -1685,7 +1685,7 @@ jobs: while IFS= read -r j; do [[ -z "$j" ]] && continue if ! grep -qxF "$j" <<<"$results"; then - echo "::error file=$ci::job '$j' is in ci-required-checks.needs but missing from the aggregate-job-checks job-results input — its failure would be silently ignored" + echo "::error file=$ci::job '$j' is in ci-required-checks.needs but missing from the trusted inline JOB_RESULTS value — its failure would be silently ignored" status=1 fi done <<<"$needs" @@ -1694,7 +1694,7 @@ jobs: while IFS= read -r j; do [[ -z "$j" ]] && continue if ! grep -qxF "$j" <<<"$needs"; then - echo "::error file=$ci::job '$j' is referenced in job-results but not in ci-required-checks.needs — its result will never be populated" + echo "::error file=$ci::job '$j' is referenced in JOB_RESULTS but not in ci-required-checks.needs — its result will never be populated" status=1 fi done <<<"$results" @@ -2508,17 +2508,13 @@ jobs: - test-run-dotnet-tests-gate-lockstep - test-run-dotnet-tests-coverage-inline-lockstep runs-on: ubuntu-latest - permissions: - contents: read + permissions: {} steps: - - name: 📑 Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - uses: ./aggregate-job-checks - with: - job-results: >- + # Keep the required gate inline: checked-out pull request actions are attacker-controlled. + - name: 📊 Summarize workflow result + shell: bash + env: + JOB_RESULTS: >- ${{ needs.test-approve-pr.result }} ${{ needs.test-cleanup-ghcr-packages.result }} ${{ needs.test-create-issues-from-todos.result }} @@ -2592,3 +2588,24 @@ jobs: ${{ needs.test-run-dotnet-tests-blocks.result }} ${{ needs.test-run-dotnet-tests-gate-lockstep.result }} ${{ needs.test-run-dotnet-tests-coverage-inline-lockstep.result }} + run: | + set -Eeuo pipefail + set -f + read -r -a results <<< "$JOB_RESULTS" + + for result in "${results[@]}"; do + case "$result" in + success|skipped) + ;; + failure|cancelled) + echo "❌ CI - Required Checks — at least one job failed or was cancelled." + exit 1 + ;; + *) + echo "❌ CI - Required Checks — unknown job result: '$result'." + exit 1 + ;; + esac + done + + echo "✅ CI - Required Checks — all jobs succeeded or were skipped." From 8f8cafd9bfb9235c54d040bc2b45db9e25368737 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 8 Aug 2026 15:46:07 +0200 Subject: [PATCH 2/4] test(ci): prove required-check trust boundary --- .../tests/test-ci-required-checks-boundary.sh | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/tests/test-ci-required-checks-boundary.sh diff --git a/.github/tests/test-ci-required-checks-boundary.sh b/.github/tests/test-ci-required-checks-boundary.sh new file mode 100644 index 00000000..26b1279e --- /dev/null +++ b/.github/tests/test-ci-required-checks-boundary.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +workflow=${1:-.github/workflows/ci.yaml} +gate='.jobs.ci-required-checks' + +fail() { + printf 'ci-required-checks boundary: %s\n' "$1" >&2 + exit 1 +} + +[ -f "$workflow" ] || fail "workflow not found: $workflow" + +permissions=$(yq -o=json -I=0 "$gate.permissions" "$workflow") +[ "$permissions" = '{}' ] || + fail "the required gate must have no token permissions, got: $permissions" + +uses_steps=$(yq -r "$gate.steps[]? | select(.uses != null) | .uses" "$workflow") +[ -z "$uses_steps" ] || + fail "the required gate must not execute checked-out or external actions, got: $uses_steps" + +step_count=$(yq -r "[$gate.steps[]? | select(.name == \"📊 Summarize workflow result\")] | length" "$workflow") +[ "$step_count" = '1' ] || + fail "expected exactly one inline summary step, got: $step_count" + +script=$(yq -r "$gate.steps[] | select(.name == \"📊 Summarize workflow result\") | .run" "$workflow") +[ -n "$script" ] && [ "$script" != 'null' ] || + fail "the inline summary step has no executable script" + +run_case() { + local label=$1 input=$2 expected=$3 needle=$4 output rc=0 + + output=$(JOB_RESULTS="$input" bash -c "$script" 2>&1) || rc=$? + + if [ "$expected" = pass ] && [ "$rc" -ne 0 ]; then + fail "$label should pass, got exit $rc: $output" + fi + if [ "$expected" = fail ] && [ "$rc" -eq 0 ]; then + fail "$label should fail closed, got exit 0: $output" + fi + if [[ "$output" != *"$needle"* ]]; then + fail "$label should explain the result with '$needle', got: $output" + fi +} + +run_case 'success and skipped results' 'success skipped' pass 'all jobs succeeded or were skipped' +run_case 'failed result' 'success failure' fail 'failed or was cancelled' +run_case 'cancelled result' 'cancelled' fail 'failed or was cancelled' +run_case 'unknown result' 'success pending' fail "unknown job result: 'pending'" +run_case 'empty result list' '' fail 'no job results were provided' + +printf 'ci-required-checks boundary and behavior are enforced\n' From 5299370e8c8f3bb7e07161cf58f60afd13dd8a20 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 8 Aug 2026 15:52:05 +0200 Subject: [PATCH 3/4] test(ci): follow inline required-check results --- .github/tests/test-zizmor-routing.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/tests/test-zizmor-routing.sh b/.github/tests/test-zizmor-routing.sh index c20b168e..070250d3 100644 --- a/.github/tests/test-zizmor-routing.sh +++ b/.github/tests/test-zizmor-routing.sh @@ -141,8 +141,8 @@ aggregate_needs="$( aggregate_results="$( yq -r ' .jobs.ci-required-checks.steps[] - | select(.uses == "./aggregate-job-checks") - | .with["job-results"] + | select(.name == "📊 Summarize workflow result") + | .env.JOB_RESULTS ' "$ci" | grep -oE 'needs\.[a-z0-9-]*zizmor[a-z0-9-]*\.result' | sed -E 's/^needs\.//; s/\.result$//' | From 5881220950de5e73d06400c5ba23cf8f378445da Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 8 Aug 2026 15:56:30 +0200 Subject: [PATCH 4/4] test(ci): align merge-group gate boundary --- .github/tests/ci-merge-group-isolation-bad-fixture.yaml | 3 +-- .github/tests/test-ci-merge-group-isolation.sh | 8 ++++---- .github/workflows/ci.yaml | 3 ++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/tests/ci-merge-group-isolation-bad-fixture.yaml b/.github/tests/ci-merge-group-isolation-bad-fixture.yaml index 2a046f1d..658d9328 100644 --- a/.github/tests/ci-merge-group-isolation-bad-fixture.yaml +++ b/.github/tests/ci-merge-group-isolation-bad-fixture.yaml @@ -16,7 +16,6 @@ jobs: ci-required-checks: if: "${{ always() }}" runs-on: ubuntu-latest - permissions: - contents: read + permissions: {} steps: - run: echo "Required gate" diff --git a/.github/tests/test-ci-merge-group-isolation.sh b/.github/tests/test-ci-merge-group-isolation.sh index d028d85c..705d2087 100755 --- a/.github/tests/test-ci-merge-group-isolation.sh +++ b/.github/tests/test-ci-merge-group-isolation.sh @@ -45,12 +45,12 @@ required_condition="$(yq -r '.jobs.ci-required-checks.if // ""' "$ci")" fail "ci-required-checks must remain the always-running merge-queue completion gate" required_permissions="$( - yq -r \ - '.jobs.ci-required-checks.permissions | to_entries | map(.key + "=" + .value) | sort | join(",")' \ + yq -o=json -I=0 \ + '.jobs.ci-required-checks.permissions' \ "$ci" )" -[[ "$required_permissions" == "contents=read" ]] || - fail "ci-required-checks must retain only contents=read; got: $required_permissions" +[[ "$required_permissions" == "{}" ]] || + fail "ci-required-checks must retain zero token permissions; got: $required_permissions" required_job="$(yq -o=json -I=0 '.jobs.ci-required-checks' "$ci")" if grep -qF 'secrets.' <<<"$required_job"; then diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d05787c8..969e7014 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -131,7 +131,8 @@ jobs: runs-on: ubuntu-latest # The underlying action only produces a diff on pull_request events; on push / # merge_group there is no PR to review, so the test is PR-gated (skipped - # otherwise, which aggregate-job-checks tolerates — like test-approve-pr). + # otherwise, which the inline required-check collector tolerates — like + # test-approve-pr). if: "${{ github.event_name != 'merge_group' && !startsWith(github.head_ref, 'release-please--') && !startsWith(github.event.head_commit.message, 'chore(main): release ') && startsWith(github.event_name, 'pull_request') }}" permissions: contents: read