ci: run only the selected lanes on PRs; dry run and release unchanged

pr.yml's `changes` job now lists the changed files (renames with their old
path too), runs scripts/ci/select-lanes.sh in a credential-less step and
outputs tier, full, lanes and the pr-smoke matrix. security, lint and test
pass the lanes to their reusable workflows, which take a `lanes` input
defaulting to "all": dry-run.yml and release.yml pass none and run exactly
as before (setup-matrix output for every broad/shard combination is
byte-identical; the TSan matrix moved into setup-matrix with the same legs).

Under a selection, setup-matrix keeps only the tagged legs of the selected
lanes (an untagged leg is an error, never a silent drop) and each lane job
has an `if:` on its lane. pr-smoke and memwaste run on their lanes instead of
the old product regex. Docs-only PRs (T0a) get a `contracts` job running the
new `scripts/test.sh --contracts-only` (Step 0 contracts, no build). ci-ok
needs contracts, lists the run's jobs (actions: read) and passes LANES/JOBS
to require-all-green.sh; shard-completeness passes --lanes.

tests/test_lane_wiring_contract.sh (Step 0z4) pins it: dry run and release
pass no lanes and the inputs default to all, pr.yml wires the selection
through, and every lane is served by a tagged leg or an `if:` naming it.
The smoke-fixture contract now checks the selector's decisions for the
smoke-harness paths instead of the removed regex; select-lanes.sh joins the
venue-parity interface probes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-09-30 23:21:43 +02:00
1 parent fb558a6b13
commit adec281d1a
9 files changed
+345 -57

No files matched your search

