Release Train

How merged code becomes a release: drafts, notes, and publishing

The Train

Merging into main never publishes to nuget.org. Every merge that touches src/** runs the deploy_main workflow, which builds, packs, and pushes the packages to the private GitHub package feed (dogfoodable at any time), and then maintains the draft release for the version tag GitVersion computed for the current head of main.

The only ship event is the maintainer publishing that draft release: one click per finished milestone. Publishing triggers deploy_release (the nuget.org push) and deploy_rforge_docs (this site going live). Nothing in the pipeline publishes on its own.

Consumers: GitHub feed packages are current with every merge. nuget.org and this docs site always reflect the last published release.

One Draft Per Version

Each merge refreshes the draft release for the pending version so its notes cover everything merged so far. Before issue #119, the workflow called gh release create on every merge, so successive merges stacked duplicate drafts under the same version tag, each with a different (and stale) changelog. That is fixed: deploy_main now keeps exactly one draft per version tag, maintained by draft-release.sh (PR #124).

Each run handles four cases:

State for the version tag What the run does
No release exists Creates a draft release with auto-generated notes, wrapped in the auto markers.
A draft already exists Refreshes it in place: notes are regenerated for the new head of main and only the auto region is rewritten. Everything outside the markers is kept verbatim.
Several drafts stacked (legacy duplicates from before the fix) Keeps the newest draft, absorbs the older drafts' hand-written text under a ## Preserved from duplicate draft heading so nothing is lost, and deletes the extras.
A published release exists for the tag Leaves it untouched. Any stray draft for that version is swept. A published release is never edited or unpublished by the pipeline.

The maintenance logic is plain shell on top of gh (no third-party actions), and it is pinned by a runnable acceptance harness in the repo: bash .github/scripts/tests/test-draft-release.sh (14 scenarios, including the original live incident).

Draft Notes and the Auto Region

The draft body is split by two HTML comments into a bot-owned region and a human-owned region:

<!-- auto-release-notes:start -->
(auto-generated changelog: merged PRs since the previous release,
 plus the Full Changelog compare link; regenerated on every merge)
<!-- auto-release-notes:end -->

(your scratch text lives outside the markers and is never deleted)

Inside the markers is the bot's: it is overwritten on every subsequent merge, so do not edit it by hand. Outside the markers is the maintainer's scratch space: the refresh never deletes text, so release notes you type around the auto region survive every refresh. Drafts created before the markers existed are preserved wholesale as human text.

Publishing

When a milestone is done, open the single draft release for the version. Its notes now list everything merged so far, plus any scratch text, so there is nothing to reconcile between duplicates. Publishing it ships everything at once: the GitHub Release for the tag, the nuget.org push, and the docs site update.

Maintainers: publishing is a deliberate, one-click act per milestone. Do not create releases by hand for unreleased version tags; the next merge will collide with them.

Reading the Draft List

Two GitHub quirks to know when you run gh release list or browse releases:

  • A draft whose tag has not been published yet is not resolvable by tag, and GitHub rewrites its tag name to an untagged- placeholder shortly after creation. The version number survives in the draft's name, so look drafts up by name, not by tag.
  • If you ever see multiple drafts for one version (leftovers from before the fix), no manual cleanup is needed: the next deploy_main run collapses them to one, preserving all hand-written text.
An unhandled error has occurred. Reload 🗙