ci: run the workflow on demand, on any ref

Adds workflow_dispatch: to ci.yml. One key, no inputs, and no job,
matrix, step, permission or timeout changes --- a dispatched run does
exactly what a push run does.

THE SCOPE IS ONE REGISTRY ROW, and the framing took three revisions to
get that right. CI never invokes scripts/gate --- zero occurrences ---
so every local-gate red already has a merge-base control that needs no
CI at all: the exact failing command, with its flags, at the recorded
merge base, in a separate worktree. U11 alone needs this trigger,
because it is macOS-specific and this project has no Mac. When it
recurred on #243 the only contemporaneous main-side control available
was a re-run of a run eight days old.

What this is NOT: it does not run scripts/gate, does not vary R7's gate
conditions, and does not select U9's alternate test commands. Those need
their own changes. This is infrastructure they would build on.

The concurrency interaction is stated at its real strength: once a
dispatched run is in_progress, a second same-SHA dispatch will not
cancel it. cancel-in-progress: false protects a RUNNING run and not a
pending one, and a main push and a dispatch at the same SHA do share
ci-<sha>. A PR run never shares a dispatch's group at all, so no
PR-involving test exercises the predicate.

D1 passes and bites pre-merge: the working-tree file parses and
workflow_dispatch is a key under `on`; dropping it fails the assertion.
The witness looks up both 'on' and the boolean True, because YAML 1.1
parses `on:` as a boolean and a naive d['on'] raises before checking
anything.

D2 and D3 are OWED POST-MERGE and recorded as such --- GitHub offers
workflow_dispatch only for a workflow already on the default branch, so
neither can run before this lands. D2 carries a void-and-retry rule so
it cannot pass when the two runs never actually overlap, and D3 requires
each job to conclude success or failure rather than merely not-skipped.

Framing docs/ci-manual-dispatch-framing.md revision 3, approved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-30 10:53:04 +02:00
parent 2e9f62b579
commit d9cc0fa58a
No known key found for this signature in database
3 changed files with 234 additions and 0 deletions

View File

@ -4,6 +4,19 @@ on:
push:
branches: [main]
pull_request:
# Run CI on demand, on any ref.
#
# A CI-side red on a platform this project has no machine for --- U11
# is macOS/`lua54` --- cannot have its merge-base control run locally.
# Without this trigger the only way to get a contemporaneous `main`
# run is to re-run a job from whenever `main` last happened to be
# pushed, which on PR #243 meant a run EIGHT DAYS OLD.
#
# No inputs, deliberately. This runs exactly what a push runs; it is
# not a hook for varying commands. Experiments that need that ---
# U9's test-binary concurrency control, R7's gate conditions --- need
# their own changes, and CI does not invoke `scripts/gate` at all.
workflow_dispatch:
env:
CARGO_TERM_COLOR: always

View File

@ -270,6 +270,40 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
## Manual CI dispatch — ACTIVE
**Written with the branch's FIRST commit**, per the standing correction
from #171 and #215.
- **Branch `ci-manual-dispatch`**, base `githubsucks/main` @
**`2e9f62b`** exactly, in worktree
`/home/jeans/Repos/personal/pmacs-ci-dispatch`.
**`githubsucks/ci-manual-dispatch` is the authoritative tip** (the
ref, not a SHA).
- **Framing `docs/ci-manual-dispatch-framing.md`, revision 3 —
APPROVED.** It took three revisions and every one was a correction:
revision 1 claimed four registry rows needed this (**one does**),
revision 2's D2 could pass without the two runs ever overlapping, and
its D3 accepted an aborted job as proof the matrix ran.
- **What it does:** adds `workflow_dispatch:` to `ci.yml`. One key. No
job, matrix, step, permission or timeout changes, and **no inputs**.
- **Why, and the scope is ONE ROW.** **CI never invokes `scripts/gate`
— zero occurrences.** Every local-gate red therefore has a merge-base
control needing no CI at all. **U11 alone needs this**, because it is
macOS-specific and this project has no Mac: when it recurred on #243
the only contemporaneous `main` control available was a re-run of a
run **eight days old**.
- **D1 PASSES pre-merge and BITES**: the working-tree file parses, and
`workflow_dispatch` is a key under `on` — dropping it fails the
assertion. Note `on:` parses as the **boolean `True`** under YAML 1.1,
so the witness looks up both keys; a naive `d['on']` would raise
before it ever checked anything.
- **D2 AND D3 ARE OWED POST-MERGE**, not skipped. GitHub offers
`workflow_dispatch` only for a workflow already on the default
branch, so neither can run before this lands. Their exact procedures
are in the framing §6, including the void-and-retry rule that stops
D2 passing when the two runs never overlap.
## Parse-budget diagnosability — MERGED as #244 (`a85205a`)
- **MERGED 2026-08-29T22:04:17Z** at approved head `7b50682`, merge

