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.
π¨ Superseded for the platform roll by policy
platform-rolls-independently-of-packages(Policy Not Prose): no package answer holds the roll any more β it is an advisory; Release Gates β "What a refusal does".
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. They are still FIXED, never silenced: the remedy is a central PackageVersion in Directory.Packages.props plus a versionless PackageReference β¦ PrivateAssets="all" override in the root and the test Directory.Build.props (transitive pinning is off, so the bare PackageVersion alone lifts nothing) β e.g. System.Security.Cryptography.Pkcs/.Xml at the patched 10.0.13. An override for a package only a FEW projects pull is SCOPED to those projects with a Condition on $(MSBuildProjectName), so no other graph gains a package β e.g. MessagePack 2.5.303 (GHSA-qhrr-8q5h-9q3h, via Aspire.Hosting β StreamJsonRpc) for Memex.Aspire.Hosting and Memex.Deploy.AppHost only; each project that carries it needs its own entry, since a PrivateAssets override does not flow across a ProjectReference. The exception is a PACKED project whose consumers inherit the same vulnerable chain (Memex.Aspire.Hosting ships as MeshWeaver.Aspire.Hosting.Memex): its override is a normal, exported dependency, so the nuspec declares the patched minimum and lifts every consumer. Because PrivateAssets stops the override at a ProjectReference, MeshWeaver.Plugins (whose non-test props set no WarningsNotAsErrors) carries the same versionless pair in its src/Directory.Build.props. 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): every portal instance carries 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 (the fleet) | The portal detects the release and hands it to the control instance β one signed self-update-available event into the control instance's inbox β and the control plane opens the Roll that aks-ops.yml executes: unattended when the record already pins that tag, behind an approval in the mesh otherwise. The portal holds NO right on the cluster (MeshWeaver#4098; Self-Update on the Control Lane). |
A standalone Kubernetes install (selfUpdate.canPatch: true) |
The pre-#4098 shape: the portal patches its own Deployment image from inside the pod (Kubernetes API, projected service-account token), rolling the portal AND migration deployments to the new tag together; k8s does the rolling update. Only with a chart that renders the self-patch Role. |
| Local k3s on Mac | memex-local rolls host-side (autoroll, deploy/homebrew/README.md); the in-pod patch is not used. (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 platform rolls independently of its packages
π¨ The platform rolls independently of its packages β policy
platform-rolls-independently-of-packages(Policy Not Prose). A package-availability verdict is never a hold on the platform roll. It is an advisory: logged, recorded onAdmin/UpdatePolicy, shown on the Updates and About tabs β and the roll proceeds to the newest admitted tag. Packages catch up on their own (their own update policy, the held-update lane), never the reverse.
Whichever target it is, the poller still asks the release availability gate
about the newest tag the update policy admits: does every package this deployment runs have a usable
artifact for it, can its framework identity be resolved, can the catalogue be read, can every landed
module load against the surface it published (the link gate), and β
from the combo gate β do the landed modules compile and test against it. Those
are questions about packages, and under policy platform-rolls-independently-of-packages their answers never decide which platform build
an instance runs:
- The gate answers yes β the roll proceeds, with whatever it reported 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).
- The gate answers no or "cannot determine" β a missing bake under
Modules:RequirePrebuilt, an unresolvable framework identity, a package without an artifact, an unreadable catalogue, an unloadable module, a recorded combo Red, even an unregistered gate β the roll still proceeds. The refusal becomes the first advisory line ("the platform rolls independently of packages β N package(s) β¦ catch up through their own update policy: β¦"), every package that does not follow is named, and the check verdict saysPACKAGE ADVISORY. NoHeldTagis written, and a hold recorded by an older build is cleared on the roll.
Everything is written to the same node (AdvisoriesTag, Advisories) and shown on the Updates tab
beneath the available line; the About tab says, in one translated sentence, that the platform rolls
(or rolled) with package advisories and points to the Updates tab β the advisories themselves are
server-written diagnostics and stay on that admin surface β and no longer reads βΈοΈ Update held β a package β¦ is not available. The roll selection
CD reads (/api/plugins/roll-target), the manual-roll check (/api/plugins/is-updatable, which now
returns the waived reason as waivedHold), the reboot's image step and the Apply update now
button all apply the same rule, so no roll path selects differently from the poller.
Why. Measured on an enterprise deployment on 2026-10-11: Admin/UpdatePolicy read
HOLDING 3.1.10502 β the framework identity of release 3.1.10502 is not resolvable β no content bake has been published for it β¦ (heldIndeterminate=true), while every package update on the same
instance was held because it needed platform >= 3.1.10493. The platform waited for the packages and
the packages waited for the platform β a deadlock only one direction can break. Another deployment
ran 3.1.10502 fine.
The pure predicate (ReleaseAvailability.IsUpdatable) still answers the package question truthfully
β "cannot determine" is still not "available" β because the CI build gate and every surface that
lists package availability need that answer. Only the platform roll waives it
(UpdatabilityVerdict.ForPlatformRoll).
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 (ACR polling; the in-pod patch only where it is still allowed)
The Helm chart (deploy/helm/templates/memex-portal/) ships these:
serviceaccount.yamlβmemex-portal-sa(the pod runs as it). It carries no Kubernetes right by default.rbac.yamlβ rendered only withselfUpdate.canPatch: true: aRolegrantingget,patchon the portal + migration Deployments only (scoped byresourceNames), bound to the SA. The same value rendersSelfUpdate__CanPatchinto the ConfigMap, so with the default (false) the poller never attempts the PATCH β it hands the release to the control instance instead (Self-Update on the Control Lane). Ahelm upgradewith the default DELETES a Role a previous release created.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 β on the fleet it hands the latest tag to the control instance (the same event the poller sends); on a standalone install that may self-patch it patches.
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.