diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 48992a60f..ba13ba714 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,6 +12,17 @@ on:
# true on push AND on merge_group — never `== 'push'`.
merge_group:
types: [checks_requested]
+ # The full main lane on demand, for a long-lived branch before it enters the
+ # queue: `gh workflow run ci.yml --ref ` (docs/CI.md, #1787). A
+ # dispatch is not a pull request, so every heavy worker runs as on a push to
+ # main. It runs the DISPATCHED ref's own copy of this file, so what follows
+ # holds for main's text; a branch that edits it can change it, exactly as its
+ # pull request run already could. In this text the one name main owns that is
+ # written here, the rolling ci-main tag, moves only on a push to
+ # refs/heads/main at main's tip; every non-fork ref pushes its own ci-.
+ # tools/ci_tier_check.sh checks those properties ([ci-main], [writer-actions],
+ # [secrets]).
+ workflow_dispatch:
permissions:
contents: read
@@ -26,14 +37,22 @@ permissions:
env:
ASAN_SHARDS: 3
-# Only one CI run per ref in flight: a new push (or PR update) cancels the
-# older run for the same branch. Prevents superseded/hung runs from piling
+# One run per concurrency group in flight: a new push to main (or a PR update)
+# cancels the older run in its group. Prevents superseded/hung runs from piling
# up (2026-08-18: three main-push runs — 214m/118m/81m — alive at once after
# a valgrind flake, none superseding the others). Pairs with the per-job
# timeout-minutes, which bounds a single hang; this bounds the *fan-out*.
+# A dispatch has its own group (`ci-dispatch-[`), apart from push, PR and
+# merge-queue runs, main's included. GitHub compares group names
+# case-insensitively, so refs differing only in case share one. A dispatch is
+# never cancelled in progress: a newer dispatch on the same ref waits, and a
+# third replaces the waiting one before it starts any job. So no cancelled
+# dispatch leaves its `if: always()` aggregators failing under required names.
+# Nothing else stops a running dispatch either, a later push to its branch
+# included: cancel it with `gh run cancel `.
concurrency:
- group: ci-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
+ group: ci-${{ github.event_name == 'workflow_dispatch' && format('dispatch-{0}', github.ref) || github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
# Pull requests run the release suite plus build, lint/LSP, self-test,
# precheck, and docs gates. Heavy variants report their required names with a
@@ -121,28 +140,41 @@ jobs:
# GHCR requires a lowercase owner; compute the ref once and hand it to
# the downstream jobs through a job output. `image` is what the Linux
- # legs run in; `tags` is what this job pushes (empty on fork PRs —
- # nothing is pushed; main pushes also advance the rolling ci-main tag).
+ # legs run in; `tags` is what this job pushes: empty on fork PRs, this
+ # commit's ci- on every other run, and the rolling ci-main tag on a
+ # push to main at main's tip only. tools/ci_tier_check.sh runs this step
+ # across event x ref x tip to check that ([ci-main]).
- name: Compute image ref
id: ref
env:
OWNER: ${{ github.repository_owner }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork == true }}
- # The event is read from the runner's GITHUB_EVENT_NAME, not an
- # expression: tools/ci_tier_check.sh reds event-derived expression
- # values on a required path (#1264). Only push advances ci-main.
+ # The event and ref are read from the runner's GITHUB_EVENT_NAME and
+ # GITHUB_REF, not expressions: tools/ci_tier_check.sh reds
+ # event-derived expression values on a required path (#1264). Only a
+ # push to main whose SHA is main's tip moves ci-main: a dispatch from
+ # any ref, main included, and a re-run of an older push push only
+ # ci-. Do not gate on GITHUB_RUN_ATTEMPT: a re-run at the tip is
+ # legitimate. Keep it bash-3.2 clean (tr, not ${OWNER,,}): precheck runs
+ # it, and macOS ships bash 3.2.
run: |
- BASE="ghcr.io/${OWNER,,}/eigenscript-dev"
+ BASE="ghcr.io/$(printf %s "$OWNER" | tr '[:upper:]' '[:lower:]')/eigenscript-dev"
+ SHA="${{ github.sha }}"
if [ "$IS_FORK" = "true" ]; then
echo "image=$BASE:ci-main" >> "$GITHUB_OUTPUT"
echo "tags=" >> "$GITHUB_OUTPUT"
- elif [ "$GITHUB_EVENT_NAME" = "push" ]; then
- echo "image=$BASE:ci-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- echo "tags=$BASE:ci-${{ github.sha }},$BASE:ci-main" >> "$GITHUB_OUTPUT"
- else
- echo "image=$BASE:ci-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- echo "tags=$BASE:ci-${{ github.sha }}" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ TAGS="$BASE:ci-$SHA"
+ if [ "$GITHUB_EVENT_NAME" = "push" ] && [ "$GITHUB_REF" = "refs/heads/main" ]; then
+ # Move ci-main only while this SHA is main's tip, so re-running an
+ # older push run cannot point it back at an older image.
+ tip=$(git ls-remote origin refs/heads/main | cut -f1)
+ [ -n "$tip" ] || { echo "::error::cannot read main's tip; ci-main not moved"; exit 1; }
+ if [ "$tip" = "$SHA" ]; then TAGS="$TAGS,$BASE:ci-main"; else echo "main has moved to $tip: ci-main stays"; fi
fi
+ echo "image=$BASE:ci-$SHA" >> "$GITHUB_OUTPUT"
+ echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push dev image
if: github.event.pull_request.head.repo.fork != true
@@ -207,6 +239,9 @@ jobs:
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
base='${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}'
+ # A workflow_dispatch carries no base: select what the dispatched ref
+ # changes against main, as its PR would. An empty base selects nothing.
+ [ -n "$base" ] || base=origin/main
bash tools/selftests.sh --changed "$base" --list > selection.txt
cat selection.txt
n=$(sed -n 's/^selftests: enrolment .*; \([0-9][0-9]*\) self-tests selected$/\1/p' selection.txt)
@@ -1530,12 +1565,20 @@ jobs:
# not origin/main — otherwise a PR is charged for the Ir cost of the PRs
# ahead of it and falsely rejected (#1273 code review). Read from the
# event payload inside the script, never via ${{ }} (tier gate rule).
+ # On a dispatch the baseline is the branch's merge-base with main: the
+ # branch's own changes, as the queue will measure them, not main's
+ # changes since the fork counted against it (or hiding its own).
run: |
base=origin/main
if [ "$GITHUB_EVENT_NAME" = merge_group ]; then
base=$(jq -r '.merge_group.base_sha' "$GITHUB_EVENT_PATH")
[ -n "$base" ] && [ "$base" != null ] || { echo "merge_group event without base_sha"; exit 1; }
git fetch --no-tags --depth=1 origin "$base"
+ elif [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
+ if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --no-tags --unshallow origin main
+ else git fetch --no-tags origin main; fi
+ base=$(git merge-base origin/main HEAD)
+ echo "dispatch: the merge-base of $GITHUB_REF with origin/main ($(git rev-parse origin/main)) is $base"
else
git fetch --no-tags --depth=1 origin main
fi
diff --git a/README.md b/README.md
index b4eb4cb2f..46cb564af 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
]
-
+
diff --git a/docs/CI.md b/docs/CI.md
index d6dcae450..397e59fec 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -189,6 +189,66 @@ on the commit that will land, before it lands — see **Platform tiers** below.
#1138 and #1158 both carried lanes only CI could run. Contributors never wait
on it and never rebase to satisfy it; the queue does both.
+### The main lane on demand (`workflow_dispatch`)
+
+A long-lived branch, such as an integration branch that lives through many
+review rounds, can run the full main lane before it enters the queue (#1787):
+
+ gh workflow run ci.yml --ref
+ gh run list --workflow ci.yml --event workflow_dispatch --branch
+ gh run list --workflow ci.yml --commit
+
+- **What runs.** A dispatch is not a pull request, so every job runs as on a
+ push to `main`. That includes every heavy worker: the ASan shards, both TSan
+ variants, the native macOS suite, valgrind, the bench gate, the extension
+ suites and the db lane.
+ - The bench gate measures the branch against its merge-base with `main`, so
+ the branch is charged for its own changes, as the queue will measure them.
+ - The changed-gate self-tests select what the branch changes against `main`.
+- **When.** Dispatch on an integration branch before it is queued, and again
+ after a rebase that pulls in a lot. The queue still runs everything on the
+ candidate commit, so a dispatch is never a substitute for it.
+- **Which `ci.yml` runs.** The dispatched ref's own copy, with its own
+ `tools/ci_tier_check.sh`. What follows holds for `main`'s text. A branch that
+ edits `ci.yml` can change it, exactly as its pull-request run already could,
+ since a pull request also runs the branch's own workflow file.
+- **What it writes.** In `main`'s `ci.yml`, the only job that can write is
+ `build dev/ci image`:
+ - Every non-fork run pushes the commit's own `ci-` image tag, a
+ dispatch included.
+ - The rolling `ci-main` tag is the one name written here that `main` owns
+ (fork PRs run in it). It moves only on a push to `main` whose SHA is still
+ `main`'s tip, so neither a dispatch from any ref nor a re-run of an older
+ push moves it (`[ci-main]`, below).
+ - The build cache goes to the run's ref scope. A dispatch on a branch writes
+ the branch's scope; a dispatch on `main` writes `main`'s, as a push does.
+ - Artifacts stay in the run.
+
+ A dispatch on `main` also adds runs to `main`'s commit, but the README badge
+ reads only push runs (`badge.svg?branch=main&event=push`).
+- **Concurrency.** There is one run per concurrency group.
+ - A dispatch's group is `ci-dispatch-[`, separate from push,
+ pull-request and merge-queue runs, `main`'s included. GitHub compares group
+ names case-insensitively, so refs that differ only in case share one.
+ - The group never cancels a running dispatch. A newer dispatch on the same
+ ref waits, and a third replaces the waiting one before it starts any job.
+ - A later push to the branch does not stop a dispatch either. To drop an
+ outdated one, run `gh run cancel ` and dispatch again. A cancelled
+ run's `if: always()` aggregators then conclude failure under their required
+ names on that commit; that failure records the cancellation, not a test
+ result.
+- **Where the results show.** In the dispatch run itself: `gh run view
+ `, or `gh run list --commit `. GitHub's status rollup for a
+ commit, which pull requests and the queue read, leaves out `workflow_dispatch`
+ suites. (Measured: a commit that carries only a dispatch's 49 check runs has
+ no rollup at all.) So a dispatch neither appears among a pull request's
+ checks nor gates it.
+
+GitHub reads the trigger from the dispatched ref's own copy of `ci.yml`. A
+branch whose copy predates the trigger is refused with HTTP 422, "Workflow
+does not have 'workflow_dispatch' trigger" (measured on a stale branch). So
+rebase or merge `main` into a long-lived branch before its first dispatch.
+
## Nightly (`.github/workflows/nightly.yml`)
- `macos-15-intel`, the full suite — the 35-minute job that used to set the PR
@@ -478,8 +538,9 @@ index headers and compiles `eigenlsp.c` with the trio in its flag bundle.
Main CI did not finish green from 2026-09-16 to 2026-09-22 although every
required check passed on every merge: one lane that no pull request had to pass
(`macos / macos-15-intel`, main lane only) hit its timeout on nearly every
-push. The README badge is the status of the whole `ci.yml` workflow on `main`,
-so **any** `ci.yml` job that can fail there colours it, required or not.
+push. The README badge is the status of the whole `ci.yml` workflow's latest
+push run on `main` (`badge.svg?branch=main&event=push`), so **any** `ci.yml`
+job that can fail there colours it, required or not.
The fix copies Rust (tiers plus a merge queue), CPython and Go:
@@ -541,7 +602,14 @@ self-tests` job. A missing PyYAML is exit 2, never a pass.
those must use exactly `github.event_name != 'pull_request'`. A step `if:`
may mention the event only as `github.event_name ==/!= 'pull_request'`, or
via the PR payload `github.event.pull_request.*` (empty on push and in the
- queue alike). Dot and bracket syntax are both read. The same rule covers
+ queue alike). Beyond those two forms, a condition may read only the `github`
+ fields whose value does not depend on the event that started the run:
+ `repository`, `repository_owner`, `repository_id`,
+ `repository_owner_id`, `server_url`, `api_url`, `graphql_url`, `workspace`,
+ `workflow`, `job`, `action`, `token` and `retention_days`. Any other field
+ (such as `ref_protected`, `workflow_ref`, `sha` or `actor`), or a bare
+ `github`, is red: that is an allowlist (#1787), not a list of known event
+ fields. Dot and bracket syntax are both read. The same rule covers
indirection: an `env`, job `outputs`, workflow `env` or matrix value on a
required path may not read the event (outside those two forms), and an
`if:` that reads `env.*`, `needs.*.outputs` or `steps.*.outputs` is traced
@@ -559,6 +627,48 @@ self-tests` job. A missing PyYAML is exit 2, never a pass.
- `[uncovered]` — a `ci.yml` job is neither required nor the worker of exactly
one required `if: always()` aggregator. A failing non-required job does not
stop the queue, yet it colours the badge: the `macos-15-intel` shape.
+- `[ci-main]` — `ci-main` is the one name `ci.yml` writes that `main` owns.
+ The check finds the step that computes the image push's tags and runs it,
+ under `bash -e`, across every event (push, pull request, merge queue,
+ dispatch), ref (`main`, a branch, a pull request's merge ref, the queue's
+ ref) and tip state (this SHA is `main`'s tip, is not, or the tip cannot be
+ read), plus fork pull requests. It requires `ci-main` exactly on a push to
+ `refs/heads/main` at `main`'s tip. Every non-fork run pushing its own
+ `ci-` is by design.
+ - `on.push` must be exactly `branches: [main]`.
+ - Each image push must take its tags from that one step.
+ - The matrix runs the step with the workflow, job and step env merged as the
+ runner merges them, and reads its `tags` output in any case, quotes
+ stripped.
+ - The check refuses what its matrix cannot model:
+ - a custom shell or directory (`defaults:`, `shell:`,
+ `working-directory:`);
+ - env in any scope that redirects bash or git (`PATH`, `BASH_ENV`, `ENV`,
+ `SHELLOPTS`, `BASHOPTS`, `GITHUB_*`, `GIT_*`, `BASH_FUNC_*`, `LD_*`);
+ - an expression it cannot evaluate;
+ - a multi-line `tags<<` output.
+- `[writer-actions]` — a `ci.yml` job that can write uses only the allowlisted
+ actions `actions/checkout`, `docker/setup-buildx-action`,
+ `docker/login-action` and `docker/build-push-action`.
+ - The allowlist compares action identities without the `@ref`, so a
+ Dependabot bump stays green.
+ - Each action takes only the inputs vetted for it. So
+ `docker/build-push-action` takes no `outputs:` and only a `type=gha`
+ `cache-to:`: an `outputs:` or a registry cache can push to any name, and a
+ registry cache is shared across refs.
+ - A new input goes red until someone vets it and adds it to `INPUTS`.
+ - No `./` or `docker://` action, and no reusable-workflow call.
+ - No script except the tag step that `[ci-main]` runs.
+
+ A job can write when it holds a write permission: its own, or the workflow's,
+ where a missing block counts as the repository default, which may write.
+- `[secrets]` — `ci.yml` reads no secret but `secrets.GITHUB_TOKEN`, in dot or
+ bracket syntax, and passes none to a reusable workflow. So `permissions:`
+ alone decide what it can write.
+
+These rules were each proven once, when written, against planted copies
+recorded in PR #1785 (`docs/PRINCIPLES.md` §5); none is re-proven on every
+run.
Whether an aggregator's script really fails on every non-success worker
result remains a code-review question. The gate's other accepted limits
@@ -626,7 +736,9 @@ The suite runs each gate's live check; `tools/selftests.sh --changed origin/main
runs checker self-tests whose scripts, fixtures or helpers changed, including
uncommitted and untracked files, and `make precheck` includes that selection.
The required `gate self-tests (changed rows)` check uses the
-PR base SHA or merge-group base SHA, while nightly runs `tools/selftests.sh --all`
+PR base SHA or merge-group base SHA, the pushed range on `main`, or
+`origin/main` on a `workflow_dispatch` (which carries no base SHA), while
+nightly runs `tools/selftests.sh --all`
and reports failures through its tracking issue.
`tools/selftests.txt` owns the trigger globs, bounded commands and existing
result pins; changing it or the driver selects every row.
diff --git a/tools/ci_tier_check.sh b/tools/ci_tier_check.sh
index 0b618d476..d89fc7bab 100755
--- a/tools/ci_tier_check.sh
+++ b/tools/ci_tier_check.sh
@@ -13,7 +13,7 @@ check() {
CT_AWK=$(awk '/^jobs:/ {j=1; next} j && /^[^ #]/ {j=0}
j && /^ [A-Za-z0-9_-]+:[[:space:]]*(#.*)?$/ {n++} END {print n+0}' "$WF_DIR/ci.yml") \
CT_GREP=$(grep -cvE '^(#|$)' "$REQ_FILE") CT_WF="$WF_DIR" CT_REQ="$REQ_FILE" python3 - <<'PY'
-import hashlib, itertools, os, re, sys; from fnmatch import fnmatchcase
+import hashlib, itertools, os, re, subprocess, sys, tempfile; from fnmatch import fnmatchcase
def instrument(m): print(f"ci-tier: INSTRUMENT ERROR — {m}; nothing was checked"); sys.exit(2)
try:
import yaml
@@ -24,7 +24,7 @@ def V(code, msg): bad.append(code); print(f"FAIL [{code}] {msg}")
req = [l for l in open(os.environ["CT_REQ"], encoding="utf-8").read().split("\n") if l and not l.startswith("#")]
if not req or len(req) != int(os.environ["CT_GREP"]) or len(set(req)) != len(req):
V("vacuous", f"required-checks.txt: parsed {len(req)} names, {len(set(req))} distinct, grep counts {os.environ['CT_GREP']}")
-REQ = set(req); W = {}
+REQ = set(req); W = {}; PERMS = {}; DOCS = {}
for fn in sorted(os.listdir(os.environ["CT_WF"])):
if not fn.endswith((".yml", ".yaml")): continue
try: doc = yaml.safe_load(open(os.path.join(os.environ["CT_WF"], fn), encoding="utf-8"))
@@ -34,6 +34,8 @@ for fn in sorted(os.listdir(os.environ["CT_WF"])):
on = {on: {}} if isinstance(on, str) else {str(k): {} for k in on} if isinstance(on, list) else \
{str(k): (v if isinstance(v, dict) else {}) for k, v in (on or {}).items()}
W[fn] = (on, doc["jobs"], doc.get("env") or {})
+ PERMS[fn] = doc.get("permissions", "write-all") # absent: the repository default, which may write
+ DOCS[fn] = doc
def triggers(on, ev):
if ev not in on: return False
c = on[ev]
@@ -46,17 +48,25 @@ def needs(j): n = j.get("needs") or []; return [n] if isinstance(n, str) else li
def always(j): return cond(j.get("if", "")) == "always()"
G = r"github\s*(?:\.\s*{0}\b|\[\s*['\"]{0}['\"]\s*\])"
PR_ATOM = re.compile(G.format("event_name") + r"\s*[=!]=\s*['\"]pull_request['\"]|['\"]pull_request['\"]\s*[=!]=\s*" + G.format("event_name"), re.I)
-PR_BODY = re.compile(G.format("event") + r"\s*(?:\.\s*pull_request\b|\[\s*['\"]pull_request['\"]\s*\])", re.I); EVP = r"(?:event_name|event|ref|ref_name|ref_type|head_ref|base_ref)\b"
-EV = re.compile(r"\bgithub\b(?!\s*(?:\.\s*(?!" + EVP + r")\w|\[\s*['\"](?!" + EVP + r")\w+['\"]\s*\]))", re.I)
+PR_BODY = re.compile(G.format("event") + r"\s*(?:\.\s*pull_request\b|\[\s*['\"]pull_request['\"]\s*\])", re.I)
+# An ALLOWLIST (#1787): a condition may read only github fields whose value does not depend
+# on the event (push, merge_group, pull_request or workflow_dispatch). Any other field
+# (ref_protected, workflow_ref, actor, sha, ...) or a bare `github` can select a lane.
+INVARIANT = {"repository", "repository_owner", "repository_id", "repository_owner_id", "server_url",
+ "api_url", "graphql_url", "workspace", "workflow", "job", "action", "token", "retention_days"}
+GH = re.compile(r"\bgithub\b(?:\s*\.\s*([A-Za-z_][\w-]*)|\s*\[\s*['\"]([^'\"]*)['\"]\s*\])?", re.I)
+def reads_event(t):
+ return next((m.group(0).strip() for m in GH.finditer(t) if (m.group(1) or m.group(2) or "").lower() not in INVARIANT), None)
IND = re.compile(r"\b(env|vars)\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?|\b(needs|steps)\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?"
r"\s*(?:\.\s*|\[\s*['\"])(outputs|result|outcome|conclusion)['\"]?\s*\]?(?:\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?)?", re.I)
WAIVE = {("ci.yml", "scope", "detect"): "35784ca855b3e3fd",
- ("ci.yml", "gate-selftests", "select"): "411e6c561962764a"}
+ ("ci.yml", "gate-selftests", "select"): "cd5e284eee927730"}
used = set()
def exprs(v): return re.findall(r"\$\{\{(.*?)\}\}", str(v), re.S)
def bad_expr(e, wf, jid, pr_ok, depth=0):
"""Why expression `e` (in wf:jid) may differ between push and merge_group, or None."""; t = PR_BODY.sub("_", PR_ATOM.sub("_", e)) if pr_ok else e
- if EV.search(t): return f"`{e.strip()}` reads the event"
+ hit = reads_event(t)
+ if hit: return f"`{e.strip()}` reads `{hit}`, not one of the event-invariant github fields"
if re.search(r"\b(env|vars|steps|needs)\b", IND.sub("_", t), re.I): return f"`{e.strip()}` reads a context this gate cannot resolve"
for m in IND.finditer(t):
(ctx, name, kind, job, field, key), j = m.groups(), W[wf][1].get(jid, {})
@@ -72,9 +82,11 @@ def bad_expr(e, wf, jid, pr_ok, depth=0):
if v is None: return f"`needs.{job}.outputs.{key}` is not declared"
why = next((w for x in exprs(v) for w in [bad_expr(x, wf, job, pr_ok, depth + 1)] if w), None)
else:
- src = next((yaml.safe_dump(s, sort_keys=True) for s in j.get("steps") or [] if isinstance(s, dict) and s.get("id") == job), None); pin = WAIVE.get((wf, jid, job))
- if pin and src is not None and hashlib.sha256(src.encode()).hexdigest()[:16] == pin: used.add((wf, jid, job)); continue
- return f"`steps.{job}.outputs.{key}` comes from a script (not waived, or the waived script changed)"
+ st = next((s for s in j.get("steps") or [] if isinstance(s, dict) and s.get("id") == job), None); pin = WAIVE.get((wf, jid, job))
+ now = None if st is None else hashlib.sha256(yaml.safe_dump(st, sort_keys=True).encode()).hexdigest()[:16]
+ if pin and now == pin: used.add((wf, jid, job)); continue
+ return (f"`steps.{job}.outputs.{key}` comes from a script that is not waived" if not pin else
+ f"`steps.{job}.outputs.{key}` comes from a waived script that changed (it now hashes to {now}): review it, then re-pin WAIVE")
if why: return f"{m.group(0).strip()} -> {why}"
return None
def names(jid, j):
@@ -136,7 +148,7 @@ for wf, jid in sorted(path):
if isinstance(st, dict) for k, v in (st.get("env") or {}).items()]
for k, v in vals:
for x in exprs(v):
- if EV.search(PR_BODY.sub("_", PR_ATOM.sub("_", x))):
+ if reads_event(PR_BODY.sub("_", PR_ATOM.sub("_", x))):
V("event-condition", f"{wf}:{jid}: `{k}: ${{{{{x}}}}}` derives a value from the event")
if j.get("continue-on-error") not in (None, False): V("continue-on-error", f"{wf}:{jid} sets continue-on-error: its failure would not fail the check")
for i, st in enumerate(j.get("steps") or []):
@@ -170,10 +182,117 @@ for jid in sorted(HEAVY_WORKERS):
V("heavy-missing", f"ci.yml:{jid}: declared heavy worker is missing")
elif cond(ci[jid].get("if", "")) != "github.event_name != 'pull_request'":
V("heavy-worker", f"ci.yml:{jid}: worker must skip only pull_request (and therefore run on merge_group and push)")
+# workflow_dispatch runs every non-PR path of ci.yml from ANY ref (#1787), so the
+# writes are checked by what they do, not pinned by their text:
+# [ci-main] the rolling ci-main tag is the one name ci.yml writes that main owns
+# (fork PRs run in it). The step that computes the pushed tags is RUN across
+# event x ref x "this SHA is main's tip"; ci-main must appear exactly on a push to
+# refs/heads/main at main's tip (no dispatch, queue run or re-run of an older push).
+# ci- tags go out from every non-fork ref by design. What the matrix cannot
+# model (a custom shell or directory, env that redirects bash or git, an expression
+# it cannot evaluate, tags it cannot read) is refused.
+# [writer-actions] a job that can write uses only allowlisted actions (compared
+# without @ref) with the inputs vetted for each (no outputs:, cache-to: only type=gha),
+# no ./ or docker:// actions, and no script but that tag step.
+# [secrets] ci.yml reads no secret but GITHUB_TOKEN, so only permissions: can write.
+def writes(p): return p == "write-all" or (isinstance(p, dict) and "write" in p.values())
+INPUTS = {"actions/checkout": set(), "docker/setup-buildx-action": set(), "docker/login-action": {"registry", "username", "password"},
+ "docker/build-push-action": {"context", "file", "platforms", "push", "tags", "cache-from", "cache-to"}}
+EVENTS = ("push", "pull_request", "merge_group", "workflow_dispatch")
+REFS = ("refs/heads/main", "refs/heads/a-branch", "refs/pull/1/merge", "refs/heads/gh-readonly-queue/main/pr-1-0123abc")
+def env_value(v, fork):
+ """A tag-step env value, or None when the matrix cannot evaluate it."""
+ known = {"github.repository_owner": "Owner", "github.event.pull_request.head.repo.fork==true": "true" if fork else "false"}
+ out = str(v)
+ for x in exprs(out):
+ if re.sub(r"\s+", "", x) not in known: return None
+ out = out.replace("${{" + x + "}}", known[re.sub(r"\s+", "", x)])
+ return out
+def ci_main(jid, j, tag):
+ """Run the tag step across the matrix; returns how many runs it judged."""
+ where = f"ci.yml:{jid} step {tag.get('id')!r}"
+ if DOCS["ci.yml"].get("defaults") or j.get("defaults") or "shell" in tag or "working-directory" in tag:
+ V("ci-main", f"{where}: a custom shell or directory (defaults:, shell:, working-directory:) can change what the step computes; the matrix runs it under bash -e in the checkout"); return 0
+ envs = {} # workflow < job < step, as the runner merges them
+ for scope, d in (("workflow", W["ci.yml"][2]), ("job", j.get("env") or {}), ("step", tag.get("env") or {})):
+ bad_env = [k for k in d if re.match(r"(PATH|BASH_ENV|ENV|SHELLOPTS|BASHOPTS)$|GITHUB_|GIT_|BASH_FUNC_|LD_", str(k), re.I)]
+ if bad_env: V("ci-main", f"{where}: {scope} env sets {bad_env}, which can change what the step or its git computes"); return 0
+ envs.update(d)
+ script = str(tag.get("run", ""))
+ odd = sorted({x.strip() for x in exprs(script)} - {"github.sha"})
+ if odd or any(env_value(v, False) is None for v in envs.values()):
+ V("ci-main", f"{where}: uses an expression the matrix cannot evaluate ({odd or 'in its workflow, job or step env'})"); return 0
+ runs = 0
+ with tempfile.TemporaryDirectory() as t:
+ with open(os.path.join(t, "git"), "w") as f: # a git that answers only `ls-remote origin refs/heads/main`
+ f.write('#!/bin/sh\n[ "$*" = "ls-remote origin refs/heads/main" ] || { echo "unexpected git $*" >&2; exit 97; }\n'
+ '[ -n "$TIP" ] || exit 128\nprintf "%s\\trefs/heads/main\\n" "$TIP"\n')
+ os.chmod(os.path.join(t, "git"), 0o755)
+ cases = [(e, r, tip, False) for e in EVENTS for r in REFS for tip in (True, False, None)]
+ cases += [("pull_request", r, True, True) for r in REFS] # fork pull requests
+ for ev, ref, tip, fork in cases:
+ sha, out = "1" * 40, os.path.join(t, "out")
+ open(out, "w").close()
+ env = {"PATH": t + ":" + os.environ["PATH"], "HOME": t, "GITHUB_EVENT_NAME": ev, "GITHUB_REF": ref,
+ "GITHUB_SHA": sha, "GITHUB_OUTPUT": out, "TIP": {True: sha, False: "2" * 40, None: ""}[tip],
+ **{str(k): env_value(v, fork) for k, v in envs.items()}}
+ p = subprocess.run(["bash", "-e", "-c", re.sub(r"\$\{\{\s*github\.sha\s*\}\}", sha, script)], env=env,
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=20)
+ runs += 1
+ text = open(out).read() # output names are case-insensitive; build-push-action strips quotes
+ tags = [x.strip().strip("\"'") for line in text.split("\n") if line[:5].lower() == "tags=" for x in line[5:].split(",")]
+ has = any(x.endswith(":ci-main") for x in tags)
+ want = ev == "push" and ref == "refs/heads/main" and tip is True and not fork
+ what = f"event={ev} ref={ref} tip={'?' if tip is None else tip}{' fork' if fork else ''}"
+ if re.search(r"^tags<<", text, re.M | re.I):
+ V("ci-main", f"{where} writes tags as a multi-line output for {what}, which the matrix does not read; write one tags= line")
+ elif p.returncode != 0 and not (tip is None and ev == "push" and ref == "refs/heads/main"):
+ V("ci-main", f"{where} failed for {what}: {p.stderr.strip()[-200:]}")
+ elif has != want:
+ V("ci-main", f"{where} {'pushes' if has else 'does not push'} ci-main for {what}; ci-main is main's: only a push to refs/heads/main at main's tip may move it")
+ return runs
+if W["ci.yml"][0].get("push") != {"branches": ["main"]}:
+ V("ci-main", f"ci.yml: on.push is {W['ci.yml'][0].get('push')!r}, not exactly branches: [main]")
+writers, judged = [], 0
+for jid, j in ci.items():
+ if not writes(j["permissions"] if "permissions" in j else PERMS["ci.yml"]): continue
+ writers.append(jid)
+ if "uses" in j: V("writer-actions", f"ci.yml:{jid} can write and calls a reusable workflow ({j['uses']}) this check cannot vet")
+ steps = [st for st in j.get("steps") or [] if isinstance(st, dict)]
+ push = [st for st in steps if re.sub(r"@[^@]*$", "", str(st.get("uses", ""))).lower() == "docker/build-push-action"]
+ tag_ids = {m.group(1) for st in push for m in [re.fullmatch(r"\$\{\{\s*steps\.([\w-]+)\.outputs\.tags\s*\}\}", str((st.get("with") or {}).get("tags", "")).strip())] if m}
+ if len(tag_ids) != len(push) or len(tag_ids) > 1:
+ V("ci-main", f"ci.yml:{jid}: each image push must take its tags from one step (`tags: ${{{{ steps..outputs.tags }}}}`), the step the matrix runs")
+ tag = next((st for st in steps if st.get("id") in tag_ids), None)
+ for i, st in enumerate(steps):
+ u = str(st.get("uses", "")).strip(); ident = re.sub(r"@[^@]*$", "", u).lower(); w = st.get("with") or {}
+ if u and (u.startswith(("./", "docker://")) or ident not in INPUTS):
+ V("writer-actions", f"ci.yml:{jid} step {i} uses {u!r}: a job that can write uses only {sorted(INPUTS)} (any @ref), no ./ or docker:// action")
+ elif u and (set(map(str, w)) - INPUTS[ident] or any(not l.strip().startswith("type=gha") for l in str(w.get("cache-to", "type=gha")).splitlines() if l.strip())):
+ V("writer-actions", f"ci.yml:{jid} step {i} gives {ident} {sorted(map(str, w))}: in a job that can write, an action takes only the inputs vetted for it "
+ f"({sorted(INPUTS[ident])}, cache-to: only type=gha); outputs: or a registry cache can push any name")
+ elif "run" in st and st is not tag:
+ V("writer-actions", f"ci.yml:{jid} step {i} ({st.get('name', '?')}) runs a script in a job that can write; only the tag step may, because the matrix runs it")
+ if tag is not None: judged += ci_main(jid, j, tag)
+if judged == 0: V("ci-main", "no image push in ci.yml took its tags from a step the matrix could run: the ci-main property was not checked")
+def strings(x):
+ if isinstance(x, str): yield x
+ elif isinstance(x, dict):
+ for k, v in x.items(): yield from strings(k); yield from strings(v)
+ elif isinstance(x, list):
+ for v in x: yield from strings(v)
+SECRET = re.compile(r"\bsecrets\b(?:\s*\.\s*([A-Za-z_][\w-]*)|\s*\[\s*['\"]([^'\"]*)['\"]\s*\])?", re.I)
+for x in {e for v in strings(DOCS["ci.yml"]) for e in exprs(v)}:
+ for m in SECRET.finditer(x):
+ if (m.group(1) or m.group(2) or "").upper() != "GITHUB_TOKEN":
+ V("secrets", f"ci.yml reads `{m.group(0)}` in `${{{{{x.strip()}}}}}`: only secrets.GITHUB_TOKEN may appear, so permissions: alone decide what ci.yml can write")
+for jid, j in ci.items():
+ if "secrets" in j: V("secrets", f"ci.yml:{jid} passes secrets ({j['secrets']!r}) to a reusable workflow")
if set(WAIVE) - used: V("event-condition", f"waiver(s) {sorted(set(WAIVE) - used)} matched nothing — stale; remove or re-pin")
if bad: print(f"ci-tier: FAIL — {len(bad)} violation(s): {' '.join(sorted(set(bad)))}"); sys.exit(1)
print(f"ci-tier: OK — ci.yml jobs={len(ci)} (awk={os.environ['CT_AWK']}) required={counts['required']} worker={counts['worker']}; "
- f"{len(req)} required names, each produced once on pull_request+merge_group; {len(path)} jobs on required paths, no push-only condition, no continue-on-error")
+ f"{len(req)} required names, each produced once on pull_request+merge_group; {len(path)} jobs on required paths, no push-only condition, no continue-on-error; "
+ f"ci-main only on a push to main at its tip ({judged} event x ref x tip runs); {len(writers)} writing job(s), allowlisted actions and inputs only; no secret but GITHUB_TOKEN")
PY
}
live() {
diff --git a/tools/precheck.sh b/tools/precheck.sh
index acc9a1366..aea22d61e 100644
--- a/tools/precheck.sh
+++ b/tools/precheck.sh
@@ -118,12 +118,13 @@ lane 0 & p0=$!
lane 1 & p1=$!
wait "$p0" "$p1"
# The self-tests can build scratch fixtures: run serially after the static lanes.
-# CI owns this row separately, with the event's base SHA rather than origin/main.
+# CI owns this row separately: the PR's or merge group's base SHA, the pushed range on
+# main, or origin/main on a workflow_dispatch (which carries no base).
i=0
while IFS='|' read -r class cmd; do
if [ "$class" = selftest ]; then
if [ "${PRECHECK_SELFTESTS:-1}" = 0 ]; then
- echo "SKIP|0|CI runs the driver separately with the event base SHA" > "$OUT/$i.st"
+ echo "SKIP|0|CI runs the driver separately (event base SHA, or origin/main on a dispatch)" > "$OUT/$i.st"
else
run_gate "$i" "$class" $cmd
cat "$OUT/$i.log"
]