View File

@ -0,0 +1,187 @@
# Manual CI dispatch — a contemporaneous run on any ref
**Status: revision 3 — AWAITING APPROVAL. Nothing implemented.**
Revision 3 answers review of 2: D2 could pass without the two runs ever
overlapping, D3 accepted an aborted job as evidence the matrix ran, the
merge-base instruction named the wrong commit and the wrong command, and
"when it runs automatically" was still too strong.
Revision 2 answered review of 1, whose first change was a large
reduction in what this claims to be worth.
## 1. What this fixes
`ci.yml` triggers on `push` to `main` and on `pull_request`. There is no
way to run it on demand, so **a CI run on `main` can only be obtained
from whenever `main` last happened to be pushed** — or by re-running a
job from that stale run.
## 2. Why, and exactly which row needs it
**Revision 1 claimed "four registry rows name a control they cannot
obtain". That was wrong, and the correction is worth stating plainly:
CI never invokes `scripts/gate` — zero occurrences in `ci.yml`.** It
runs `cargo test` directly.
So every local-gate red in the registry has a merge-base control that
needs no CI at all. **Stated precisely, because the sloppy version of
this sentence is itself a trap:** run **the exact failing command, with
its flags**, at **the recorded merge base** — not at whatever `main`
happens to be now — **in a separate worktree**, which this repository's
shared-checkout discipline requires anyway. `git checkout main &&
./scripts/gate` is none of those three things.
Measured against the registry:
| row | where it reds | needs this? |
|---|---|---|
| **U11** | **`Test (macos-latest / lua54)`** | **YES** |
| R7 | local — originally `cargo test --workspace --features crdt` under sweep load, and **its later occurrences at `scripts/gate`'s `gpu` step**, including the two on the parse-budget lane | no |
| U6 | local, `scripts/gate` step `04-lib-crdt` | no |
| U9 | local, `scripts/gate` step `11-sweep` | no |
| U10 | local, `scripts/gate` steps `13-sweep` / `15-sweep-crdt` | no |
| U12 | local, `scripts/gate` step `04-lib-crdt` | no |
| U13 | local, `scripts/gate` step `07-sweep` | no |
**One row, not four.** U11 needs it because it is macOS-specific and
this project has no Mac: its control cannot be run locally at all. When
U11 recurred on PR #243, the only way to get a contemporaneous
`main`-side macOS run without touching `main` or opening a PR was to
re-run a job from a run **eight days old**. That is the whole of the
demonstrated need.
## 3. What this is, and is NOT, for the other experiments
**A no-input dispatch of the unchanged workflow runs exactly what a
push runs.** It therefore:
- **directly enables** a contemporaneous `main`-side run of the
existing fourteen checks — U11's case, and any future CI-side,
platform-specific red;
- **does NOT run `scripts/gate`**, which CI does not invoke, so it does
nothing for R7's in-gate/out-of-gate question;
- **does NOT vary R7's gate conditions**, which would need the gate in
CI plus a way to change one condition per run;
- **does NOT select U9's alternate test commands**, which would need
workflow inputs or a branch carrying different commands.
Those remain separate work. **This key is infrastructure they would
build on, not a substitute for them**, and revision 1 blurred that.
## 4. What lands
```yaml
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
```
One key. No job, matrix, step, permission or timeout changes.
## 5. The two interactions, both checked
### 5a. Concurrency — the guarantee, stated at its real strength
```yaml
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```
**`cancel-in-progress` is evaluated for the INCOMING run.**
**The defensible guarantee is narrow: once a dispatched run is
`in_progress`, a second same-SHA dispatch will not cancel it.**
Revision 1 said "two dispatches cannot cancel each other", which is too
broad. `cancel-in-progress: false` **protects a running run; it does not
protect a PENDING one** — GitHub still replaces an existing pending run
in the same concurrency group. And a `main` push and a dispatch at the
same SHA **do** share `ci-<sha>`, so the two are not isolated from each
other the way revision 1 implied.
A PR run and a dispatch never share a group — PR runs key on
`pull_request.number` — so no test involving a PR exercises the
predicate at all.
### 5b. No job is event-conditional
Measured: `ci.yml` contains exactly one `if:`, and it is
`runner.os == 'Linux'` (line 171). `github.event_name` appears **only**
inside the concurrency predicate (line 28). A dispatched run therefore
expands to the same fourteen jobs a push does.
## 6. Acceptance
**The witnesses split across the merge, and they must.** GitHub offers
`workflow_dispatch` only for a workflow **already on the default
branch**, so no dispatch can be demonstrated before this lands. `gh
workflow view ci.yml` shows a summary and `--yaml` is needed for the
file — but even that reads the default branch's copy, which is why D1
parses the working tree instead.
| # | when | contract | witness | mutation |
|---|---|---|---|---|
| D1 | pre-merge | `ci.yml` is valid YAML and declares the trigger | parse the working-tree file; assert `workflow_dispatch` is a key under `on` | drop the key → the assertion fails |
| D2 | **post-merge** | a second same-SHA dispatch does not cancel a first that is already running | see below | `cancel-in-progress: true` unconditionally → the first ends `cancelled` |
| D3 | **post-merge** | a dispatched run executes the full matrix for real | see below | make any job event-conditional → that job is `skipped` |
**D2's procedure, because the obvious version passes its own mutant.**
"Same ref" does not mean same SHA — `main` can move between the two
dispatches, giving them different groups and letting the mutant
survive. And a cancelled run still reaches status `completed`, so
status is not the discriminator. So:
1. dispatch `main`, record **run id A** and its `headSha`;
2. wait until A is **`in_progress`**, not merely queued — the guarantee
is about a running run, and a pending one is genuinely replaceable;
3. dispatch `main` again, record **run id B** and its `headSha`;
4. **observe a state in which A is `in_progress` AND B is `queued`.**
Without this the witness passes vacuously: A can finish naturally
between steps 2 and 3, and the unconditional-cancellation mutant
then has nothing to cancel. **If that state is never observed, the
attempt is VOID — retry it; do not report it as a pass;**
5. **assert `headSha(A) == headSha(B)`**, or the run proves nothing —
`main` can move between dispatches and hand the two different
groups;
6. assert **A's final `conclusion` is not `cancelled`**.
**D3's procedure, for the same reason.** A job suppressed by a
job-level `if:` still appears as a check and reports `skipped`, which
rolls up as success — so "all fourteen checks are listed" does not
detect the mutant. So:
1. assert every one of the fourteen expected jobs **started**
(`started_at` present) **and concluded `success` or `failure`**.
"Not `skipped`" is not enough — it still admits `cancelled` and
`timed_out`, neither of which is a job that ran the matrix;
2. keep a **structural** check for step-level conditionals: the only
`if:` in the file is `runner.os == 'Linux'`, and no expression
references `github.event_name` or `github.event` outside the
concurrency block.
**D2 and D3 are recorded as OWED at merge.** This framing does not
pretend a pre-merge check can stand in for them.
## 7. Coherence impact (`COHERENCE.md` §20)
- **Journey steps touched: NONE.** No product behaviour changes.
- **Interaction islands: none added.**
- **Config registry: no entry.**
- **Background work: none started.** A dispatched run is
human-initiated by definition.
## 8. What this does NOT do
- **It does not make R7, U6, U9, U10, U12 or U13 more answerable.**
Those are local-gate reds; CI does not run the gate.
- **It does not run any control.** It makes one class of control —
CI-side, on an arbitrary ref — obtainable without a stale rerun.
- **It does not change what CI runs, or the automatic trigger
conditions.** Note the narrower wording: SCHEDULING can change, because
a dispatch sharing `ci-<sha>` may replace an existing **pending**
`main`-push run. What is unchanged is when CI fires by itself.
- **It does not weaken the PR-superseding saving**, which is keyed on
`pull_request.number` and gated on the event.