Platform Script Resolution
A node repo does not own its gate scripts. The reusable lanes fetch the platform's
.github/scripts/* at a ref and run them against the caller's tree; the repo keeps only its
policy — scripts/compile-check.allow, scripts/gen-manifests.config.json. That rule is stated in
Module Build Architecture; this page is about the part
that bites afterwards — which ref, and who resolves it.
What the lane actually does
node-repo-compile-check.yml fetches the script at the caller's platform-ref input and runs it
with the caller's tree and allow-file:
gh api "repos/Systemorph/MeshWeaver/contents/.github/scripts/compile-check.py?ref=${PLATFORM_REF}" \
--jq .content | base64 -d > "$RUNNER_TEMP/compile-check.py"
python3 "$RUNNER_TEMP/compile-check.py" --refs ../refs
# MW_REPO_ROOT: ${{ github.workspace }}/repo
# MW_ALLOW_FILE: ${{ github.workspace }}/repo/scripts/compile-check.allow
and the input's own description states the contract:
platform-ref:
description: The Systemorph/MeshWeaver ref whose .github/scripts this gate runs ...
Default main; pin it to the same sha as the `uses:` line for a reproducible run.
required: false
A call that omits platform-ref runs core main — a floating ref. Two runs of identical code
can execute different guards, and nothing in either run says so.
Pin Set Consistency explains why the pin gate cannot see this:
invariant I3 compares literals, so a call passing no platform-ref has nothing to compare and the
repository is reported consistent.
The local runner, and the rule that makes it correct
A developer still needs to run the gate before pushing. The runner is scripts/platform-script.py,
and its single job is to resolve the ref the lane would resolve and apply the lane's env, so that
a local verdict is CI's verdict:
python3 scripts/platform-script.py compile-check.py # run it against this repo
python3 scripts/platform-script.py --path compile-check.py # where the cached file is
python3 scripts/platform-script.py --ref compile-check.py # which ref it resolved to
The resolution rule is one line, and it mirrors the lane rather than restating it:
<lane>.with.platform-ref if the call passes one
"main" otherwise — the lane's own documented default
Two consequences worth stating out loud:
- The ref is per SCRIPT, not per repo. The ref that decides which
compile-check.pyruns is the compile-check lane's; the ref that decides whichcheck-workflow-timeouts.pyruns is the validate lane's. A repo whose lanes sit at different shas resolves different scripts at different refs, and that is correct. - A
mainresult must not be cached across runs.mainmoves, so a cache keyed by it is a stale-guard generator — exactly the failure the loader exists to end. Cache only an immutable 40-hex ref.
🚨 The loader is NOT portable between repos
This is the trap, because the migration looks like a copy-paste job and is not.
MeshWeaver.Reinsurance's loader resolves one sha and refuses unless every
node-repo-*.yml@<sha> in ci.yml agrees:
shas = sorted(set(USES.findall(CI.read_text())))
if len(shas) != 1:
raise SystemExit("✗ ci.yml pins N different node-repo lane sha(s) — the lanes are ONE contract")
That assumption holds in Reinsurance and fails in MeshWeaver.Manufacturing, which pins four lane
shas — and is supposed to. check-pin-set-consistency.py reads that as consistent, because I3
pairs a lane call with its own platform-ref and never with another lane's:
pin-set consistency: 1 repository(ies) examined, 1 consistent, 0 not.
So dropping Reinsurance's file into Manufacturing yields a tool that refuses on every invocation — a local runner nobody can run, which sends the next person straight back to a vendored copy. Manufacturing's loader therefore resolves per script, from the lane that runs it.
Measure the target repo's lane shas before adopting, and pick the shape that matches. Neither loader is "the" loader.
Why a vendored copy is worse than no copy
A local scripts/compile-check.py is never what CI runs, so it can only ever be a second
opinion — and a second opinion that drifts is the worst shape a gate can have: it is confidently
wrong, and it disagrees with the check that actually blocks the PR.
Measured across the fleet on 2026-09-08, one file forked three ways, none of them what CI ran:
| repo | vendored scripts/compile-check.py |
scripts/platform-script.py |
|---|---|---|
| MeshWeaver.Crm | 40,610 B | absent |
| MeshWeaver.SocialMedia | 39,515 B | absent |
| MeshWeaver.Manufacturing | removed | present (per-script resolution) |
| MeshWeaver.Reinsurance | removed | present (single-sha resolution) |
core main — what CI actually runs |
56,145 B | — |
Deleting the copy is not the whole fix. README.md, AGENTS.md and
.claude/skills/gates/SKILL.md in these repos document python3 scripts/compile-check.py as a hard
gate. A bare delete leaves three lying instructions and no way to run the gate at all — which is how
the copy comes back. Land the loader and repoint the docs in the same change.
The one copy a repo may NOT delete yet — scripts/gen-manifests.py
Everything above assumes the end state is "no copy". One vendored script is still required by a
lane: node-repo-resolve-locks.yml regenerates a conflicting pull request's manifest.locks with
the caller's scripts/gen-manifests.py --resolve and refuses a repository that has none (the
platform's canonical runs only the post-check). So that copy decides whether a merge is reported
resolvable, and — for a repository still on centralized-gen-manifests: false — it is the
--check verdict too. It is exactly the file whose vendoring the resolver guard's own header cites
as the precedent for why guards exist: six copies drifted to five vintages and each fix landed in
one of them.
That precedent repeated (MeshWeaver#4777). #4775 fixed three ways --resolve reported
✓ … the merge can be committed over a question it had not answered — a failed git read coerced to
"", a suffix classifier at three sites, an ls-files --exclude-standard read past — and four
copies were re-copied with it. Two could not be: MeshWeaver.Education predates the config contract
(no scripts/gen-manifests.config.json, a module-level SKIP) and the canonical refuses to guess
which directories are packages; MeshWeaver.Plugins loads project-closure.py eagerly at import
where the canonical loads it lazily. Both still carry all three. And within days the four were
three canonical commits behind again — a re-copy is a snapshot, not a subscription.
The guard now compares this copy too — check-resolver-copy.py --subject gen-manifests, run by
node-repo-validate right after it fetches and self-tests the canonical — at the code level, with
the same function-level report (copy LACKS _unmerged_paths, resolve differs). Measured on every
satellite's main the day it landed: Education 706 code lines from the canonical, Plugins 507, the
four re-copied repositories 138 each (the three post-#4775 commits) — a drift figure is a
measurement with a date on it, and every run of the lane re-prints the current one, so read the
annotation, not this paragraph, before acting.
🚨 It is advisory by declaration, and deliberately carries no flip date. The resolver guard's
flip was safe because a resolver copy is deletable: a repository that cannot keep up removes the
file and the lanes resolve for it. A gen-manifests copy is not — the resolve lane refuses without
it — so a red on drift would be a red on every canonical change, fleet-wide, for a file nobody
may delete; the canonical moved four times in the three days after #4775. A dated flip here is the
"whole fleet red at midnight" shape, not a ratchet. The flip is therefore a change, not a date:
when node-repo-resolve-locks.yml resolves with the platform's canonical (which needs every caller
to have declared its config first), the copy becomes optional, the no-copy notice becomes the end
state exactly as for the resolver, and the subject can be given a red_from. Until then the guard
does the job the precedent lacked: every run answers "did the fix reach this copy?" in its
annotations, instead of a person remembering to ask. --red-from is refused for this subject so a
workflow argument cannot schedule the flip by accident; what stays red on every run is a canonical
that cannot be read or parsed and a copy that cannot be parsed.
A pin older than the guard's second subject fetches a guard without it. The lane names that
(::notice:: gen-manifests copy NOT compared: the guard fetched at <ref> predates its gen-manifests subject) rather than skipping — "not compared" and "compared and clean" must never print the same
sentence.
Adopting it in a repo
- Read the repo's lane shas out of
ci.yml. One sha across all lanes, or several? That decides which resolution shape the loader needs. - Add
scripts/platform-script.pywith aLANE_FORmapping from script name to the lane whoseplatform-refdecides its ref. Refuse loudly on an unknown script rather than guessing — a guess reintroduces the local-verdict-disagrees-with-CI bug the loader removes. - Gitignore the cache (
.platform-scripts/) and add it to every package enumerator'sSKIPset.check-skip-sets.pyasserts those sets are equal, so they move together; the count going up by one is the positive signal that the edit landed. - Delete
scripts/compile-check.py, keepscripts/compile-check.allow. The allow file is the repo's own policy and the one piece that belongs there. - Repoint the docs that name the deleted path.
- Verify by running it, not by reading it:
platform-script.py compile-check.py --self-test, plus--refon a script from a lane that does passplatform-refand one from a lane that does not, so both branches are exercised.
Related
- Module Build Architecture — the one build shape, and the centralization rule this page implements.
- Pin Set Consistency — why a lane call that omits
platform-refis invisible to the pin gate. - Keeping the Platform Source Pin Current —
MW_PLATFORM_REFis the commit a repo'ssrc/compiles against; it is a different object from a lane's script ref. - Module Versioning — what the build derives versus what you author.