Releasing Orleans.Lattice packages
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at RELEASING.md, and llms.txt lists every page.This document describes the per-package tag-and-publish protocol the Publish GitHub Actions workflow expects. It is the canonical reference for human release engineers; the same protocol is encoded in the repo's automation rules.
Packages
The package family ships from this repository:
| Package | csproj path |
|---|---|
Orleans.Lattice |
src/lattice/Orleans.Lattice.csproj |
Orleans.Lattice.Replication |
src/lattice.replication/Orleans.Lattice.Replication.csproj |
Orleans.Lattice.Replication.Grpc |
src/lattice.replication.grpc/Orleans.Lattice.Replication.Grpc.csproj |
Orleans.Lattice.Storage.AzureTable |
src/lattice.storage.azuretable/Orleans.Lattice.Storage.AzureTable.csproj |
Orleans.Lattice.Storage.File |
src/lattice.storage.file/Orleans.Lattice.Storage.File.csproj |
Orleans.Lattice.Dashboards |
src/lattice.dashboards/Orleans.Lattice.Dashboards.csproj |
Orleans.Lattice.Api.Abstractions |
src/lattice.api.abstractions/Orleans.Lattice.Api.Abstractions.csproj |
Orleans.Lattice.Api.State |
src/lattice.api.state/Orleans.Lattice.Api.State.csproj |
Orleans.Lattice.Api.State.Grpc |
src/lattice.api.state.grpc/Orleans.Lattice.Api.State.Grpc.csproj |
Orleans.Lattice.Membership |
src/lattice.membership/Orleans.Lattice.Membership.csproj |
Orleans.Lattice.Membership.Entra |
src/lattice.membership.entra/Orleans.Lattice.Membership.Entra.csproj |
Orleans.Lattice.Membership.Entra.Graph |
src/lattice.membership.entra.graph/Orleans.Lattice.Membership.Entra.Graph.csproj |
Orleans.Lattice.Membership.Oidc |
src/lattice.membership.oidc/Orleans.Lattice.Membership.Oidc.csproj |
Orleans.Lattice.Auth |
src/lattice.auth/Orleans.Lattice.Auth.csproj |
Orleans.Lattice.Apps |
src/lattice.apps/Orleans.Lattice.Apps.csproj |
Orleans.Lattice.Api.Auth |
src/lattice.api.auth/Orleans.Lattice.Api.Auth.csproj |
Orleans.Lattice.Api.Auth.Grpc |
src/lattice.api.auth.grpc/Orleans.Lattice.Api.Auth.Grpc.csproj |
Orleans.Lattice.Api.Data |
src/lattice.api.data/Orleans.Lattice.Api.Data.csproj |
Orleans.Lattice.Api.Data.Grpc |
src/lattice.api.data.grpc/Orleans.Lattice.Api.Data.Grpc.csproj |
Orleans.Lattice.Backup |
src/lattice.backup/Orleans.Lattice.Backup.csproj |
Orleans.Lattice.Backup.AzureBlob |
src/lattice.backup.azureblob/Orleans.Lattice.Backup.AzureBlob.csproj |
Orleans.Lattice.Api.Backup |
src/lattice.api.backup/Orleans.Lattice.Api.Backup.csproj |
Orleans.Lattice.Api.Backup.Grpc |
src/lattice.api.backup.grpc/Orleans.Lattice.Api.Backup.Grpc.csproj |
Orleans.Lattice.Api.Replication |
src/lattice.api.replication/Orleans.Lattice.Api.Replication.csproj |
Orleans.Lattice.Api.Replication.Grpc |
src/lattice.api.replication.grpc/Orleans.Lattice.Api.Replication.Grpc.csproj |
Orleans.Lattice.Api.Mcp |
src/lattice.api.mcp/Orleans.Lattice.Api.Mcp.csproj |
Orleans.Lattice.Api.Mcp.Telemetry |
src/lattice.api.mcp.telemetry/Orleans.Lattice.Api.Mcp.Telemetry.csproj |
Orleans.Lattice.Api.Mcp.Telemetry.Azure |
src/lattice.api.mcp.telemetry.azure/Orleans.Lattice.Api.Mcp.Telemetry.Azure.csproj |
Orleans.Lattice.Api.Mcp.RepoContext |
src/lattice.api.mcp.repocontext/Orleans.Lattice.Api.Mcp.RepoContext.csproj |
Orleans.Lattice.Api.Mcp.RepoContext.Replication |
src/lattice.api.mcp.repocontext.replication/Orleans.Lattice.Api.Mcp.RepoContext.Replication.csproj |
Orleans.Lattice.Api.Mcp.Apps |
src/lattice.api.mcp.apps/Orleans.Lattice.Api.Mcp.Apps.csproj |
Orleans.Lattice.Api.Schema |
src/lattice.api.schema/Orleans.Lattice.Api.Schema.csproj |
Orleans.Lattice.Api.Schema.Grpc |
src/lattice.api.schema.grpc/Orleans.Lattice.Api.Schema.Grpc.csproj |
Orleans.Lattice.Api.TreeAdmin |
src/lattice.api.treeadmin/Orleans.Lattice.Api.TreeAdmin.csproj |
Orleans.Lattice.Api.TreeAdmin.Grpc |
src/lattice.api.treeadmin.grpc/Orleans.Lattice.Api.TreeAdmin.Grpc.csproj |
Orleans.Lattice.Api.Apps |
src/lattice.api.apps/Orleans.Lattice.Api.Apps.csproj |
Orleans.Lattice.Api.Apps.Grpc |
src/lattice.api.apps.grpc/Orleans.Lattice.Api.Apps.Grpc.csproj |
Orleans.Lattice.Api.TenantAdmin |
src/lattice.api.tenantadmin/Orleans.Lattice.Api.TenantAdmin.csproj |
Orleans.Lattice.Api.TenantAdmin.Grpc |
src/lattice.api.tenantadmin.grpc/Orleans.Lattice.Api.TenantAdmin.Grpc.csproj |
Orleans.Lattice.Api.Telemetry |
src/lattice.api.telemetry/Orleans.Lattice.Api.Telemetry.csproj |
Orleans.Lattice.Api.Telemetry.Grpc |
src/lattice.api.telemetry.grpc/Orleans.Lattice.Api.Telemetry.Grpc.csproj |
Orleans.Lattice.Schema |
src/lattice.schema/Orleans.Lattice.Schema.csproj |
Orleans.Lattice.Tenancy |
src/lattice.tenancy/Orleans.Lattice.Tenancy.csproj |
Orleans.Lattice.Explorer.Core |
src/lattice.explorer/Core/Orleans.Lattice.Explorer.Core.csproj |
Orleans.Lattice.Explorer.UI |
src/lattice.explorer/UI/Orleans.Lattice.Explorer.UI.csproj |
Orleans.Lattice.Explorer.Web |
src/lattice.explorer/WebHosting/Orleans.Lattice.Explorer.Web.csproj |
Orleans.Lattice.Explorer.Entra |
src/lattice.explorer.entra/Orleans.Lattice.Explorer.Entra.csproj |
Orleans.Lattice.Explorer.Entra.Web |
src/lattice.explorer.entra.web/Orleans.Lattice.Explorer.Entra.Web.csproj |
Orleans.Lattice.Explorer.AppKit |
src/lattice.explorer/AppKit/Orleans.Lattice.Explorer.AppKit.csproj |
Orleans.Lattice.Caching.AzureBlob |
src/lattice.caching.azureblob/Orleans.Lattice.Caching.AzureBlob.csproj |
Orleans.Lattice.Scaling |
src/lattice.scaling/Orleans.Lattice.Scaling.csproj |
Orleans.Lattice.GrainIndex |
src/lattice.grainindex/Orleans.Lattice.GrainIndex.csproj |
Orleans.Lattice.Vector |
src/lattice.vector/Orleans.Lattice.Vector.csproj |
Retired packages
These package ids are no longer built from this repository. Only
Orleans.Lattice.Explorer.Access, Orleans.Lattice.Explorer.Backup and
Orleans.Lattice.Explorer.Schema were ever published; the design system, the
plugins and the MAUI head were never tagged, so nuget.org has no version of them.
The Explorer rewrite (epic #3807)
removed the Explorer plugin model with no successor extension point, retired the
MAUI desktop head, and folded the design system into Orleans.Lattice.Explorer.UI,
which now ships the rewritten Explorer under its existing id. The retired ids
have no publish tag glob and no row in the tables above; the last released
versions of the three published ids stay on nuget.org and should be marked
deprecated there, pointing at Orleans.Lattice.Explorer.Web (the hosting entry
point) as the alternative.
Orleans.Lattice.Explorer.DesignSystem, never tagged (its tag shape waslattice.explorer.designsystem-v<X.Y.Z>). Its successor isOrleans.Lattice.Explorer.UI.Orleans.Lattice.Explorer.Access, last taggedlattice.explorer.access-v<X.Y.Z>. Its successor is the Access area, compiled intoOrleans.Lattice.Explorer.UI.Orleans.Lattice.Explorer.Backup, last taggedlattice.explorer.backup-v<X.Y.Z>. Its successor is the Backups area, compiled intoOrleans.Lattice.Explorer.UI.Orleans.Lattice.Explorer.Schema, last taggedlattice.explorer.schema-v<X.Y.Z>. Its successor is the Schema area, compiled intoOrleans.Lattice.Explorer.UI.Orleans.Lattice.Explorer.Plugins.*(Abstractions, Selection, Data, History, Metrics, Topology, TagIndex, DeadLetter, Telemetry, Tenancy, Tenants, MyTenant), never tagged (their tag shape waslattice.explorer.plugins.<name>-v<X.Y.Z>). There is no successor: the Explorer has no plugin model, its native areas are compiled in, and the only third-party UI surface is a Lattice App UI.Orleans.Lattice.Explorer, the MAUI desktop head, never tagged. Its successor isOrleans.Lattice.Explorer.Web, the Blazor Server head.
A patch to one of the three published ids can still be cut from an older release/<X.Y> line
that carries its sources and its tag glob, exactly like any held-back package.
Tag shape
The publish workflow's per-tag trigger globs match these tag shapes:
| Package | Tag shape |
|---|---|
Orleans.Lattice |
lattice-v<X.Y.Z> |
Orleans.Lattice.Replication |
lattice.replication-v<X.Y.Z> |
Orleans.Lattice.Replication.Grpc |
lattice.replication.grpc-v<X.Y.Z> |
Orleans.Lattice.Storage.AzureTable |
lattice.storage.azuretable-v<X.Y.Z> |
Orleans.Lattice.Storage.File |
lattice.storage.file-v<X.Y.Z> |
Orleans.Lattice.Dashboards |
lattice.dashboards-v<X.Y.Z> |
Orleans.Lattice.Api.Abstractions |
lattice.api.abstractions-v<X.Y.Z> |
Orleans.Lattice.Api.State |
lattice.api.state-v<X.Y.Z> |
Orleans.Lattice.Api.State.Grpc |
lattice.api.state.grpc-v<X.Y.Z> |
Orleans.Lattice.Membership |
lattice.membership-v<X.Y.Z> |
Orleans.Lattice.Membership.Entra |
lattice.membership.entra-v<X.Y.Z> |
Orleans.Lattice.Membership.Entra.Graph |
lattice.membership.entra.graph-v<X.Y.Z> |
Orleans.Lattice.Membership.Oidc |
lattice.membership.oidc-v<X.Y.Z> |
Orleans.Lattice.Auth |
lattice.auth-v<X.Y.Z> |
Orleans.Lattice.Apps |
lattice.apps-v<X.Y.Z> |
Orleans.Lattice.Api.Auth |
lattice.api.auth-v<X.Y.Z> |
Orleans.Lattice.Api.Auth.Grpc |
lattice.api.auth.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.Data |
lattice.api.data-v<X.Y.Z> |
Orleans.Lattice.Api.Data.Grpc |
lattice.api.data.grpc-v<X.Y.Z> |
Orleans.Lattice.Backup |
lattice.backup-v<X.Y.Z> |
Orleans.Lattice.Backup.AzureBlob |
lattice.backup.azureblob-v<X.Y.Z> |
Orleans.Lattice.Api.Backup |
lattice.api.backup-v<X.Y.Z> |
Orleans.Lattice.Api.Backup.Grpc |
lattice.api.backup.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.Replication |
lattice.api.replication-v<X.Y.Z> |
Orleans.Lattice.Api.Replication.Grpc |
lattice.api.replication.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp |
lattice.api.mcp-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp.Telemetry |
lattice.api.mcp.telemetry-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp.Telemetry.Azure |
lattice.api.mcp.telemetry.azure-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp.RepoContext |
lattice.api.mcp.repocontext-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp.RepoContext.Replication |
lattice.api.mcp.repocontext.replication-v<X.Y.Z> |
Orleans.Lattice.Api.Mcp.Apps |
lattice.api.mcp.apps-v<X.Y.Z> |
Orleans.Lattice.Api.Schema |
lattice.api.schema-v<X.Y.Z> |
Orleans.Lattice.Api.Schema.Grpc |
lattice.api.schema.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.TreeAdmin |
lattice.api.treeadmin-v<X.Y.Z> |
Orleans.Lattice.Api.TreeAdmin.Grpc |
lattice.api.treeadmin.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.Apps |
lattice.api.apps-v<X.Y.Z> |
Orleans.Lattice.Api.Apps.Grpc |
lattice.api.apps.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.TenantAdmin |
lattice.api.tenantadmin-v<X.Y.Z> |
Orleans.Lattice.Api.TenantAdmin.Grpc |
lattice.api.tenantadmin.grpc-v<X.Y.Z> |
Orleans.Lattice.Api.Telemetry |
lattice.api.telemetry-v<X.Y.Z> |
Orleans.Lattice.Api.Telemetry.Grpc |
lattice.api.telemetry.grpc-v<X.Y.Z> |
Orleans.Lattice.Schema |
lattice.schema-v<X.Y.Z> |
Orleans.Lattice.Tenancy |
lattice.tenancy-v<X.Y.Z> |
Orleans.Lattice.Explorer.Core |
lattice.explorer.core-v<X.Y.Z> |
Orleans.Lattice.Explorer.UI |
lattice.explorer.ui-v<X.Y.Z> |
Orleans.Lattice.Explorer.Web |
lattice.explorer.web-v<X.Y.Z> |
Orleans.Lattice.Explorer.Entra |
lattice.explorer.entra-v<X.Y.Z> |
Orleans.Lattice.Explorer.Entra.Web |
lattice.explorer.entra.web-v<X.Y.Z> |
Orleans.Lattice.Explorer.AppKit |
lattice.explorer.appkit-v<X.Y.Z> |
Orleans.Lattice.Caching.AzureBlob |
lattice.caching.azureblob-v<X.Y.Z> |
Orleans.Lattice.Scaling |
lattice.scaling-v<X.Y.Z> |
Orleans.Lattice.GrainIndex |
lattice.grainindex-v<X.Y.Z> |
Orleans.Lattice.Vector |
lattice.vector-v<X.Y.Z> |
The v<X.Y.Z> family tag (e.g. v9.6.0) names "the whole family at this version". It is deliberately not a publish trigger: the Publish workflow keys exclusively off the per-package <package>-v* globs and carries no bare v* glob, so pushing it starts no run. That inertness is exactly what makes it useful as the release anchor - it pins the commit a wave ships from without any side effect.
Release lines
A release wave is long. It pushes one tag per package, serially - each only after the previous tag's publish run has started - and the core tag last, once every other run has succeeded (see step 6 of the release protocol). main does not hold still for that - it takes automated performance, testing and dependency PRs continuously. So the protocol never releases "from main". It releases from a pinned commit, named two ways:
- The family anchor tag
v<X.Y.Z>- immutable. It records the exact tree the wave shipped. Cut once, at the moment the release chore PR merges. - The release line branch
release/<X.Y>- mutable, and the only place fixes for that line ever land. Cut from the same commit as the anchor tag.
Every per-package tag in a wave is cut from the release line branch, never from main. Once the branch exists, main is free to move: it cannot change what the wave ships.
The branch is named for the minor line, not for a point version - one release/9.6 carries 9.6.0, then 9.6.1, then 9.6.2. Naming it release/9.6.0 would strand the next patch on a differently-named branch and defeat the purpose.
Release line branches are never merged back into main; they diverge by design. Anything on a line that main also needs - the fix itself, a changelog entry, a <Version> bump - reaches main through its own ordinary PR.
Hotfixes
A patch release is a cherry-pick onto the release line, never a tag on main:
git switch release/<X.Y>(fetch it first if this is a fresh clone).git cherry-pick <fix-commit-from-main>.- Bump the
<Version>slot of only the packages being patched, and add the datedCHANGELOG.mdsection. - Push the line:
git push origin release/<X.Y>. The publish workflow's release-line guard looks for the tagged commit in therelease/*branches onorigin, so a tag cut from a cherry-pick that was never pushed fails before anything reaches NuGet. - Tag and push per package exactly as a full wave does (steps 4 onward below).
This is what keeps a patch a patch. Tagging main instead would publish everything that landed since the last wave under a patch digit. That is why 9.5.1 was cut from its release line rather than trunk: main was already carrying additive public API queued for the next minor, and a patch tag would have shipped a minor's worth of surface.
Reconcile main afterwards with a small separate PR carrying the changelog entry and the <Version> bump, so trunk's history records the patch.
If the patch pushes the core lattice-v<X.Y.Z> tag - the only tag that fires Docs - on a line that is no longer the newest, expect its Docs run to be green with a skipped deploy job - see Only the newest release line publishes the site. That is the guard working, not a failure to investigate.
Held-back packages
A package can be deliberately withheld from a wave (see PACKAGES.md, the ship/no-ship authority). It then sits on an older line than the rest of the family while main moves on beneath it, so main is not a valid base for patching it. Hence the invariant:
Every shipped package must have a live release line branch containing the tree it shipped from.
Concretely: while the Explorer family is held at 9.4.x and the rest of the family ships 9.9.x, both release/9.4 and release/9.9 stay alive. An Explorer patch is cut from release/9.4, because main's Explorer sources have since been rewritten twice - the plugin console of #1790, then the rewrite of epic #3807 that replaced it - and cutting from trunk would drag those rewrites into a patch release.
Retire a release line branch only once no shipped package still points at it.
Documentation fixes between waves
The documentation site is deployed by the Docs workflow, and it is not manual-only. Three triggers, only two of which deploy:
- A pull request that touches the documentation (the paths
docs.ymlwatches) builds the corpus but never deploys. The build is the link-integrity gate (-MaxWarnings 0), so it fails closed on any broken relative link or in-page anchor. - A push of the core
lattice-v<X.Y.Z>tag builds and deploys automatically. It is the only tag that does: the glob islattice-v*, which matches the core tag alone and not the dotted per-package tags such aslattice.storage.file-v9.6.0, so one wave deploys the site once rather than once per package. - A manual
workflow_dispatchbuilds from whatever ref it is dispatched on, but deploys only when that ref is arelease/<X.Y>line branch, and only the newest one (see Only the newest release line publishes the site).
Because the deploy hangs off the core tag, the published site reflects the wave's pinned ship commit, not main. That is the intended contract: the site documents what a user can actually install.
That contract is what makes main the wrong ref for an out-of-band docs fix. Publishing from main would document unreleased work, silently putting the site ahead of every package on NuGet. So a docs fix reaches the site the same way a code fix reaches NuGet - through the release line:
- Merge the fix to
mainas an ordinary PR, so trunk carries it into the next wave. git switch release/<X.Y>,git cherry-pick <fix-commit-from-main>, andgit push origin release/<X.Y>: the dispatched run builds the line as it stands onorigin.- Dispatch the workflow on that line:
gh workflow run Docs --ref release/<X.Y>.
No tag is involved: the site is a whole-repository artifact with no version of its own, so republishing it does not constitute a release and needs no <Version> bump or changelog entry.
This is enforced twice rather than merely documented. First, the guard in docs.yml publishes a dispatch only from a release/<X.Y> branch: dispatched on any other ref, main included, it logs SKIP: refusing to publish the site from '<ref>', and the run is green with a skipped deploy job. Behind it, the github-pages environment's deployment branch policy admits the tag pattern lattice-v* and the branch pattern release/*, and deliberately does not admit main; a deploy the policy refuses presents as the zero-step deploy failure described in step 7 below. docs.yml has no push trigger for main, so nothing legitimate needs it.
What a published build records
Each build states which release it documents, because nothing else on a page can tell a reader - or an agent - whether it describes the version they installed. docs-site/stage.ps1 reads it from the ref that triggered the run: a lattice-v<X.Y.Z> tag push documents X.Y.Z, and a dispatch on release/<X.Y> documents the newest lattice-v<X.Y>.* tag on that line. The footer of every page names that version, the ref and commit it was built from, and the build date; every page's source link, and every link from the site into the repository, points at the same ref rather than at main; and the NuGet badges on the Packages page carry each package's newest published version as text, read from its newest <package>-v<X.Y.Z> tag when the site is built. The badge image itself stays live, so the two differ only after a wave that pushes no core tag: such a wave does not redeploy the site, and its versions reach the text at the next deploy.
The build also publishes the site's surface for agents and LLM tooling, generated afresh from the tree it builds: llms.txt, listing every page from the same catalogue as the documentation map, with the release history, the samples' source and the pages beyond the documentation under its Optional section; llms-full.txt, every documentation page in one file, and beside each package's documentation (and the CRDT guide's) an llms-full.txt holding that set's pages alone, so an agent can take one package whole without the rest of the site; sitemap.xml, listing every rendered page without modification dates, because DocFX stamps every entry with the build's own time and writes its hour on a 12-hour clock; a markdown alternate of every page at the same address ending in .md; and the agent-only specifications under docs/agents, which it publishes as raw YAML and JSON resources that never render as pages (the build fails if one is missing from the site or renders as a page), lists under the Agent specifications section of llms.txt, and announces from every page's head through a rel="describedby" link to their manifest, docs/agents/index.json. Each alternate's front matter names the release the site documents and, on a package's page, the package, the version of it the page describes, which llms.txt also gives beside each package, and the address of the package's one-file copy. A package's documented version is its newest tag on the site's release line or an earlier one, never a later one, so a per-package tag pushed on the next line before its core tag does not claim these pages. Every rendered page carries the same statement in a visually hidden note under its title, with links to its markdown and to llms.txt, because a reader that takes a page as text - a screen reader, or any tool that simplifies HTML - never sees the page's head, where the alternate is announced, or its footer. The repository's own llms.txt points at the published one rather than duplicating it, so the index a reader finds always matches the release the site documents.
Only the newest release line publishes the site
GitHub Pages serves one site, and every deploy replaces it wholesale, so the published site is whichever deploy ran last - which is not the same thing as the newest release. That distinction matters because the lattice-v* trigger also matches a core hotfix on an older line. The family has cut exactly such a tag before: lattice-v9.5.2 was pushed from release/9.5 after 9.6.0 had shipped. On a line whose docs.yml lacks the guard described below, a patch like that would silently regress the public documentation to the older version. A dispatch on an older release/<X.Y> branch would do the same.
docs.yml therefore carries a newest-line guard. It resolves the line the run would publish (from the tag for a push, from the branch for a dispatch), compares it against the highest lattice-v* tag in the repository, and allows the deploy only when the two lines match.
An older line is skipped, not failed. The packages that hotfix publishes are perfectly legitimate; it is only the site that must not move. So the Docs run for an old-line hotfix is green with a skipped deploy job, and the guard's log says which line it saw and which line is newest. Do not "fix" that by re-running it or dispatching the site by hand - a green run with a skipped deploy is the guard working.
The guard lives in the workflow file, and GitHub resolves that file from the ref that triggered the run - the tagged commit for a tag push, the dispatched ref for a dispatch. It never reads main. A guard merged only to main therefore protects nothing: the run that needs stopping is the one firing from the line. So the guard defends a line only if that line's own docs.yml carries it. Lines cut from main after the guard landed inherit it and need no action; release/9.6 was cut before it existed and was backfilled onto the line directly; release/9.4 and release/9.5 predate docs.yml altogether, so they fire no Docs run at all and cannot move the site under any trigger. When auditing this, read the file on the line, not on main.
The practical consequences are worth stating plainly:
- The site always describes the newest released minor line, and no older-line activity can move it.
- A documentation fix that must appear on the site has to reach the newest line. Cherry-picking it only onto an older line updates that line's sources but will never publish.
- Once a line stops being the newest, its documentation is frozen as far as the site is concerned. There is no per-version docs archive; see the limitation below.
One known limitation: the site is a single artifact built from one commit, so while a package is held back its documentation is published from the wave's commit rather than from the older line it actually shipped from. While the Explorer family sits at 9.4.x and the rest ships 9.9.x, the site therefore describes Explorer ahead of its released surface. Versioning the site is the only real fix; the hold-back is expected to be temporary, so this is accepted for now.
Release protocol
Confirm the PR has merged, and capture its merge commit. The wave ships from that commit and nothing else:
git checkout main git pull origin main $sha = git rev-parse HEADRecord
$sha- every later step derives from it. Do not re-resolvemainlater in the wave. If the release is interrupted and resumed,mainwill have moved, and re-resolving it silently ships a different tree under the same family version. Nothing downstream catches that: the publish workflow's version guard compares the packed<Version>against the tag's version, so a drifted commit whose<Version>is untouched passes cleanly.Push the family anchor tag at that commit. It fires no workflow; it exists to pin the wave:
git tag v<X.Y.Z> $sha git push origin v<X.Y.Z>Cut the release line branch at the same commit, and switch to it. Every per-package tag below is cut from here:
git branch release/<X.Y> $sha git push origin release/<X.Y> git switch release/<X.Y>For a patch wave the line branch already exists: skip the create,
git switchto it, cherry-pick the fix onto it instead, and push it (see Hotfixes). For a wave that includes a held-back package, that package's tags are cut from its line branch, not this one (see Held-back packages). The rest of the protocol is identical either way.This step is enforced, not merely documented: the publish workflow refuses to build a tag whose commit is not contained in some
release/*branch onorigin, and fails before anything is pushed to NuGet. If you see that error, you skipped this step.Verify the working tree's
<Version>slot. For each package being released,Get-Content <csproj> | Select-String "<Version>", with<csproj>the path the Packages table gives (for examplesrc/lattice.replication/Orleans.Lattice.Replication.csproj), must show the version you intend to ship. The<Version>slot is authoritative - the publish workflow reads it to set the NuGet package version.Confirm CI was green on the PR before it merged. CI (the
build-and-testjob) runs onpull_requestevents (and on pushes to epic integration branches), not onpushtomain. So there is no CI run on the commit the merge lands onmain(a squash commit, or a true merge commit for an epic or bucket integration branch), and that commit's combined status readspendingwith zero checks - this is expected, not a failure, so do not go hunting for a push-to-main run, check-suites, or check-runs on the merge commit. The green gate is the merged PR's final CI run:gh pr checks <pr-number>(orgh run list --branch <feature-branch> --limit 5) must show thebuild-and-testruncompleted/success. Becausemainrequires branches to be up to date before merging, either merge method lands exactly the tree that run tested, so that PR run is the authoritative signal that the commit you are tagging is green.Tag each package independently. The publish workflow's per-tag trigger globs fire on
pushevents to a single tag ref. A bulk push (git push origin tag1 tag2 tag3) sends all the refs in one HTTP request and GitHub coalesces them into a single push event - so the publish workflow fires for at most one of the tags, and the trailing tags ship no NuGet packages and create no GitHub Release. Cut every tag from the release line branch (step 3), and push them one at a time:git push origin <package>-v<X.Y.Z>After each push, poll
gh run listfor a matchingevent=push, headBranch=<tag>, name=Publishrun before pushing the next tag. A "no run detected within 2 min" result means the workflow trigger glob did not match - fix the trigger or the tag spelling before pushing further tags.The
lattice-v<X.Y.Z>core tag also triggers theDocsworkflow, which rebuilds the documentation site from that commit and deploys it to GitHub Pages. It is deliberately the only tag that does so: the family ships in coordinated waves anchored on the core package, and the site is a single whole-repository artifact, so triggering on every per-package tag would rebuild and redeploy the identical site once per package. Push the corelattice-v<X.Y.Z>tag LAST - this ordering is required, not a preference. That single push event fires bothPublish(the core package) andDocs(a live deploy of the public site), and the two cannot be decoupled: there is no way to ship the core package without also republishing the site. Ordering is therefore the only control available. Push every other tag in the wave first, verifying each as described above, and push the core tag only once they have all reachedcompleted/success:<every other package in the wave, one at a time, each verified> -> lattice-v<X.Y.Z>This is deliberately the opposite of dependency order, and the trade is a conscious one. Nearly every other package in a wave depends on the core package, directly or through another family package it references, and packs a
>= <X.Y.Z>floor against each family package it references (a referenced project's<Version>slot sets both that package's version and the floor its dependents emit), so a strict topological push - core first - would be the NuGet-ideal order: for the length of the wave, a dependent that shipped ahead of core carries a floor nothing can satisfy. That window is real, but it is small, unannounced, already blurred by NuGet indexing lag, and it closes by itself when the wave completes. A premature site deploy does not close by itself: it is a public artifact that stands until the next deploy, telling readers a version is available while some of its packages are not yet on NuGet. Prefer the smaller self-healing inconsistency over the visible persistent one.Two consequences worth internalising:
- Deferring the core tag costs the site nothing, and keeps its versions current.
Docsbuilds from that tag's tree, which is the wave's commit either way, so deferring the core tag changes when the site goes live, not what its pages say. Apart from the build date, the one input the build takes from outside that tree is the set of package tags, from which it writes each package's published version as text (see What a published build records). With the core tag pushed last, every other tag in the wave already exists, so every version the site states is the one the wave shipped. - A core-only wave has nothing to order. A patch that ships only
Orleans.Lattice(as9.5.1did) pushes one tag, and this rule costs it nothing.
- Deferring the core tag costs the site nothing, and keeps its versions current.
Verify each publish run reaches
completed/successbefore declaring the release done. Failed runs leave NuGet in an inconsistent state where some packages of a coordinated release have shipped and others have not.A
Publishrun is four jobs.Prepare releaseruns the release-line guard, the tag parse and the csproj version check, then plans the released package's test legs with the same planner, shard partition and duration table as CI, sotest/latticefans out across its shards. One job per planned leg then runs those tests (a package with no test project plans none), theDurable active-active release gateruns beside them, andPack and publish- the only job that can push to NuGet or create the GitHub Release - runs only when all of them succeed. A red test leg or release gate therefore refuses that package before anything is packed.When the wave included the core
lattice-v<X.Y.Z>tag, also confirm theDocsrun for that tag reachedcompleted/success(gh run list --workflow Docs --limit 5). Its build step fails closed on any broken relative link or in-page anchor in the corpus, so a redDocsrun means the published documentation would have shipped a broken cross-reference. That does not affect the NuGet packages already pushed - fix the link and re-run the workflow (or dispatch it manually) to republish the site.A
Docsrun whosebuildjob succeeded - including the link and anchor gate - but whosedeployjob reportscompleted/failurewith no steps and no log (gh run view <id> --log-failedanswerslog not found) was not broken by the workflow at all: it was refused by thegithub-pagesenvironment's deployment branch policy. A wave deploys the site from a tag ref, so that environment has to permit one, and a policy list that names only the branchmainwill reject every release deploy while leaving the chore PR's ownDocsrun green (it never reachesdeploy). Inspect the policy withgh api repos/<owner>/<repo>/environments/github-pages/deployment-branch-policies; it must list the tag patternlattice-v*and the branch patternrelease/*(and, per Documentation fixes between waves, must not listmain). Add a missing tag policy withgh api --method POST repos/<owner>/<repo>/environments/github-pages/deployment-branch-policies -f name='lattice-v*' -f type='tag', then re-run the failed job withgh run rerun <id> --failed. As with a broken link, the NuGet packages already pushed are unaffected.Bump the reference-architecture package pins as a post-release action. The package version bump itself (the
<Version>slot) belongs in the shipping chore PR alongside the changelog, per steps 1 and 4 - that is what tag-and-publish releases to NuGet. The deployed hosts underreference-architecture/hosts/, by contrast, consume the family throughPackageReferenceto published NuGet packages (neverProjectReferenceintosrc/; only thereference-architecture/local-dev/harness builds from source), and CI's reference-architecture lane (a step of theextrasjob, which the requiredbuild-and-testcheck aggregates) restores those versions from nuget.org. So a pin bump to a version that has not shipped yet fails restore withNU1102: Unable to find package ... with version (>= X.Y.Z). Never bump a reference-architecture pin in the same PR that ships the package - that PR cannot go green until the very package it is publishing exists. Instead, the order is: (a) the chore PR bumps<Version>+ folds the changelog and merges; (b) the tag publishes the package (steps 6-7); (c) raise a separate follow-up PR that advances the affectedreference-architecture/**/*.csprojpins to the just-published version(s). You do not need to pre-verify that the new version is indexed on nuget.org. That follow-up PR's ownbuild-and-testreference-architecture lane builds every kit csproj, and because the kit consumes the family byPackageReferencethe build restores each pinned package from nuget.org - so the restore is the published-and-restorable gate, and a green lane is positive proof the pins resolve. If NuGet indexing has not caught up yet (it lags the publish run'scompleted/successby a few minutes) the lane fails withNU1102and you simply re-run it once indexing lands. The same restore runs a second time server-side at deploy time, whenaz acr buildbuilds the three host images fromreference-architecture/, so an unrestorable pin cannot reach a deployed environment. Only reference-architecture hosts that actually consume a bumped package need updating; leave the others untouched.
Recovery for an accidental bulk push
If multiple tags were pushed in a single git push origin tag1 tag2 ... operation, only one publish run will fire. To recover:
Identify which package's publish run did fire (
gh run list --workflow Publish --limit 5).Delete the trailing remote tags:
git push origin --delete <missed-tag>Local tags can stay in place; only the remote refs need the delete-and-re-push.
Re-push each missed tag individually (step 6 above), polling for the matching publish run between each push.
Updating CHANGELOG.md
Every release folds the working tree's ## Unreleased section into a dated ## [YYYY-MM-DD] section keyed by the release date, not by a version. The dated sections live under a plain ## Released heading - the counterpart to ## Unreleased; both rolling titles are unbracketed and unlinked so they render as plain text, while each dated section keeps its bracketed ## [YYYY-MM-DD] form. Because the family ships per-package (patch digits advance independently, and there is usually no single family version for a given ship), a day can carry several package waves - they all belong to one date section for that day:
- If a
## [YYYY-MM-DD]section for today does not exist yet, create it: an opening paragraph that enumerates every package version shipped that day (new-package debuts and per-package advances alike), followed by### Added/### Changed/### Fixed/### Securitysubsections (Keep a Changelog order). - If a
## [YYYY-MM-DD]section for today already exists, merge the new entries into it rather than opening a second dated section: add each bullet under the shared subheading for its change kind, and extend the opening paragraph with the newly-shipped package versions. - Every bullet names the exact package version(s) that carried it (e.g.
(`Orleans.Lattice.Replication` 8.0.4)), so the granular per-package history survives the consolidation. - A coordinated lockstep release uses the same date header; its opening paragraph states the single family version every package advanced to.
The ship commit that merges the changelog edit is the commit the family anchor tag and the release line branch are cut from, and therefore the commit every per-package tag in the wave points at.
Entry style
This is the single source of truth for how an individual entry is written, and it applies to every entry under ## Unreleased however it got there - a feature pull request, an epic coordinator, or a catch-up pass over an integration branch.
- Compact, and hard-capped at 300 characters. The bold heading plus the prose that follows it must total 300 characters or fewer - the trailing link list and package tag are excluded from the count. The changelog records what changed for the user; the reproduction, the mechanism, the rationale, and the measurements live in the issue and the pull request, which is exactly what the links are for. An entry that runs to a paragraph has copied an issue body into a file nobody maintains, and it buries the twenty entries around it.
- The cap is not negotiable, and the remedy is to split, not to compress. When a grouped entry cannot be stated inside 300 characters, the group is too broad: divide it into two narrower areas, each with its own heading and its own share of the links. Squeezing four unrelated fixes into one 300-character sentence produces an entry nobody can act on.
- Prefixed with a single word. Every entry opens
**<Prefix> - <Short title>.**, where<Prefix>is one word naming the affected area -WAL,Replay,Leaf,Shard,Scan,CRDT,Atomic,Replication,Vector,Retrieval,Indexing,Backlog,Container,Config,Memory,Backup,Explorer,Dashboards,Observability,Gates,CI,Docs,Storage,Serialization,Security,Auth,Schema,Core,Query,Performance,Tests. The prefix is what makes a long## Unreleasedsection scannable and what tells the next contributor which existing entry to extend, so reuse an established prefix rather than minting a synonym. - Linked. Every entry ends with the issue or issues it implements, as inline links:
([#2784](https://github.com/NSTA1/Orleans.Lattice/issues/2784)). Where a change has no tracked issue, link its pull request instead (/pull/N). An entry carrying no link is not finished, because it has nowhere to put the detail rule 1 keeps out.- Link the issue the change actually resolves, not every issue the pull request mentions. A pull request body routinely cites neighbouring issues under a
## Relatedheading, as the defect it deliberately did not fix, or as one it filed on the way past. Mining those in produces an entry that claims work it did not do. Read the surrounding sentence before taking a number.
- Link the issue the change actually resolves, not every issue the pull request mentions. A pull request body routinely cites neighbouring issues under a
- Grouped by area, never one per commit. One entry covers one coherent user-visible area, however many commits, pull requests, or issues fed it: a single "WAL garbage collection now reports why a pass reclaimed nothing" entry carrying fourteen issue links is correct, and fourteen entries are not. Group first by the subsection the change belongs to, then by affected area within it. When the area you are touching already has an entry under
## Unreleased, extend it - add your link to its list and widen the sentence if the scope grew - rather than opening a second entry beside it. - Tagged with its packages, always. Close with the packages the change ships in, for example
(`Orleans.Lattice`, `Orleans.Lattice.Dashboards`), naming each one by its exact<PackageId>, as the Packages table lists it - not by itssrc/directory name, which is what the pull request's package labels use. A directory that ships several packages, such assrc/lattice.explorer/, has no package of its own, so name each package the change ships in. The tag is mandatory and never omitted: a change that ships no package at all - CI configuration, a repository-wide test gate, contributor documentation - is tagged`repository-wide`. Consistency is the point, so a reader can filter the section by package without having to decide whether a missing tag means "none" or "nobody wrote one".
The resulting shape:
- **<Prefix> - <Short title>.** <Supporting detail; 300 characters including the heading.> ([#1234](https://github.com/NSTA1/Orleans.Lattice/issues/1234), [#1240](https://github.com/NSTA1/Orleans.Lattice/issues/1240)) (`Orleans.Lattice`)
Do not invent subsections: use the Keep a Changelog set (### Added, ### Changed, ### Fixed, ### Deprecated, ### Removed, ### Security), creating one only when it is genuinely absent under ## Unreleased.
Catching up an integration branch
An epic or bucket branch accumulates far more commits than changelog entries, so the two drift, and the catch-up is where the rules above are most often abandoned. Do not replay the branch commit by commit. Classify every member commit into a subsection and an area, write one entry per subsection-and-area pair, and attach that group's deduplicated set of issue links to it.
Mining those links is less obvious than it looks. A member pull request targeting a non-default base carries no closing keywords - the Guard - inert closing keywords CI step forbids them there - so gh pr view --json closingIssuesReferences comes back empty for every pull request on the branch, and reads as though no issues exist. Take the issue number from the pull request body prose instead (Refs #N, Addresses #N, Implements issue #N), and use gh issue list, which excludes pull requests, to decide whether a given #N renders as /issues/N or /pull/N.
Two further habits keep a catch-up honest. Bring the whole ## Unreleased section up to standard, not only the entries the branch added - a section that mixes twenty capped, prefixed, tagged entries with eighty inherited paragraphs is not readable, and the inherited ones are the reason the rules were written. And check each mined number against the sentence it sits in: on a branch this size the ## Related citations, the deliberately-untouched defects, and the issues a pull request merely filed on its way past are numerous enough that taking every #N at face value will attribute work that was never done.
Section titles and compare links
The two rolling section titles - ## Unreleased and ## Released - are plain, unbracketed headings, and CHANGELOG.md carries no footer link-reference definitions; each dated section keeps its bracketed ## [YYYY-MM-DD] form.
Earlier revisions followed the "Keep a Changelog" convention of a footer [YYYY-MM-DD]: .../compare/<base>...<target> block (plus [Unreleased]: .../compare/vX.Y.Z...HEAD). That convention was dropped because it rendered inconsistently: the family tags per-package (lattice.<pkg>-v<X.Y.Z>) and mints a family tag (vX.Y.Z) only to anchor a release wave, not for every date that ships, so most dates had no single tag to anchor a link - only the occasional lockstep date and Unreleased turned into links, while every per-package-wave date stayed plain bracketed text, a half-linked ladder. Each dated section's opening paragraph already enumerates the exact per-package versions and their lattice.<pkg>-v<X.Y.Z> tags, which is the authoritative record; compare those tags directly for a diff. Do not reintroduce footer compare-link definitions.