Repository navigation
ci: never block a core merge; one promotion gate arms the fleet - #5824
Conversation
Policies core-merge-never-blocked, build-latest-green, one-promotion-gate, paired-change-sets (register: Doc/Architecture/PolicyNotProse). - dotnet-test.yml: the dependent-suites run is ADVISORY. `Consolidate test results` no longer needs preflight / dependent-suites-dispatch / dependent-suites, and the step that failed on a Plugins silence is gone. The run is requested by the `dependent-suites` label or a declared `Pairs-with: Systemorph/MeshWeaver.Plugins#<n>` (then against that PR's head — a paired change set tested together). merge_group no longer runs it (the queue is off). MeshWeaver#5807 sat 4 h green and was ejected on a 42-minute silence; that cannot recur. - main-cd.yml: `promote` keeps phases A+B (CI-facing) and uploads a promotion record (both commits, base, versions, verdict key). Phases C+D (the portal's <version>, the line pointers) and the release event move to a new `arm` job that runs in every CD run and arms the newest promoted set, newer than every armed one, whose MeshWeaver.Plugins dependent-suites verdict for exactly its pair is green (arm-promoted-set.py). Override: dispatch input arm_override + arm_reason. notify-platform-update follows `arm` and stamps coreSha/pluginsSha into the build fact. delivery-verdict no longer needs notify (platform delivery never waits on Plugins). gate's bake-only version recovery reads memex-migration (phase A). - check-image-set.sh --pointers no longer asserts the portal's version tag. - resolve-platform.py: self-test cases for latest-green (an unarmed set resolves by identity tag) and red-main fallback. - guards updated; docs: OnePromotionGate, PairedChangeSets (new), MergeQueue (queue OFF), CrossRepoPairGate, ImageTagContract, PolicyNotProse, AGENTS.md, ci/pullrequest skills. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate issues remain in fleet arming, workflow permissions, trust boundaries, and paired-change handling.
Review effort: Lite
Findings: 5
Open (17)
Match override selection by exact portal version · New Reject fork-originated counterpart heads · New Grant packages write permission for GHCR pushes · New Export V_PLUGIN before Phase C registry writes · New Serialize arming operations across workflow runs · New Advance arming cursor only after complete sequence · New Keep policy effective-date transition in PolicyNotProse · New Remove hard-coded policy date from script comment · New Remove hard-coded policy date from workflow comment · New Remove hard-coded policy date from workflow comment · New Remove hard-coded policy date from workflow comment · New Keep policy effective date only in PolicyNotProse · New Align continuous-delivery contract with moved phase C · New Remove hard-coded policy date from test comment · New Remove policy date from test data · New Remove hard-coded policy date from comment · New Remove hard-coded policy date from test comment · New
What changed in this PR
This PR decouples core merges from Plugins validation and adds asynchronous, verdict-gated fleet promotion.
Changes:
- Makes dependent suites advisory and adds paired-change-set support.
- Adds promotion records and fleet arming.
- Updates CI workflows, image validation, policies, documentation, and tests.
| File | Description |
|---|---|
test/MeshWeaver.Documentation.Test/PlatformReleaseNotifyGuard.cs |
Updates release notification guards. |
test/MeshWeaver.Documentation.Test/PlatformNeverDependsOnPluginsGuard.cs |
Updates dependency expectations. |
test/MeshWeaver.Documentation.Test/PlatformDeliveryNeverWaitsOnPluginsGuard.cs |
Guards delivery independence. |
test/MeshWeaver.Documentation.Test/PlatformBakeLaneGuard.cs |
Updates bake-version assertions. |
src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md |
Registers promotion policies. |
src/MeshWeaver.Documentation/Data/Architecture/PairedChangeSets.md |
Documents paired changes. |
src/MeshWeaver.Documentation/Data/Architecture/OnePromotionGate.md |
Documents promotion and arming. |
src/MeshWeaver.Documentation/Data/Architecture/MergeQueue.md |
Records merge-queue status. |
src/MeshWeaver.Documentation/Data/Architecture/ImageTagContract.md |
Updates tag-phase contracts. |
src/MeshWeaver.Documentation/Data/Architecture/CrossRepoPairGate.md |
Documents advisory cross-repository testing. |
src/MeshWeaver.Documentation/Data/Architecture.md |
Updates architecture links. |
AGENTS.md |
Updates repository guidance. |
.github/workflows/main-cd.yml |
Adds promotion arming and notification flow. |
.github/workflows/dotnet-test.yml |
Adds advisory dependent-suite pairing. |
.github/scripts/test-check-image-set.py |
Updates image-set tests. |
.github/scripts/resolve-platform.py |
Tests latest-green resolution. |
.github/scripts/check-image-set.sh |
Updates image pointer validation. |
.github/scripts/arm-promoted-set.py |
Selects verdict-qualified promoted sets. |
.claude/skills/pullrequest/SKILL.md |
Updates merge guidance. |
.claude/skills/ci/SKILL.md |
Updates CI guidance. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| n = run_number_of(override) | ||
| if n is None: | ||
| raise ValueError(f"--override {override!r} is not a set name X.Y.Z[-pre]-ci.N") | ||
| hit = next((r for r in records if int(r["run_number"]) == n), None) |
There was a problem hiding this comment.
Fixed in d75a92a. --override now matches r["v_portal"] == override exactly and never the run number alone. A new self-test row feeds an override naming the right run on another version line (3.0.1-ci.9459) and checks that it is RED.
| read -r state head < <(gh api "repos/Systemorph/MeshWeaver.Plugins/pulls/$PAIRED" --jq '"\(.state) \(.head.sha)"') \ | ||
| || { echo "::error::cannot read MeshWeaver.Plugins#$PAIRED with the dispatch App's token — the pair declared in this PR's body cannot be tested together."; exit 1; } | ||
| [[ "$head" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::MeshWeaver.Plugins#$PAIRED has no readable head sha ('$head')"; exit 1; } |
There was a problem hiding this comment.
Fixed in d75a92a. The paired head now resolves through .github/scripts/paired-plugins-head.py:
- An open head is taken only when
head.repo.full_nameisSystemorph/MeshWeaver.Plugins(compared case-insensitively). - A fork's head, or a deleted head repository, is REFUSED, red, and the message names where the head lives.
- A closed or merged pair falls back to Plugins' default branch.
It has 7 self-test rows, and workflow-shell runs the self-test on every PR. Checked live: MeshWeaver.Plugins#2489 (open, same repository) resolves its head, and #2479 (merged) falls back to the default branch.
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| actions: read # the promotion records are run ARTIFACTS of this workflow |
There was a problem hiding this comment.
Fixed in d75a92a: arm now declares packages: write, the grant phase D's GHCR imagetools create needs. The job-level map replaces the workflow-level one, as you say.
| V_PORTAL_SEL: ${{ steps.select.outputs.v_portal }} | ||
| V_MIGRATION_SEL: ${{ steps.select.outputs.v_migration }} | ||
| SHA_SEL: ${{ steps.select.outputs.short }} | ||
| PLUGINS_SEL: ${{ steps.select.outputs.plugins_short }} | ||
| run: | | ||
| { | ||
| echo "V_PORTAL=$V_PORTAL_SEL"; echo "V_MIGRATION=$V_MIGRATION_SEL"; echo "SHA=$SHA_SEL" | ||
| echo "SRC_PORTAL_TAG=$SHA_SEL-p$PLUGINS_SEL" | ||
| } >> "$GITHUB_ENV" |
There was a problem hiding this comment.
Fixed in d75a92a. Point the arming phases now exports V_PLUGIN from steps.select.outputs.v_plugin along with the other fields. Before writing anything, it also asserts that every selected field (V_PORTAL, V_MIGRATION, V_PLUGIN, SHA, the Plugins short sha) is non-empty, so a missing one fails before any registry write instead of after. I executed the extracted step: with all fields present it exports V_PLUGIN=… and exits 0; with V_PLUGIN_SEL empty it exits 1, naming the field.
| echo "SRC_PORTAL_TAG=$SHA_SEL-p$PLUGINS_SEL" | ||
| } >> "$GITHUB_ENV" | ||
| - name: "Phase C — arm the release: memex-portal-ai:<version>" | ||
| if: steps.select.outputs.armed == 'true' |
There was a problem hiding this comment.
Fixed in d75a92a. arm now carries a job-level concurrency: {group: main-cd-arm, cancel-in-progress: false}}, shared by every main-cd run, so reading the cursor, selecting and writing phase C happen one run at a time, and select always sees the previous run's cursor. The release event is a separate job, so it also re-checks never-backwards at the send: if a newer set was armed meanwhile, it sends nothing (the new step "Is this still the newest armed set?").
| ### One promotion gate | ||
|
|
||
| `promote` (phases A and B) makes a set usable by CI. It no longer ARMS it. The arming writes — | ||
| phase C, `memex-portal-ai:<version>` (what `SelfUpdateHostedService` lists, pattern `3.0.0-ci*`); | ||
| phase D, the `<major>-latest` / `<major.minor>-latest` / `3.0.0-latest` pointers; and the signed | ||
| release event (`notify-platform-update`) — moved to the `arm` job, which runs in EVERY main-cd run | ||
| and arms the newest promoted set that is |
There was a problem hiding this comment.
Fixed in d75a92a. ContinuousDeliveryContract.md § "Rollback by ordering" now describes the split: promote runs phases A and B for CI, and arm runs phases C and D plus the event for the fleet. The phase table adds a Job column and rows for D and the event. The rule now reads: phase C never before phase A's tags exist, and nothing an INSTALL selects is written before it. What follows C is not selectable, but it is never forgotten, because of the completion marker and resume. The "Changing the pipeline" checklist is updated to match. OnePromotionGate.md gains the same completion, resume and serialisation section, so the two pages now agree.
| // 🚨 From memex-MIGRATION's tag set, not the portal's (policy `one-promotion-gate`, | ||
| // 2026-09-27): the portal's `<version>` is the ARMING write `arm` makes only once the |
There was a problem hiding this comment.
Fixed in d75a92a: the date is removed and the line cites the policy id alone. The effective date now lives only in the PolicyNotProse register row.
| "`arm` (policy `one-promotion-gate`, 2026-09-27) reads the dependent-suites verdict " | ||
| + "MeshWeaver.Plugins writes at refs/core-candidate/pair-<core7>-p<plugins7> for a " |
There was a problem hiding this comment.
Fixed in d75a92a: the ledger string now reads "Under policy core-merge-never-blocked it is ADVISORY" and "arm (policy one-promotion-gate) reads", with no date.
| /// CANDIDATE — advisory since policy <c>core-merge-never-blocked</c> (2026-09-27). The promotion | ||
| /// gate's verdict (main-cd <c>arm</c>) is requested by MeshWeaver.Plugins' own poller, so |
There was a problem hiding this comment.
Fixed in d75a92a: the date is removed and the line cites the policy id alone. The effective date now lives only in the PolicyNotProse register row.
Test Results (shard 0) 1 files 1 suites 2m 57s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)461 tests 461 ✅ 51s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 701 tests 1 701 ✅ 4m 49s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results (shard 4) 3 files 3 suites 6m 33s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)770 tests 579 ✅ 7m 7s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 5 files 5 suites 14m 10s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
Test Results 17 files 17 suites 36m 31s ⏱️ Results for commit d75a92a. ♻️ This comment has been updated with latest results. |
|
Coordination: the maintainer kept this design (core #5824 + Plugins #2461). I closed #5817 and Plugins #2455, and I've been asked to port three gaps onto this design as small follow-ups. One of them changes files in this PR, so I'm raising it here before touching them. I won't push to (1) The bundle base: the newest ARMED set, not the first parent (touches
|
|
The gap-1 fix is now ready as #5848, a draft stacked on this branch. Its base is It records the bundle base as the core commit of the newest armed set instead of the first parent:
I dry-ran the step against live ACR and GitHub, and all three branches came out correct. The details are in #5848. When this PR merges I'll retarget #5848 to |
|
Adopting this PR (coordinator assignment, 2026-09-28): the author session has been idle since 07:49Z and the maintainer kept this design. From here I push to |
…irst parent Gap 1 of 3 ported onto one-promotion-gate (maintainer decision 2026-09-27). promote wrote the record with base = the core commit's first parent, and the verdict is pinned to rec["base"] — so Plugins' scope diffed, and its control arm re-ran at, ONE merge of a set that usually carries several, and `arm` then armed every other merge unmeasured. - arm-promoted-set.py `armed-base`: names the newest armed set from memex-portal-ai's manifest metadata (the armed <version> tag shares its manifest with the 7-char sha tag — measured on 3.0.0-ci.9538 / b9fe5ed); pure, self-tested (6 rows incl. uninterpretable manifests = RED). - promote's record step: base = that commit when it resolves AND is an ancestor of the candidate (base_kind `armed`); `first-parent` when nothing was ever armed or the candidate IS the armed set; `unresolved` otherwise — CI still promotes, but select REFUSES to arm it and pending does not measure it (the next promoted set retries). Legacy records without base_kind judge as before. - OnePromotionGate.md: "The bundle's base is the newest ARMED set". Dry-run against live ACR + GitHub: main 003b70d → armed, base b9fe5ed; b9fe5ed itself → first-parent; the older 9357060 → unresolved (compare: behind), with the warning. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erialised, exports V_PLUGIN, can write GHCR; fork heads refused Adopted from the idle author session; every Copilot finding on #5824 addressed. - arm: `packages: write` (phase D pushes the line pointers to GHCR with GITHUB_TOKEN); V_PLUGIN exported with the rest (phase C's summary expanded it unbound under `set -u`, after the registry writes) and every selected field asserted non-empty before any write. - arm is serialised across runs (job concurrency `main-cd-arm`, never cancelled): the push and reconcile lanes are separate workflow groups, so two runs could select different sets and the older write phase C after the newer. - The cursor no longer advances past an incomplete arming: notify-platform-update writes the non-selectable marker `arm-complete-<version>` only after the build fact was delivered; `armed-state` reads it; `select --resume` re-arms the incomplete newest set when nothing newer is green (a newer green set still wins; a set armed before the marker, with no record, is reported, never red). notify re-checks never-backwards at the send. - --override matches the EXACT version, never the run number alone. - dotnet-test: a paired Plugins FORK head is refused (paired-plugins-head.py, self-tested and run in workflow-shell) — it would be built beside Plugins' private source. - ContinuousDeliveryContract: phases C/D/event now in `arm`, with the completion/resume rule — no longer contradicting OnePromotionGate (which documents the same). - Policy dates in prose replaced by the policy id (the register keeps the date). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>



Implements the maintainer's 2026-09-27 decisions "never block a core merge" and "can't we fast forward and always build latest?" (answered yes), as four registered policies —
core-merge-never-blocked,build-latest-green,one-promotion-gate,paired-change-sets(+content-neutral-push-keeps-run, whose mechanism is in Plugins).dependent-suites-gateis retired. Design of record:Doc/Architecture/OnePromotionGate(new) andDoc/Architecture/PairedChangeSets(new). Measurements: Systemorph/Memexdocs/build-bottlenecks-2026-09-27.md. Plugins half: Systemorph/MeshWeaver.Plugins#2461. Refs #5801, #5807.Why (measured)
#5807 was green at 13:21Z (run 36320927353); its merge-queue entries failed at 15:25, 16:00 and 16:43 (runs 36325608971, 36327871720, 36330515253), each on
Dependent suites (MeshWeaver.Plugins)— "did not answer within 42 min … Silence is not a pass". It merged at 17:51 only after the queue was switched off: 4 h 30 min green-to-merged, blocked by another repository's runner capacity.Before → after
Consolidate test results+Automatic review answered;Consolidateneededpreflight,dependent-suites-dispatch,dependent-suitesand failed on a Plugins silenceConsolidateneeds nothing cross-repo. No ruleset change needed — measured: nomerge_queuerule, no cross-repo contextdependent-suites, or a declaredPairs-with: Systemorph/MeshWeaver.Plugins#<n>→ run against the core candidate and that Plugins PR's head (paired change set tested together)<version>(the self-updater's roll target) and3.0.0-latestpair-<core7>-p<plugins7>)armjob, every CD run: the newest promoted set, newer than every armed one, whose Plugins dependent-suites verdict for exactly its pair is green (arm-promoted-set.py). Waits on silence, refuses red, newer green supersedes. Override: dispatcharm_override+arm_reasonnotify-platform-update)delivery-verdictarm(only for an armed set), stampscoreSha/pluginsSha; NOT a leg ofdelivery-verdict(platform delivery never waits on Plugins)Also: gate's bake-only version recovery reads
memex-migration(phase A) instead of the portal's arming tag;check-image-set.sh --pointersno longer asserts the portal's<version>.Verification
MeshWeaver.Documentation.Test(Release,-warnaserror): 650/650 pass (guards updated:PlatformReleaseNotifyGuard,PlatformDeliveryNeverWaitsOnPluginsGuard+ newTheArmingIsNeverAPlatformDeliveryLeg,PlatformNeverDependsOnPluginsGuardApiReadLedger,PlatformBakeLaneGuard).arm-promoted-set.py --self-test(20 cases),resolve-platform.py --self-test(194),test-check-image-set.py(9),test-cd-steps.py,await-dependent-verdict.py --self-test, workflow lints (timeouts, permission pairing, PR secret preflight, yaml keys, guard-step masking).Precondition — met
Systemorph/MeshWeaver.Plugins#2461 is merged (2026-09-27 22:56Z), so the verdicts arming reads now exist.
Adopted — what was added since (2026-09-28)
I adopted this PR from the idle author session under a coordinator assignment. The additions:
arm-promoted-set.py armed-basereads the armed set from memex-portal-ai's manifests.promoterecords that base with abase_kind:armed,first-parent(nothing armed yet, or this commit IS the armed set) orunresolved.unresolvedset is still promoted for CI, but it is never armed and never measured.packages: writeadded onarm.V_PLUGINis exported, and every selected field is asserted non-empty before any write.armis serialised across runs (job concurrencymain-cd-arm).arm-complete-<version>,armed-state,select --resume.notifyre-checks never-backwards before it posts.--overridematches the exact version.paired-plugins-head.py, self-tested inworkflow-shell).ContinuousDeliveryContract.mdis reconciled withOnePromotionGate.md.MeshWeaver.Documentation.Testpasses 650/650 (Release,-warnaserror).test-cd-steps.pyand the self-tests forarm-promoted-set.pyandpaired-plugins-head.pypass.actionlintfindings onmain-cd.ymlare byte-identical to before;dotnet-test.ymlis clean.