+8 -1
View File
@@ -3,13 +3,19 @@
name: Lint & Security name: Lint & Security
on: on:
workflow_call: {} workflow_call:
inputs:
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
type: string
default: all
permissions: permissions:
contents: read contents: read
jobs: jobs:
lint: lint:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 15 min proved too tight on a slow runner day and a timed-out lint reads # 15 min proved too tight on a slow runner day and a timed-out lint reads
# as "cancelled", which the release graph must treat as a hard stop; keep # as "cancelled", which the release graph must treat as a hard stop; keep
@@ -60,6 +66,7 @@ jobs:
# provability (never suppressed — the NOLINT ban applies here too). # provability (never suppressed — the NOLINT ban applies here too).
# Vendored-tree diagnostics are path-filtered, mirroring .cppcheck. # Vendored-tree diagnostics are path-filtered, mirroring .cppcheck.
lint-mem: lint-mem:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint-mem') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
+8 -1
View File
@@ -4,7 +4,12 @@
name: Security Gate name: Security Gate
on: on:
workflow_call: {} workflow_call:
inputs:
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
type: string
default: all
permissions: permissions:
contents: read contents: read
@@ -23,6 +28,7 @@ jobs:
run: scripts/security-vendored.sh run: scripts/security-vendored.sh
license-gate: license-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'license-gate') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 240 timeout-minutes: 240
steps: steps:
@@ -39,6 +45,7 @@ jobs:
run: scripts/audit-license-provenance.py run: scripts/audit-license-provenance.py
codeql-gate: codeql-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 240 timeout-minutes: 240
# Without security-events:read this job inherits the workflow's # Without security-events:read this job inherits the workflow's
+61 -11
View File
@@ -16,6 +16,10 @@ on:
description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology' description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology'
type: boolean type: boolean
default: false default: false
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list): only those legs and jobs run; "all" = every one (dry run, release)'
type: string
default: all
permissions: permissions:
contents: read contents: read
@@ -30,18 +34,25 @@ jobs:
outputs: outputs:
unix: ${{ steps.set.outputs.unix }} unix: ${{ steps.set.outputs.unix }}
windows: ${{ steps.set.outputs.windows }} windows: ${{ steps.set.outputs.windows }}
tsan: ${{ steps.set.outputs.tsan }}
has_unix: ${{ steps.set.outputs.has_unix }}
has_windows: ${{ steps.set.outputs.has_windows }}
has_tsan: ${{ steps.set.outputs.has_tsan }}
steps: steps:
- name: Compute matrices - name: Compute matrices
id: set id: set
env: env:
BROAD: ${{ inputs.broad_platforms }} BROAD: ${{ inputs.broad_platforms }}
SHARDS: ${{ inputs.shard_suites }} SHARDS: ${{ inputs.shard_suites }}
LANES: ${{ inputs.lanes }}
run: | run: |
# Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh)
# it serves; the tag is what lane selection filters on below.
CORE_UNIX='[ CORE_UNIX='[
{"os":"ubuntu-latest","cc":"gcc","cxx":"g++"}, {"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"},
{"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++"}, {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"},
{"os":"macos-14","cc":"cc","cxx":"c++"}, {"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"},
{"os":"macos-15-intel","cc":"cc","cxx":"c++"} {"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"}
]' ]'
# Broad matrix legs are REQUIRED gates (no optional/continue-on-error # Broad matrix legs are REQUIRED gates (no optional/continue-on-error
# escape hatches): the release dry run must be all-green, every platform. # escape hatches): the release dry run must be all-green, every platform.
@@ -59,7 +70,7 @@ jobs:
# interception crashed (interception_win: unhandled instruction). With # interception crashed (interception_win: unhandled instruction). With
# the native ARM64 toolchain ASan instruments native ARM64 code, so it # the native ARM64 toolchain ASan instruments native ARM64 code, so it
# is a real (non-optional) gate, not a tolerated emulated-flake. # is a real (non-optional) gate, not a tolerated emulated-flake.
CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64"}]' CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64","lane":"windows"}]'
BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]' BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]'
if [ "$BROAD" = "true" ]; then if [ "$BROAD" = "true" ]; then
UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b') UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b')
@@ -68,6 +79,30 @@ jobs:
UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a') UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a')
WIN=$(jq -cn --argjson a "$CORE_WIN" '$a') WIN=$(jq -cn --argjson a "$CORE_WIN" '$a')
fi fi
# The ThreadSanitizer legs (test-tsan), tagged the same way.
TSAN='[
{"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"},
{"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"},
{"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"}
]'
# Lane selection. "all" (dry run, release) keeps every leg and drops
# the tags, so those matrices -- and their job names -- stay exactly
# as before lane selection. A PR keeps only its selected legs, and
# the tag stays in the job name, where ci-ok reads it back. An
# untagged leg could never be selected: that is a configuration
# error here, never a leg silently dropped.
if [ "$LANES" = "all" ]; then
UNIX=$(jq -c 'map(del(.lane))' <<< "$UNIX")
WIN=$(jq -c 'map(del(.lane))' <<< "$WIN")
TSAN=$(jq -c 'map(del(.lane))' <<< "$TSAN")
else
for M in "$UNIX" "$WIN" "$TSAN"; do
jq -e 'all(.[]; has("lane"))' <<< "$M" > /dev/null || { echo "::error::untagged test leg in $M"; exit 1; }
done
UNIX=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$UNIX")
WIN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$WIN")
TSAN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$TSAN")
fi
# Suite sharding (opt-in via the shard_suites input): each platform's # Suite sharding (opt-in via the shard_suites input): each platform's
# C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N" # C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N"
# slices deterministically inside run-tests-parallel.sh; each # slices deterministically inside run-tests-parallel.sh; each
@@ -89,9 +124,16 @@ jobs:
fi fi
echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT" echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT"
echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT" echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT"
echo "tsan={\"include\":$TSAN}" >> "$GITHUB_OUTPUT"
# GitHub rejects an empty matrix, so a job whose legs were all
# deselected is skipped through these flags instead.
echo "has_unix=$(jq 'length > 0' <<< "$UNIX")" >> "$GITHUB_OUTPUT"
echo "has_windows=$(jq 'length > 0' <<< "$WIN")" >> "$GITHUB_OUTPUT"
echo "has_tsan=$(jq 'length > 0' <<< "$TSAN")" >> "$GITHUB_OUTPUT"
test-unix: test-unix:
needs: setup-matrix needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_unix == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }} matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }}
@@ -205,6 +247,7 @@ jobs:
# inode/flock behavior and Windows handle/link behavior are each gating, # inode/flock behavior and Windows handle/link behavior are each gating,
# without adding Node/Go provisioning to every sanitizer/container leg. # without adding Node/Go provisioning to every sanitizer/container leg.
test-package-wrappers: test-package-wrappers:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'pkg-wrappers') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -245,6 +288,7 @@ jobs:
# parallel suite — catches what an older compiler-rt can miss. Same # parallel suite — catches what an older compiler-rt can miss. Same
# canonical scripts/test.sh wave as every other leg. # canonical scripts/test.sh wave as every other leg.
test-diag: test-diag:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'diag') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
@@ -308,6 +352,7 @@ jobs:
# list both venues now share. Zero use-of-uninitialized-value findings were # list both venues now share. Zero use-of-uninitialized-value findings were
# reported across the lane on either architecture. # reported across the lane on either architecture.
test-msan: test-msan:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'msan') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 180 timeout-minutes: 180
steps: steps:
@@ -332,6 +377,7 @@ jobs:
# so this leg is the ordinary ASan suite built with that toolchain and run # so this leg is the ordinary ASan suite built with that toolchain and run
# with detect_leaks=1. Mirrors the local `make -f Makefile.cbm test-lsan`. # with detect_leaks=1. Mirrors the local `make -f Makefile.cbm test-lsan`.
test-lsan-macos: test-lsan-macos:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lsan-macos') }}
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
@@ -370,13 +416,11 @@ jobs:
key: ccache-lsan-${{ github.ref }}-${{ github.sha }} key: ccache-lsan-${{ github.ref }}-${{ github.sha }}
test-tsan: test-tsan:
needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_tsan == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix: ${{ fromJSON(needs.setup-matrix.outputs.tsan) }}
include:
- { os: ubuntu-latest, cc: clang, cxx: clang++ }
- { os: ubuntu-24.04-arm, cc: clang, cxx: clang++ }
- { os: macos-14, cc: cc, cxx: c++ }
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
@@ -429,6 +473,7 @@ jobs:
test-windows: test-windows:
needs: setup-matrix needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_windows == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.windows) }} matrix: ${{ fromJSON(needs.setup-matrix.outputs.windows) }}
@@ -562,8 +607,12 @@ jobs:
with: with:
pattern: shard-manifest-* pattern: shard-manifest-*
path: manifests path: manifests
# A PR with no test leg selected uploads no manifest; the script
# accepts that only when the selection really holds no leg.
- name: Verify cross-shard union per leg - name: Verify cross-shard union per leg
run: repo/scripts/ci/verify-shard-union.sh manifests env:
LANES: ${{ inputs.lanes }}
run: repo/scripts/ci/verify-shard-union.sh manifests --lanes "$LANES"
# Windows product-surface regression guards. Distinct from test-windows above # Windows product-surface regression guards. Distinct from test-windows above
# (the sanitizer C suite): these drive a real product binary + external UI pack # (the sanitizer C suite): these drive a real product binary + external UI pack
@@ -574,6 +623,7 @@ jobs:
# CLI arguments being mangled by the narrow-argv main() (#423/#20, fixed by the # CLI arguments being mangled by the narrow-argv main() (#423/#20, fixed by the
# wide-argv entrypoint that reads GetCommandLineW). # wide-argv entrypoint that reads GetCommandLineW).
test-windows-guards: test-windows-guards:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'windows-guards') }}
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
+85 -30
View File
@@ -1,7 +1,10 @@
# PR validation: security gates + lint + full test suite. Builds, smoke and # PR validation: security gates + lint + the test legs this PR's changes need.
# soak stay maintainer-driven via the dry-run workflow_dispatch. Branch # `changes` runs scripts/ci/select-lanes.sh on the PR's file list; every lane
# job below runs only when selected (unknown paths, workflows, the harness,
# Makefile.cbm and vendored code select everything). Builds, smoke and soak at
# full breadth stay maintainer-driven via the dry-run workflow_dispatch. Branch
# protection requires `dco` + `ci-ok` — a single stable summary context that # protection requires `dco` + `ci-ok` — a single stable summary context that
# fails unless every PR stage succeeded. # fails unless every PR stage succeeded and every selected lane ran green.
name: PR name: PR
on: on:
@@ -21,6 +24,7 @@ concurrency:
jobs: jobs:
security: security:
needs: [changes]
uses: ./.github/workflows/_security.yml uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning # The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants, # alerts; a reusable workflow cannot request more than its caller grants,
@@ -30,15 +34,22 @@ jobs:
security-events: read security-events: read
actions: read actions: read
secrets: inherit secrets: inherit
with:
lanes: ${{ needs.changes.outputs.lanes }}
lint: lint:
needs: [changes]
if: ${{ contains(fromJSON(needs.changes.outputs.lanes), 'lint') || contains(fromJSON(needs.changes.outputs.lanes), 'lint-mem') }}
uses: ./.github/workflows/_lint.yml uses: ./.github/workflows/_lint.yml
with:
lanes: ${{ needs.changes.outputs.lanes }}
test: test:
needs: [lint] needs: [changes, lint]
if: ${{ !cancelled() && needs.lint.result == 'success' }} if: ${{ !cancelled() && needs.lint.result == 'success' }}
uses: ./.github/workflows/_test.yml uses: ./.github/workflows/_test.yml
with: with:
lanes: ${{ needs.changes.outputs.lanes }}
# Perf assertions are timing-sensitive on shared runners; they stay in # Perf assertions are timing-sensitive on shared runners; they stay in
# dry runs and releases where a flaky red does not block a merge. # dry runs and releases where a flaky red does not block a merge.
skip_perf: true skip_perf: true
@@ -47,16 +58,19 @@ jobs:
# shard-completeness job). Dry runs and releases shard identically. # shard-completeness job). Dry runs and releases shard identically.
shard_suites: true shard_suites: true
# ── Which files changed? Gate the (heavier) build+smoke on product code so # ── Which lanes does this PR need? The changed files go through
# docs / CI / test-only PRs stay fast. ── # scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI /
# test-only PRs stay fast, anything it cannot place runs everything. ──
changes: changes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
outputs: outputs:
product: ${{ steps.f.outputs.product }} tier: ${{ steps.select.outputs.tier }}
full: ${{ steps.select.outputs.full }}
lanes: ${{ steps.select.outputs.lanes }}
smoke: ${{ steps.select.outputs.smoke }}
steps: steps:
- name: Detect product-code changes - name: List the changed files
id: f
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }} PR: ${{ github.event.pull_request.number }}
@@ -64,7 +78,9 @@ jobs:
run: | run: |
# The full .diff endpoint rejects large-but-valid PRs at 20k lines. # The full .diff endpoint rejects large-but-valid PRs at 20k lines.
# The paginated files endpoint remains filename-only for this gate. # The paginated files endpoint remains filename-only for this gate.
if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'); then # A rename lists its old path too, as the fallback's --no-renames
# diff does: moving a file away is a change to where it was.
if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[] | .filename, (.previous_filename // empty)'); then
echo "file list: pulls/files API" echo "file list: pulls/files API"
else else
# The files endpoint renders the diff server-side too, and answers # The files endpoint renders the diff server-side too, and answers
@@ -81,31 +97,41 @@ jobs:
"https://github.com/$REPO" "refs/pull/$PR/merge" "https://github.com/$REPO" "refs/pull/$PR/merge"
FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD) FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD)
fi fi
printf '%s\n' "$FILES" printf '%s\n' "$FILES" > "$RUNNER_TEMP/changed-files.txt"
# A here-string, not a pipe: under this shell's pipefail, grep -q cat "$RUNNER_TEMP/changed-files.txt"
# exits on its first match, printf dies of SIGPIPE once the list
# outgrows the pipe buffer, and the MATCH is then reported as a # The selector is PR code: it runs in its own step, after the
# failure -- product=false, smoke skipped, on the largest PRs. # token-bearing one, from a checkout that keeps no credentials.
if grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)' <<<"$FILES"; then - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
echo "product=true" >> "$GITHUB_OUTPUT" with:
else persist-credentials: false
echo "product=false" >> "$GITHUB_OUTPUT"
fi - name: Select lanes
id: select
run: |
scripts/ci/select-lanes.sh --format github-output "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT"
# pr-smoke's legs, each tagged with its lane (kept in the job name,
# where ci-ok reads it back); the job gets the selected ones.
SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]'
LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt")
echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT"
# ── Light smoke: build the PRODUCTION binary and run the core smoke on the # ── Light smoke: build the PRODUCTION binary and run the core smoke on the
# three RELIABLE NATIVE platforms. Catches built-binary regressions at PR # three RELIABLE NATIVE platforms. Catches built-binary regressions at PR
# time (e.g. the Windows CreateProcess argv-quoting class that previously # time (e.g. the Windows CreateProcess argv-quoting class that previously
# only surfaced in the release dry run). The full broad/emulated smoke stays # only surfaced in the release dry run). The full broad/emulated smoke stays
# in the dry run. Only product-code PRs pay this; every leg gates. # in the dry run. Only the selected legs run (ubuntu from the extraction
# tier up, all three for platform/packaging changes); every leg gates.
# The maintained local wrappers add a race-free release fixture so the # The maintained local wrappers add a race-free release fixture so the
# download/checksum/install/update phases run here as well. ── # download/checksum/install/update phases run here as well. ──
pr-smoke: pr-smoke:
needs: [changes] needs: [changes]
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }} if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-mac') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-win')) }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix: ${{ fromJSON(needs.changes.outputs.smoke) }}
os: [ubuntu-latest, macos-14, windows-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@@ -177,27 +203,56 @@ jobs:
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and # ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs: # the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
# it uploads evidence and prints the ratchet, it never blocks a merge. Only # it uploads evidence and prints the ratchet, it never blocks a merge. Only
# product-code PRs pay for it. ── # PRs whose selection holds the memwaste lane (memory core, memwaste
# tooling, full runs) pay for it. ──
memwaste: memwaste:
needs: [changes] needs: [changes]
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }} if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'memwaste') }}
uses: ./.github/workflows/_memwaste.yml uses: ./.github/workflows/_memwaste.yml
with: with:
mode: pr mode: pr
# ── Docs-only PRs (tier T0a) select no test leg, so this runs the static
# contract steps of the canonical test leg on their own (they police
# docs surfaces too: language counts, version metadata). Every test leg
# runs the same steps, so the job exists only when no leg does. ──
contracts:
needs: [changes]
if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Contract steps only
run: scripts/test.sh --contracts-only
ci-ok: ci-ok:
# The one required context (besides dco) — fails unless every PR stage # The one required context (besides dco) — fails unless every PR stage
# succeeded, so matrix renames can never silently deadlock merges. `skipped` # succeeded, so matrix renames can never silently deadlock merges, and
# is OK: pr-smoke skips on docs/CI/test-only PRs (changes.product == false). # every lane the selector chose ran green: `skipped` is OK only for a lane
needs: [security, lint, test, changes, pr-smoke] # nobody selected (a selected lane that skipped is a silent gate loss).
# memwaste and the test-impact shadow are report-only and not needed here.
needs: [changes, security, lint, test, pr-smoke, contracts]
if: ${{ always() }} if: ${{ always() }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
# actions:read lists this run's jobs, the lane-level truth behind `needs`.
permissions:
contents: read
actions: read
steps: steps:
# Needs a checkout (unlike before): the gate logic lives in the canonical # Needs a checkout (unlike before): the gate logic lives in the canonical
# scripts/ci entry, not inline YAML (venue-parity contract). # scripts/ci entry, not inline YAML (venue-parity contract).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: All PR stages must have succeeded - name: List this run's jobs
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \
--jq '.jobs[] | {name, status, conclusion}' > "$RUNNER_TEMP/jobs.jsonl"
- name: All PR stages and every selected lane must have succeeded
env: env:
RESULTS: ${{ toJSON(needs) }} RESULTS: ${{ toJSON(needs) }}
LANES: ${{ needs.changes.outputs.lanes }}
JOBS: ${{ runner.temp }}/jobs.jsonl
run: scripts/ci/require-all-green.sh run: scripts/ci/require-all-green.sh
+1 -1
View File
@@ -17,7 +17,7 @@ codes) and rejects unknown flags with exit 2 + `Please consult --help.`
| leg | entry | what a run gives you | | leg | entry | what a run gives you |
|---|---|---| |---|---|---|
| **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. CLANGARM64 gets CI's trap-UBSan flags automatically. | | **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. `--contracts-only` = the static contract steps alone (PR CI's docs-only job). CLANGARM64 gets CI's trap-UBSan flags automatically. |
| **package wrappers** | `ci/test-package-wrappers.sh` | Go, npm, and PyPI runtime-set publication/lock suites on the current host. CI runs the same entry on Linux and Windows so both platform lock implementations gate. | | **package wrappers** | `ci/test-package-wrappers.sh` | Go, npm, and PyPI runtime-set publication/lock suites on the current host. CI runs the same entry on Linux and Windows so both platform lock implementations gate. |
| **build** | `build.sh` | CLEAN production runtime set (native executable + authenticated integration asset; `--with-ui` adds one content-addressed UI pack). ccache via `env.sh` makes repeats fast; `CCACHE_COMPILERCHECK=content` guarantees a hit is byte-identical to a cold compile — never stale. `--version`, `STATIC=1`, `BUILD_DIR=`. | | **build** | `build.sh` | CLEAN production runtime set (native executable + authenticated integration asset; `--with-ui` adds one content-addressed UI pack). ccache via `env.sh` makes repeats fast; `CCACHE_COMPILERCHECK=content` guarantees a hit is byte-identical to a cold compile — never stale. `--version`, `STATIC=1`, `BUILD_DIR=`. |
| **lint** | `lint.sh` | clang-tidy + cppcheck + clang-format (+ no-skips policy). `--ci` = the CI gate set (no clang-tidy). Drives the same make targets as `make lint`/`lint-ci`. | | **lint** | `lint.sh` | clang-tidy + cppcheck + clang-format (+ no-skips policy). `--ci` = the CI gate set (no clang-tidy). Drives the same make targets as `make lint`/`lint-ci`. |
+20 -2
View File
@@ -10,7 +10,7 @@ cd "$ROOT"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: scripts/test.sh [--suites LIST] [--arch ARCH] [VAR=VAL ...] Usage: scripts/test.sh [--suites LIST | --tsan | --contracts-only] [--arch ARCH] [VAR=VAL ...]
The canonical test entry: identical in local CI, PR CI, dry run and release. The canonical test entry: identical in local CI, PR CI, dry run and release.
DEFAULT (no --suites) is exactly what CI runs: static contract checks DEFAULT (no --suites) is exactly what CI runs: static contract checks
@@ -29,6 +29,10 @@ Modes:
--tsan ThreadSanitizer leg (data-race gate): builds and runs the --tsan ThreadSanitizer leg (data-race gate): builds and runs the
widened TSan runner via make test-tsan — the same leg CI's widened TSan runner via make test-tsan — the same leg CI's
tsan jobs and the compose test-tsan service run. tsan jobs and the compose test-tsan service run.
--contracts-only
Only the static contract steps (Step 0*) of the default leg:
no compiler, no build, no suites. PR CI runs this for
docs-only changes, where no test leg is selected.
Options: Options:
--arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta. --arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta.
@@ -64,11 +68,13 @@ EOF
# silently swallowed — agents must know exactly what a run will do. # silently swallowed — agents must know exactly what a run will do.
SUITES="" SUITES=""
TSAN=0 TSAN=0
CONTRACTS_ONLY=0
prev_arg="" prev_arg=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
--tsan) :;; --tsan) :;;
--contracts-only) :;;
--suites) :;; # next arg is the value, handled below --suites) :;; # next arg is the value, handled below
--suites=*) SUITES="${arg#--suites=}" ;; --suites=*) SUITES="${arg#--suites=}" ;;
--arch) :;; # next arg is the value, handled below --arch) :;; # next arg is the value, handled below
@@ -96,6 +102,7 @@ done
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--tsan) TSAN=1 ;; --tsan) TSAN=1 ;;
--contracts-only) CONTRACTS_ONLY=1 ;;
arm64|x86_64) arm64|x86_64)
if [[ "${prev_arg2:-}" == "--arch" ]]; then if [[ "${prev_arg2:-}" == "--arch" ]]; then
export CBM_ARCH="$arg" export CBM_ARCH="$arg"
@@ -121,6 +128,10 @@ if [ "$TSAN" -eq 1 ] && [ -n "$SUITES" ]; then
echo "test.sh: --tsan and --suites are separate modes (the TSan leg has its own suite set). Please consult --help." >&2 echo "test.sh: --tsan and --suites are separate modes (the TSan leg has its own suite set). Please consult --help." >&2
exit 2 exit 2
fi fi
if [ "$CONTRACTS_ONLY" -eq 1 ] && { [ "$TSAN" -eq 1 ] || [ -n "$SUITES" ]; }; then
echo "test.sh: --contracts-only is its own mode (no build, no suites). Please consult --help." >&2
exit 2
fi
prev_arg="" prev_arg=""
# Also support --arch=value # Also support --arch=value
@@ -152,7 +163,7 @@ for arg in "$@"; do
CC=*|CXX=*) export "${arg}" ;; CC=*|CXX=*) export "${arg}" ;;
--arch|--arch=*) ;; # already handled --arch|--arch=*) ;; # already handled
arm64|x86_64) ;; # already handled arm64|x86_64) ;; # already handled
--tsan) ;; # already handled --tsan|--contracts-only) ;; # already handled
--suites|--suites=*) ;; # already handled (value skipped via prev_arg below) --suites|--suites=*) ;; # already handled (value skipped via prev_arg below)
BUILD_DIR=*) BUILD_DIR="${arg#BUILD_DIR=}"; MAKE_ARGS+=("$arg") ;; BUILD_DIR=*) BUILD_DIR="${arg#BUILD_DIR=}"; MAKE_ARGS+=("$arg") ;;
SANITIZE=*) SANITIZE=*)
@@ -347,6 +358,13 @@ echo "=== Step 0z2: lane selector history replay ==="
bash "$ROOT/scripts/test-impact/replay-selector.sh" bash "$ROOT/scripts/test-impact/replay-selector.sh"
echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ==="
bash "$ROOT/tests/test_lane_gate_contract.sh" bash "$ROOT/tests/test_lane_gate_contract.sh"
echo "=== Step 0z4: lane wiring (PRs select, dry run and release run all) ==="
bash "$ROOT/tests/test_lane_wiring_contract.sh"
if [ "$CONTRACTS_ONLY" -eq 1 ]; then
echo "=== test.sh: contracts-only — every contract step passed ==="
exit 0
fi
# Verify compiler supports target arch # Verify compiler supports target arch
verify_compiler "$CC" verify_compiler "$CC"
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Lane-wiring contract: the workflows honour the lane selection, and only PRs
# are selected at all.
#
# 1. dry-run.yml and release.yml pass no `lanes` (and no shard profile) to
# _test.yml / _lint.yml / _security.yml, whose inputs default to "all":
# those runs execute every job exactly as before lane selection.
# 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the
# docs-only contracts job, and hands ci-ok both the selection and the
# run's job list (actions: read) -- ci-ok needs `contracts` but never the
# report-only memwaste / shadow jobs.
# 3. every lane of scripts/ci/select-lanes.sh --list-lanes is served by a
# job: matrix lanes as a "lane":"<name>" tag in the leg data (the tag
# ci-ok reads back from the job name), single lanes by an `if:` that
# names them. A lane nothing serves would be selected and never run.
# Text-level checks (no YAML library on every leg), like the other workflow
# contracts.
#
# Usage: tests/test_lane_wiring_contract.sh [repo-root]
set -euo pipefail
ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
LANES=$(bash "$ROOT/scripts/ci/select-lanes.sh" --list-lanes)
python3 - "$ROOT" "$LANES" <<'PY'
import pathlib
import re
import sys
root = pathlib.Path(sys.argv[1])
lanes = sys.argv[2].split()
wf = root / ".github" / "workflows"
failures = []
def text(name):
return (wf / name).read_text(encoding="utf-8")
def jobs(source):
"""job id -> body (the lines indented under ` <id>:`)."""
out = {}
for m in re.finditer(r"(?m)^ ([A-Za-z0-9_-]+):\s*$", source):
rest = source[m.end():]
end = re.search(r"(?m)^ [A-Za-z0-9_#-]", rest)
out[m.group(1)] = rest[:end.start()] if end else rest
return out
def require(cond, message):
if not cond:
failures.append(message)
# 1. dry run and release: no selection, so every job runs.
REUSABLE = ("_test.yml", "_lint.yml", "_security.yml")
for caller in ("dry-run.yml", "release.yml"):
for job, body in jobs(text(caller)).items():
called = re.search(r"uses: \./\.github/workflows/(_\w+\.yml)", body)
if called and called.group(1) in REUSABLE:
require(not re.search(r"(?m)^\s+(lanes|shard_profile):", body),
f"{caller} job {job} passes a lane selection to {called.group(1)}; "
f"dry runs and releases must run every lane")
for callee in REUSABLE:
src = text(callee)
m = re.search(r"(?ms)^ lanes:\n(.*?)(?=^ \S|^\S|\Z)", src)
require(m is not None, f"{callee} must declare a `lanes` input")
if m:
require(re.search(r"(?m)^ type: string$", m.group(1)) is not None,
f"{callee} lanes input must be a string")
require(re.search(r"(?m)^ default: all$", m.group(1)) is not None,
f"{callee} lanes input must default to all")
# 2. pr.yml wiring.
pr = jobs(text("pr.yml"))
changes = pr.get("changes", "")
require("scripts/ci/select-lanes.sh --format github-output" in changes,
"pr.yml changes must run scripts/ci/select-lanes.sh --format github-output")
for key in ("tier", "full", "lanes"):
require(re.search(rf"(?m)^ {key}: \$\{{\{{ steps\.select\.outputs\.{key} \}}\}}$", changes),
f"pr.yml changes must output {key}")
for job in ("security", "lint", "test"):
require("lanes: ${{ needs.changes.outputs.lanes }}" in pr.get(job, ""),
f"pr.yml {job} must pass the selected lanes")
require("scripts/test.sh --contracts-only" in pr.get("contracts", ""),
"pr.yml contracts job must run scripts/test.sh --contracts-only")
ci_ok = pr.get("ci-ok", "")
needs = re.search(r"needs: \[([^\]]*)\]", ci_ok)
needs = {n.strip() for n in needs.group(1).split(",")} if needs else set()
require({"changes", "security", "lint", "test", "pr-smoke", "contracts"} <= needs,
f"ci-ok must need every gating stage incl. contracts, has {sorted(needs)}")
require(not needs & {"memwaste", "test-impact-shadow"},
"ci-ok must not need the report-only memwaste / shadow jobs")
for needle, why in (("LANES: ${{ needs.changes.outputs.lanes }}", "the selection"),
("JOBS: ", "the run's job list"),
("actions: read", "actions: read to list jobs"),
("jobs?filter=latest", "the latest attempt of every job"),
("scripts/ci/require-all-green.sh", "the canonical gate")):
require(needle in ci_ok, f"ci-ok must use {why} ({needle})")
test_wf = jobs(text("_test.yml"))
require('--lanes "$LANES"' in test_wf.get("shard-completeness", ""),
"shard-completeness must pass the selection to verify-shard-union.sh")
# 3. every lane is served by a job.
MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_test.yml",
"unix-macos-intel": "_test.yml", "windows": "_test.yml", "tsan-x86": "_test.yml",
"tsan-arm": "_test.yml", "tsan-mac": "_test.yml", "smoke-ubuntu": "pr.yml",
"smoke-mac": "pr.yml", "smoke-win": "pr.yml"}
SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"),
"license-gate": ("_security.yml", "license-gate"),
"codeql-gate": ("_security.yml", "codeql-gate"),
"pkg-wrappers": ("_test.yml", "test-package-wrappers"),
"diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"),
"lsan-macos": ("_test.yml", "test-lsan-macos"),
"windows-guards": ("_test.yml", "test-windows-guards"),
"memwaste": ("pr.yml", "memwaste"), "contracts": ("pr.yml", "contracts")}
ALWAYS = {"security-static": ("_security.yml", "security-static"),
"shard-completeness": ("_test.yml", "shard-completeness")}
for lane in lanes:
if lane in MATRIX:
require(f'"lane":"{lane}"' in text(MATRIX[lane]),
f"lane {lane}: no leg in {MATRIX[lane]} is tagged with it")
elif lane in SINGLE:
name, job = SINGLE[lane]
body = jobs(text(name)).get(job)
cond = re.search(r"(?m)^ if: (.*)$", body or "")
require(body is not None and cond is not None and f"'{lane}'" in cond.group(1),
f"lane {lane}: {name} job {job} must exist and run only when selected")
elif lane in ALWAYS:
name, job = ALWAYS[lane]
body = jobs(text(name)).get(job)
require(body is not None, f"lane {lane}: {name} job {job} must exist")
else:
failures.append(f"lane {lane}: served by no job this contract knows")
if failures:
print("LANE WIRING CONTRACT VIOLATED:")
for failure in failures:
print(f" {failure}")
sys.exit(1)
print(f"lane-wiring contract OK ({len(lanes)} lanes served; dry run and release unselected)")
PY
+17 -11
View File
@@ -5,10 +5,22 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY' # PR smoke runs the legs scripts/ci/select-lanes.sh selects, so the paths the
# release-fixture smoke depends on are checked against its real decisions.
SMOKE_DECISIONS=""
for changed_path in install.sh install.ps1 scripts/smoke-local.sh \
scripts/smoke-fixture-server.py scripts/gen-third-party-notices.sh \
test-infrastructure/vm/vm-smoke.sh test-infrastructure/vm/windows-user-path-guard.ps1; do
decision=$(printf '%s\n' "$changed_path" | bash "$ROOT/scripts/ci/select-lanes.sh")
SMOKE_DECISIONS="$SMOKE_DECISIONS$changed_path $decision
"
done
python3 - "$ROOT" "$SMOKE_DECISIONS" <<'PY'
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import os import os
import pathlib import pathlib
import re import re
@@ -408,17 +420,11 @@ require(
"Phase 14 must assert update leaves the binary byte-identical", "Phase 14 must assert update leaves the binary byte-identical",
) )
for changed_path in ( for line in sys.argv[2].splitlines():
"install\\.(sh|ps1)", changed_path, decision = line.split(" ", 1)
"scripts/smoke-local",
"scripts/smoke-fixture-server",
"scripts/gen-third-party-notices",
"test-infrastructure/vm/vm-smoke",
"windows-user-path-guard",
):
require( require(
changed_path in pr_workflow, {"smoke-ubuntu", "smoke-mac", "smoke-win"} <= set(json.loads(decision)["lanes"]),
f"PR product-change detector must include {changed_path}", f"a change to {changed_path} must select all three PR smoke legs",
) )
require( require(
"tests/test_smoke_fixture_contract.sh" in test_driver, "tests/test_smoke_fixture_contract.sh" in test_driver,
+2
View File
@@ -392,6 +392,7 @@ scripts/smoke-invariants.sh
scripts/ci/preflight-docker.sh scripts/ci/preflight-docker.sh
scripts/ci/require-all-green.sh scripts/ci/require-all-green.sh
scripts/ci/verify-shard-union.sh scripts/ci/verify-shard-union.sh
scripts/ci/select-lanes.sh
scripts/ci/generate-sbom.py scripts/ci/generate-sbom.py
scripts/package-release.sh scripts/package-release.sh
scripts/ci/smoke-artifact.sh scripts/ci/smoke-artifact.sh
@@ -436,6 +437,7 @@ test-infrastructure/vm/vm-smoke.sh
scripts/smoke-invariants.sh scripts/smoke-invariants.sh
scripts/fuzz.sh scripts/fuzz.sh
scripts/memwaste.sh scripts/memwaste.sh
scripts/ci/select-lanes.sh
" "
for entry in $STRICT_ENTRIES; do for entry in $STRICT_ENTRIES; do
out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$? out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$?