Table of Contents

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 was lattice.explorer.designsystem-v<X.Y.Z>). Its successor is Orleans.Lattice.Explorer.UI.
  • Orleans.Lattice.Explorer.Access, last tagged lattice.explorer.access-v<X.Y.Z>. Its successor is the Access area, compiled into Orleans.Lattice.Explorer.UI.
  • Orleans.Lattice.Explorer.Backup, last tagged lattice.explorer.backup-v<X.Y.Z>. Its successor is the Backups area, compiled into Orleans.Lattice.Explorer.UI.
  • Orleans.Lattice.Explorer.Schema, last tagged lattice.explorer.schema-v<X.Y.Z>. Its successor is the Schema area, compiled into Orleans.Lattice.Explorer.UI.
  • Orleans.Lattice.Explorer.Plugins.* (Abstractions, Selection, Data, History, Metrics, Topology, TagIndex, DeadLetter, Telemetry, Tenancy, Tenants, MyTenant), never tagged (their tag shape was lattice.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 is Orleans.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:

  1. git switch release/<X.Y> (fetch it first if this is a fresh clone).
  2. git cherry-pick <fix-commit-from-main>.
  3. Bump the <Version> slot of only the packages being patched, and add the dated CHANGELOG.md section.
  4. Push the line: git push origin release/<X.Y>. The publish workflow's release-line guard looks for the tagged commit in the release/* branches on origin, so a tag cut from a cherry-pick that was never pushed fails before anything reaches NuGet.
  5. 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.yml watches) 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 is lattice-v*, which matches the core tag alone and not the dotted per-package tags such as lattice.storage.file-v9.6.0, so one wave deploys the site once rather than once per package.
  • A manual workflow_dispatch builds from whatever ref it is dispatched on, but deploys only when that ref is a release/<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:

  1. Merge the fix to main as an ordinary PR, so trunk carries it into the next wave.
  2. git switch release/<X.Y>, git cherry-pick <fix-commit-from-main>, and git push origin release/<X.Y>: the dispatched run builds the line as it stands on origin.
  3. 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

  1. 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 HEAD
    

    Record $sha - every later step derives from it. Do not re-resolve main later in the wave. If the release is interrupted and resumed, main will 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.

  2. 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>
    
  3. 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 switch to 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 on origin, and fails before anything is pushed to NuGet. If you see that error, you skipped this step.

  4. 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 example src/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.

  5. Confirm CI was green on the PR before it merged. CI (the build-and-test job) runs on pull_request events (and on pushes to epic integration branches), not on push to main. So there is no CI run on the commit the merge lands on main (a squash commit, or a true merge commit for an epic or bucket integration branch), and that commit's combined status reads pending with 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> (or gh run list --branch <feature-branch> --limit 5) must show the build-and-test run completed/success. Because main requires 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.

  6. Tag each package independently. The publish workflow's per-tag trigger globs fire on push events 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 list for a matching event=push, headBranch=<tag>, name=Publish run 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 the Docs workflow, 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 core lattice-v<X.Y.Z> tag LAST - this ordering is required, not a preference. That single push event fires both Publish (the core package) and Docs (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 reached completed/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. Docs builds 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 (as 9.5.1 did) pushes one tag, and this rule costs it nothing.
  7. Verify each publish run reaches completed/success before 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 Publish run is four jobs. Prepare release runs 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, so test/lattice fans out across its shards. One job per planned leg then runs those tests (a package with no test project plans none), the Durable active-active release gate runs beside them, and Pack 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 the Docs run for that tag reached completed/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 red Docs run 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 Docs run whose build job succeeded - including the link and anchor gate - but whose deploy job reports completed/failure with no steps and no log (gh run view <id> --log-failed answers log not found) was not broken by the workflow at all: it was refused by the github-pages environment'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 branch main will reject every release deploy while leaving the chore PR's own Docs run green (it never reaches deploy). Inspect the policy with gh api repos/<owner>/<repo>/environments/github-pages/deployment-branch-policies; it must list the tag pattern lattice-v* and the branch pattern release/* (and, per Documentation fixes between waves, must not list main). Add a missing tag policy with gh 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 with gh run rerun <id> --failed. As with a broken link, the NuGet packages already pushed are unaffected.

  8. 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 under reference-architecture/hosts/, by contrast, consume the family through PackageReference to published NuGet packages (never ProjectReference into src/; only the reference-architecture/local-dev/ harness builds from source), and CI's reference-architecture lane (a step of the extras job, which the required build-and-test check aggregates) restores those versions from nuget.org. So a pin bump to a version that has not shipped yet fails restore with NU1102: 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 affected reference-architecture/**/*.csproj pins 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 own build-and-test reference-architecture lane builds every kit csproj, and because the kit consumes the family by PackageReference the 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's completed/success by a few minutes) the lane fails with NU1102 and you simply re-run it once indexing lands. The same restore runs a second time server-side at deploy time, when az acr build builds the three host images from reference-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:

  1. Identify which package's publish run did fire (gh run list --workflow Publish --limit 5).

  2. 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.

  3. 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 / ### Security subsections (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.

  1. 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.
  2. 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 ## Unreleased section scannable and what tells the next contributor which existing entry to extend, so reuse an established prefix rather than minting a synonym.
  3. 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 ## Related heading, 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.
  4. 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.
  5. 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 its src/ directory name, which is what the pull request's package labels use. A directory that ships several packages, such as src/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.

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.