Repository navigation
CD refuses to ship a partial image set, and heals main's HEAD on its own - #1039
Conversation
Every leg pushed straight to its consumer-visible tags, so one leg dying left main with a portal increment the bake gate never certified (4f0c35c / #1026) — and nothing ever re-attempted it, because main-cd only reacts to a push. Build everything, publish nothing until all of it built: - each leg now pushes ONLY a per-run staging tag (staging-<sha>-<run_id>), which VersionSelect.PlatformVersionTag (^\\d+\\.\\d+\\.\\d+) can never select; - a new promote job, gated by the default needs-success semantics, applies the real tags as a manifest-only operation, ordered so memex-portal-ai:<version> — the one write the self-updater acts on — is the LAST, atomic act of the pipeline. Self-heal: a 3-hourly schedule (plus workflow_dispatch) resolves main's tip via the API, asks ACR whether that commit has a complete set, and publishes only when it does not. The workflow_run push gate is untouched, so a fork PR still cannot ship. Refs #1026
Verified against the real registry: an in-repo promote is 2.1 s (portal-ai) / 1.9 s (portal-next); a multi-arch source keeps its OCI index and both platforms, so verify-images' architecture assertion still passes on a promoted tag; the single-arch portal-next gets wrapped in a one-entry manifest list. Also updates AGENTS.md — 'main-cd has no workflow_dispatch, so CD cannot be kicked directly' is no longer true, and 'the only thing that produces a tag is the next PR merge' is exactly the property this change removes.
check-image-set.sh only observes ACR, so a GHCR miss placed after the arming write would never be reconciled and nothing would notice the plugins repo's 'latest' going stale. Failing the release is the lesser evil and self-heals.
…roperties That page is what a reader consults to learn how a merge reaches a pod, and its CD paragraph described neither the required-check gate nor all-or-nothing publication nor the reconciler.
There was a problem hiding this comment.
Pull request overview
Updates the main-branch CD workflow to prevent partial image publication and to add a reconciler path (scheduled/manual) that can republish main’s HEAD when ACR is missing part of the expected image set.
Changes:
- Introduces a staging-tag → promote flow so consumer-visible tags are only applied after all image legs succeed.
- Adds
scheduleandworkflow_dispatchtriggers plus agatejob that resolves the target SHA and decides whether to publish (push vs reconcile). - Extracts the “complete image set” assertion into
.github/scripts/check-image-set.shand reuses it for both reconcile decision-making and post-publish verification.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| AGENTS.md | Updates operational guidance to reflect staging/promote and reconciler behavior (“verify the image, never the tick”). |
| .github/workflows/main-cd.yml | Implements all-or-nothing publishing, adds reconcile triggers, centralizes target selection in gate, adds promote job, and reuses a shared image-set assertion. |
| .github/scripts/check-image-set.sh | New shared script to assert the full expected image set exists in ACR for a given short SHA. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
github.event.workflow_run.head_sha is empty on a scheduled run, so the alert issue would have read 'for commit ``' for exactly the failures the reconciler exists to surface. Take the commit from gate, which resolves it for both entry paths, and tell the reader the reconciler will re-attempt HEAD by itself.
If Build-and-Test never ran on the merge commit, the required check reads 'missing', nothing can publish that commit, and the reconciler would have gone quiet about it every 3 h forever — the same invisible-state failure the heal exists to remove, one layer up. The reconcile path now asks the registry FIRST (a complete set means nothing is wrong, whatever the check says) and, when the set is incomplete AND the check is not green, says so ONCE per commit on the ci-failure issue. It still refuses to publish: CD must never ship an untested tree. That state clears itself the moment Build-and-Test goes green on HEAD — this reconciler reads the CHECK on the commit, not the event that produced it, so unlike the old push-gated CD it picks up a manual 'gh workflow run "MeshWeaver Build and Test" --ref main'.
The reconcile path read only the check's CONCLUSION, which is null both while Build-and-Test is running and when it never ran — opposite situations. A tick landing in the ~22 min after a merge would therefore have posted 'main cannot be published' on the ci-failure issue for a perfectly healthy commit, roughly once a day, until nobody read that issue any more. Read STATUS as well, and how old the commit is: still running, or merged minutes ago with no check yet, means WAIT silently. Only a check that has settled on non-success, or one still absent two hours on, is the unhealable state. Nothing before that point writes to the ledger at all.
Job-level permissions REPLACE the workflow map, and gate reads two APIs the workflow map never granted: commits/<sha>/check-runs (the required-check gate) and gh run list (the in-flight-publish check, new here). A 403 there would break the one job that decides whether anything ships. Caught in review.
Only the reconciler needs to know how old HEAD is (to tell 'no check yet' from 'no check ever'). Computing it in the step every push-path delivery depends on added an API call and a `date -d` to that path for nothing — and anything that throws there under set -e stops delivery outright. Scoped to reason == reconcile, with REASON now actually in the step's env (under set -u it would otherwise have failed every run).
Test Results (shard 0)685 tests 685 ✅ 13m 49s ⏱️ Results for commit 534f17f. |
Test Results (shard 1)1 199 tests 1 199 ✅ 6m 18s ⏱️ Results for commit 534f17f. |
Test Results (shard 5)1 725 tests 1 533 ✅ 6m 7s ⏱️ Results for commit 534f17f. |
Test Results (shard 4)1 284 tests 1 279 ✅ 7m 1s ⏱️ Results for commit 534f17f. |
Test Results (shard 3) 11 files 11 suites 6m 15s ⏱️ Results for commit 534f17f. |
Test Results (shard 2)1 912 tests 1 910 ✅ 6m 43s ⏱️ Results for commit 534f17f. |
Test Results 65 files 65 suites 46m 16s ⏱️ Results for commit 534f17f. |
Refs #1026
#1031 made a partial release loud. This makes it impossible, and makes a failed CD run
heal itself instead of waiting for an unrelated merge to paper over it.
Two properties CD did not have:
image in the set built.
simply never got an image and the hole closed only incidentally, when some later PR merged. That
incidental repair publishes the later commit, so the failed commit's set is never completed;
the state does not heal, it is superseded. That is precisely how
4f0c35cwent unnoticed.1. All-or-nothing: build everything, publish only after everything built
Each leg now pushes its layers under a per-run staging tag —
staging-<sha>-<run_id>— and anew
promotejob applies the real tags only after all five legs succeed.The staging tag is unselectable by construction, not by convention:
VersionSelect.PlatformVersionTagis^\d+\.\d+\.\d+([-+].*)?$, so astaging-…tag can never bepicked by any self-updater. (That regex is not decorative — it exists because an all-digit git-sha
tag once parsed as version
6943991.0.0and froze every portal on ci.122.)Why staging-then-promote, and not build-to-archive
I looked at both. The bytes have to reach the registry for a push to happen at all, so pushing them
to a staging tag costs exactly what pushing them to the real tag costs — the only thing worth
withholding is the tag, because the tag is the entire consumer contract. Promotion is then a
manifest-only operation.
The SDK does support
ContainerArchiveOutputPath, but publishing to an archive and pushing latermeans moving multi-GB tarballs — full base layers included, ×2 architectures — through GitHub
artifact storage between jobs, and
docker loadcannot load a multi-arch OCI archive on therunner's classic image store at all (it would need skopeo/crane as a new dependency). Same
guarantee, an order of magnitude more machinery, and a large new I/O bill on every run.
Rejected outright: one giant sequential job that builds all five then pushes. That is
15.4 + 13.5 + 13.1 + 10 + 2.4 ≈ 55 min of critical path; #979 split these jobs precisely to
avoid that.
I used
docker buildx imagetools createrather thanaz acr import: a manifest PUT is the exactpermission every leg already exercises, and the same primitive already mirrors mw-plugin-test to
GHCR in this workflow.
importImage/actionis not part of AcrPush, and finding that out onmain is not a good trade.
Rollback: ordered so there is nothing to roll back
Tagging five repositories is not one atomic act. Rather than add a compensating delete that can
fail on its own — and that cannot work anyway for a moving pointer, since untagging
latestdestroys it rather than reverting it, and needs an
AcrDeletethis identity may not hold — thepromotion is ordered so a mid-flight failure is unobservable:
<version>,<sha>) on every repo exceptmemex-portal-ai:<version>maineverywhere,lateston mw-plugin-test, the GHCR mirrormemex-portal-ai:<version>— one PUT, the last thing the pipeline doesPhase C is the arming write and nothing else is.
SelfUpdateHostedServicelists tags formemex-portal-aionly, picks the newest^\d+\.\d+\.\d+one, and patches the portal andmigration Deployments to it. So:
never written. The residue is inert identity/pointer tags on a commit that did not ship; nothing
resolves them and the next run supersedes them.
verify-imagesstill fails,alert-on-failurestill files, and the reconcilerre-drives HEAD within 3 h.
Deliberately not added: a cleanup pass that untags staging. It buys nothing — accumulating tags
per .NET repo per run actually go down, 6 → 5 (staging index + its two RID variants + version +
sha, versus today's version/sha indexes plus four RID-suffixed variants) — and it would add a delete
permission and a second failure mode to the one job that must have neither.
Cost
Measured on real CD runs
31324251944and31326034137(job wall clocks):Promotion itself, measured tag-to-tag within one repository against the real registry (which is all
the job ever does): portal-ai 2.1 s, portal-next 1.9 s — ~15 s of work inside a ~1 min job.
one job and nothing else.
memex-portal-ai's version tag specifically: ~13m06s → ~16m30s (+~3.4 min). The portal nowwaits for bake. That is the policy:
4f0c35cshipped a portal increment the NodeType bake gatenever certified, and ~3 minutes is what not doing that costs.
2. Self-healing: a reconciler, not a retry
A
schedule(every 3 h) and aworkflow_dispatchnow enter the same pipeline throughgate, whichresolves the target commit and decides whether to publish.
It is driven by observed state, never by a failure it is papering over. It asks "does main's
current HEAD have a complete image set in ACR?" — the same question
verify-imagesasks, answeredby the same script — and only when the answer is no does it drive a publish. It wraps no failing
operation in a retry and suppresses no failure signal:
alert-on-failureis untouched and now alsocovers
gateandpromote.1. What triggers the heal? A 3-hourly schedule, plus manual
workflow_dispatch. 3 h sitscomfortably inside the installs' 6 h self-update poll, so a healed image is picked up on the very
next poll. A healthy tick is one ~40 s job —
gatestops the moment ACR says the set iscomplete — so this costs ~5 min of runner time a day when nothing is wrong.
🚨 The push gate is untouched. The
workflow_runpath still requiresevent == 'push' && head_branch == 'main', which is what stops a fork'spull_requestrun(whose
head_branchcan also be "main") from publishing untrusted code with this repo's secrets.The new triggers do not read the event payload at all: they resolve the target as the tip of this
repo's main through the API, and re-check
Consolidate test resultson it. A fork's code cannever be the tip of
Systemorph/MeshWeaver's main, so the safety property is preserved by astronger check rather than by a proxy. A manual dispatch therefore cannot publish an untested tree.
2. What does it heal to? HEAD, and your instinct is right for a stronger reason than low
value: re-publishing a superseded commit is unsound. The version tag comes from the building
run's number, so republishing older code would mint a higher
-ci.<n>for it — and every installwould roll "forward" onto older code, breaking the monotonic-build-number invariant
VersionSelectdocuments in red ("A non-monotonic build number would make a newer build sort lower and break 'pick
the newest'"). HEAD is also the declared desired state; reconcilers converge on desired state, not
on history. The heal comment on the issue says this explicitly, so nobody re-derives it under
pressure.
A HEAD that cannot ship is not silent either — but it must not cry wolf. The reconcile path
asks the registry first (a complete set means nothing is wrong whatever the check says), and
when the set is incomplete it reads the required check's status as well as its conclusion. Those
two are not interchangeable:
conclusionisnullboth while Build-and-Test is running and when itnever ran at all, so a conclusion-only reading would have posted "main cannot be published" for a
perfectly healthy commit on any tick landing in the ~22 min after a merge — roughly once a day,
until nobody read that issue any more. Still running, or merged minutes ago with no check yet, means
wait, silently, writing nothing. Only a check that has settled on non-success, or one still
absent two hours on (AGENTS.md's trap #1), is the unhealable state — and that is said once per
commit on the
ci-failureissue, still refusing to publish. That state clears itself the moment CI goes green on HEAD — and note this also repairsAGENTS.md's trap #2: the reconciler reads the check on the commit, not the event that produced
it, so
gh workflow run "MeshWeaver Build and Test" --ref mainnow actually leads to an image,which under the old push-only gate it never could.
3. Does it terminate? Each tick is one attempt and does not re-trigger itself, so a tick
terminates by construction. Persistent failure is bounded at 3 attempts per commit, with the
ci-failureissue as the ledger (the surface a human already watches — no new state store):reporting cannot buy infinite retries.
🛑 Automatic healing STOPPED …) and labelsthe issue
cd-unhealed. Later ticks then cost ~40 s and print nothing. Loud once, then quiet— the opposite of a repeating alarm nobody reads. Closing the issue is a deliberate human act and
legitimately grants a fresh budget.
without consuming a slot.
✅ Healed <sha>on the same issue, so the ledger cannot only grow.4. Does it interact safely with all-or-nothing? It is literally the same job graph — same
staging tags, same
promotegate, sameverify-images. A reconcile run cannot publish a partialset any more than a push run can.
Also in here
portal-next-imagenow gates ongatelike every other leg. It gated onworkflow_run.conclusion == 'success'— the umbrella conclusion the DELIVERY GATE block exists toreject, because one starved job that never ran a step poisons it. That leg alone would then be
skipped while the other four shipped: silently, the exact partial-release shape of CD failed on main: run 31322066110 #1026. Under
all-or-nothing it is also the one way a skipped-but-not-failed leg could block the whole set, so
the two gates must agree.
gate's output).git rev-parse --shortusescore.abbrev=autoand can hand out 8 characters for an ambiguous prefix; a promote that tagged 7while verify looked for 8 would be a silent, unreproducible miss.
.github/scripts/check-image-set.sh— the assertionverify-imagesmakes and the questionthe reconciler asks, in one file, for the same reason
shard-assign.shexists: if the two everdisagreed, the reconciler would heal forever or never heal at all.
github.event.workflow_run.head_shais empty on a scheduled run, so theci-failureissue wouldhave read
for commit ` `for exactly the failures the reconciler exists to surface. It nowtakes the commit from
gate.silently leaving the plugins repo's
lateststale. It is a real coupling and the lesser evil:check-image-set.shonly observes ACR, so a GHCR miss placed after the arming write would neverbe reconciled and nothing would notice. Here it fails CD, alerts, and the reconciler re-drives it.
portal-nexthand-writes3.0.0-ci.<n>while every .NET legcomputes
$(Version)=3.0.0-rc1.ci.<n>, so its version tag has never matched its siblings'.Nothing selects it (
SelfUpdateOptionsonly readsmemex-portal-ai; deployments pin portal-nextexplicitly) and changing a published tag shape would break whatever is pinned today. It is now
documented at the line that produces it.
main-cd.ymlhas noworkflow_dispatchof its own, so CD cannot be kickeddirectly" and "the only thing that produces a tag is a real push to main" are exactly the
properties this PR removes.
deploy/CHANGE-PROPAGATION.md— the page a reader consults to learn how a merge reaches a poddescribed neither the required-check gate CD has used since perf(ci): split CD bake job, mirror plugin image to GHCR, add per-project test parallelism #979, nor all-or-nothing, nor the
reconciler.
No band-aids
No retry around a publish. No dropped architecture. No
continue-on-erroranywhere (assertedmechanically). No timeout raised or hidden. A genuine build failure still fails CD loudly and
completely — that is the entire point, and the reconciler's job is to re-attempt HEAD later, not
to make a failure look like a success.
Verification
The job graph, parsed with a YAML loader (
yaml.safe_load+ assertions, not by eye):Asserted mechanically: every job transitively depends on
gate;alert-on-failure'sneedscoversevery job including the two new ones;
promoteneeds all five image jobs and carries noalways()/!cancelled(); nothing outsidepromotepushes a non-staging tag; nocontinue-on-error; the push/branch gate is still present on theworkflow_runpath. All 36run:blocks pass
bash -n.The gate blocks — proved end-to-end on real GitHub Actions, not by reading the docs. A throwaway
branch carried a workflow mirroring these exact
needs/ifshapes with trivial jobs(run 31328585220, branch since
deleted):
a-bake: failure→a-promote: skipped(its body wasexit 1"PROMOTED A PARTIAL SET"; it never ran) ·a-verify: success(ran underalways()) ·a-alert: success(fired)b-promote: success·b-verify: successpublish=false)The reconciler's decision table, driven locally against the
Decidestep extracted verbatimfrom the workflow with a stubbed
gh:Against the real registry (
az acr manifest show), reproducing #1026 from ACR state alone:So the reconciler would have fired for
4f0c35cand stayed silent for86578a4.Promotion mechanics, on the real registry (probe tags created and removed):
imagetools createtag-to-tag within a repository took 2.1 s (portal-ai) and 1.9 s (portal-next); the
multi-arch source kept its
application/vnd.oci.image.index.v1+jsonwithlinux/amd64 + linux/arm64, soverify-images' architecture assertion passes on a promoted tag; the single-archportal-next is wrapped in a one-entry manifest list, a media-type change every OCI runtime resolves
identically and nothing asserts or pins.
What I could not verify locally: the promote job running under CD's own OIDC identity against
ACR, and a real scheduled tick. The first is a manifest PUT — the same operation every leg's push
already performs with that identity; the second is exercised by the schedule after merge.
What will exercise this change: merging this PR is itself a push to main, so the very next CD
run publishes through the new staging → promote path and
verify-imagesasserts the result. Thereconciler's healthy path runs within 3 h of the merge and should be a single ~40 s
gatejobreporting "already has a complete image set". Verify the image, never the tick:
No What's New entry
Delivery infrastructure with no user-visible behaviour: nothing in the portal looks or behaves
differently, and the entry categories are
Feature/Fixfor things a user can notice. Theaudience for this change is whoever is on the hook when CD goes red, and AGENTS.md is where they
look — which is why the correction went there instead.
🤖 Generated with Claude Code