The fleet's ONE CI process
Maintainer, 2026-09-13: "see that process is same as in plugins everywhere ⇒ harmonize." This page is the process every node repo runs — MeshWeaver.Plugins, Education, Reinsurance, SocialMedia, Crm, Manufacturing — and where each piece lives. The rule behind it: the mechanism lives in this repo as a shared lane or a shared script; a satellite carries a thin caller and its own content. A satellite that re-implements a piece drifts, and the next incident is measured twice.
The pieces, and where they live
| Piece | Mechanism (this repo) | The caller carries |
|---|---|---|
| Validate node JSON + sources, workflow guards | node-repo-validate.yml |
the call, enforce-cancel-rule: true |
| Compile-check against the platform surface | node-repo-compile-check.yml |
the call |
| The Tests-area gate (import + compile + render + run) | node-repo-gate.yml |
the call, permissions: actions: read |
| Module pack / publish / tag / bake / canary / ref-bump | node-repo-*.yml |
the calls |
Generated-lock conflicts — a PR that conflicts only on */manifest.lock lands anyway |
node-repo-resolve-locks.yml + .github/scripts/resolve-generated-conflicts.py |
a 12-line workflow on push: main, hourly, dispatch; the App secrets |
Verdict adoption — a merge from main (the resolver, update-branch, a hand merge) or a lock-only commit never costs the run |
.github/scripts/adopt-verdict.py, decided by node-repo-gate.yml (adopted-verdict: auto) |
nothing (Plugins decides itself in its change-set classifier and passes the URL) |
| Cancellation — nothing on main cancels; a person's PR push supersedes; the bot's push never | .github/scripts/check-main-runs-not-cancelled.py, run by the validate lane |
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.sender.type != 'Bot' }} |
| Auto-merge armed on every non-draft PR to main; draft is the only hold | auto-arm.yml |
the call |
| Build queue front door (admission through the build instance) | Plugins admission job today; a node-repo-admission.yml lane next |
— |
Why adoption is sound
main's protection is strict: false in every repo: a green PR merges without being up to date.
A merge of main into the branch, or a regeneration of the generated locks, changes nothing the
PR authored — so the verdict of the previous head is the verdict of this one. adopt-verdict.py
checks exactly that: the leading first-parent chain is merges-from-base or lock-only commits, the
authored diff (everything but manifest.lock, blob ids and hunk positions stripped) is
byte-identical to a candidate head's, and that head's newest run of the caller's workflow completed
green. Any doubt gates as usual and prints why. Measured cause: three 30-minute Plugins runs cancelled
by lock-only pushes on 2026-09-13 (#1788 carried eight such merges).
A shared script does not know where it is — point it at the repository
The resolver moved into this repo's .github/scripts/ and the lane fetches it into
$RUNNER_TEMP at the caller's scripts-ref, then runs it against the caller's checkout. Its root
had been Path(__file__).parent.parent, which was true only of the shape it came from — a copy at
<repo>/scripts/, run from its own checkout. Delivered out of tree that expression resolves to
/home/runner/work, and the lane's first git fetch died with fatal: not a git repository.
The lane was red on 5 of 5 runs in each of Education, Reinsurance, SocialMedia, Manufacturing and
Crm from the hour it was adopted, having resolved nothing; Plugins stayed green because its own
copy still runs in-tree, so the fleet's green/red split looked like an adoption gap rather than a
defect. The lane's --self-test step passed throughout: every case tested a helper, and the root was
computed in main.
The rule for any script this repo lends to a satellite lane: it acts on the repository it is
POINTED at. --root (default: the working directory) is resolved to a git top level and refused
by name if there is none, and the lane passes --root "$GITHUB_WORKSPACE" explicitly. The self-test
now drives the entry point, with run swapped out — the only shape that covers the wiring rather
than the helper it calls.
Convergence, 2026-09-13 — done
| Repo | resolver | cancel rule | adoption | admission |
|---|---|---|---|---|
| MeshWeaver.Plugins | own workflow (source of the lane) | ✓ (#1791) | own classifier (#1791) | ✓ |
| Education | ✓ lane caller (#326) | ✓ expression (#326) | ✓ (actions: read, #326) |
— |
| Reinsurance | ✓ (#204) | ✓ (#204) | ✓ (#204) | — |
| SocialMedia | ✓ (#185) | ✓ (#185) | ✓ (#185) | — |
| Crm | ✓ (#97) | ✓ (#97) | ✓ (#97) | — |
| Manufacturing | ✓ (#86) | ✓ (#86) | ✓ (#86) | — |
The resolver column means the caller is wired and the lane RESOLVES: wiring landed 16:10Z–17:20Z, and the lane itself only began doing anything after #4231 (the section above) — a row that said ✓ before that would have been describing a caller, not a resolution.
All five satellite PRs merged between 16:10Z and 17:20Z on 2026-09-13, after core #4201 recorded
their new callers in .github/lane-caller-grants.yml as pending:; this page's PR turns those rows
into plain rows and drops the superseded gate rows (the roster is a measurement, not a memory). What
is NOT converged: the admission front door is still Plugins' own admission job; the satellites
have no build-queue admission until node-repo-admission.yml exists.
The freeze is a POSITIONAL instrument — four rules, each paid for once
The pack receipt and the tests matrix must agree. The module-pack lane's test-modules input
narrows which selected modules run their suite. The select job cut the tests-lane matrix by that
list, but the pack job's plan step still answered need_test from the entry's own test flag, so on
a lane-only diff 41 receipts said tests: lane while the lane's matrix was empty — and verify
rightly refused "the suite ran in NEITHER lane". Core #4203: the plan step narrows by the same list
in the same order (the ledger's REUSE answer first), and the receipt says tests: none for a module
the caller did not name.
1. A freeze is not a pin, and lifting it can move pull requests BACKWARDS. Plugins'
MW_PLATFORM_REF selects one sealed set for every lane. With it unset, a push or pull_request
run resolves the newest sealed set this repository's main has passed — measured 2026-09-13
18:01Z: "3.0.0-ci.8484 … 25 newer run(s) passed over" while 8506 was sealed, because Plugins' main
runs were queued for hours behind the dind pool. A PR that needs a symbol from the newest set
(#1801, TestTimeouts.WriteConvergence from core #4206) therefore reds on CS0117 with the freeze
lifted and compiles with it set to 3.0.0-ci.8506. Rule: while main's own runs are backed up, keep
an explicit set in the freeze and move it forward by hand; lift only once main is green on the
newest set. The twin-parity guard (TeardownTwinParityTest) adds the ordering: the set carrying the
core change seals → the freeze moves → the Plugins port lands under it; every other branch that runs
in between reds once on the guard and re-runs after merging main.
2. The value must name a sealed set the volume CARRIES — which is the newest one, or a set the refresh restores for you. The CI
runners mount /opt/platform read-only, and a CronJob refreshes it every ten minutes (Memex
deployments/aks/ci-runners/ci-platform-refresh.py). It installs the newest sealed set — and,
since Memex#329, also the set each subscribed repository's main last PASSED, installing that
one back when it is missing rather than merely declining to purge it. What it KEEPS is those two
plus a bounded window of the newest sets by install time. 🚨 Never write the window's size into a
document or a diagnostic: it is the cluster's to set, it moved 3 → 16 on 2026-09-17, and the
gate's own refusal spent that day telling three sessions the volume keeps the 3 newest while it kept
16. Pin anything else
and every heavy leg misses the mount, falls back to pulling the image, and that pull fails:
could not pull meshweaver.azurecr.io/memex-portal-ai@sha256:… for the registry fallback, with
the platform mount at /opt/platform holds no COMPLETE set for the run's tester digest beside it.
That is what held Plugins main red on the evening of 2026-09-13 while the freeze named 8506.
3. Until Plugins#1809 a freeze had to be a git REF. The three image lanes handed
MW_PLATFORM_REF straight to actions/checkout, so a set name died on
+refs/heads/3.0.0-ci.8506*:… exit 1 and every image lane was down from the moment the variable
was set. The core sha of the set satisfied both readers; since #1809 the lanes translate a set name
through resolve-platform.py themselves.
4. Neither kind of re-run is a remedy for a purged set, and the difference matters.
rerun-failed-jobs REUSES the run's original platform resolution: the job that resolved it
SUCCEEDED, so it is not re-run and its output is replayed verbatim — measured on Plugins#1816, which
failed the same three catalog assertions twice across a seal. A re-run of the WHOLE workflow does
run Resolve the released platform again — and returns the SAME set for as long as main has not
PASSED on a newer one, which while main is red is forever. Measured 2026-09-17 on
Plugins#2040: three attempts (original, rerun-failed-jobs, full rerun), one resolution
(3.0.0-ci.8820) each time. So when the refusal is "below the OLDEST set kept", the fix is the
volume carrying the set again — rule 2 above — and a re-run only helps AFTER that. Reaching for a
re-run first is the standing trap here: it is the action the tooling used to recommend, it is free,
and it cannot work.
The deadlock they produce, and how to break it. main red → no green main run → every pull
request resolves an old set → the pull request that would FIX main is itself red on that old set.
Measured 2026-09-13 23:44Z: Plugins#1822 (the one-line fix restoring publication) died on
KeyVaultSecretRef.CopyFrom — a symbol main had required since #1541 merged at 22:24Z and which
the resolved set predated. Break it by pinning the newest sealed set, never by waiting for main.
🚨 The RETENTION form of the same deadlock, and why rule 2 is the thing that ends it (2026-09-17,
Memex#329). When main is red its main-passed set stops moving, so every pull request keeps
resolving that one set. If that set also ages out of the window, every PR in the repository goes red
in resolve-gate-platform on a diff that cannot reach it — including the PR that would green
main — and no re-run of any kind re-resolves anywhere else, because there is nowhere else to
resolve to. Measured that evening: MeshWeaver.Plugins was red on main and on all seven branches
with recent runs, every shard refused 3.0.0-ci.8820 while the volume held three sets whose oldest
was #8836. Rule 2 is exactly the invariant that makes this unreachable: the set a subscribed
repository's PRs resolve is on the volume, installed back if absent, for as long as that repository's
main has not moved. A red main therefore pins its own set in place instead of losing it — the
longer main stays red, the more firmly the set its PRs need is held. 🚨 And committing that rule
is not running it: the rule sat in git from 09-14 and was applied to the cluster on 09-17, and for
those three days the volume kept the 3 newest exactly as before. The lane that applies it is Memex
.github/workflows/ci-runners-apply.yml; editing ci-platform.yaml without dispatching it changes
nothing at all.
So: setting a freeze, moving it and clearing it all need two things in view at once — the colour of
main and the set the mount carries. A cleared freeze is not a neutral state; it is a decision to
follow whatever main last passed.
Draft is the hold — until the PR is IN the merge queue
auto-arm.yml arms auto-merge on every non-draft PR whose base is main, so draft is the opt-out
and the way any session holds a PR after a late finding. It stops being a hold the moment the entry
is admitted to the merge queue, and that is not obvious from either UI.
Measured twice on 2026-09-13/14, the second time with the conversion landing mid-flight (core #4269):
05:18:19 auto_merge_enabled meshweaver-cloud[bot]
05:29:16 added_to_merge_queue meshweaver-cloud[bot]
05:30:17 convert_to_draft (a session holding it on a defect it had just found)
05:33:18 merged + removed_from_merge_queue github-merge-queue[bot]
The PR reads draft: true and auto_merge: false after the merge: the conversion did disable
auto-merge and did not touch the admitted queue entry, which GitHub then completed. Nothing in the
fleet's lanes decided this and no lane can prevent it.
So the hold depends on where the PR is:
| state | how to hold it |
|---|---|
| open, not queued | convert to draft — that is the whole mechanism |
| in the merge queue | dequeue FIRST (GraphQL dequeuePullRequest — REST cannot express it), then draft |
Check before assuming, because the window is a minute wide:
gh api graphql -f query='{repository(owner:"Systemorph",name:"MeshWeaver"){
mergeQueue(branch:"main"){entries(first:20){nodes{pullRequest{number} state}}}}}'