Skip to content

fix(#3461): phase 4 — core publishes the plugins prefix as generations - #4269

Merged
meshweaver-cloud[bot] merged 1 commit into
mainfrom
work/pub-issues-4
Sep 14, 2026
Merged

meshweaver-cloud[bot] merged 1 commit into
mainfrom
work/pub-issues-4

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Refs #3461 — phase 4's core half. #4249 removed the reason the flip had to be atomic across two repositories; this takes it.

What changes

main-cd.yml's plugins-bake passes publication-layout: generation. From this merge, core's publications of prebuilt-bundles/<identity>/plugins are written into their own run-named directory, verified there, sealed there, and only then pointed at by _current — written last. The prefix is never mutated in place again for a reader that follows the pointer.

The precondition, measured rather than assumed

🚨 The prefix-ownership mechanism (#4249) lives in the PUBLISHER, so "either order" is a claim about which publisher each producer is RUNNING. A producer resolving a publish-bake-bundles.sh that predates #4249 would refresh the flat copy in place and never move the pointer — the stale serve the mechanism claims to have made unreachable. That is the one thing that had to be true before flipping, and it is checkable.

Measured 2026-09-14, all six node repos (ci.yml on each main, read over REST):

repo lane uses: scripts-ref:
MeshWeaver.Plugins …/node-repo-{validate,gate,publish-bake}.yml@main main (×5)
MeshWeaver.Crm …@main (validate, compile-check, gate, publish-bake) main (×5)
MeshWeaver.Education …@main main (×4)
MeshWeaver.Reinsurance …@main main (×5)
MeshWeaver.SocialMedia …@main main (×5)
MeshWeaver.Manufacturing …@main main (×5)

No pinned publisher and no pinned reader of this prefix exists in the fleet. MeshWeaver.Plugins' publish-bake in particular passes scripts-ref: main, so the other producer of plugins runs core main's publisher on every publish — it starts writing generations on its very next run, with no change in its repository, announced by the ::warning:: #4249 added. Its own flip is then explicitness, not correctness.

The one exception is this lane itself: plugins-bake passes no scripts-ref, so it resolves its scripts at platform-ref = gate.outputs.sha, the commit the run publishes. On a reconcile run that can be older than #4249; such a run refreshes the flat copy without moving the pointer, which leaves pointer-following readers on the newer generation — the safe direction, and the same flat-copy residual phase 5 removes.

Why the blast radius is bounded

  • Every reader fails safe to today's behaviour. ResolvePublicationPointer degrades an absent, blank, unreadable, escaping or dangling pointer to the source directory, where the flat compatibility copy still sits, still sealed. Worst case is the pre-flip serve.
  • The single reader that may not take that fallback is node-repo-gate.yml's seed — download-batch recurses and flattens, so a union is the one thing it must not build. It refuses instead, transiently; a re-run reads the publication that now applies. That workflow is @main for all six repos (table above).
  • Retention cannot collect anything for a month. PrebuiltBundleRetention.MinimumAge is 30 days and the config key refuses a shorter value, so the first generations written by this change are not collectable until 2026-10-14 at the earliest, under rules whose protected set is {what _current names} ∪ {everything young}.
  • BAKE_PUBLICATION_DIR (the CD publishes every image and every sealed bundle publication to cr.meshweaver.cloud (#3353) #3760 fleet-registry push) is a local runner directory materialised from the run's own verified upload plan — layout-independent, unaffected.

Negative control — the harness fails when the SDK it pins is absent

$ python3 .github/scripts/test-publish-bake-overlap.py            # host python, no pinned SDK
  FAIL sdk-check passes against the REAL, pinned SDK — rc=1:
       ['importlib.metadata.PackageNotFoundError: No package metadata was found for azure-storage-file-share']
  FAIL a settled publication seals — rc=1, 0/10 file(s) present, sealed=False
  …46 failures

$ venv/bin/python .github/scripts/test-publish-bake-overlap.py    # azure-storage-file-share==12.26.0, azure-identity==1.25.3
  90 passed, 0 failed, 90 assertion(s) total.

Both runs are against a .github/scripts/ tree byte-identical to origin/main (git diff --stat origin/main -- .github/scripts/ is empty) — this PR changes no script. So the 90 green assertions are a measurement of the generation path this flip enters, on the exact publisher that will run it, and the first run shows the harness is not structurally unable to fail.

Verification (each step able to fail)

step result
test-publish-bake-overlap.py (pinned SDK) 90 passed, 0 failed — incl. the generation cases: own-directory seal, pointer-moves-before-flat-copy, two interleaved publishers with no shared byte, already-published read from the generation, unusable pointer degrades to the prefix, mixed-layout flat caller
test-publication-pointer-readers.py 14 cases over 2 readers — all green (needed pyyaml; it refuses rather than skips without it)
dotnet build test/Memex.Portal.Shared.Test -c Release -warnaserror 0 Warning(s), 0 Error(s), 33.3 s (a real build)
dotnet test --filter PublicationGeneration --no-build Passed! Failed: 0, Passed: 18 — 18 ran, not zero
yaml.safe_load(main-cd.yml) → jobs['plugins-bake'].with['publication-layout'] 'generation'
check-workflow-timeouts.py 87 jobs, 0 violations, cap 45 min
check-workflow-yaml-keys.py 36 workflows, 0 violations
check-workflow-shell.py 1 finding, 0 live, 1 allow-listed, 0 stale — identical to main

What this does NOT do

Docs: Doc/Architecture/SealedPublicationGenerations → phase 4 records what landed, the measured fleet-wide precondition, the reconcile-run residual and the fail-safe reader argument; "Where this stands" is corrected (it still said no caller anywhere passes publication-layout). What's New: 2026-09-14-an-update-is-never-read-while-it-is-being-written (Category: Fix).

Pairs-with: none — CI lane input + docs; no public type or member leaves src/. No interface gains a member. No i18n catalog key changes.

🤖 Generated with Claude Code

`main-cd.yml`'s `plugins-bake` passes `publication-layout: generation`, so
core's publications of `prebuilt-bundles/<identity>/plugins` are written into
their own run-named directory, verified there, sealed, and only then pointed at
by `_current`. The prefix is no longer mutated in place for any reader that
follows the pointer — which is every portal, both Azure-direct readers, and the
satellites' gate seed.

The precondition that makes a one-producer-at-a-time flip safe is that every
producer runs a publisher carrying #4249 (the layout belongs to the prefix, so a
run resolving a live `_current` publishes a generation whatever its caller
passed). Measured 2026-09-14 across all six node repos — Plugins, Crm,
Education, Reinsurance, SocialMedia, Manufacturing — every `node-repo-*.yml`
`uses:` is `@main` and every `scripts-ref:` is `main`. No pinned publisher and
no pinned reader of this prefix exists in the fleet.

Refs #3461

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 14, 2026 05:18
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

262 tests  ±0   262 ✅ ±0   2m 23s ⏱️ -9s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The unresolved pointer-ordering race can serve older plugin bytes, and the related guard and documentation corrections remain outstanding.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR enables generation-based publishing for core Plugins and documents the phase 4 rollout.

Changes:

  • Enables publication-layout: generation in core CD.
  • Updates architecture rollout documentation.
  • Adds a user-facing What's New entry.
File summaries
File Review summary
.github/workflows/main-cd.yml Critical (1 vote): Pointer updates can regress to an older generation; add monotonic/CAS protection and a regression test. Moderate (2 votes): Add a persistent guard for the generation-layout wiring.
src/MeshWeaver.Documentation/Data/Architecture/SealedPublicationGenerations.md Nit (1 vote): Resolve contradictory phase-status text. Nit (1 vote): Align related architecture pages or mark them historical.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-an-update-is-never-read-while-it-is-being-written.md Nit (3 votes): Qualify the guarantee for installations still using the flat compatibility copy.
Review details

Suppressed comments (4)

src/MeshWeaver.Documentation/Data/Architecture/SealedPublicationGenerations.md:559

  • This new status bullet conflicts with the Phase 3 status above, which still says its pin half and phases 4–5 are open. After adding the landed Phase 4 entry, update that earlier status block (or scope it explicitly to the satellite) so the architecture page does not report Phase 4 as both landed and open.
- **Phase 4's core half is landed** (2026-09-14) — `main-cd.yml`'s `plugins-bake` passes
  `publication-layout: generation`, so `prebuilt-bundles/<identity>/plugins` is on the generation
  layout and every reader that follows `_current` stops reading a directory mutated in place. The
  satellite's own flip is explicitness, not correctness (phase 4 above). **Phase 5 is open.**

src/MeshWeaver.Documentation/Data/Architecture/SealedPublicationGenerations.md:559

  • This status change leaves other shipped architecture pages contradictory: SealedPublicationReads.md:576-588 still says plugins must flip in ONE change set, and BundleDeliveryStages.md:213 says phase 4 is unreachable and unlanded. Update those status passages in the same change, or explicitly mark them as historical snapshots, so readers do not get two incompatible migration states.
- **Phase 4's core half is landed** (2026-09-14) — `main-cd.yml`'s `plugins-bake` passes
  `publication-layout: generation`, so `prebuilt-bundles/<identity>/plugins` is on the generation
  layout and every reader that follows `_current` stops reading a directory mutated in place. The
  satellite's own flip is explicitness, not correctness (phase 4 above). **Phase 5 is open.**

src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-an-update-is-never-read-while-it-is-being-written.md:33

  • The pointer is moved after the generation is sealed but before the flat compatibility copy is refreshed (publish_publication calls move_pointer before publish_one_target for the flat directory), so it is not the publication's “very last step.” This ordering is load-bearing; please describe it accurately.
**Each publication is now written into its own folder**, checked there, and only then pointed at.
A one-line pointer says which folder currently applies, and it is moved as the very last step —
after everything in the new folder is verified complete.

src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-an-update-is-never-read-while-it-is-being-written.md:43

  • This says every reader falls back to the flat copy, but node-repo-gate.yml's recursive seed reader explicitly refuses when it cannot resolve a usable pointer because falling back would flatten multiple generations into one seed. Scope this claim to pointer-aware portal readers or mention that gate exception.
**Nothing has to be upgraded to benefit.** A copy in the old flat layout is still published beside
the new one, so an installation that predates this change reads exactly what it read before. If the
pointer is ever missing or unreadable, every reader falls back to that copy — the worst case is the
behaviour of the day before yesterday, not a failure.
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# publishes; on a reconcile run that can be older, and a publisher predating #4249 would then
# refresh the flat copy without moving the pointer — which leaves pointer-following readers on
# the NEWER generation, the direction that is safe. Doc/Architecture/SealedPublicationGenerations.
publication-layout: generation
# publishes; on a reconcile run that can be older, and a publisher predating #4249 would then
# refresh the flat copy without moving the pointer — which leaves pointer-following readers on
# the NEWER generation, the direction that is safe. Doc/Architecture/SealedPublicationGenerations.
publication-layout: generation
---
Name: An update is never read while it is being written
Category: Fix
Description: Installations read the platform's prepared plugin content out of a folder that was being overwritten in place while they read it. Each update is now written into its own folder and only pointed at once it is complete, so a read can no longer catch an update half-finished.
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 449 tests  ±0   1 449 ✅ ±0   2m 23s ⏱️ +12s
    2 suites ±0       0 💤 ±0 
    2 files   ±0       0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

898 tests  ±0   898 ✅ ±0   3m 18s ⏱️ -5s
  2 suites ±0     0 💤 ±0 
  2 files   ±0     0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 844 tests  ±0   1 652 ✅ ±0   4m 50s ⏱️ -1s
    4 suites ±0     192 💤 ±0 
    4 files   ±0       0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    6 files  ±0      6 suites  ±0   4m 55s ⏱️ -18s
2 129 tests ±0  2 129 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 133 runs  ±0  2 133 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 061 tests  ±0   1 059 ✅ ±0   6m 17s ⏱️ -5s
    3 suites ±0       2 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   18 files  ±0     18 suites  ±0   24m 9s ⏱️ -23s
7 643 tests ±0  7 449 ✅ ±0  194 💤 ±0  0 ❌ ±0 
7 647 runs  ±0  7 453 ✅ ±0  194 💤 ±0  0 ❌ ±0 

Results for commit 0e80e9e. ± Comparison against base commit 965b834.

@rbuergi

rbuergi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Two process facts from this PR, measured — one of them is a correction of my own first reading

Recorded here because both are about the lane rather than the change, and the first one is the sort of thing the next person reaches for under time pressure.

1. 🚨 Drafting a PR does NOT hold it once the merge queue has admitted it

Copilot's review landed a real finding (the pointer-ordering race — fixed in #4273) after this PR's required checks were green. I converted it to draft to hold it, and it merged three minutes later anyway.

My first reading was "draft conversion did not stop the bot". That is wrong, and the timeline says so:

05:18:19Z  auto_merge_enabled       meshweaver-cloud[bot]
05:29:16Z  added_to_merge_queue     meshweaver-cloud[bot]     ← a minute BEFORE the draft
05:30:17Z  convert_to_draft         rbuergi
05:33:18Z  merged  +  removed_from_merge_queue  github-merge-queue[bot]

The conversion worked as far as it goes — this PR is still draft: true with auto_merge: false. What merged it was the queue entry admitted a minute earlier: GitHub merges an admitted entry regardless of the head PR's draft state.

The rule: draft alone holds only a PR that has not been admitted to the queue. For one that has, the hold is GraphQL dequeuePullRequest first, then draft — and you verify by reading the queue back (repository.mergeQueue.entries), never by reading the draft field, which will say true while the entry merges.

This had already cost core #4147 the same way earlier the same night. It is going onto the fleet CI page.

2. "Green + armed auto-merge" raced the review, on a live delivery path

The finding was correct and material: under the generation layout two overlapping publications write disjoint directories, so the byte-level postcondition has no mix to refuse and whichever run finishes last moves _current — handing every reader a complete, sealed, out-of-date publication with nothing red. Under flat that class went red instead. Measured: against this PR's merged publisher, 4 assertions of the new ordered-pair harness case fail and _current ends on the older generation.

So a change to the fleet's most-read publication prefix was merged with a valid, unaddressed review finding against it, purely because the required checks finished before the reviewer did. Nothing currently expresses "a delivery-path change does not arm until its review has landed" — auto-arm.yml arms every non-draft PR on its own checks, and draft (the only opt-out) is exactly what point 1 shows is not a hold after admission.

Not proposing the policy here; recording that the gap is real and that it was exercised, not hypothesised.

#4273 carries the fix, its harness case, the guard that holds the flip in place, and the doc section.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants