Skip to content

Commit fbcf05e

Browse files
dmealingclaude
andcommitted
ci: select lanes by "not known-green", not by "what this push changed"
Affected-ports alone has a hole, and it bit during the 1.0.1 cut: five lanes failed on c59bb31, the next commit was docs-only, its run skipped every code lane and reported GREEN, and the red sat unverified under a green tip until the full matrix was dispatched by hand. The selector now answers "what is not currently known-green on main?" — affected(push) UNION the ports whose newest verdict is not a success — so a stale red lane is RE-RUN rather than merely reported stale. A report would leave the red unverified, which is the defect itself. Replayed against the real incident: run 34488532161 (@1aeccc9b8) skipped all seven code lanes; the run beneath it had ts-fast, ts-slow, java-slow and python failing. The new selector returns `ts java python` there. Rules, all asserted by table-driven tests: - `skipped` is walked past — it describes the SELECTION, not the code. Counting it as a verdict is the bug. - anything not `success` is not-green, `cancelled` included: a cancelled lane was never verified, and cancel-in-progress makes that common enough that treating it as green reopens the hole from the other side. - a lane with no verdict in the window is UNKNOWN, which widens. - an unreadable API widens to ALL. The selector may only ever widen; coverage shrinks only when the shrink is certain, which is the policy affected-ports already commits to. curl + jq rather than gh: both are in /usr/bin on the runner, while gh sits on a user PATH the runner service does not necessarily inherit. A missing binary would fail open to the full matrix — safe, but silently expensive forever. Also wires the selector tests into the gates lane. test-ci-affected-ports.sh was referenced by no lane at all, so the mapping that decides which ports get tested was itself the one thing nothing tested — the worst place in the tree for an unrun test, because a wrong answer there does not fail, it silently stops testing a port. The new test additionally asserts the lane -> port map against the workflow's real job list in both directions, so a lane added without registering it cannot go invisible to the known-green check. local-ci gains `permissions: contents/actions: read` — actions:read is load-bearing for reading its own history, and declaring the block pins the token down from whatever the repo default is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 1aeccc9 commit fbcf05e

6 files changed

Lines changed: 277 additions & 7 deletions

File tree

‎.github/workflows/local-ci.yml‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@
55
# write access, so fork-submitted code cannot reach the runner. Keep it that way.
66
#
77
# Tiers:
8-
# push to main -> affected ports only (scripts/ci-affected-ports.sh)
8+
# push to main -> ports not known-green (scripts/ci-ports-to-run.sh)
99
# nightly + dispatch -> full matrix
1010
#
11+
# "Not known-green" rather than "affected", because affected-alone has a hole that
12+
# bit during the 1.0.1 cut: five lanes failed on c59bb31c0, the next commit was
13+
# docs-only, its run skipped every code lane and reported GREEN, and the red sat
14+
# unverified under a green tip until the full matrix was dispatched by hand. So the
15+
# selection is affected(push) UNION the ports whose newest verdict on main is not a
16+
# success — which RE-RUNS the stale lane instead of merely reporting it stale.
17+
# That needs `actions: read` below to see its own history.
18+
#
1119
# The ts and java ports are each split into parallel jobs — a FAST lane (the
1220
# quick pure-code correctness signal: ts-fast = build+typecheck/conformance/
1321
# completeness, java-fast = java+kotlin conformance), a UNIT lane for ts (ts-unit =
@@ -42,6 +50,14 @@ on:
4250
options: [full, affected]
4351
default: full
4452

53+
# Least privilege, and `actions: read` is load-bearing: `detect` reads this
54+
# workflow's own past runs to find each lane's newest verdict. Declaring the block
55+
# also pins the token down from whatever the repo default happens to be — this is a
56+
# public repo whose jobs run on a self-hosted runner, and none of them writes.
57+
permissions:
58+
contents: read
59+
actions: read
60+
4561
concurrency:
4662
group: local-ci-${{ github.ref }}
4763
cancel-in-progress: true
@@ -59,11 +75,12 @@ jobs:
5975
- id: sel
6076
env:
6177
BASE: ${{ github.event.before }}
78+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6279
run: |
6380
if [ "${{ github.event_name }}" = "push" ]; then
64-
ports="$(scripts/ci-affected-ports.sh "$BASE" "$GITHUB_SHA")"
81+
ports="$(scripts/ci-ports-to-run.sh "$BASE" "$GITHUB_SHA")"
6582
elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.tier }}" = "affected" ]; then
66-
ports="$(scripts/ci-affected-ports.sh "$GITHUB_SHA~1" "$GITHUB_SHA")"
83+
ports="$(scripts/ci-ports-to-run.sh "$GITHUB_SHA~1" "$GITHUB_SHA")"
6784
else
6885
ports="ts java python csharp"
6986
fi

