Release & Self-Update Strategy
Support lifetime and official artifact retention follow the Release Support Policy.
✅ Rule change, 2026-09-07 (maintainer) — Module Adoption Policy, implemented by #3648, #3649, #3650 and #3651. This page describes the mechanism as it runs after those changes: a declared floor is advisory, a refused generation falls back to the previous one, a new build is adopted eagerly, and a platform roll is held only by a module that provably cannot load on the target.
The production model in one picture:
PR ──[merge preconditions]──▶ main ──[CI: build ALL images, tag by version]──▶ ACR
│
each install polls ACR per Admin/UpdatePolicy ◀┘
Stable (default) → newest clean release · Continuous + pattern → newest build the pattern admits · None → manual
│
AKS / local k3s: patch own deployment MAUI: notify + relaunch
One central version, two channels (continuous build vs official release), and every install keeps itself up to date. The version mechanics live in Release Process & Versioning; where the images go is Deployment.
1. Merge preconditions
A PR may merge to main only when all four hold. CI enforces (a)+(b); branch protection +
a reviewer enforce (c)+(d). The checklist is in .github/pull_request_template.md.
| Precondition | Enforced by | |
|---|---|---|
| a | Build is green with warnings-as-errors | Live. dotnet-test.yml's build step runs dotnet build --no-restore -c Release -p:CIRun=true -warnaserror, and Directory.Build.props additionally sets TreatWarningsAsErrors=true centrally — so a compiler warning cannot merge. Doc-completeness warnings (CS1591/CS1573/CS1712) and the NU1510 restore advisory are suppressed centrally (NoWarn); NuGet vulnerability advisories (NU1901–NU1904) stay visible as warnings via WarningsNotAsErrors, so a newly-disclosed transitive CVE doesn't fail every unrelated PR. Doc-quality warnings (CS1574/CS0419/CS1570) are NOT suppressed — they fail the gate. |
| b | Tests are green | The sharded suite + the consolidated Test Results check (dotnet-test.yml). |
| c | Reviewed (AI or human) | A required approving review (branch protection). We don't care whether the reviewer is a person or an AI. .github/CODEOWNERS requests an owner. |
| d | Review comments dealt with | "Require conversation resolution before merging" (branch protection). We expect at least one comment or code change tied to the review. |
Branch-protection settings to configure once (GitHub → Settings → Branches →
main;ghcan't set these): require the Build and Test Results status checks; require ≥1 approving review; require review from Code Owners; require conversation resolution before merging.
2. Versions: current-build vs official
Two shapes, and no others: X.Y.Z-ci.<n> for every continuous build, clean X.Y.Z for the
release. No rc, no preview, no beta, no labelled line, ever — the scheme, the mint-versus-read
split and the ordering consequences are stated once, authoritatively, in
Release Process & Versioning §1; this page describes what the two
channels do with them.
The one number is PlatformVersion in Directory.Build.props — today 3.0.0, the next
release. Every build derives its version from it (details):
- Current build (continuous):
3.0.0-ci.<n>— the default.<n>is the GitHub Actions run number (monotonic), so newer builds always sort higher. 🔴 This monotonicity is load-bearing: the self-updater picks the newest version, and the old seconds-since-midnight build number reset at midnight (a morning build would sort below the prior evening's). Do not revert it. - Official release: clean
3.0.0— the same bytes as one continuous build, promoted byrelease.ymlwhen the annotated tagv3.0.0is pushed on a commitmain-cdhas promoted and sealed. Nothing is rebuilt. Cut only when every open issue is closed.
🚨 Candidates are ranked by the CD run number, not by the version string (#3542).
<n>is the ordering key: a3.1.0-ci.7841published before3.0.0-ci.7977loses to it, and so does a retired3.0.0-rc9.ci.7824— SemVer compares pre-release identifiers as text, which is how a mislabelled line outranked every sealed set for ever and rolled both AKS portals three days backwards on 2026-09-07. An official release carries no run number of its own (it is a promotion of a sealed set), so it is ranked by version — which is what keeps a Stable install running a continuous build able to reach it. Full reference: Self-Update Target Selection.No pre-release label on the core beyond
-ci.<n>, and no rc line — retired 2026-09-05, settled 2026-09-07.-ci.<n>is a channel marker, never a version; the release is a promotion of a sealed continuous set, not a rebuild; and nothing else may ever be minted. The bump to3.1.0still happens the day3.0.0is tagged — the lane opens that pull request. Full rule: Release Process & Versioning §1.
Cutting an official release and starting the next line
- Cut the official release: commit the notes page
Doc/ReleaseNotes/3_0_0, then push an annotatedv3.0.0tag on a promoted, sealed commit.release.ymlretags that commit's set with the clean version in ACR, mirrors it to GHCR, records the release marker and publishes the GitHub Release. Stable installs pick it up. - Start the next line: merge the pull request the lane opened —
PlatformVersion3.0.0→3.1.0. Continuous builds are now3.1.0-ci.<n>, above the release.
3. CI produces ALL images, tagged by version, on ACR
Both channels publish the full image set to meshweaver.azurecr.io, tagged by the version
string — that tag is what each install compares.
| Channel | Trigger | Version baked + image tag | Workflow |
|---|---|---|---|
| Continuous | green merge to main |
3.0.0-ci.<run#> (+ short SHA + moving main) |
main-cd.yml |
| Official | push an annotated v*.*.* tag on a promoted, sealed commit |
clean 3.0.0 — the continuous set retagged in ACR and mirrored to GHCR; nothing rebuilt |
release.yml |
So the continuous build produces all images, the release names one of them, and a running install
only has to list ACR tags and pick the best per its policy. (main-cd.yml still rolls the environments once as
the bootstrap; steady-state updates are the self-updater below.)
Two properties of the continuous leg matter to a reader of tags:
- Only the version tag is selectable.
VersionSelect.PlatformVersionTagrequires^\d+\.\d+\.\d+, so the movingmainpointer and the per-runstaging-<sha>-<run_id>tag are invisible to every self-updater by construction. - Publication is all-or-nothing. Each leg pushes only its staging tag; the
promotejob applies the real tags after all five legs succeed, ending withmemex-portal-ai:<version>— the single write the self-updater acts on.
4. The update policy — Admin/UpdatePolicy
A single mesh node, edited by platform admins under Settings → Updates (a dropdown and a
pattern field bound straight to the node). Default Stable — clean releases only (maintainer,
2026-09-08: "by default we will not upgrade as long as no version without -ci… is labelled").
| Policy | pattern |
Behaviour |
|---|---|---|
| Stable (default) | (optional; narrows to a line, e.g. 3.0.*) |
Roll only to the newest clean release (no build number). |
| Continuous | required, e.g. 3.0.1-ci* |
Roll to the newest sealed continuous build the pattern admits — latest by CD run number, not by version string (why). 3.0.0-ci* never selects 3.0.1: following a line ends when its release is tagged. Without a pattern this is Stable, and the poller says so once at Warning. |
| None | (ignored) | Never auto-update. Apply updates manually (operator, or the admin tab's Apply available update now). |
Fleet today (no clean release above 3.0.0 yet): memex and memex-cloud carry Continuous +
3.0.0-ci*, to be changed the day 3.0.1 is tagged. The record shapes and the semver rule are in
Release Process & Versioning → "Which build an install takes".
The poller (SelfUpdateHostedService) reads this node live: changing the policy re-drives it
immediately. It checks ACR a few times a day, records the latest tag it sees on the node
(LatestAvailableTag, surfaced in the admin tab and to the MAUI notifier), and — where it can —
applies the update.
5. How each install updates
| Target | What "update" does |
|---|---|
AKS (memex portal) |
The portal patches its own Deployment image from inside the pod (Kubernetes API, projected service-account token). It rolls the portal AND migration deployments to the new tag together; k8s does the rolling update. |
| Local k3s on Mac | Same Helm chart as AKS → same in-pod patch. (A version-specific tag pulls even under imagePullPolicy: IfNotPresent because the tag isn't cached. A pure local-build dev loop without ACR is effectively None.) See LocalColimaMac. |
| Monolith (non-k8s) | No self-patch (no service-account token) → detect-only: records LatestAvailableTag for visibility; the operator updates the binary. |
| MAUI app | Detect + notify. A sandboxed app can't replace its own binary, so on connecting to a remote mesh that runs a newer platform version it shows an in-app alert: update from the store and relaunch. |
The availability gate — "newer" is not sufficient
Whichever target it is, a newer tag is not on its own a reason to roll. Before patching
anything, the poller asks the release availability gate whether a module this
deployment has landed provably cannot load on that release — the landed bytes linked against the
surface the release published (the link gate, MeshWeaver#3651). If
one cannot, the update is held: the poller stays on the current image, writes the refusal to
Admin/UpdatePolicy (HeldTag, HeldReason, HeldAt), and the Updates tab reports it — the
About tab shows ⏸️ Update held rather than ⬆️ Update available, because an install that has
refused a build must not look like one that is about to take it.
What the gate reports without holding — a course with no prebuilt bake for that build, which
compiles when the new build starts; a module whose loadability could not be measured, which the
platform decides at start-up and keeps the previous version of if the new one cannot load — is
written to the same node (AdvisoriesTag, Advisories) and shown on the tab beneath the available
line. Only Modules:RequirePrebuilt turns the missing bake back into a hold.
A hold is not sticky. It is re-evaluated on every poll and every green-build event and clears itself the moment the missing artifact is published, so nothing has to be un-stuck by hand. The manual Apply update now button consults the same verdict — a gate the unattended path respects and the manual one does not is no gate at all.
🚨 "Cannot determine" is a hold too, and it says so distinctly: an unreadable artifact catalogue is an availability incident to fix, not an incompatible release to re-bake.
Postgres ("auto-update pg")
- Schema /
db_versionis kept in step automatically: the migration container is rolled to the same version as the portal on every update — the migration is what applies schema changes, so the database is always current for the running code. This is the meaningful, safe "auto-update pg." - The Postgres SERVER image stays at its pinned major (e.g.
pgvector:pg17). A major upgrade is never automated — it needspg_upgradeagainst the data volume (data-loss risk) and is a deliberate, manual runbook. On AKS/Container Apps Postgres is a managed Flexible Server (Azure handles minor upgrades; the in-pod updater never touches it).
6. AKS prerequisites (for the in-pod patch + ACR polling)
The Helm chart (deploy/helm/templates/memex-portal/) ships these so the portal can update itself:
serviceaccount.yaml—memex-portal-sa(the pod runs as it).rbac.yaml— aRolegrantingget,patchon the portal + migration Deployments only (scoped byresourceNames), bound to the SA. Without it the PATCH is403; the poller logs and keeps ticking (no crash).deployment.yaml— setsserviceAccountName, and (whenselfUpdate.azureClientIdis set) theazure.workload.identity/uselabel +AZURE_CLIENT_ID.
For ACR polling on AKS you must, once per environment, create a user-assigned managed identity,
federate it to system:serviceaccount:<ns>:memex-portal-sa, grant it AcrPull on
meshweaver.azurecr.io, and set selfUpdate.azureClientId (e.g. in values.aks.yaml / via Key
Vault) to its client id. (Mirrors the existing pgbackrest-sa workload-identity wiring.) The
in-cluster Deployment PATCH works without this; it only authenticates the tag-list call.
7. Operate & verify
- Set the policy: Settings → Updates (platform admin). The dropdown writes
Admin/UpdatePolicy. - Watch a continuous roll (AKS): merge to
main→ a new…-ci.<n>tag lands on ACR → within the poll window aContinuousinstall patchesmemex-portal-deployment+memex-migration-deployment(kubectl rollout status). - Pin an environment: set the policy to
None.Stable(the default) is releases-only; aContinuousinstall follows only the line itspatternnames. - Manual apply: Settings → Updates → Apply available update now (installs that can self-patch).
The decision logic (which tag each policy picks; "is newer") is unit-pinned in
VersionSelectTest (ordering, and the three-valued "does my own tag still exist" check) and
SelfUpdateStrandRecoveryTest (the poller, against a real mesh); the enum dropdown in
MeshNodeEditorFieldTest.
8. Cut a release — operator runbook
Three operator actions, two independent channels, and steady state is self-update: you push
images (by merging or tagging) and installs roll themselves per Admin/UpdatePolicy — you do
not kubectl set image by hand. Forcing a specific tag on one instance is a Roll
Hosting/InstanceAction on the control instance
(OperatingFromThePortal); the manual
AKS runbook is what the operator runs for it, and the
break-glass path when the control plane itself cannot act.
| Step | Action | What ships | Who rolls to it |
|---|---|---|---|
| a | Merge to main (preconditions §1 green) |
main-cd.yml builds the multi-arch image set (amd64 + arm64), tags it 3.0.0-ci.<run#> (+ short SHA + moving main), pushes to ACR, bakes and seals |
Continuous installs whose pattern admits the tag (dev/test; the fleet's 3.0.0-ci* today) |
| b | Push the annotated tag v3.0.0 on a promoted, sealed commit |
release.yml retags that set 3.0.0 in ACR, mirrors it to GHCR, records _releases/3.0.0, publishes the GitHub Release |
Stable installs — the default (prod) |
| c | Merge the bump the lane opened (PlatformVersion → 3.1.0) |
continuous builds become 3.1.0-ci.<n> |
opens the next development line |
# (a) ship a continuous build — just merge; CI builds + pushes + seals the image set
git switch main && git pull
# (b) cut the official release — an immutable, ANNOTATED tag on a commit whose CD run sealed
# (Doc/ReleaseNotes/3_0_0 must already be committed; the lane refuses a release without notes)
git tag -a v3.0.0 -m "MeshWeaver 3.0.0" <sha> && git push origin v3.0.0
# (c) open the next line — merge the pull request release.yml opened:
# release: PlatformVersion 3.0.0 → 3.1.0
(a) can look shipped and produce no image. CD reacts to MeshWeaver Build and Test completing on a
pushto main — a hand-kickedworkflow_dispatchof that workflow turns main green and CD still skips. Verify the IMAGE (.github/scripts/check-image-set.sh <short-sha>), and re-drive CD withgh workflow run main-cd.yml --ref mainif it is missing (it also self-heals HEAD on a hourly schedule).
(b) depends on (a): a tag can only name a set that a merge already shipped and sealed — that is the whole point of a promotion. (c) follows (b) once per release line, opened by the lane and merged by you. The version mechanics behind each step are in Release Process & Versioning; §2 above covers the same cut from the version-scheme angle.
9. See also
- The Continuous Delivery Contract — what
main-cd.ymlguarantees about the set: all-or-nothing publication, the promote ordering, the reconciler, and verifying the image rather than the tick. - Release Process & Versioning — the version-number mechanics.
- Deployment — the two deploy routes (AKS vs Container Apps).
- DeploymentAKS · LocalColimaMac.
- Request via stream.Update · Controlled I/O pooling — the patterns the poller is built on.