Module Versioning
π¨ Rule change, 2026-09-07 (maintainer) β see Module Adoption Policy. Delivery is no longer keyed on SemVer alone: a rebuilt same-version bundle for this platform's identity is adopted, and a fallback generation re-examines every new build. Implemented in PR #3661 (2026-09-08); the sections below describe the mechanism as it runs now.
A package's version is not decoration. It is the ONLY thing that decides whether anything you built reaches a portal that already has an older copy. Get it wrong and the pipeline stays green, the bytes reach the shelf, and no installation ever asks for them.
This page is the authoring reference for that number. For the node revision counter β a different number entirely β see MeshNode Versioning. For what a module IS and how a deployment activates one, see Modules.
π¨ The PLATFORM's version is a third number, and it is not authored here. PlatformVersion has
exactly two shapes β X.Y.Z-ci.<n> for every continuous build and clean X.Y.Z for the release, no
rc, no preview, no labelled line, ever β stated authoritatively in
Release Process & Versioning Β§1. Read it before writing a
package's content.minMeshVersion floor: a floor is a platform version, and SemVer Β§11.4 ranks
pre-release identifiers as text, which is how 42 packages came to declare floors no shipping platform
could ever satisfy (#3554). π¨ A floor is an authoring claim checked at pack time
(check-module-platform-floor.py refuses one above the platform the bundle is built against) β at
runtime it is advisory and loadability is measured, never declared
(issue #3648). Getting it wrong therefore
fails your build; it is not a knob for keeping a module off a deployment.
π¨ The compatibility LADDER β what keys compiled bytes (policy platform-backwards-compatibility)
Policy
platform-backwards-compatibility: platform builds are backwards compatible within a major and a declared compatibility epoch. This section is the manual for it; every older section below that speaks of "the framework identity" moving on a platform roll describes the scheme this one replaced and is kept as the record of why.
The ladder. platform1+plugin1 β platform2+plugin1 β platform2+plugin2 β platform3+plugin2 β β¦.
A platform roll keeps the OLD plugin bytes β no rebuild, no re-seal. A plugin rolls independently,
built against the platform that is RUNNING. Only a declared break (an epoch bump, or a major bump)
steps off the ladder, and it does so on purpose.
The key. Compiled NodeType bytes and module bundles are keyed on the platform compatibility
key c<major:D3>e<epoch:D3> β e.g. c003e001 β never on a per-build identity:
| what | where |
|---|---|
| the key | FrameworkBuildIdentity.FrameworkVersion (== CompatibilityKey == PrebuiltAssemblySeeder.LiveFrameworkMvid == ProducerStatedIdentity), computed by PlatformCompatibility.KeyOf(major, epoch) |
major |
the AssemblyVersion major, derived from $(PlatformVersion) |
epoch |
src/MeshWeaver.Compiler/platform-compatibility.json β "epoch" β the ONE source: Directory.Build.props reads it into $(PlatformCompatibilityEpoch) and stamps AssemblyMetadata("MeshWeaverCompatibilityEpoch") into every assembly, and the runtime reads the same file embedded (PlatformCompatibility.Declaration) |
| read off a foreign host | FrameworkBuildIdentity.ResolveIdentityForDirectory(/app) β the anchor MeshWeaver.Compiler.dll's metadata, no manifest needed (mw-plugin-test framework-identity β¦ --expect compares this) |
| the store tag | the whole 8-char key (v<version>-c003e001-<hash>.dll), so every build of an epoch hits the previous build's bytes |
| a dependency record's platform entries | compat:c003e001 (CompiledDependencies.CreateCompatibilityIdResolver, ToolchainIdOf) β a platform build moves no record; a MODULE entry is still its min: floor |
The per-build surface/commit identity (s<hash> / g<sha>) is provenance only β
FrameworkBuildIdentity.BuildProvenance, ProducerStatedProvenance,
ResolveBuildProvenanceForDirectory, FrameworkIdentity.ReadProvenance. It is logged (the pre-warmer
names key, platform build and provenance together) and recorded, and it NEVER decides a cache miss,
an adoption, a load or a roll. PlatformCompatibilityRatchetGuard reds any statement that compares
one of those members.
The RANGE: floor and ceiling. Within one key, bytes carry a platform range:
| field | bundle manifest | NodeType record | meaning |
|---|---|---|---|
| FLOOR | producerPlatformVersion |
CompiledPlatformVersion |
the platform build that PRODUCED the bytes (PlatformBuildInfo.PlatformVersion of the compiling/baking process; mw-plugin-test images carry MESHWEAVER_PLATFORM_VERSION for it) |
| CEILING | platformCeiling |
PlatformCeiling |
the highest platform build the bytes claim, open when absent |
The one rule, PlatformCompatibility.DeclineReason(producedKey, floor, ceiling, liveKey, running):
same key AND floor β€ running β€ ceiling β adopt and bind to the running platform; anything else β
declined LOUDLY, both versions named. There is no other version gate. An absent floor is "unknown
producer = older" β accepted, so every record written before the field existed stays adoptable.
Ordering is PlatformReleaseOrder (the ci.<n> run ordinal); a release against a continuous build is
unordered and therefore never a "newer" reading.
Binding. A plugin/NodeType assembly compiled against platform 3.0.0.0 binds on a platform
stamped 3.1.0.0: PlatformBinding.MayBind(compiledAgainst, running) is running >= compiledAgainst,
applied by the module link probe (ModulePlatformLink: lower β linkable with an advisory, higher β
BindingConflict, i.e. floor not met), and the NodeType load context and ModulesAssemblyLoadContext
resolve every platform name through the default context (TPA roll-forward) β never a private copy,
never an exact-version hard link.
What a mixed roll does. Two builds of one epoch serving one mesh (P2 draining, P3 new): P3
adopts every record P2 wrote (older floor β in range) and recompiles nothing. A record P3 writes
(a source change) carries floor P3; P2 reads it as out of range and β because the ORDER is known
from the run ordinal β yields unconditionally (NodeTypeBuildIdentity.OwnedByANewerPlatformBuild
β DecideFrameworkStale = Yield), overlaying that one type instead of re-keying it backwards. No
re-key war. The census (bake-report's LIVE RECORD CENSUS) counts those records as foreign on P2
by the same reader. The ONE roll that crosses from the per-build scheme to the key (old image sβ¦,
new image c003e001) behaves exactly as every roll did before this change: records re-key once
(the #5353 leaving-yield keeps the draining replica from fighting it), and from then on rolls within
the epoch re-key nothing.
A DECLARED BREAK. When compiled bytes must be invalidated β a public API removal or signature
change compiled content can bind, or a change to the skeleton generator
(DynamicMeshNodeAttributeGenerator) or any toolchain-generated compile input whose old output is no
longer loadable or correct β bump "epoch" in platform-compatibility.json and append a breaks
entry: { "epoch": <new>, "previousEpochCeiling": "<last platform build of the old epoch>", "reason": "β¦", "declaredIn": "#<PR>", "members": [...], "affected": [...] }. Everything built
against the old epoch then declines (its effective ceiling is previousEpochCeiling,
CompatibilityDeclaration.CeilingForEpoch), and ONLY then does a coordinated rebuild and seal of the
affected plugins sit on the path. Never gate on an exact build identity, never seal or pin an image
around a break: fix compatibility (an [Obsolete] forwarder, an API restore, the binder) or declare
the break.
The three numbers, and who owns each
| number | lives in | owned by |
|---|---|---|
| MAJOR.MINOR β the series | the package root's index.json β content.version |
you, by hand |
| PATCH | manifest.lock β version |
the build (gen-manifests.py), never you |
moduleVersion β a content hash |
manifest.lock |
the build. Unordered; nothing can pin against it |
You author a series. The build derives the patch against the last published release and settles it
on main in tag-modules, so a branch never races the trunk for a number.
The hash input is the Git-visible package tree: tracked files plus non-ignored untracked files
in the working tree. Git-ignored local outputs (for example, a test .trx file under a package)
are not package content and do not affect moduleVersion. This keeps a developer's post-merge lock
identical to the lock CI derives from the commit; a new non-ignored content file still needs to be
committed with its regenerated lock.
- Bump the MINOR for a feature. Bump the MAJOR for a break. That is the whole authoring rule.
- π¨ Never hand-edit the PATCH. It is derived. A hand-set patch is a claim about a tree you did not measure.
- π¨ Never commit a node's
versionfield. That isMeshNode.Version, the owner's persistence clock β not authorable content. A committed counter collides with the durable row on a fresh mesh,MonotonicWriteGuardrefuses the write, and the symptom surfaces four causal steps away as "never reached compilationStatus Ok". Stripversion,lastModifiedandlastModifiedByfrom anything you copy out of a live mesh. - π¨ A published version describes exactly one tree, forever.
tag-modules.pyrefuses to move an existing tag onto different content. Bump; never re-cut.
Why an unchanged version means "delivered to nobody"
Module delivery is keyed on SemVer and nothing else. Two independent gates close on an unchanged version, and neither looks at the bytes:
ModuleUpdateDecisionβlandedComparison == 0βSkipUpToDate. The verdict is taken before any download, and the fetch carries noIf-None-Match, so there is no byte-level fallback.CatalogLayoutAreasβ the manual Update button returnsInstallResult(0, 0)onModuleVersionequality, before the module half is even composed. The human override does not override.
CI still rebuilds the assembly and POSTs it to bundles/<Package>?version=<unchanged>. The shelf is
correct. Nobody requests it. A fresh install gets the new code; every existing portal keeps the old
one forever β and the repo, CI and the registry all look green. This is
MERGED is not delivered one layer down.
π¨ The blind spot: src/ and mixed packages
A mixed package declares content.module and ships an assembly built from src/.
gen-manifests.py enumerates packages with plugin_dirs(), which skips the set the repo declares
in scripts/gen-manifests.config.json (see "One checker, every repo" below):
{"skip": ["src", "test", "scripts", "tools", "e2e", "app", "clients", "meshweaver", "β¦"],
"hashModuleSources": true}
That skip is deliberate and correct for enumeration β src/ is not a package, and skipping it is
what keeps this gate independent of step order and of validate-repos.py. Do not "fix" this by
deleting "src" from the skip list. That changes what moduleVersion means for every package.
The narrower, correct switch is hashModuleSources.
The defect was narrower: for a mixed package, the module's own source under src/ was never hashed
into moduleVersion β so a change confined to src/ moved no version, by construction, and was
therefore built, shelved and delivered to nobody.
Measured on 2026-08-29: 12 of 29 mixed packages were in exactly that state. The worst was the AI
engine itself β AI/manifest.lock on main was byte-identical to the tag AI/v1.0.0
(version 1.0.0, moduleVersion be7ff235b95fb8e4) across 205 changed src/ files. Since
MeshWeaver.AI left the portal image, the module bundle is its only delivery channel, and
Agent/ + Skill/ content ships as embedded resources inside that assembly β so an edit to a
built-in agent reached nobody, silently.
The fix belongs in the derivation, not in a checklist. For a package declaring
content.module, the hash must cover what actually ships in that bundle, so a src/-only change
moves the hash, moves the derived patch, and is delivered. Nothing to remember.
What "what actually ships" means β the closure is narrower than the reference graph
Do not hash the whole dependency network. A bundle's contents are decided by DepsClosure.Derive,
which walks the module's own deps.json from its direct references and stops at MeshWeaver.*
nodes β those are never bundled, because a bundled copy would shadow the one in /app. Diamonds
ride deliberately: /app wins in the default load context while the platform carries a copy, and
the module's copy takes over when the platform sheds it, which is what lets platform slim-downs
land with no re-land coordination.
So for a mixed package the hash should cover:
| in the hash? | why | |
|---|---|---|
| the module project's own sources | yes | they are the assembly |
the module's .csproj |
yes | it pins package versions, and a NuGet bump changes the shipped bundle |
non-MeshWeaver.* transitive deps |
yes, by resolved version | they are bundled alongside the module |
module-owned MeshWeaver.* project references |
yes β and today they are NOT | they RIDE in the bundle (see below), so changing one changes this module's bytes |
image-shipped MeshWeaver.* project references |
no | never bundled β /app supplies them, so changing one does not change this module's bytes |
π¨ MeshWeaver.* is not one row β the container lane split it in two
The version derivation implemented for the fix above hashes the module's own project alone,
on the reasoning that a MeshWeaver.* reference is never bundled. That reasoning is only half
true, and the half it misses is live.
DepsClosure.Derive does stop at MeshWeaver.*, and that is what the sdk pack path uses
(--deps-closure). The container path β now the default for nearly every entry β does not use
it. It reads the module's closure manifest and, for every MeshWeaver.* sibling in it, asks
module-owned-platform.sh one question: is this project's source in this repo's src/ and does
the platform host not already ship it? If yes, the sibling is copied into the bundle and
passed as --with <Name>.dll, and the job log says so:
π¨ That second half used to read "and absent from src/platform-shipped.txt" β a hand-maintained
list, which drifted in both directions and put 27 duplicate copies into 14 of 37 bundles. It is now
MEASURED off the pinned image; see The Platform-Shipped Witness.
closure: MeshWeaver.Blazor.dll RIDES β module-owned (its source is in this repo's src/,
so it is nowhere in the image's /app)
It must ride: nothing else would supply it at run time. But the version derivation does not know
that, so a bundle can change its bytes without moving its version β the exact defect the
src/ fix above was written to remove, one hop out.
Measured on MeshWeaver.Plugins, 2026-09-01 (35 matrix entries, 119 module-owned
MeshWeaver.* projects): MeshWeaver.Blazor is module-owned and rides in 7 published
bundles β Analysis, AppleMaps, EntityViews, GoogleMaps, GraphViews, OpenStreetMap,
Radzen. Not one of those seven manifest.lock files hashes a single src/MeshWeaver.Blazor/
path. An edit there changes what all seven bundles ship and moves none of their versions, so every
existing portal keeps the old copy β SkipUpToDate, before any bytes are read.
The MeshWeaver.AI example in the row above is currently safe only by accident of a transition:
MeshWeaver.AI is listed in that repo's src/platform-shipped.txt while MeshWeaver.Blazor.Portal
still references the engine. That line is marked to leave with MeshWeaver#2599 β and the moment
it does, every AI-provider bundle joins the seven above. Do not read "a change to
MeshWeaver.AI does not require bumping MeshWeaver.AI.OpenAI" as a standing rule; it is a
statement about one entry in one exclusion list on one day.
The correct scope is the RIDING closure, not the whole reference graph. Hash the module's own
project plus exactly the MeshWeaver.* siblings module-owned-platform.sh says ride in its
bundle. That is neither "own project only" (which under-covers, as measured) nor "the whole
closure" (which bumps every dependent on any change and destroys the signal in the number) β it is
the set whose bytes are actually inside the artifact being versioned.
The ProjectReference walker for this already exists β see scripts/check-surface-manifest.py
(assembly names reachable from start through ProjectReference), scripts/project-closure.py,
and the floor rule in scripts/check-module-floors.py. Reuse one of those rather than writing a
fourth; the module-owned/image-shipped split is .github/scripts/module-owned-platform.sh, the
same script the pack step and the bundle inspection call, so the three cannot disagree.
Until that lands, registry-version β manifest-version is NOT a sound publication baseline.
It is proposed periodically as a way to narrow the module lane on push (Plugins#889 option 3);
version equality does not imply byte equality while any sibling rides, so narrowing on it would
silently under-publish exactly the bundles above. A sound baseline has to be a commit β the
analogue of the bake's source-commit.txt β diffed with project-closure.py, which walks
transitive in-repo ProjectReferences and therefore sees riders for free.
If you are reading this while that derivation is still landing: bump
content.version's MINOR by hand for anysrc/-only change to a mixed package, and say in the PR that you did so and why.
π¨ Version strictness β what a platform roll ADOPTS (Modules:VersionStrictness)
Maintainer directive, 2026-09-08: "typically it should accept newer platform versions, especially within the same family β we can have a setting for version strictness; but for dev we should be very tolerant and only use min versions." And earlier: "for every new platform release we should be able to just roll it and the old modules should work β then it is fully decoupled."
Until this setting, a portal adopted a prebuilt bundle only when it was sealed under the portal's
exact framework identity (<root>/<identity>/<source>/). Every platform roll therefore adopted
nothing until every satellite had re-sealed for the new identity, and on 2026-09-08 β with CD
unable to produce a sealed set at all β that meant no roll. The identity gate is still the safest
statement there is, and it is still the default for the image's own bundles; but a bundle's real
requirement is the set of platform TYPES its bytes link against, and that is measurable from the
assembly's own metadata (ModulePlatformLink, the module lane's gate β see
Module Platform Link Gate). So adoption now has a strictness, decided
by PrebuiltAdoptionPolicy and read once per sweep:
Modules:VersionStrictness |
adopts a bundle sealed for another identity when⦠| default where |
|---|---|---|
Exact |
never β this identity's seal only (the rule before 2026-09-08) | β |
Family |
its _releases marker places it on the same major line (3.x on 3.y), its declared floor (minMeshVersion) is satisfied, and every assembly's platform type references resolve against the running process |
every deployment |
Minimum |
its floor is satisfied and its links resolve β the platform line is not consulted | a Development host (the Monolith, the Aspire dev profiles) |
What the policy does per bundle:
- This identity's seal adopts as before β no link check; the bytes were compiled against exactly this platform.
- Otherwise the identity is placed on a line through the
_releases/<version>markers (SealedPublicationIndex.ReleasesOf).Familydeclines an identity no marker names β it cannot establish the line and does not guess;Minimumdoes not need the line. - A candidate the line and floor admit is measured:
ModulePlatformLink.Checkover each assembly's type references against the running platform's surface. Linkable β adopted, stamped with the live dependency ids (PrebuiltAdoptionPolicy.LiveStampOf, so the build-currency clause does not immediately call it stale); unlinkable β compiled from source on a mesh that may compile, or refused loudly β naming the type and the missing member β on aModules:RequirePrebuiltmesh. - The sweep takes, for each source this identity has not sealed, the newest sealed publication
among the admitted identities (
ShippedPrebuiltBundles.FallbackPublishedBundlesOf). A source this identity HAS sealed is never shadowed.
What the link check does not see, so nobody assumes it does: a member that moved on a type that
still exists. That surfaces at activation (MissingMethodException), where the stale-build self-heal
recompiles the type from source β the same fallback a declined bundle takes, one step later.
Development. Minimum is what "very tolerant" means: yesterday's bakes load on an unreleased
platform unless a type they need is genuinely gone. A developer who wants the production rule sets
Modules:VersionStrictness=Family (or Exact) in the Monolith's configuration β a configured value
always wins over the environment default.
π¨ What this setting does NOT decide: whether the replicas of one installation converge on one sealed set. #3417 (fix for #3395) settled the mechanism β two compiles in one process resolve one module set β and deliberately left the POLICY open: whether a replica whose pinned set no longer matches should (a) restart itself, (b) flip readiness so the rollout replaces it, or (c) decline to write NodeType compile records while it is behind (#3417 β "Still open"; Modules β "Replicas of ONE deployment can run DIFFERENT module sets"). This setting inherits that gap unchanged: every replica reads the same shared storage under the same rule, so two replicas of one image choose the same publication unless a seal lands between their boots β the in-place-republish window Sealed Publication Reads describes, which
Family/Minimumneither widen in kind nor close (they only admit more candidate directories). A replica's adoption is written onto the SHARED NodeType record with that replica's coordinates, exactly as a local compile is today. Whether replicas must converge β and how β is the maintainer's decision, and it is stated here so that it is not settled as a side effect of the strictness default.
Full rebuild when the platform updates
π¨ Superseded for every platform build within one epoch β see "The compatibility LADDER" above (policy
platform-backwards-compatibility). A platform release no longer moves the key a module is declined on, so it no longer obliges a rebuild; the table below still describes what the lanes COMPILE, and a full rebuild is owed only behind a DECLARED break.
A module is built against a platform pin. When the platform releases, the pin moves and EVERY
module must be rebuilt and republished β not only the ones whose source changed β or every portal
reads FrameworkDeclined (built against <old>, live <new>) and adopts nothing.
So the two lanes differ deliberately:
| trigger | bake lane | module lane |
|---|---|---|
pull_request / merge_group |
the affected closure | the affected closure |
push to main |
the affected closure, baselined on the PUBLICATION (source-commit.txt), never github.event.before |
everything β this lane records no publication marker, and the registry version is not one (see the riding-closure note above) |
repository_dispatch / schedule (release-follow) |
everything | everything |
workflow_dispatch |
everything | everything |
The push row is the only one where the two lanes differ, and the difference is a missing
marker, not a policy: bake-scope.sh reads a source-commit.txt sealed beside the bundles, so it
knows the commit its own last publication covered. The module lane's scope still answers FULL on a
push β but since 2026-09-02 the module build ledger sits below the scope
(ModuleBuildArchitecture β "Content-addressed outputs"):
every selected module is keyed by its whole compiled+tested closure plus both image digests and the
platform ref, and a key the ledger already holds as Published is reused, not rebuilt. So a push
compiles every module whose key has no usable Published record β the Plugins#889 baseline, derived
from content rather than from a version or a commit, which is what makes it immune to the riding
blind spot above. Callers opt in with ledger: required.
One checker, every repo
gen-manifests.py is the platform's, at .github/scripts/gen-manifests.py. node-repo-validate.yml
fetches it at the caller's pinned platform-ref and runs it against the caller's tree, exactly as the
compile-check lane runs .github/scripts/compile-check.py (AGENTS.md β "never hand-roll a node repo's
CI"; Module Build Architecture β "scripts are centralized β
the lane fetches the platform's copy at the pin; repos keep only allow-files").
It was vendored per repo until 2026-09-07, and by then the six copies were five different vintages β
MeshWeaver.Plugins 1157 lines, Crm 646, Education 646, Reinsurance 644, SocialMedia 643, Manufacturing
305. Each fix landed in whichever copy hit the bug and the other five kept it: #434 (a release has two
witnesses), #942/#1023 (--resolve), and #1426 β the trunk baseline resolved against the remote's
live tip, which reds every intermediate commit of a merge-queue group on main, because the tree
under test is compared against locks committed after it. Manufacturing's copy never grew the trunk
witness at all, so it also had no --resolve and no self-test.
What stays in the caller is one allow-file, scripts/gen-manifests.config.json:
| key | |
|---|---|
skip |
required β the top-level directories that are NOT packages. Per-repo by construction (a scratch directory in one repo is a shipping package in another), and validate-repos.py's package_dirs(root) must return the same packages plugin_dirs does, which the lane's check-package-enumeration.py asserts on the checkout and on a fixture (see below). There is no default: a guessed skip list either demands a manifest.lock for scripts/ or silently stops versioning a real module. |
hashModuleSources |
optional, default false β the #878 fix (hash a mixed package's src/ project, and the siblings riding its bundle, into its moduleVersion). It needs the caller's scripts/project-closure.py to expose graph_of / module_owned / riding_siblings, and asking for it without one is an error, never a quiet fall-back to the smaller hash. Default false because turning it on moves every mixed package's version β a release event, not a script upgrade. |
lockOwner |
optional, branch (default) or main β who writes the committed manifest.lock. See Who writes the lock below. An unknown value is an error, never read as the default. |
Both settings are declared, never inferred from whether a file happens to exist: a capability that degrades silently on a missing input is the skip-trapdoor shape AGENTS.md forbids, and here it would change what a published version means without saying so.
π¨ The declared skip is NOT the whole rule β ask --list-packages for the effective answer
plugin_dirs() applies the declared skip and an implicit rule of its own: a top-level
dot-directory is never a package. Tooling writes them (check-examples.py builds under
.example-check/, platform-script.py caches under .platform-scripts/) and hashing one as a
module is how a scratch build ends up demanding a manifest.lock.
That second half is what makes check-skip-sets.py β which asserts
validate-repos.SKIP == skip_set(root) β unable to see a divergence it exists to catch (#4774).
A repo enumerates its top-level packages twice: here, and in its own validate-repos.py. Measured on
five satellites' main, the second enumerator has no dot rule, so an undeclared top-level .foo is
skipped by the canonical and walked by the validator β validated as nodes it does not contain β
while the two declared sets are identical and the guard passes. That is the failure
check-skip-sets.py's own docstring names: "a guard whose subject moved out from under it and that
answers green has checked nothing, which is worse than the drift it exists to catch."
So a guard compares effective enumerations, never declarations:
MW_REPO_ROOT=$PWD python3 <platform>/.github/scripts/gen-manifests.py --list-packages
stdout carries only the package names, one per line, sorted β a caller diffs its own enumerator's
output against that and needs no copy of either rule; the count goes to stderr so an empty answer can
still be told from a broken one. The canonical's --self-test pins both directions over a fixture
tree holding a .example-check/, a declared skip and two real packages.
The guard is the platform's, and it calls both enumerators. node-repo-validate.yml fetches
.github/scripts/check-package-enumeration.py beside the canonical and runs it on every caller. The
contract is one function: the caller's scripts/validate-repos.py exposes
package_dirs(root) -> list[Path] β its skip set and the dot-directory rule, a pure function of
the top-level names β and its own main() enumerates through it. The guard then compares what the two
enumerators return, twice:
- on the checkout, name for name; and
- on a fixture holding every name either side declares, every top-level directory the checkout has, a probe dot-directory and a probe package β so a disagreement that needs a directory the tree does not happen to contain today (the dot rule, a skip name one side forgot) is red now rather than the day a tool writes one.
That second half is what the per-repo check-skip-sets.py could never do, and it found a live
instance on the first run: MeshWeaver.Plugins skipped dist in both of its SKIP sets and not in
the gen-manifests.config.json the canonical actually reads β equal declarations, a divergent
verdict. A repo that adopts deletes its check-skip-sets.py; the platform guard supersedes it.
π¨ The ordering. A caller whose validate-repos.py has no package_dirs yet is NAMED
(::warning::package enumeration NOT compared) and not failed β reddening it first would red every
satellite's required validate context for a condition none of them can fix without their own PR,
the fleet-wide-red shape a dated guard already produced here. Everything else is red: a missing or
unloadable validate-repos.py, a package_dirs that raises or returns anything but a list, a
main() that does not enumerate through package_dirs (checked statically β a helper added beside
an untouched root.iterdir() walk would otherwise pass while the gate still disagreed), a missing
config, any disagreement.
The flip of the absent case to red is a change to the guard once every caller has adopted, never a
date. Canonical first, repos adopt, guard tightens when it can only pass.
Adoption is one commit per repo β add the config, drop the vendored copy, give validate-repos.py
its package_dirs(root), retire check-skip-sets.py, and pass centralized-gen-manifests: true to
the lane.
Before merging one, prove it moves nothing:
MW_REPO_ROOT=$PWD python3 <platform>/.github/scripts/gen-manifests.py --check
MW_REPO_ROOT=$PWD python3 <platform>/.github/scripts/gen-manifests.py --check-versions
Both must be green on the tree as committed β the canonical has to reproduce every lock the
vendored copy wrote, or the swap republishes modules that did not change. Measured that way on
2026-09-07 across all six repos: identical verdicts everywhere, and Manufacturing gained the trunk
witness (verified against their tags β verified against the published tags and the trunk).
Who writes the lock
A manifest.lock is DERIVED: its files map is a function of the package's tree, its
moduleVersion a hash of that map, its version a function of the hash and the last release.
Two places can write it, and a repo declares which one does in gen-manifests.config.json.
lockOwner: branch (the default, and the historical contract) β every commit carries locks that
describe its own tree. A pull request runs the generator and commits the output; --check reds a
stale lock; main re-derives the version in its finalize-versions step.
lockOwner: main β the lock is a MAIN artifact, and a pull request commits no lock change.
Why main exists: a lock conflict cannot be prevented while branches write locks
A lock's trailer β moduleVersion, sourceCommit, version β changes on every content change to
its module, and its files lines are alphabetical neighbours (git conflicts on ADJACENT changed
lines, not only on the same line). So two pull requests that touch one module β or one src/
project riding into many bundles β always conflict on its lock, and each merge to main turns every
open pull request that touches the same module DIRTY. GitHub decides mergeability on its own
servers, where no custom merge driver runs, so a .gitattributes merge= driver cannot prevent the
state; it only helps whoever merges locally, after the fact. Measured on MeshWeaver.Plugins on
2026-09-27: of 21 open pull requests, 10 were conflicting, and every conflicting non-draft one
conflicted on generated manifest.lock files and nothing else (one merge, #2457, turned seven DIRTY
at once; #2448 went DIRTY four times in three hours). Each DIRTY cost a merge of main and a full
CI restart on shared runners.
The only state in which a merge on main cannot conflict another pull request over a lock is one in
which pull requests do not write locks. So:
| who | what | how |
|---|---|---|
| a pull request | commits NO lock change | --check reds a diff that touches any package's manifest.lock, naming the one-line fix (git checkout <base> -- <locks>) |
| every CI job that READS a lock | reads the lock THIS tree implies | the lanes run .github/actions/materialize-locks after the content checkout: gen-manifests.py --materialize, local and network-free, never committed; a byte no-op on a settled tree |
main |
the only writer of a committed lock | the caller's settle job opens or refreshes the reserved bot settlement PR; normal required checks validate the generated locks before they merge |
| a developer | nothing | the bare generator writes nothing on a main-owned repo (so the post-merge hook commits nothing); --materialize gives a local view that must not be committed |
--materialize derives the version from the committed lock alone β same patch when the content
hash still matches it, +1 when it moved, .0 for a new series or a new module. That is
derive_release with its trunk witness already on disk: a main-owned tree's committed lock IS the
last settled claim it descends from, and no published tag outranks it (tags are cut from settled
commits). Being pure is the point β every job of one run materializes independently and must arrive
at the same bytes, which a derivation reading the live remote could not promise. --settle derives
with the verified remote (both witnesses, as ever) and asserts that its numbers equal what
--materialize gives from the committed lock whenever the trunk it derived against is the checkout
itself β the property that makes a bundle named at pull-request time the bundle main publishes.
What the other checks do on a main-owned tree:
--checkβ every committed lock is well-formed (identity, schema, aversion, and amoduleVersionthat is the hash of its ownfilesβ a hand edit or a textual merge of two locks fails), no tree would materialize a version BELOW its committed one, and, on a pull request, the lock diff is EMPTY. The base isMW_LOCK_BASEwhen set, else the merge ref's first parent (whatactions/checkoutgives apull_requestrun); a pull-request run whose base cannot be read is RED, never "no base".--check-versionsβ checks every module whose committed lock describes the tree, and NAMES the modules it could not check becausemainhas not settled them yet (their lock claims no version for this tree).--settlerefuses to finish while any module is unsettled, so the claim that is made is always checked.--resolveβ resolves a lock conflict by ADOPTING the trunk's lock (every one of them), so the branch leaves the merge carrying no lock change and can never conflict on one again.
π¨ A caller that declares lockOwner: main MUST settle FIRST and publish only what its own settle
claimed. --materialize names a changed module patch+1 over the committed lock, so two merges that
touch one module before settlement would otherwise claim the same number for different trees. The
first job of every main run therefore opens or refreshes one reserved bot PR from the generated
lock files; it never pushes to protected main. The validator grants this PR a narrow exception
only when its author, branch, same-repository origin, base branch, changed paths, and generated
contents all match, and its base is the current verified main tip. Every other PR that changes a
lock stays red. If locks need settlement, that main run reports that it does not own the settled
tree and publishes, seals, and tags nothing. After the bot PR passes the repository's ordinary
required checks and merges, the resulting main run sees the settled locks and publishes exactly
what --settle derives. MeshWeaver.Plugins' settle-locks job and its own output are the reference
shape.
π¨ A run that withholds its publication must say so, or it becomes the next run's baseline.
node-repo-publication-base.py narrows a push's selection to git log <baseline>..HEAD, where the
baseline is the newest successful main push. An unsettled merge's run is successful and publishes
nothing, so taken as the baseline it would narrow the settle merge's run to the lock-only diff and
none of the merge's modules would ever publish. Such a run uploads a marker artifact and the caller
passes --withheld-marker <name>; a marked run is walked past like a failed one, and the history
union from the older baseline carries its changes into the run that does publish.
π¨ The merge commit on main carries the locks of the commit before it until the settlement PR
lands. Everything in CI reads the materialized lock, so no bundle, seal or key sees the stale one;
what reads main's committed tree directly β a GitSynced portal importing main unsealed β sees the
new content under the old lock until settlement and treats that module as unchanged until then
(ModuleSyncDecision). A failed settlement workflow is red on main, never silent.
Before you open the PR
On a main-owned repo, none of the generator steps below apply: commit no lock, and prove the diff
carries none with MW_LOCK_BASE=origin/main python3 scripts/platform-script.py gen-manifests.py --check
(the lock diff is taken against the merge base, so a branch that is merely behind is not charged
with the locks main settled since).
python3 scripts/platform-script.py gen-manifests.py # after ANY change to a package folder
# OR to a src/ project a package bundles β both move the lock
python3 scripts/platform-script.py gen-manifests.py --check # what CI runs on your branch
platform-script.py resolves the platform's copy at the sha this repo's lanes are pinned to, so a
local verdict is CI's verdict. A repo that has not adopted yet still runs python3 scripts/gen-manifests.py.
--check is a PR gate and is entirely local: it asserts your lock describes your tree.
--check-versions is main-only β a branch is never asked to win a race against the trunk.
The question to ask before merging is not "is it green" but "will anyone receive it":
- Did I change a package's node content? β the hash moves and the patch is derived, but you still
run
gen-manifests.pyand commit its output. "Derived, not hand-edited" does not mean "generated for you": CI regenerates-and-commits only onmain(finalize-versions), and on a branch it merely runs--check, which redsValidate node reposnaming every stale module. - Did I change only
src/of a mixed package? β the same applies β editingsrc/alone moves the lock of every package that bundles that project, often a dozen at once. Run the generator, then confirmmanifest.lockactually moved: if it did not, the change reaches nobody. - Is this a feature or a break? β bump the MINOR or the MAJOR in
index.jsonby hand. - Am I tempted to edit the patch, or to re-cut an existing tag? β no.
Related
Modules Β· MeshNode Versioning Β· Plugin Packaging Β· Deploying Plugin Changes Β· Plugin Registry Β· Plugin Update on Green Build Β· Deploying Across Platform Versions