‎AGENTS.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,23 @@ bypasses branch protection. This hook closes that hole locally: when a push touc
105105
skipped entirely for non-TS pushes). Bypass in an emergency with `git push --no-verify`
106106
or `SKIP_TS_TYPECHECK=1 git push`. The Java/C#/Python compile+conformance gates do NOT run on PRs (hosted CI runs
107107
them on release tags, a nightly schedule, and manual dispatch, for cost). Instead, every push to
108-
`main` triggers `local-ci.yml` on the maintainer's self-hosted runner: affected
109-
ports only (via `scripts/ci-affected-ports.sh`), parallel per-port jobs, each
110-
running `scripts/ci-local.sh --only <port> --strict-toolchains`; a nightly
111-
dispatch runs the full matrix. PRs get the leak-scan only — run
108+
`main` triggers `local-ci.yml` on the maintainer's self-hosted runner: parallel
109+
per-port jobs, each running `scripts/ci-local.sh --only <port> --strict-toolchains`;
110+
a nightly dispatch runs the full matrix. PRs get the leak-scan only — run
112111
`scripts/ci-local.sh --quick` locally before opening one.
113112

113+
**Lane selection is "not known-green", not "affected"** (`scripts/ci-ports-to-run.sh`).
114+
The selector unions the ports this push touched (`scripts/ci-affected-ports.sh`) with
115+
the ports whose *newest* verdict on `main` is not a success, read from the workflow's
116+
own run history — so a stale red lane gets **re-run**, not merely reported. Affected
117+
alone has a hole that bit during the 1.0.1 cut: five lanes failed, the next commit was
118+
docs-only, its run skipped every code lane and reported green, and the red sat
119+
unverified under a green tip. `skipped` is walked past (it describes the selection, not
120+
the code); anything that is not `success` — `cancelled` included — and any lane with no
121+
verdict in the window counts as not-green, because the selector may only ever widen.
122+
Both selector scripts are tested by the `ci lane selection` gate in the `gates` lane,
123+
which also asserts the lane→port map against the workflow's real job list.
124+
114125
## Monorepo layout
115126

116127
This repo holds all implementations of the standard, organized by deployment target → language/platform → framework integration:

‎scripts/ci-affected-ports.sh‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
# Fail-open policy: an unknown base (force-push zeros), an unresolvable
99
# range, or ANY path not matched by a rule selects ALL ports — this script
1010
# may only ever shrink coverage when it is certain.
11+
#
12+
# NOT the whole selection. local-ci calls `ci-ports-to-run.sh`, which unions this
13+
# answer with the ports that are not known-green on main. "What changed" alone
14+
# cannot see a lane that failed under an EARLIER commit and was skipped by this
15+
# one — read that file before changing selection behaviour here.
1116
set -uo pipefail
1217

1318
ALL="ts java python csharp"

