From 05223c7ab246b843c0daf6776b5e0bd5ae8e9fac Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:14:50 +0200 Subject: [PATCH 01/11] ci: a draft PR takes no runner slot; ready_for_review re-runs CI (D1, FLOW-010) Heavy jobs (x86-main, advisories, workspace-test-shard, mg-reuse-self-test) skip on a draft PR. The three required aggregators stay if: always() and fail on a draft (a skipped required check passes on GitHub). Guard scripts/check_ci_draft_gate.sh is RED on main, GREEN here, with 2 mutations. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 8 ++- .../814-ci-draft-gate-self-test.cmd | 2 + scripts/check_ci_draft_gate.sh | 51 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd create mode 100755 scripts/check_ci_draft_gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0f3850566..f9807ef87a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ on: tags: ['v*'] pull_request: branches: [main, master] + # D1 (FLOW-010 runner-wait): a draft PR runs no job on a runner; marking it ready re-runs CI. + types: [opened, synchronize, reopened, ready_for_review] # Merge queue: GitHub dispatches a `merge_group` event for each batch it forms. # Both required checks (`ci / gate` via the reusable sovereign-ci call, and the # top-level `workspace-test` job) live in THIS workflow, so triggering here makes @@ -140,6 +142,7 @@ jobs: # The same case table on every event, so a change that breaks the key is RED on # its own PR, not first in the queue. gate requires it. mg-reuse-self-test: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: [self-hosted, Linux, clean-room] timeout-minutes: 10 permissions: @@ -155,7 +158,7 @@ jobs: x86-main: needs: [mg-reuse] - if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' }} + if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }} # pull_request/push stay on the shared clean-room pool; a merge_group entry takes the mq lane (custom label exactly mq) runs-on: ${{ github.event_name == 'merge_group' && fromJSON('["self-hosted","Linux","X64","mq"]') || fromJSON('["self-hosted","Linux","X64","clean-room"]') }} timeout-minutes: 240 @@ -313,7 +316,7 @@ jobs: # no evidence for them. ci / gate and gate require this job whenever they reuse. x86-main-advisories: needs: [mg-reuse] - if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' }} + if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }} runs-on: [self-hosted, Linux, X64, clean-room] timeout-minutes: 30 permissions: @@ -440,6 +443,7 @@ jobs: # explicit runner's round-robin N/3), in parallel on the X64 clean-room pool. # The section body is ci/sections.yml `workspace-test-shard`, unchanged. workspace-test-shard: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} # same lanes as x86-main: clean-room for pull_request/push, mq for a merge_group entry runs-on: ${{ github.event_name == 'merge_group' && fromJSON('["self-hosted","Linux","X64","mq"]') || fromJSON('["self-hosted","Linux","X64","clean-room"]') }} timeout-minutes: 180 diff --git a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd new file mode 100644 index 0000000000..65c2d8743c --- /dev/null +++ b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd @@ -0,0 +1,2 @@ +# D1 (FLOW-010 runner-wait): a draft PR takes no clean-room/mq slot; plants two mutations and must name each RED. +bash scripts/check_ci_draft_gate.sh --self-test diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh new file mode 100755 index 0000000000..70211c85fd --- /dev/null +++ b/scripts/check_ci_draft_gate.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# D1 (FLOW-010 runner-wait): a DRAFT pull request takes no runner slot. +# +# Every job of ci.yml that asks for the clean-room or mq pool must either +# - carry `pull_request.draft` in its `if:` (skipped on a draft), or +# - never run on a pull_request (`push`-only, or `event_name != 'pull_request'`), or +# - be one of the AGGREGATORS below, which stay `if: always()` so the three +# required check names exist and FAIL on a draft (a skipped required check +# counts as passing on GitHub, so skipping them would let an armed draft merge). +# The workflow must also re-run on `ready_for_review`, or a draft flipped to +# ready would never get CI. +# +# Usage: check_ci_draft_gate.sh [ci.yml] | check_ci_draft_gate.sh --self-test +set -euo pipefail + +AGGREGATORS=" workspace-test ci-gate gate " + +check() { + local f="$1" bad=0 job runs iff + yq -e '.on.pull_request.types | contains(["ready_for_review"])' "$f" >/dev/null 2>&1 \ + || { echo "FAIL: on.pull_request.types lacks ready_for_review"; bad=1; } + while IFS= read -r job; do + runs=$(yq -r ".jobs.\"$job\".\"runs-on\" | tostring" "$f") + case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac + iff=$(yq -r ".jobs.\"$job\".if // \"\"" "$f") + if [[ "$AGGREGATORS" == *" $job "* ]]; then + [ "$iff" = "always()" ] || { echo "FAIL: aggregator $job must be if: always() (got '$iff')"; bad=1; } + continue + fi + case "$iff" in + *pull_request.draft* | *"event_name == 'push'"* | *"event_name != 'pull_request'"*) ;; + *) echo "FAIL: job $job takes a runner slot on a draft PR (if: '$iff')"; bad=1 ;; + esac + done < <(yq -r '.jobs | keys | .[]' "$f") + return "$bad" +} + +if [ "${1:-}" = "--self-test" ]; then + src="${CI_YML:-.github/workflows/ci.yml}" + check "$src" || { echo "self-test: real workflow is not draft-gated"; exit 1; } + t=$(mktemp -d); trap 'rm -rf "$t"' EXIT + # Mutation 1: drop the draft guard from x86-main -> must be RED. + sed "s/ && !(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)//" "$src" >"$t/m1.yml" + if check "$t/m1.yml" >/dev/null; then echo "self-test: mutation 1 (no draft guard) not caught"; exit 1; fi + # Mutation 2: drop ready_for_review -> must be RED. + sed 's/, ready_for_review//' "$src" >"$t/m2.yml" + if check "$t/m2.yml" >/dev/null; then echo "self-test: mutation 2 (no ready_for_review) not caught"; exit 1; fi + echo "check_ci_draft_gate: self-test OK (2 mutations caught)" + exit 0 +fi +check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From cda0933aa3a3ab17a4c18c329af2b7784e51a5c2 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:31:37 +0200 Subject: [PATCH 02/11] ci: D1 guard also proves merge_group is untouched (3 mutations) Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_ci_draft_gate.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index 70211c85fd..16089a7c08 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -27,6 +27,10 @@ check() { [ "$iff" = "always()" ] || { echo "FAIL: aggregator $job must be if: always() (got '$iff')"; bad=1; } continue fi + # merge_group untouched: a draft guard must be qualified by the pull_request event. + if [[ "$iff" == *pull_request.draft* && "$iff" != *"event_name == 'pull_request'"* && "$iff" != *"event_name != 'pull_request'"* ]]; then + echo "FAIL: job $job draft guard is not scoped to the pull_request event (would skip merge_group)"; bad=1 + fi case "$iff" in *pull_request.draft* | *"event_name == 'push'"* | *"event_name != 'pull_request'"*) ;; *) echo "FAIL: job $job takes a runner slot on a draft PR (if: '$iff')"; bad=1 ;; @@ -45,7 +49,10 @@ if [ "${1:-}" = "--self-test" ]; then # Mutation 2: drop ready_for_review -> must be RED. sed 's/, ready_for_review//' "$src" >"$t/m2.yml" if check "$t/m2.yml" >/dev/null; then echo "self-test: mutation 2 (no ready_for_review) not caught"; exit 1; fi - echo "check_ci_draft_gate: self-test OK (2 mutations caught)" + # Mutation 3: unscope the draft guard (draft is empty on merge_group -> would still run, but the scope rule must fire). + sed "s/!(github.event_name == 'pull_request' && github.event.pull_request.draft)/!github.event.pull_request.draft/" "$src" >"$t/m3.yml" + if check "$t/m3.yml" >/dev/null; then echo "self-test: mutation 3 (unscoped guard) not caught"; exit 1; fi + echo "check_ci_draft_gate: self-test OK (3 mutations caught)" exit 0 fi check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From 79eb76967ee11851314724ef2f41d1339305343c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:21:48 +0200 Subject: [PATCH 03/11] fix(ci): mg-reuse wiring rows expect the D1 draft clause on x86-main and x86-main-advisories The D1 draft gate added a draft clause to both if: lines; ci_mg_reuse.sh still required the old text (3 failures in mg-reuse-self-test). Rows and the x86-main-if-loses-cancelled mutant now match the new line; 105 passed, 0 failed. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci_mg_reuse.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/ci_mg_reuse.sh b/scripts/ci_mg_reuse.sh index 86573f5aff..8ccb3991d2 100755 --- a/scripts/ci_mg_reuse.sh +++ b/scripts/ci_mg_reuse.sh @@ -505,7 +505,7 @@ STUB wire mg-reuse 'PBEFORE: ${{ github.event.before }}' wire mg-reuse-self-test 'bash scripts/ci_mg_reuse.sh --self-test' wire x86-main 'needs: [mg-reuse]' - wire x86-main "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' }}" + wire x86-main "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }}" wire determinism 'needs: [mg-reuse, x86-main]' # T42: determinism also waits on x86-main wire determinism "if: \${{ !cancelled() && needs.mg-reuse.outputs.det != '1' && github.event_name != 'pull_request' }}" # Q6 quorum, C324: mac-check is LAB and left ci.yml (nightly.yml now). The `mac` key is @@ -514,7 +514,7 @@ STUB fail=$((fail + 1)); echo "FAIL wiring: ci.yml runs mac-check, a LAB job (Q6 quorum, C324)"; fi if grep -qF 'outputs.mac' <<<"$(job mg-reuse)"; then fail=$((fail + 1)); echo "FAIL wiring: mg-reuse still exports mac, which no ci.yml job reads"; else pass=$((pass + 1)); fi - wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' }}" + wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }}" wire x86-main-advisories 'cargo deny check advisories' wire x86-main-advisories 'bash scripts/check_deny_exemptions_live.sh' wire ci-gate 'MG-REUSE-VERDICT-BEGIN' @@ -592,7 +592,7 @@ STUB fail=$((fail + 1)); echo "FAIL mutant mgshape-unreadable: an unreadable workflow passed green (fails open)"; fi mgmut mg-if-old-merge_group "mg-reuse if names merge_group" "s/^ if: github.event_name == 'push' && github.ref == 'refs\/heads\/main'/ if: github.event_name == 'merge_group' || (github.event_name == 'push' \&\& github.ref == 'refs\/heads\/main')/" mgmut mg-if-removed-string-elsewhere "is not the push-only key" "/^ if: github.event_name == 'push' && github.ref == 'refs\/heads\/main'/{s/.*/ # if: github.event_name == 'push' \&\& github.ref == 'refs\/heads\/main'/}" - mgmut x86-main-if-loses-cancelled "job x86-main needs mg-reuse but its if lacks" "s/^ if: \\$\{\{ !cancelled\(\) && needs.mg-reuse.outputs.x86 != '1' \}\}/ if: \${{ needs.mg-reuse.outputs.x86 != '1' }}/" + mgmut x86-main-if-loses-cancelled "job x86-main needs mg-reuse but its if lacks" "s/^ if: \\$\{\{ !cancelled\(\) && needs.mg-reuse.outputs.x86 != '1'/ if: \${{ needs.mg-reuse.outputs.x86 != '1'/" mgmut determinism-if-loses-cancelled "job determinism needs mg-reuse but its if lacks" '/^ determinism:$/,/^ runs-on:/{s/^ if: \$\{\{ !cancelled\(\) && / if: ${{ /}' mgmut x86-main-needs-lookalike "job x86-main no longer needs mg-reuse" '/^ x86-main:$/,/^ if:/{s/^ needs: \[mg-reuse\]$/ needs: [mg-reuse-self-test]/}' mgmut x86-main-drops-needs "job x86-main no longer needs mg-reuse" '/^ x86-main:$/,/^ if:/{/^ needs: \[mg-reuse\]$/d}' From 6c6336f14a9f953b57e539c15d05479e7ebd9a18 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:55:35 +0200 Subject: [PATCH 04/11] fix(ci): draft-gate self-test trap guards the rm -rf target (SEC011) Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_ci_draft_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index 16089a7c08..f68395803b 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -42,7 +42,7 @@ check() { if [ "${1:-}" = "--self-test" ]; then src="${CI_YML:-.github/workflows/ci.yml}" check "$src" || { echo "self-test: real workflow is not draft-gated"; exit 1; } - t=$(mktemp -d); trap 'rm -rf "$t"' EXIT + t=$(mktemp -d); trap 'rm -rf "${t:?}"' EXIT # Mutation 1: drop the draft guard from x86-main -> must be RED. sed "s/ && !(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)//" "$src" >"$t/m1.yml" if check "$t/m1.yml" >/dev/null; then echo "self-test: mutation 1 (no draft guard) not caught"; exit 1; fi From 889f8bb0d8836a603083b3bfb9b9882f95a40849 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 20:27:46 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix(guard):=20check=5Fci=5Fdraft=5Fgate?= =?UTF-8?q?=20used=20yq,=20which=20the=20clean-room=20runner=20lacks=20?= =?UTF-8?q?=E2=80=94=20parse=20ci.yml=20with=20awk/grep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_ci_draft_gate.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index f68395803b..e29c34e978 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -15,14 +15,23 @@ set -euo pipefail AGGREGATORS=" workspace-test ci-gate gate " +# jobfield FILE JOB KEY -> the single-line value of ` KEY:` inside job JOB (empty if absent) +jobfield() { + awk -v job=" $2:" -v key=" $3:" ' + $0 == job {f = 1; next} + f && /^ [A-Za-z0-9_-]+:[ ]*$/ {exit} + f && index($0, key) == 1 {v = substr($0, length(key) + 1); sub(/^ +/, "", v); print v; exit}' "$1" +} + check() { local f="$1" bad=0 job runs iff - yq -e '.on.pull_request.types | contains(["ready_for_review"])' "$f" >/dev/null 2>&1 \ + # no yq on the runner: the pull_request `types:` line is the only 4-space `types:` key in ci.yml + grep -qE '^ types: \[.*ready_for_review' "$f" \ || { echo "FAIL: on.pull_request.types lacks ready_for_review"; bad=1; } while IFS= read -r job; do - runs=$(yq -r ".jobs.\"$job\".\"runs-on\" | tostring" "$f") + runs=$(jobfield "$f" "$job" runs-on) case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac - iff=$(yq -r ".jobs.\"$job\".if // \"\"" "$f") + iff=$(jobfield "$f" "$job" if) if [[ "$AGGREGATORS" == *" $job "* ]]; then [ "$iff" = "always()" ] || { echo "FAIL: aggregator $job must be if: always() (got '$iff')"; bad=1; } continue @@ -35,7 +44,7 @@ check() { *pull_request.draft* | *"event_name == 'push'"* | *"event_name != 'pull_request'"*) ;; *) echo "FAIL: job $job takes a runner slot on a draft PR (if: '$iff')"; bad=1 ;; esac - done < <(yq -r '.jobs | keys | .[]' "$f") + done < <(awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [A-Za-z0-9_-]+:[ ]*$/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$f") return "$bad" } From d99d73631d3b4e1a1fde5942558600d700d1d533 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 22:30:59 +0200 Subject: [PATCH 06/11] fix(guard): draft-gate check matches the exact guard forms, counts jobs, self-tests polarity/aggregator/empty-parse mutants Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_ci_draft_gate.sh | 36 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index e29c34e978..3eace4b26b 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -23,28 +23,35 @@ jobfield() { f && index($0, key) == 1 {v = substr($0, length(key) + 1); sub(/^ +/, "", v); print v; exit}' "$1" } +# The only two draft-guard forms ci.yml uses; anything else (a flipped polarity, a bare +# `pull_request.draft`) is RED. G_ALL covers the jobs that run on every event, G_PQ the +# `!cancelled() && ...` jobs. +G_ALL="github.event_name != 'pull_request' || !github.event.pull_request.draft" +G_PQ="!(github.event_name == 'pull_request' && github.event.pull_request.draft)" + check() { - local f="$1" bad=0 job runs iff + local f="$1" bad=0 job runs iff pool=0 aggs=0 # no yq on the runner: the pull_request `types:` line is the only 4-space `types:` key in ci.yml grep -qE '^ types: \[.*ready_for_review' "$f" \ || { echo "FAIL: on.pull_request.types lacks ready_for_review"; bad=1; } while IFS= read -r job; do - runs=$(jobfield "$f" "$job" runs-on) - case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac iff=$(jobfield "$f" "$job" if) if [[ "$AGGREGATORS" == *" $job "* ]]; then + aggs=$((aggs + 1)) [ "$iff" = "always()" ] || { echo "FAIL: aggregator $job must be if: always() (got '$iff')"; bad=1; } continue fi - # merge_group untouched: a draft guard must be qualified by the pull_request event. - if [[ "$iff" == *pull_request.draft* && "$iff" != *"event_name == 'pull_request'"* && "$iff" != *"event_name != 'pull_request'"* ]]; then - echo "FAIL: job $job draft guard is not scoped to the pull_request event (would skip merge_group)"; bad=1 - fi + runs=$(jobfield "$f" "$job" runs-on) + case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac + pool=$((pool + 1)) case "$iff" in - *pull_request.draft* | *"event_name == 'push'"* | *"event_name != 'pull_request'"*) ;; - *) echo "FAIL: job $job takes a runner slot on a draft PR (if: '$iff')"; bad=1 ;; + *"$G_ALL"* | *"$G_PQ"* | *"&& github.event_name != 'pull_request' }}"* | *"event_name == 'push'"*) ;; + *) echo "FAIL: job $job takes a runner slot on a draft PR, or its draft guard is not one of the two allowed forms (if: '$iff')"; bad=1 ;; esac done < <(awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [A-Za-z0-9_-]+:[ ]*$/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$f") + # a parse that finds nothing must not pass + [ "$aggs" -eq 3 ] || { echo "FAIL: saw $aggs of the 3 aggregators (workspace-test, ci-gate, gate)"; bad=1; } + [ "$pool" -ge 1 ] || { echo "FAIL: no clean-room/mq job found — parser saw nothing"; bad=1; } return "$bad" } @@ -61,7 +68,16 @@ if [ "${1:-}" = "--self-test" ]; then # Mutation 3: unscope the draft guard (draft is empty on merge_group -> would still run, but the scope rule must fire). sed "s/!(github.event_name == 'pull_request' && github.event.pull_request.draft)/!github.event.pull_request.draft/" "$src" >"$t/m3.yml" if check "$t/m3.yml" >/dev/null; then echo "self-test: mutation 3 (unscoped guard) not caught"; exit 1; fi - echo "check_ci_draft_gate: self-test OK (3 mutations caught)" + # Mutation 4: flip the polarity of the guard (job would run ONLY on drafts) -> must be RED. + sed "s/!(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)/(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)/" "$src" >"$t/m4.yml" + if check "$t/m4.yml" >/dev/null; then echo "self-test: mutation 4 (flipped polarity) not caught"; exit 1; fi + # Mutation 5: an aggregator loses if: always() -> must be RED. + awk '/^ gate:$/ {g = 1} g && /^ if: always\(\)/ {sub(/always\(\)/, "success()"); g = 0} {print}' "$src" >"$t/m5.yml" + if check "$t/m5.yml" >/dev/null; then echo "self-test: mutation 5 (aggregator not always) not caught"; exit 1; fi + # Mutation 6: nothing parses (empty jobs) -> must be RED, not vacuously green. + printf 'on:\n pull_request:\n types: [ready_for_review]\njobs:\n' >"$t/m6.yml" + if check "$t/m6.yml" >/dev/null; then echo "self-test: mutation 6 (empty parse) not caught"; exit 1; fi + echo "check_ci_draft_gate: self-test OK (6 mutations caught)" exit 0 fi check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From 1d04157540f9a2a599dbfc7982fdd76c4cb27719 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 22:44:17 +0200 Subject: [PATCH 07/11] fix(guard): check_ci_draft_gate matches the whole if: value, anchors ready_for_review, refuses an unreadable runs-on; mutants for || true, push || true, ready_for_review_x, block-list runs-on (Refs #5801) Co-Authored-By: Claude Sonnet 5.5 --- .../814-ci-draft-gate-self-test.cmd | 2 +- scripts/check_ci_draft_gate.sh | 46 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd index 65c2d8743c..cca570e872 100644 --- a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd +++ b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd @@ -1,2 +1,2 @@ -# D1 (FLOW-010 runner-wait): a draft PR takes no clean-room/mq slot; plants two mutations and must name each RED. +# D1 (FLOW-010 runner-wait): a draft PR takes no clean-room/mq slot; plants ten mutations and must name each RED. bash scripts/check_ci_draft_gate.sh --self-test diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index 3eace4b26b..7bd1dd9802 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -29,10 +29,26 @@ jobfield() { G_ALL="github.event_name != 'pull_request' || !github.event.pull_request.draft" G_PQ="!(github.event_name == 'pull_request' && github.event.pull_request.draft)" +# draft_guard_ok IF -> 0 when the whole `if:` value is an allowed form. The comparison is on the WHOLE +# expression (the `${{ }}` wrapper stripped), never a substring: a padded `... || true`, a trailing +# `# comment` or `push || true` must not pass. A form is the exact guard, or a conjunction that adds +# only `&&`-ed terms (no `||`, no `#`) in front of it, so extra terms can only narrow when the job runs. +draft_guard_ok() { + local e="$1" + e="${e#\$\{\{ }"; e="${e% \}\}}" + [[ "$e" == *"||"* && "$e" != "$G_ALL" ]] && return 1 + [[ "$e" == *"#"* || "$e" == *"!!"* ]] && return 1 + [ "$e" = "$G_ALL" ] && return 0 + [[ "$e" == *"$G_PQ" ]] && return 0 + [[ "$e" == *" && github.event_name != 'pull_request'" ]] && return 0 + [[ "$e" == "github.event_name == 'push' && "* ]] && return 0 + return 1 +} + check() { local f="$1" bad=0 job runs iff pool=0 aggs=0 # no yq on the runner: the pull_request `types:` line is the only 4-space `types:` key in ci.yml - grep -qE '^ types: \[.*ready_for_review' "$f" \ + grep -qE '^ types: \[([^]]*, ?)?ready_for_review(,[^]]*)?\]' "$f" \ || { echo "FAIL: on.pull_request.types lacks ready_for_review"; bad=1; } while IFS= read -r job; do iff=$(jobfield "$f" "$job" if) @@ -42,12 +58,16 @@ check() { continue fi runs=$(jobfield "$f" "$job" runs-on) + # a runs-on the single-line parser cannot read (block list) on a job that is not a reusable call + # would be skipped silently, so it is RED, not "not a pool job" + if [ -z "$runs" ] && [ -z "$(jobfield "$f" "$job" uses)" ]; then + echo "FAIL: job $job has no single-line runs-on and no uses: — the draft gate cannot read it"; bad=1; continue + fi case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac pool=$((pool + 1)) - case "$iff" in - *"$G_ALL"* | *"$G_PQ"* | *"&& github.event_name != 'pull_request' }}"* | *"event_name == 'push'"*) ;; - *) echo "FAIL: job $job takes a runner slot on a draft PR, or its draft guard is not one of the two allowed forms (if: '$iff')"; bad=1 ;; - esac + if ! draft_guard_ok "$iff"; then + echo "FAIL: job $job takes a runner slot on a draft PR, or its draft guard is not one of the allowed exact forms (if: '$iff')"; bad=1 + fi done < <(awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [A-Za-z0-9_-]+:[ ]*$/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$f") # a parse that finds nothing must not pass [ "$aggs" -eq 3 ] || { echo "FAIL: saw $aggs of the 3 aggregators (workspace-test, ci-gate, gate)"; bad=1; } @@ -74,10 +94,24 @@ if [ "${1:-}" = "--self-test" ]; then # Mutation 5: an aggregator loses if: always() -> must be RED. awk '/^ gate:$/ {g = 1} g && /^ if: always\(\)/ {sub(/always\(\)/, "success()"); g = 0} {print}' "$src" >"$t/m5.yml" if check "$t/m5.yml" >/dev/null; then echo "self-test: mutation 5 (aggregator not always) not caught"; exit 1; fi + # Mutation 7: a padded guard (`|| true`) on a G_PQ job and on a G_ALL job -> must be RED. + sed "s/draft) }}/draft) || true }}/" "$src" >"$t/m7.yml" + if check "$t/m7.yml" >/dev/null; then echo "self-test: mutation 7 (guard padded with || true) not caught"; exit 1; fi + sed "s/!github.event.pull_request.draft }}/!github.event.pull_request.draft || true }}/" "$src" >"$t/m7b.yml" + if check "$t/m7b.yml" >/dev/null; then echo "self-test: mutation 7b (G_ALL padded with || true) not caught"; exit 1; fi + # Mutation 8: a push-looking if that is true on a draft -> must be RED. + awk '/^ workspace-test-shard:/ {g = 1} g && /^ if:/ {print " if: ${{ github.event_name == '"'"'push'"'"' || true }}"; g = 0; next} {print}' "$src" >"$t/m8.yml" + if check "$t/m8.yml" >/dev/null; then echo "self-test: mutation 8 (push || true) not caught"; exit 1; fi + # Mutation 9: a near-miss ready_for_review token -> must be RED. + sed 's/ready_for_review\]/ready_for_review_x]/' "$src" >"$t/m9.yml" + if check "$t/m9.yml" >/dev/null; then echo "self-test: mutation 9 (ready_for_review_x) not caught"; exit 1; fi + # Mutation 10: a job whose runs-on is a block list the parser cannot read -> must be RED. + awk '/^ mg-reuse-self-test:/ {g = 1} g && /^ runs-on:/ {print " runs-on:"; print " - self-hosted"; print " - clean-room"; g = 0; next} {print}' "$src" >"$t/m10.yml" + if check "$t/m10.yml" >/dev/null; then echo "self-test: mutation 10 (block-list runs-on) not caught"; exit 1; fi # Mutation 6: nothing parses (empty jobs) -> must be RED, not vacuously green. printf 'on:\n pull_request:\n types: [ready_for_review]\njobs:\n' >"$t/m6.yml" if check "$t/m6.yml" >/dev/null; then echo "self-test: mutation 6 (empty parse) not caught"; exit 1; fi - echo "check_ci_draft_gate: self-test OK (6 mutations caught)" + echo "check_ci_draft_gate: self-test OK (10 mutations caught)" exit 0 fi check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From 59c8a1bbd00c8d6a00537ae5be3566bc649e9b2c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 22:51:06 +0200 Subject: [PATCH 08/11] fix(guard): check_ci_draft_gate is string equality per job against a CANON table; job set must equal the list; 15 self-test mutants (Refs #5801) Co-Authored-By: Claude Sonnet 5.5 --- .../814-ci-draft-gate-self-test.cmd | 2 - scripts/check_ci_draft_gate.sh | 126 +++++++++--------- 2 files changed, 63 insertions(+), 65 deletions(-) diff --git a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd index cca570e872..e69de29bb2 100644 --- a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd +++ b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd @@ -1,2 +0,0 @@ -# D1 (FLOW-010 runner-wait): a draft PR takes no clean-room/mq slot; plants ten mutations and must name each RED. -bash scripts/check_ci_draft_gate.sh --self-test diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh index 7bd1dd9802..e2b635201b 100755 --- a/scripts/check_ci_draft_gate.sh +++ b/scripts/check_ci_draft_gate.sh @@ -1,77 +1,65 @@ #!/usr/bin/env bash # D1 (FLOW-010 runner-wait): a DRAFT pull request takes no runner slot. # -# Every job of ci.yml that asks for the clean-room or mq pool must either -# - carry `pull_request.draft` in its `if:` (skipped on a draft), or -# - never run on a pull_request (`push`-only, or `event_name != 'pull_request'`), or -# - be one of the AGGREGATORS below, which stay `if: always()` so the three -# required check names exist and FAIL on a draft (a skipped required check -# counts as passing on GitHub, so skipping them would let an armed draft merge). -# The workflow must also re-run on `ready_for_review`, or a draft flipped to -# ready would never get CI. +# EQUALITY, not pattern matching. Three rounds of "the pattern accepts X" ended here: each job +# of ci.yml has exactly one canonical `if:` below, and the check is string equality with that +# value (a trailing ` # comment` stripped), byte for byte. The set of jobs in ci.yml must equal +# the CANON list, so a new job is RED until it is added here on purpose with its draft behaviour. +# - jobs that ask for a clean-room/mq runner on a pull_request carry the draft guard in their `if:`; +# - the three AGGREGATORS (workspace-test, ci-gate, gate) stay `if: always()`: they must exist and +# FAIL on a draft (a skipped required check counts as passing on GitHub); +# - on.pull_request.types must be exactly the canonical list, which includes ready_for_review. # # Usage: check_ci_draft_gate.sh [ci.yml] | check_ci_draft_gate.sh --self-test set -euo pipefail -AGGREGATORS=" workspace-test ci-gate gate " +TYPES='types: [opened, synchronize, reopened, ready_for_review]' -# jobfield FILE JOB KEY -> the single-line value of ` KEY:` inside job JOB (empty if absent) -jobfield() { - awk -v job=" $2:" -v key=" $3:" ' - $0 == job {f = 1; next} - f && /^ [A-Za-z0-9_-]+:[ ]*$/ {exit} - f && index($0, key) == 1 {v = substr($0, length(key) + 1); sub(/^ +/, "", v); print v; exit}' "$1" +# canon JOB -> the one allowed `if:` value (empty output = job not in the list) +canon() { + case "$1" in + mg-reuse) printf '%s' "github.event_name == 'push' && github.ref == 'refs/heads/main'" ;; + mg-reuse-self-test | workspace-test-shard) printf '%s' '${{ github.event_name != '"'pull_request'"' || !github.event.pull_request.draft }}' ;; + x86-main) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.x86 != '"'1'"' && !(github.event_name == '"'pull_request'"' && github.event.pull_request.draft) }}' ;; + x86-main-advisories) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.x86 == '"'1'"' && !(github.event_name == '"'pull_request'"' && github.event.pull_request.draft) }}' ;; + determinism) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.det != '"'1'"' && github.event_name != '"'pull_request'"' }}' ;; + workspace-test | ci-gate | gate) printf '%s' 'always()' ;; + esac } +JOBS="mg-reuse mg-reuse-self-test x86-main x86-main-advisories determinism workspace-test-shard workspace-test ci-gate gate" -# The only two draft-guard forms ci.yml uses; anything else (a flipped polarity, a bare -# `pull_request.draft`) is RED. G_ALL covers the jobs that run on every event, G_PQ the -# `!cancelled() && ...` jobs. -G_ALL="github.event_name != 'pull_request' || !github.event.pull_request.draft" -G_PQ="!(github.event_name == 'pull_request' && github.event.pull_request.draft)" +# strip a trailing ` # comment` and trailing blanks +nocomment() { sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//'; } -# draft_guard_ok IF -> 0 when the whole `if:` value is an allowed form. The comparison is on the WHOLE -# expression (the `${{ }}` wrapper stripped), never a substring: a padded `... || true`, a trailing -# `# comment` or `push || true` must not pass. A form is the exact guard, or a conjunction that adds -# only `&&`-ed terms (no `||`, no `#`) in front of it, so extra terms can only narrow when the job runs. -draft_guard_ok() { - local e="$1" - e="${e#\$\{\{ }"; e="${e% \}\}}" - [[ "$e" == *"||"* && "$e" != "$G_ALL" ]] && return 1 - [[ "$e" == *"#"* || "$e" == *"!!"* ]] && return 1 - [ "$e" = "$G_ALL" ] && return 0 - [[ "$e" == *"$G_PQ" ]] && return 0 - [[ "$e" == *" && github.event_name != 'pull_request'" ]] && return 0 - [[ "$e" == "github.event_name == 'push' && "* ]] && return 0 - return 1 +# jobs_of FILE -> every 2-space key under `jobs:` (a trailing comment on the header is allowed) +jobs_of() { + awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [^ #][^:]*:/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$1" +} + +# ifof FILE JOB -> the job's ` if:` value (comment stripped), or nothing +ifof() { + awk -v job="$2" ' + /^ [^ #][^:]*:/ { n = $0; sub(/^ /, "", n); sub(/:.*/, "", n); f = (n == job); next } + f && index($0, " if: ") == 1 { v = substr($0, 9); print v; exit }' "$1" | nocomment } check() { - local f="$1" bad=0 job runs iff pool=0 aggs=0 - # no yq on the runner: the pull_request `types:` line is the only 4-space `types:` key in ci.yml - grep -qE '^ types: \[([^]]*, ?)?ready_for_review(,[^]]*)?\]' "$f" \ - || { echo "FAIL: on.pull_request.types lacks ready_for_review"; bad=1; } + local f="$1" bad=0 job want got seen=0 + # the pull_request `types:` line is the only 4-space `types:` key in ci.yml + got=$(/usr/bin/grep -E '^ types: ' "$f" | nocomment | sed -E 's/^ +//' || true) + [ "$got" = "$TYPES" ] || { echo "FAIL: on.pull_request.types is '$got', want '$TYPES'"; bad=1; } while IFS= read -r job; do - iff=$(jobfield "$f" "$job" if) - if [[ "$AGGREGATORS" == *" $job "* ]]; then - aggs=$((aggs + 1)) - [ "$iff" = "always()" ] || { echo "FAIL: aggregator $job must be if: always() (got '$iff')"; bad=1; } - continue - fi - runs=$(jobfield "$f" "$job" runs-on) - # a runs-on the single-line parser cannot read (block list) on a job that is not a reusable call - # would be skipped silently, so it is RED, not "not a pool job" - if [ -z "$runs" ] && [ -z "$(jobfield "$f" "$job" uses)" ]; then - echo "FAIL: job $job has no single-line runs-on and no uses: — the draft gate cannot read it"; bad=1; continue - fi - case "$runs" in *clean-room* | *mq*) ;; *) continue ;; esac - pool=$((pool + 1)) - if ! draft_guard_ok "$iff"; then - echo "FAIL: job $job takes a runner slot on a draft PR, or its draft guard is not one of the allowed exact forms (if: '$iff')"; bad=1 - fi - done < <(awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [A-Za-z0-9_-]+:[ ]*$/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$f") - # a parse that finds nothing must not pass - [ "$aggs" -eq 3 ] || { echo "FAIL: saw $aggs of the 3 aggregators (workspace-test, ci-gate, gate)"; bad=1; } - [ "$pool" -ge 1 ] || { echo "FAIL: no clean-room/mq job found — parser saw nothing"; bad=1; } + seen=$((seen + 1)) + want=$(canon "$job") + [ -n "$want" ] || { echo "FAIL: job $job is not in the CANON list of check_ci_draft_gate.sh (add it with its draft behaviour)"; bad=1; continue; } + got=$(ifof "$f" "$job") + [ "$got" = "$want" ] || { echo "FAIL: job $job: if: '$got' != canonical '$want'"; bad=1; } + done < <(jobs_of "$f") + # every listed job must exist: a parse that finds fewer must not pass + for job in $JOBS; do + jobs_of "$f" | /usr/bin/grep -qx "$job" || { echo "FAIL: listed job $job not found in $f"; bad=1; } + done + [ "$seen" -ge 1 ] || { echo "FAIL: parser saw no jobs"; bad=1; } return "$bad" } @@ -105,13 +93,25 @@ if [ "${1:-}" = "--self-test" ]; then # Mutation 9: a near-miss ready_for_review token -> must be RED. sed 's/ready_for_review\]/ready_for_review_x]/' "$src" >"$t/m9.yml" if check "$t/m9.yml" >/dev/null; then echo "self-test: mutation 9 (ready_for_review_x) not caught"; exit 1; fi - # Mutation 10: a job whose runs-on is a block list the parser cannot read -> must be RED. - awk '/^ mg-reuse-self-test:/ {g = 1} g && /^ runs-on:/ {print " runs-on:"; print " - self-hosted"; print " - clean-room"; g = 0; next} {print}' "$src" >"$t/m10.yml" - if check "$t/m10.yml" >/dev/null; then echo "self-test: mutation 10 (block-list runs-on) not caught"; exit 1; fi + # Mutation 11: `&& true != !(...)` — a comparison that makes the guard vacuous -> must be RED. + sed "s/ && !(github.event_name == 'pull_request'/ \&\& true != !(github.event_name == 'pull_request'/" "$src" >"$t/m11.yml" + if check "$t/m11.yml" >/dev/null; then echo "self-test: mutation 11 (true != !(...)) not caught"; exit 1; fi + # Mutation 12: a double negation `! !(...)` -> must be RED. + sed "s/ && !(github.event_name == 'pull_request'/ \&\& ! !(github.event_name == 'pull_request'/" "$src" >"$t/m12.yml" + if check "$t/m12.yml" >/dev/null; then echo "self-test: mutation 12 (! !(...)) not caught"; exit 1; fi + # Mutation 13: a job whose header carries a comment suffix and has lost its `if:` -> must be RED. + awk '/^ workspace-test-shard:/ {print " workspace-test-shard: # no guard here"; g = 1; next} g && /^ if:/ {g = 0; next} {print}' "$src" >"$t/m13.yml" + if check "$t/m13.yml" >/dev/null; then echo "self-test: mutation 13 (comment-suffixed unguarded job) not caught"; exit 1; fi + # Mutation 14: a brand-new unguarded pool job (not in CANON), header with a comment -> must be RED. + awk '{print} /^jobs:/ {print " extra-job: # unguarded"; print " runs-on: [self-hosted, Linux, clean-room]"; print " steps: []"}' "$src" >"$t/m14.yml" + if check "$t/m14.yml" >/dev/null; then echo "self-test: mutation 14 (new job outside CANON) not caught"; exit 1; fi + # Mutation 15: a trailing comment is stripped, but a comment hiding a padded guard must not help -> RED. + sed "s/draft) }}\$/draft) || true }} # fine/" "$src" >"$t/m15.yml" + if check "$t/m15.yml" >/dev/null; then echo "self-test: mutation 15 (|| true then comment) not caught"; exit 1; fi # Mutation 6: nothing parses (empty jobs) -> must be RED, not vacuously green. printf 'on:\n pull_request:\n types: [ready_for_review]\njobs:\n' >"$t/m6.yml" if check "$t/m6.yml" >/dev/null; then echo "self-test: mutation 6 (empty parse) not caught"; exit 1; fi - echo "check_ci_draft_gate: self-test OK (10 mutations caught)" + echo "check_ci_draft_gate: self-test OK (15 mutations caught)" exit 0 fi check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From c4f813faf2498599d6310580f6964871d02240d2 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 22:59:07 +0200 Subject: [PATCH 09/11] test(ci): D1 draft gate as a parsed Rust invariant (serde-saphyr), 18 planted mutants; drop the shell guard and its empty .cmd (Refs #5801) Co-Authored-By: Claude Sonnet 5.5 --- .../814-ci-draft-gate-self-test.cmd | 0 .../tests/monorepo_invariants.rs | 171 ++++++++++++++++++ scripts/check_ci_draft_gate.sh | 117 ------------ 3 files changed, 171 insertions(+), 117 deletions(-) delete mode 100644 ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd delete mode 100755 scripts/check_ci_draft_gate.sh diff --git a/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd b/ci/explicit-test-commands.d/814-ci-draft-gate-self-test.cmd deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/aprender-core/tests/monorepo_invariants.rs b/crates/aprender-core/tests/monorepo_invariants.rs index 65b06840f3..f59550eabe 100644 --- a/crates/aprender-core/tests/monorepo_invariants.rs +++ b/crates/aprender-core/tests/monorepo_invariants.rs @@ -1125,3 +1125,174 @@ fn falsify_mono_014_path_pins_name_their_target_version() { [workspace.dependencies] entry instead): {stale:#?}" ); } + +// --------------------------------------------------------------------------- +// D1 (FLOW-010 runner-wait, #5801): a DRAFT pull request takes no runner slot. +// +// Parsed, not grepped: three rounds of shell patterns over ci.yml each accepted a padded guard. +// The workflow is read with the workspace YAML reader (`serde-saphyr`, the dev-dependency the +// contract tests use; `serde_yaml` is frozen to the modernbert test, #5208) and compared by +// equality: each job's `if:` scalar equals ONE canonical string, the job set equals JOBS, and +// `on.pull_request.types` equals the canonical list. A new job is RED until added here on purpose. +// The three aggregators stay `always()` so the required check names exist and FAIL on a draft +// (a skipped required check counts as passing on GitHub). +// --------------------------------------------------------------------------- + +const PQ: &str = "!(github.event_name == 'pull_request' && github.event.pull_request.draft)"; +const G_ALL: &str = + "${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}"; +const CI_TYPES: [&str; 4] = ["opened", "synchronize", "reopened", "ready_for_review"]; + +/// (job, canonical `if:` scalar) +fn draft_gate_canon() -> Vec<(&'static str, String)> { + vec![ + ("mg-reuse", "github.event_name == 'push' && github.ref == 'refs/heads/main'".into()), + ("mg-reuse-self-test", G_ALL.into()), + ("x86-main", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {PQ} }}}}")), + ("x86-main-advisories", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && {PQ} }}}}")), + ("determinism", "${{ !cancelled() && needs.mg-reuse.outputs.det != '1' && github.event_name != 'pull_request' }}".into()), + ("workspace-test-shard", G_ALL.into()), + ("workspace-test", "always()".into()), + ("ci-gate", "always()".into()), + ("gate", "always()".into()), + ] +} + +/// Every violation of the draft-gate rule in `ci_yml` (empty = the rule holds). +fn draft_gate_violations(ci_yml: &str) -> Vec { + let doc: serde_json::Value = match serde_saphyr::from_str_with_options( + ci_yml, + serde_saphyr::options! { strict_booleans: true, merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary, emit_comments: false, reject_non_finite_typeless_float: false }, + ) { + Ok(d) => d, + Err(e) => return vec![format!("ci.yml does not parse: {e}")], + }; + let mut bad = Vec::new(); + let types: Vec<&str> = doc["on"]["pull_request"]["types"] + .as_array() + .map(|a| a.iter().filter_map(|t| t.as_str()).collect()) + .unwrap_or_default(); + if types != CI_TYPES { + bad.push(format!( + "on.pull_request.types is {types:?}, want {CI_TYPES:?}" + )); + } + let canon = draft_gate_canon(); + let Some(jobs) = doc["jobs"].as_object() else { + bad.push("no `jobs:` mapping".into()); + return bad; + }; + for name in jobs.keys() { + if !canon.iter().any(|(j, _)| j == name) { + bad.push(format!( + "job `{name}` is not in the CANON list (add it with its draft behaviour)" + )); + } + } + for (job, want) in &canon { + match jobs.get(*job) { + None => bad.push(format!("listed job `{job}` is missing")), + Some(j) => match j["if"].as_str() { + Some(got) if got == want => {} + got => bad.push(format!("job `{job}`: if: {got:?} != canonical {want:?}")), + }, + } + } + bad +} + +fn ci_yml_text() -> String { + std::fs::read_to_string(workspace_root().join(".github/workflows/ci.yml")).expect("read ci.yml") +} + +/// `from` -> `to`, every occurrence; the mutant must differ from the source or the fixture is dead. +fn mutate(src: &str, from: &str, to: &str) -> String { + assert!( + src.contains(from), + "fixture anchor not found in ci.yml: {from:?}" + ); + src.replace(from, to) +} + +/// The ` if:` line of `job` replaced by `new` (None deletes it). +fn rewrite_if(src: &str, job: &str, new: Option<&str>) -> String { + let header = format!(" {job}:"); + let mut out = Vec::new(); + let (mut in_job, mut done) = (false, false); + for line in src.lines() { + if line.starts_with(" ") && !line.starts_with(" ") && line.trim_end().ends_with(':') { + in_job = line.trim_end() == header; + } + if in_job && !done && line.starts_with(" if:") { + done = true; + if let Some(n) = new { + out.push(n.to_string()); + } + continue; + } + out.push(line.to_string()); + } + assert!(done, "no `if:` found for job {job}"); + out.join("\n") + "\n" +} + +/// The 18 planted mutants: (label, mutated ci.yml). Each must be RED. +fn draft_gate_mutants(src: &str) -> Vec<(&'static str, String)> { + let x86_if = + format!(" if: ${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {PQ} }}}}"); + let gate_tail = src.split_once("\n gate:\n").expect("gate job").1; + let gate_mut = src.replacen( + gate_tail, + &gate_tail.replacen(" if: always()", " if: success()", 1), + 1, + ); + vec![ + ("m1 draft guard dropped", mutate(src, &format!(" && {PQ}"), "")), + ("m2 ready_for_review dropped", mutate(src, ", ready_for_review", "")), + ("m3 guard unscoped", mutate(src, PQ, "!github.event.pull_request.draft")), + ("m4 guard polarity flipped", mutate(src, "&& !(github.event_name == 'pull_request' &&", "&& (github.event_name == 'pull_request' &&")), + ("m5 aggregator not always()", gate_mut), + ("m6 empty parse", "on:\n pull_request:\n types: [ready_for_review]\njobs:\n".to_string()), + ("m7 PQ guard padded with || true", mutate(src, "draft) }}", "draft) || true }}")), + ("m7b G_ALL padded with || true", mutate(src, "!github.event.pull_request.draft }}", "!github.event.pull_request.draft || true }}")), + ("m8 shard if = push || true", rewrite_if(src, "workspace-test-shard", Some(" if: ${{ github.event_name == 'push' || true }}"))), + ("m9 ready_for_review_x", mutate(src, "ready_for_review]", "ready_for_review_x]")), + ("m11 && true != !(...)", mutate(src, " && !(github.event_name == 'pull_request'", " && true != !(github.event_name == 'pull_request'")), + ("m12 && ! !(...)", mutate(src, " && !(github.event_name == 'pull_request'", " && ! !(github.event_name == 'pull_request'")), + ("m13 comment-suffixed header, no if", mutate(&rewrite_if(src, "workspace-test-shard", None), " workspace-test-shard:\n", " workspace-test-shard: # no guard\n")), + ("m14 new job outside CANON", mutate(src, "\njobs:\n", "\njobs:\n extra-job: # unguarded\n runs-on: [self-hosted, Linux, clean-room]\n steps: []\n")), + ("m15 || true then a comment", mutate(src, "draft) }}\n", "draft) || true }} # fine\n")), + ("m16 more-indented || true line folds into the scalar", mutate(src, &format!("{x86_if}\n"), &format!("{x86_if}\n || true\n"))), + ("m17 decoy types line, real list lacks ready_for_review", mutate(src, "types: [opened, synchronize, reopened, ready_for_review]", "types: [opened, synchronize, reopened]") + "# types: [ready_for_review]\n"), + ("m18 G_ALL polarity flipped", mutate(src, "|| !github.event.pull_request.draft", "|| github.event.pull_request.draft")), + ] +} + +/// D1 / #5801: the real workflow satisfies the draft-gate rule. +#[test] +fn falsify_ci_draft_gate_real_workflow_holds() { + let bad = draft_gate_violations(&ci_yml_text()); + assert!( + bad.is_empty(), + "ci.yml breaks the D1 draft-gate rule:\n{}", + bad.join("\n") + ); +} + +/// D1 / #5801: each of the 18 planted mutants is RED, so the rule cannot pass vacuously. +#[test] +fn falsify_ci_draft_gate_every_planted_mutant_is_red() { + let src = ci_yml_text(); + let mutants = draft_gate_mutants(&src); + assert_eq!(mutants.len(), 18, "the fixture table lost a mutant"); + for (label, text) in &mutants { + assert_ne!( + text, &src, + "mutant `{label}` is identical to ci.yml (dead fixture)" + ); + assert!( + !draft_gate_violations(text).is_empty(), + "mutant `{label}` stayed GREEN" + ); + } +} diff --git a/scripts/check_ci_draft_gate.sh b/scripts/check_ci_draft_gate.sh deleted file mode 100755 index e2b635201b..0000000000 --- a/scripts/check_ci_draft_gate.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -# D1 (FLOW-010 runner-wait): a DRAFT pull request takes no runner slot. -# -# EQUALITY, not pattern matching. Three rounds of "the pattern accepts X" ended here: each job -# of ci.yml has exactly one canonical `if:` below, and the check is string equality with that -# value (a trailing ` # comment` stripped), byte for byte. The set of jobs in ci.yml must equal -# the CANON list, so a new job is RED until it is added here on purpose with its draft behaviour. -# - jobs that ask for a clean-room/mq runner on a pull_request carry the draft guard in their `if:`; -# - the three AGGREGATORS (workspace-test, ci-gate, gate) stay `if: always()`: they must exist and -# FAIL on a draft (a skipped required check counts as passing on GitHub); -# - on.pull_request.types must be exactly the canonical list, which includes ready_for_review. -# -# Usage: check_ci_draft_gate.sh [ci.yml] | check_ci_draft_gate.sh --self-test -set -euo pipefail - -TYPES='types: [opened, synchronize, reopened, ready_for_review]' - -# canon JOB -> the one allowed `if:` value (empty output = job not in the list) -canon() { - case "$1" in - mg-reuse) printf '%s' "github.event_name == 'push' && github.ref == 'refs/heads/main'" ;; - mg-reuse-self-test | workspace-test-shard) printf '%s' '${{ github.event_name != '"'pull_request'"' || !github.event.pull_request.draft }}' ;; - x86-main) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.x86 != '"'1'"' && !(github.event_name == '"'pull_request'"' && github.event.pull_request.draft) }}' ;; - x86-main-advisories) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.x86 == '"'1'"' && !(github.event_name == '"'pull_request'"' && github.event.pull_request.draft) }}' ;; - determinism) printf '%s' '${{ !cancelled() && needs.mg-reuse.outputs.det != '"'1'"' && github.event_name != '"'pull_request'"' }}' ;; - workspace-test | ci-gate | gate) printf '%s' 'always()' ;; - esac -} -JOBS="mg-reuse mg-reuse-self-test x86-main x86-main-advisories determinism workspace-test-shard workspace-test ci-gate gate" - -# strip a trailing ` # comment` and trailing blanks -nocomment() { sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//'; } - -# jobs_of FILE -> every 2-space key under `jobs:` (a trailing comment on the header is allowed) -jobs_of() { - awk '/^jobs:/ {j = 1; next} j && /^[^ #]/ {j = 0} j && /^ [^ #][^:]*:/ {sub(/^ /, ""); sub(/:.*/, ""); print}' "$1" -} - -# ifof FILE JOB -> the job's ` if:` value (comment stripped), or nothing -ifof() { - awk -v job="$2" ' - /^ [^ #][^:]*:/ { n = $0; sub(/^ /, "", n); sub(/:.*/, "", n); f = (n == job); next } - f && index($0, " if: ") == 1 { v = substr($0, 9); print v; exit }' "$1" | nocomment -} - -check() { - local f="$1" bad=0 job want got seen=0 - # the pull_request `types:` line is the only 4-space `types:` key in ci.yml - got=$(/usr/bin/grep -E '^ types: ' "$f" | nocomment | sed -E 's/^ +//' || true) - [ "$got" = "$TYPES" ] || { echo "FAIL: on.pull_request.types is '$got', want '$TYPES'"; bad=1; } - while IFS= read -r job; do - seen=$((seen + 1)) - want=$(canon "$job") - [ -n "$want" ] || { echo "FAIL: job $job is not in the CANON list of check_ci_draft_gate.sh (add it with its draft behaviour)"; bad=1; continue; } - got=$(ifof "$f" "$job") - [ "$got" = "$want" ] || { echo "FAIL: job $job: if: '$got' != canonical '$want'"; bad=1; } - done < <(jobs_of "$f") - # every listed job must exist: a parse that finds fewer must not pass - for job in $JOBS; do - jobs_of "$f" | /usr/bin/grep -qx "$job" || { echo "FAIL: listed job $job not found in $f"; bad=1; } - done - [ "$seen" -ge 1 ] || { echo "FAIL: parser saw no jobs"; bad=1; } - return "$bad" -} - -if [ "${1:-}" = "--self-test" ]; then - src="${CI_YML:-.github/workflows/ci.yml}" - check "$src" || { echo "self-test: real workflow is not draft-gated"; exit 1; } - t=$(mktemp -d); trap 'rm -rf "${t:?}"' EXIT - # Mutation 1: drop the draft guard from x86-main -> must be RED. - sed "s/ && !(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)//" "$src" >"$t/m1.yml" - if check "$t/m1.yml" >/dev/null; then echo "self-test: mutation 1 (no draft guard) not caught"; exit 1; fi - # Mutation 2: drop ready_for_review -> must be RED. - sed 's/, ready_for_review//' "$src" >"$t/m2.yml" - if check "$t/m2.yml" >/dev/null; then echo "self-test: mutation 2 (no ready_for_review) not caught"; exit 1; fi - # Mutation 3: unscope the draft guard (draft is empty on merge_group -> would still run, but the scope rule must fire). - sed "s/!(github.event_name == 'pull_request' && github.event.pull_request.draft)/!github.event.pull_request.draft/" "$src" >"$t/m3.yml" - if check "$t/m3.yml" >/dev/null; then echo "self-test: mutation 3 (unscoped guard) not caught"; exit 1; fi - # Mutation 4: flip the polarity of the guard (job would run ONLY on drafts) -> must be RED. - sed "s/!(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)/(github.event_name == 'pull_request' \&\& github.event.pull_request.draft)/" "$src" >"$t/m4.yml" - if check "$t/m4.yml" >/dev/null; then echo "self-test: mutation 4 (flipped polarity) not caught"; exit 1; fi - # Mutation 5: an aggregator loses if: always() -> must be RED. - awk '/^ gate:$/ {g = 1} g && /^ if: always\(\)/ {sub(/always\(\)/, "success()"); g = 0} {print}' "$src" >"$t/m5.yml" - if check "$t/m5.yml" >/dev/null; then echo "self-test: mutation 5 (aggregator not always) not caught"; exit 1; fi - # Mutation 7: a padded guard (`|| true`) on a G_PQ job and on a G_ALL job -> must be RED. - sed "s/draft) }}/draft) || true }}/" "$src" >"$t/m7.yml" - if check "$t/m7.yml" >/dev/null; then echo "self-test: mutation 7 (guard padded with || true) not caught"; exit 1; fi - sed "s/!github.event.pull_request.draft }}/!github.event.pull_request.draft || true }}/" "$src" >"$t/m7b.yml" - if check "$t/m7b.yml" >/dev/null; then echo "self-test: mutation 7b (G_ALL padded with || true) not caught"; exit 1; fi - # Mutation 8: a push-looking if that is true on a draft -> must be RED. - awk '/^ workspace-test-shard:/ {g = 1} g && /^ if:/ {print " if: ${{ github.event_name == '"'"'push'"'"' || true }}"; g = 0; next} {print}' "$src" >"$t/m8.yml" - if check "$t/m8.yml" >/dev/null; then echo "self-test: mutation 8 (push || true) not caught"; exit 1; fi - # Mutation 9: a near-miss ready_for_review token -> must be RED. - sed 's/ready_for_review\]/ready_for_review_x]/' "$src" >"$t/m9.yml" - if check "$t/m9.yml" >/dev/null; then echo "self-test: mutation 9 (ready_for_review_x) not caught"; exit 1; fi - # Mutation 11: `&& true != !(...)` — a comparison that makes the guard vacuous -> must be RED. - sed "s/ && !(github.event_name == 'pull_request'/ \&\& true != !(github.event_name == 'pull_request'/" "$src" >"$t/m11.yml" - if check "$t/m11.yml" >/dev/null; then echo "self-test: mutation 11 (true != !(...)) not caught"; exit 1; fi - # Mutation 12: a double negation `! !(...)` -> must be RED. - sed "s/ && !(github.event_name == 'pull_request'/ \&\& ! !(github.event_name == 'pull_request'/" "$src" >"$t/m12.yml" - if check "$t/m12.yml" >/dev/null; then echo "self-test: mutation 12 (! !(...)) not caught"; exit 1; fi - # Mutation 13: a job whose header carries a comment suffix and has lost its `if:` -> must be RED. - awk '/^ workspace-test-shard:/ {print " workspace-test-shard: # no guard here"; g = 1; next} g && /^ if:/ {g = 0; next} {print}' "$src" >"$t/m13.yml" - if check "$t/m13.yml" >/dev/null; then echo "self-test: mutation 13 (comment-suffixed unguarded job) not caught"; exit 1; fi - # Mutation 14: a brand-new unguarded pool job (not in CANON), header with a comment -> must be RED. - awk '{print} /^jobs:/ {print " extra-job: # unguarded"; print " runs-on: [self-hosted, Linux, clean-room]"; print " steps: []"}' "$src" >"$t/m14.yml" - if check "$t/m14.yml" >/dev/null; then echo "self-test: mutation 14 (new job outside CANON) not caught"; exit 1; fi - # Mutation 15: a trailing comment is stripped, but a comment hiding a padded guard must not help -> RED. - sed "s/draft) }}\$/draft) || true }} # fine/" "$src" >"$t/m15.yml" - if check "$t/m15.yml" >/dev/null; then echo "self-test: mutation 15 (|| true then comment) not caught"; exit 1; fi - # Mutation 6: nothing parses (empty jobs) -> must be RED, not vacuously green. - printf 'on:\n pull_request:\n types: [ready_for_review]\njobs:\n' >"$t/m6.yml" - if check "$t/m6.yml" >/dev/null; then echo "self-test: mutation 6 (empty parse) not caught"; exit 1; fi - echo "check_ci_draft_gate: self-test OK (15 mutations caught)" - exit 0 -fi -check "${1:-.github/workflows/ci.yml}" && echo "check_ci_draft_gate: OK" From e1c53aa7bd338438acf352d32b617ec64563b227 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 06:03:17 +0200 Subject: [PATCH 10/11] test(d1): align draft-gate CANON with main's draft != true form for x86-main and the shard (Refs #5801) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-core/tests/monorepo_invariants.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/aprender-core/tests/monorepo_invariants.rs b/crates/aprender-core/tests/monorepo_invariants.rs index f59550eabe..6956a6348d 100644 --- a/crates/aprender-core/tests/monorepo_invariants.rs +++ b/crates/aprender-core/tests/monorepo_invariants.rs @@ -1139,6 +1139,8 @@ fn falsify_mono_014_path_pins_name_their_target_version() { // --------------------------------------------------------------------------- const PQ: &str = "!(github.event_name == 'pull_request' && github.event.pull_request.draft)"; +/// The form main already uses for `x86-main` and `workspace-test-shard`. +const DF: &str = "github.event.pull_request.draft != true"; const G_ALL: &str = "${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}"; const CI_TYPES: [&str; 4] = ["opened", "synchronize", "reopened", "ready_for_review"]; @@ -1148,10 +1150,10 @@ fn draft_gate_canon() -> Vec<(&'static str, String)> { vec![ ("mg-reuse", "github.event_name == 'push' && github.ref == 'refs/heads/main'".into()), ("mg-reuse-self-test", G_ALL.into()), - ("x86-main", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {PQ} }}}}")), + ("x86-main", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {DF} }}}}")), ("x86-main-advisories", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && {PQ} }}}}")), ("determinism", "${{ !cancelled() && needs.mg-reuse.outputs.det != '1' && github.event_name != 'pull_request' }}".into()), - ("workspace-test-shard", G_ALL.into()), + ("workspace-test-shard", format!("${{{{ {DF} }}}}")), ("workspace-test", "always()".into()), ("ci-gate", "always()".into()), ("gate", "always()".into()), @@ -1239,7 +1241,7 @@ fn rewrite_if(src: &str, job: &str, new: Option<&str>) -> String { /// The 18 planted mutants: (label, mutated ci.yml). Each must be RED. fn draft_gate_mutants(src: &str) -> Vec<(&'static str, String)> { let x86_if = - format!(" if: ${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {PQ} }}}}"); + format!(" if: ${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {DF} }}}}"); let gate_tail = src.split_once("\n gate:\n").expect("gate job").1; let gate_mut = src.replacen( gate_tail, From d69db65da31b8c9552418f5c38d15faf1ad53bff Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 06:04:16 +0200 Subject: [PATCH 11/11] ci(d1): x86-main-advisories uses the one draft != true form; CANON and mutants rebased onto it (Refs #5801) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 +- .../tests/monorepo_invariants.rs | 19 +++++++++---------- scripts/ci_mg_reuse.sh | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d55a524f0..97d97f70b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,7 +316,7 @@ jobs: # no evidence for them. ci / gate and gate require this job whenever they reuse. x86-main-advisories: needs: [mg-reuse] - if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }} + if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && github.event.pull_request.draft != true }} runs-on: [self-hosted, Linux, X64, clean-room] timeout-minutes: 30 permissions: diff --git a/crates/aprender-core/tests/monorepo_invariants.rs b/crates/aprender-core/tests/monorepo_invariants.rs index 6956a6348d..2579950bad 100644 --- a/crates/aprender-core/tests/monorepo_invariants.rs +++ b/crates/aprender-core/tests/monorepo_invariants.rs @@ -1138,8 +1138,7 @@ fn falsify_mono_014_path_pins_name_their_target_version() { // (a skipped required check counts as passing on GitHub). // --------------------------------------------------------------------------- -const PQ: &str = "!(github.event_name == 'pull_request' && github.event.pull_request.draft)"; -/// The form main already uses for `x86-main` and `workspace-test-shard`. +/// The one draft form the PR-only jobs use (main already has it on `x86-main` and the shard). const DF: &str = "github.event.pull_request.draft != true"; const G_ALL: &str = "${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}"; @@ -1151,7 +1150,7 @@ fn draft_gate_canon() -> Vec<(&'static str, String)> { ("mg-reuse", "github.event_name == 'push' && github.ref == 'refs/heads/main'".into()), ("mg-reuse-self-test", G_ALL.into()), ("x86-main", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {DF} }}}}")), - ("x86-main-advisories", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && {PQ} }}}}")), + ("x86-main-advisories", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && {DF} }}}}")), ("determinism", "${{ !cancelled() && needs.mg-reuse.outputs.det != '1' && github.event_name != 'pull_request' }}".into()), ("workspace-test-shard", format!("${{{{ {DF} }}}}")), ("workspace-test", "always()".into()), @@ -1249,21 +1248,21 @@ fn draft_gate_mutants(src: &str) -> Vec<(&'static str, String)> { 1, ); vec![ - ("m1 draft guard dropped", mutate(src, &format!(" && {PQ}"), "")), + ("m1 draft guard dropped", mutate(src, &format!(" && {DF}"), "")), ("m2 ready_for_review dropped", mutate(src, ", ready_for_review", "")), - ("m3 guard unscoped", mutate(src, PQ, "!github.event.pull_request.draft")), - ("m4 guard polarity flipped", mutate(src, "&& !(github.event_name == 'pull_request' &&", "&& (github.event_name == 'pull_request' &&")), + ("m3 guard unscoped", mutate(src, DF, "true")), + ("m4 guard polarity flipped", mutate(src, &format!(" && {DF}"), " && github.event.pull_request.draft == true")), ("m5 aggregator not always()", gate_mut), ("m6 empty parse", "on:\n pull_request:\n types: [ready_for_review]\njobs:\n".to_string()), - ("m7 PQ guard padded with || true", mutate(src, "draft) }}", "draft) || true }}")), + ("m7 DF guard padded with || true", mutate(src, "draft != true }}", "draft != true || true }}")), ("m7b G_ALL padded with || true", mutate(src, "!github.event.pull_request.draft }}", "!github.event.pull_request.draft || true }}")), ("m8 shard if = push || true", rewrite_if(src, "workspace-test-shard", Some(" if: ${{ github.event_name == 'push' || true }}"))), ("m9 ready_for_review_x", mutate(src, "ready_for_review]", "ready_for_review_x]")), - ("m11 && true != !(...)", mutate(src, " && !(github.event_name == 'pull_request'", " && true != !(github.event_name == 'pull_request'")), - ("m12 && ! !(...)", mutate(src, " && !(github.event_name == 'pull_request'", " && ! !(github.event_name == 'pull_request'")), + ("m11 && true == DF", mutate(src, &format!(" && {DF}"), &format!(" && true == {DF}"))), + ("m12 && !DF", mutate(src, &format!(" && {DF}"), &format!(" && !{DF}"))), ("m13 comment-suffixed header, no if", mutate(&rewrite_if(src, "workspace-test-shard", None), " workspace-test-shard:\n", " workspace-test-shard: # no guard\n")), ("m14 new job outside CANON", mutate(src, "\njobs:\n", "\njobs:\n extra-job: # unguarded\n runs-on: [self-hosted, Linux, clean-room]\n steps: []\n")), - ("m15 || true then a comment", mutate(src, "draft) }}\n", "draft) || true }} # fine\n")), + ("m15 || true then a comment", mutate(src, "draft != true }}\n", "draft != true || true }} # fine\n")), ("m16 more-indented || true line folds into the scalar", mutate(src, &format!("{x86_if}\n"), &format!("{x86_if}\n || true\n"))), ("m17 decoy types line, real list lacks ready_for_review", mutate(src, "types: [opened, synchronize, reopened, ready_for_review]", "types: [opened, synchronize, reopened]") + "# types: [ready_for_review]\n"), ("m18 G_ALL polarity flipped", mutate(src, "|| !github.event.pull_request.draft", "|| github.event.pull_request.draft")), diff --git a/scripts/ci_mg_reuse.sh b/scripts/ci_mg_reuse.sh index 1a687a6497..c4c442b81b 100755 --- a/scripts/ci_mg_reuse.sh +++ b/scripts/ci_mg_reuse.sh @@ -514,7 +514,7 @@ STUB fail=$((fail + 1)); echo "FAIL wiring: ci.yml runs mac-check, a LAB job (Q6 quorum, C324)"; fi if grep -qF 'outputs.mac' <<<"$(job mg-reuse)"; then fail=$((fail + 1)); echo "FAIL wiring: mg-reuse still exports mac, which no ci.yml job reads"; else pass=$((pass + 1)); fi - wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && !(github.event_name == 'pull_request' && github.event.pull_request.draft) }}" + wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && github.event.pull_request.draft != true }}" wire x86-main-advisories 'cargo deny check advisories' wire x86-main-advisories 'bash scripts/check_deny_exemptions_live.sh' wire ci-gate 'MG-REUSE-VERDICT-BEGIN'