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 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

  1. 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.
  2. Add scripts/platform-script.py with a LANE_FOR mapping from script name to the lane whose platform-ref decides its ref. Refuse loudly on an unknown script rather than guessing — a guess reintroduces the local-verdict-disagrees-with-CI bug the loader removes.
  3. Gitignore the cache (.platform-scripts/) and add it to every package enumerator's SKIP set. check-skip-sets.py asserts those sets are equal, so they move together; the count going up by one is the positive signal that the edit landed.
  4. Delete scripts/compile-check.py, keep scripts/compile-check.allow. The allow file is the repo's own policy and the one piece that belongs there.
  5. Repoint the docs that name the deleted path.
  6. Verify by running it, not by reading it: platform-script.py compile-check.py --self-test, plus --ref on a script from a lane that does pass platform-ref and one from a lane that does not, so both branches are exercised.