‎scripts/ci-local.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ gate_publish_set() { node scripts/publish-set.mjs --check && node scripts/test-p
173173
# checks names. Offline; one manifest.
174174
gate_script_name_hooks() { node scripts/check-script-name-hooks.mjs; }
175175

176+
# ── the lane selector decides what CI runs, so nothing may run it untested ────
177+
# Both selector tests existed-but-unwired at some point: test-ci-affected-ports.sh was
178+
# referenced by no lane at all, so the mapping that decides which ports get tested was
179+
# itself the one thing nothing tested. That is the worst place in the tree for an unrun
180+
# test — a wrong answer here does not fail, it silently stops testing a port.
181+
gate_ci_port_selection() {
182+
scripts/test-ci-affected-ports.sh && scripts/test-ci-ports-to-run.sh
183+
}
184+
176185
# ── the no-magic-physical-names gate is wired in all five ports ───────────────
177186
# Each port carries a de-blinded gate proving its generated code REFERENCES the
178187
# <Entity>Names constants instead of respelling a physical database name. A cross-port
@@ -704,6 +713,7 @@ if want gates; then step "publish-intent parity" gate_publish_inte
704713
if want gates; then step "publish-set parity" gate_publish_set; fi
705714
if want gates; then step "no committed pre-release version" gate_no_prerelease_versions; fi
706715
if want gates; then step "script-name hook collisions" gate_script_name_hooks; fi
716+
if want gates; then step "ci lane selection" gate_ci_port_selection; fi
707717
if want gates; then step "no-magic gate wired (5 ports)" gate_no_magic_coverage; fi
708718
if want gates; then step "test-file references resolve" gate_test_references; fi
709719
if want gates; then step "metamodel-version bump" gate_metamodel_version; fi

‎scripts/ci-ports-to-run.sh‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env bash
2+
# Select the ports local-ci must run for THIS push.
3+
#
4+
# `ci-affected-ports.sh` answers "what did this push change?" — which is the wrong
5+
# question to select lanes on by itself, and the gap is not hypothetical. Across the
6+
# 1.0.1 cut five lanes failed on c59bb31c0 and were never re-run: the next commit was
7+
# docs-only, so its run skipped every code lane and reported GREEN, and the red sat
8+
# unverified under a green tip until the full matrix was dispatched by hand.
9+
#
10+
# The question that closes it is "what is not currently known-green on main?", and the
11+
# answer is a union:
12+
#
13+
# affected(this push) ∪ ports whose newest verdict on main is not success
14+
#
15+
# Re-running is deliberately chosen over merely reporting. A report that says "java was
16+
# red three commits ago" leaves the red UNVERIFIED, which is the defect itself; running
17+
# the lane resolves it either way. So a docs push landing after a red java lane runs
18+
# java, and it either recovers or stays red loudly.
19+
#
20+
# This can only ever WIDEN the affected set — the same direction ci-affected-ports.sh's
21+
# fail-open policy already commits to, and for the same reason: coverage may only shrink
22+
# when the shrink is certain.
23+
#
24+
# Usage: scripts/ci-ports-to-run.sh <base-sha> <head-sha>
25+
# Prints a space-separated subset of: ts java python csharp
26+
set -uo pipefail
27+
28+
ALL="ts java python csharp"
29+
30+
# Resolve the sibling by the script's own location, never by the caller's cwd — the
31+
# workflow happens to run from the repo root, and a relative call would keep working
32+
# right up until something invoked this from anywhere else and it fell back to ALL.
33+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34+
35+
# How far back to look for a lane's newest verdict. The nightly full matrix gives every
36+
# lane a fresh verdict daily, so this window only has to outlive one day of pushes.
37+
RUN_WINDOW="${MO_PORTS_RUN_WINDOW:-20}"
38+
39+
# Every local-ci job that TESTS a port, mapped to the port it tests. `detect`, `gates`
40+
# and `notify` are absent on purpose: they run on every push, so they can never be the
41+
# skipped-and-stale lane this file exists to catch.
42+
#
43+
# A new lane missing from this map would be invisible to the known-green check and could
44+
# sit red forever under green runs — the same defect, one level down. Both directions are
45+
# asserted against the workflow's real job list by scripts/test-ci-ports-to-run.sh.
46+
lane_port() {
47+
case "$1" in
48+
ts-fast|ts-unit|ts-slow) echo ts ;;
49+
java-fast|java-slow) echo java ;;
50+
python) echo python ;;
51+
csharp) echo csharp ;;
52+
*) echo "" ;;
53+
esac
54+
}
55+
56+
all_lanes() { echo "ts-fast ts-unit ts-slow java-fast java-slow python csharp"; }
57+
58+
# Canonical order, deduplicated, unknown tokens dropped. The order is FIXED rather than
59+
# sorted because the workflow tests the result with `contains(..., 'ts')` — a substring
60+
# match on a string that must read the same whichever path produced it.
61+
union_ports() {
62+
local want=" $* " out="" p
63+
for p in $ALL; do
64+
case "$want" in *" $p "*) out="$out $p" ;; esac
65+
done
66+
echo "${out# }"
67+
}
68+
69+
# stdin: one "<job><TAB><conclusion>" line per job, with runs ordered NEWEST FIRST.
70+
# Prints the ports that are not known-green.
71+
#
72+
# The first non-`skipped` conclusion for a lane is that lane's newest verdict. `skipped`
73+
# is walked past rather than counted, because it describes the SELECTION and says nothing
74+
# about the code — treating it as a verdict is precisely the bug.
75+
#
76+
# Anything that is not `success` counts as not-green, `cancelled` included: a cancelled
77+
# lane was never verified, and `cancel-in-progress: true` makes cancellation common
78+
# enough that counting it as green would reopen the hole from the other side. A lane with
79+
# no verdict in the window is UNKNOWN, which widens for the same reason.
80+
not_green_ports() {
81+
local -A verdict=()
82+
local job conc
83+
while IFS=$'\t' read -r job conc; do
84+
[ -n "$job" ] || continue
85+
[ "$conc" = "skipped" ] && continue
86+
[ -n "${verdict[$job]+x}" ] && continue
87+
verdict[$job]="$conc"
88+
done
89+
90+
local out="" lane
91+
for lane in $(all_lanes); do
92+
[ "${verdict[$lane]:-}" = "success" ] || out="$out $(lane_port "$lane")"
93+
done
94+
union_ports $out
95+
}
96+
97+
# Test seam: `MO_PORTS_TEST=1 source` exposes the pure functions and makes no network
98+
# or git call. Everything above this line is pure; everything below talks to the world.
99+
[ "${MO_PORTS_TEST:-0}" = "1" ] && return 0
100+
101+
# Ask the API for each lane's newest verdict on main, newest run first.
102+
#
103+
# curl + jq rather than `gh`: both are in /usr/bin on the self-hosted runner, while `gh`
104+
# is on a user PATH the runner service does not necessarily inherit. A missing binary
105+
# here would fail open to the full matrix, which is safe but silently expensive forever.
106+
fetch_verdicts() {
107+
local repo="${GITHUB_REPOSITORY:-}" token="${GITHUB_TOKEN:-}" api="${GITHUB_API_URL:-https://api.github.com}"
108+
[ -n "$repo" ] && [ -n "$token" ] || return 1
109+
command -v curl >/dev/null && command -v jq >/dev/null || return 1
110+
111+
local get=(curl -sS --fail --max-time 30
112+
-H "Authorization: Bearer $token"
113+
-H "Accept: application/vnd.github+json"
114+
-H "X-GitHub-Api-Version: 2022-11-28")
115+
116+
local runs
117+
runs="$("${get[@]}" \
118+
"$api/repos/$repo/actions/workflows/local-ci.yml/runs?branch=main&status=completed&per_page=$RUN_WINDOW" \
119+
| jq -r '.workflow_runs[].id')" || return 1
120+
[ -n "$runs" ] || return 0 # no history is not an error; not_green_ports widens on it
121+
122+
local id
123+
for id in $runs; do
124+
# The run doing the asking is not completed and so cannot appear here — but a rerun
125+
# of it could, and its own lanes are exactly the ones with no verdict yet.
126+
[ "$id" = "${GITHUB_RUN_ID:-}" ] && continue
127+
"${get[@]}" "$api/repos/$repo/actions/runs/$id/jobs?per_page=50" \
128+
| jq -r '.jobs[] | "\(.name)\t\(.conclusion // "none")"' || return 1
129+
done
130+
}
131+
132+
BASE="${1:-}"; HEAD="${2:-HEAD}"
133+
AFFECTED="$("$HERE/ci-affected-ports.sh" "$BASE" "$HEAD")"
134+
135+
VERDICTS="$(fetch_verdicts)"
136+
if [ $? -ne 0 ]; then
137+
# Fail open, loudly. An API blip must not be able to shrink coverage; the cost of
138+
# widening is one extra matrix on a runner that bills nothing.
139+
echo "ci-ports-to-run: could not read lane history — selecting all ports" >&2
140+
union_ports $ALL
141+
exit 0
142+
fi
143+
144+
STALE="$(printf '%s\n' "$VERDICTS" | not_green_ports)"
145+
[ -n "$STALE" ] && echo "ci-ports-to-run: not known-green on main: $STALE" >&2
146+
union_ports $AFFECTED $STALE

‎scripts/test-ci-ports-to-run.sh‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
# Table-driven test for ci-ports-to-run.sh's pure core.
3+
# Sources the script with MO_PORTS_TEST=1 so no network and no git calls happen,
4+
# then feeds verdict tables straight into the functions.
5+
set -uo pipefail
6+
cd "$(dirname "$0")/.." || exit 1
7+
MO_PORTS_TEST=1 source scripts/ci-ports-to-run.sh
8+
9+
fails=0
10+
check() { # check "<expected>" "<got>" "<what>"
11+
if [ "$2" != "$1" ]; then
12+
echo "FAIL: $3 -> '$2' (expected '$1')" >&2; fails=$((fails+1))
13+
else
14+
echo "ok: $3 -> '$2'"
15+
fi
16+
}
17+
18+
# ── union_ports: canonical order, deduplicated ────────────────────────────────
19+
check "ts java python csharp" "$(union_ports ts java python csharp)" "union all"
20+
check "ts java python csharp" "$(union_ports csharp python java ts)" "union reorders to canonical"
21+
check "ts java" "$(union_ports java ts java ts)" "union deduplicates"
22+
check "python" "$(union_ports python)" "union single"
23+
check "" "$(union_ports)" "union empty"
24+
check "ts" "$(union_ports ts bogus)" "union drops unknown tokens"
25+
26+
# ── not_green_ports: newest verdict per lane wins ─────────────────────────────
27+
all_green() {
28+
printf '%s\tsuccess\n' ts-fast ts-unit ts-slow java-fast java-slow python csharp
29+
}
30+
31+
check "" "$(all_green | not_green_ports)" "every lane green -> nothing to re-run"
32+
33+
# The defect this file exists for: a docs-only run SKIPS the code lanes, so the
34+
# newest run says nothing about them and the verdict must come from the run before.
35+
check "java" "$( { printf '%s\tskipped\n' ts-fast ts-unit ts-slow java-fast java-slow python csharp
36+
printf '%s\tsuccess\n' ts-fast ts-unit ts-slow python csharp
37+
printf '%s\tfailure\n' java-fast java-slow; } | not_green_ports )" \
38+
"docs-only run skips a red java -> java still not green"
39+
40+
check "ts" "$( { printf '%s\tsuccess\n' ts-fast ts-unit java-fast java-slow python csharp
41+
printf 'ts-slow\tfailure\n'; } | not_green_ports )" \
42+
"one red ts lane selects the whole ts port"
43+
44+
# `skipped` is walked past, never counted as a verdict — it describes the
45+
# selection, not the code.
46+
check "" "$( { printf 'ts-fast\tskipped\n'
47+
printf '%s\tsuccess\n' ts-fast ts-unit ts-slow java-fast java-slow python csharp; } | not_green_ports )" \
48+
"skipped is walked past to the real verdict"
49+
50+
# A lane that was cancelled was never verified. cancel-in-progress makes this
51+
# common, so counting it as green would reopen the hole from the other side.
52+
check "csharp" "$( { printf '%s\tsuccess\n' ts-fast ts-unit ts-slow java-fast java-slow python
53+
printf 'csharp\tcancelled\n'; } | not_green_ports )" \
54+
"cancelled is not green"
55+
56+
# No verdict at all in the window is UNKNOWN, and unknown must widen.
57+
check "python" "$(printf '%s\tsuccess\n' ts-fast ts-unit ts-slow java-fast java-slow csharp | not_green_ports)" \
58+
"a lane with no verdict in the window is not green"
59+
60+
check "ts java python csharp" "$(printf '' | not_green_ports)" "no history at all -> everything"
61+
62+
# ── the lane map must cover every port-testing job in the workflow ────────────
63+
# A new lane absent from lane_port() would be invisible to the known-green check
64+
# and could sit red forever under green runs — the same defect, one level down.
65+
WF=.github/workflows/local-ci.yml
66+
mapped_missing=""
67+
for job in $(awk '/^jobs:$/{j=1;next} j && /^ [a-z][a-z0-9-]*:$/ { gsub(/[ :]/,""); print }' "$WF"); do
68+
case "$job" in detect|gates|notify) continue ;; esac
69+
[ -n "$(lane_port "$job")" ] || mapped_missing="$mapped_missing $job"
70+
done
71+
check "" "${mapped_missing# }" "every port-testing job in $WF is mapped by lane_port"
72+
73+
# ...and no mapped lane may name a job the workflow does not define.
74+
stale=""
75+
for lane in $(all_lanes); do
76+
grep -qE "^ ${lane}:$" "$WF" || stale="$stale $lane"
77+
done
78+
check "" "${stale# }" "every lane in lane_port exists as a job in $WF"
79+
80+
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed" >&2; exit 1; fi
81+
echo "all checks passed"

0 commit comments

Comments
 (0)