This document describes how a version number is decided, how binaries are built, and how a release is cut and published.
- Principles
- The Shape of It
- Version Numbers
- Cutting a Release
- Release Notes vs. Changelog
- What Each Layer Owns
Build once. Binaries are built, versioned, tested, and uploaded by CI on every run. A release does not rebuild anything — it finds the CI run for that exact commit and ships those artifacts. So what reaches a consumer is bit-for-bit what the tests passed against, and a release can never differ from the build that verified it.
A release is a decision, not a side effect.
Every merge to main is built and verified,
but that is a different question from "these changes are worth a version number".
Nothing is released until someone says so.
Nothing is drafted that cannot be thrown away. The release is created as a draft: no tag, nothing published, no downstream workflow fired. Deleting a draft leaves no trace, so getting it wrong costs nothing.
One tool decides the version, everywhere.
GitVersion reads git history and gitversion.yml only.
It does not know or care what the repo is written in,
so the same configuration governs .NET, Node, and whatever comes next.
flowchart TB
subgraph subCI ["On every push and pull request — automatic"]
direction LR
Merge[Merge to main] --> Version[Calculate<br/>version]
Version --> BuildStep[Build & test<br/>version stamped in]
BuildStep --> Pack[Pack + version.txt]
Pack --> Artifact[(Artifact attached<br/>to the run)]
end
subgraph subCut ["When you decide — manual"]
direction LR
Trigger[Run 'Draft Release']
Trigger --> Locate[Find the CI run<br/>for this commit]
Locate --> Reuse[Reuse its artifacts]
Reuse --> Notes[Generate notes<br/>from the commits]
Notes --> Draft[Draft release]
end
subgraph subShip ["After you approve"]
direction LR
Review[Review & edit] --> Publish[Publish release]
Publish --> Tag[Tag created]
Tag --> Ship[Shipped by<br/>publish-release.yml]
end
Artifact -.->|"read, never rebuilt"| Locate
Draft --> Review
classDef action fill:#E68A39,color:#000000
class Trigger,Review,Publish action
classDef result fill:#4A90E2,color:#FFFFFF
class Draft,Ship,Tag result
classDef store fill:#5C6BC0,color:#FFFFFF
class Artifact store
Versions follow Semantic Versioning and are calculated from the Conventional Commits in the history. Nobody edits a version in a file, so nobody can forget to.
Every build gets its own number.
Each commit on main takes the next major.minor.patch,
so an artifact can always be traced back to exactly one commit —
and a release can name a specific build rather than "the latest".
Numbers on branches carry a fourth component as well,
so each commit in a pull request builds a distinct version too.
Released numbers therefore have gaps. Only the tip of a merge is ever built, and only some builds are ever released. That is the trade for never having two builds claim the same version, and it is the right way round.
GitVersion applies the highest bump found since the last release tag —
not one bump per commit.
Ten feat: commits are still a single minor release.
| In the commit | Bump | Example |
|---|---|---|
! before the colon |
major | feat(api)!: rename Run |
BREAKING CHANGE: in the body |
major | in the body, not the subject |
+semver: major in the body |
major | escape hatch |
feat: |
minor | feat: add a --json flag |
+semver: minor in the body |
minor | escape hatch |
| any other type | patch | fix:, docs:, chore: |
+semver: none in the body |
patch | cancels an escalation |
| a merge commit | patch | the default; cannot escalate |
Anything that is not a Conventional Commit contributes no escalation,
but main carries a default patch increment,
so a release is never left reporting the version it already shipped.
Two ways, for two different situations.
A one-off bump the commit type does not imply — use the escape hatch in the commit body. It is recorded in the history rather than in a file someone has to remember to change back:
chore: restructure the public surface
+semver: major
A specific next release —
raise next-version in gitversion.yml:
next-version: 2.0.0This is the manual release lever.
Set it, let CI build, then run Draft Release.
Once the release is published the tag catches up
and next-version goes inert on its own,
so there is nothing to remember to undo.
next-version is a floor, not a pin.
Whichever is higher wins — it,
or whatever the commits since the last tag have earned.
It goes inert as soon as a tag reaches it,
so there is nothing to remember to undo.
That makes it right for two jobs only:
seeding a repo that has no tags yet (this is why it starts at 0.0.1),
and declaring a specific next release.
It is the wrong tool for a routine bump —
feat: and feat!: already do that,
and a floor would simply be overtaken.
Actions → Draft Release → Run workflow → main.
There is exactly one way in, on purpose.
Pushing a tag looks like it ought to work too,
but the version is stamped into the binaries when CI builds them —
so a tag could only ever name the version that was already built,
and if it named a different one the release would have to either lie or rebuild.
To choose a version, set next-version
and let CI build it.
By default the draft is for the build of the commit you ran it from —
the tip of main.
To ship an earlier build instead, give the optional version input
(1.4.2, say): the workflow finds the CI run that produced that version
and drafts from its artifacts.
It is still a build CI already verified, never a rebuild —
so if no run still holds that version's artifact, the workflow refuses
rather than guessing.
This produces a draft release with the notes generated and the CI artifacts attached. Re-running replaces the existing draft for that version rather than adding a second one, so there is never a pile of abandoned drafts to clean up.
If the version has already been tagged — meaning it has already been published — the workflow refuses, rather than quietly replacing something a consumer has already resolved.
Open the draft under Releases, and check:
- ✅ The version is what you expected
- ✅ The artifacts are attached
- ✅ The notes read correctly
If it is wrong, delete the draft. Nothing else happened.
Press Publish release.
This is the deployment.
Everything before it is reversible;
this is the step that makes the version public.
It creates the tag — which is what the next version is calculated from —
and fires the repo's publish-release.yml, if it has one.
What that does is the repo's own business:
.actions moves its v1 alias tag, a package repo pushes to its feed.
.github has none, so publishing a release here does nothing further.
The tag itself is protected by the Protect version tags ruleset:
once created, it cannot be deleted or force-moved, so a published release
cannot later point at different bits without anyone noticing. GitHub's
own release-immutability setting — enabled during scaffolding, when the
API allows it — locks a published release's assets the same way.
These are two different documents for two different readers, and conflating them is why release pages end up unreadable.
| Release notes | Changelog | |
|---|---|---|
| Answers | "Should I upgrade?" | "When did this change?" |
| Audience | someone deciding | someone investigating |
| Length | a few lines | every commit |
| Voice | prose, curated | mechanical, complete |
| Where | top of the release, always visible | folded into a <details> below |
The changelog action emits both, in one body:
- The summary — what changed and why it matters.
- The counts — the size and shape of the release at a glance.
- Breaking Changes — aggregated to the top, with the author's own explanation from the commit body. The one thing a reader must not miss, so it is never folded away.
- The changelog — everything else grouped by type,
ordered by how much a reader cares, behind a
<details>fold. - A compare link — the commit-by-commit detail, available without being inlined.
Every commit appears somewhere. A non-conventional commit lands under Other Changes rather than being dropped — untidy notes beat notes that quietly hide a change.
There is no committed CHANGELOG.md.
The releases are the changelog:
a file in the repo would need a commit to update,
which changes the history it is trying to describe.
The workflow always asks a model to draft the opening paragraph
from the generated changelog, using the workflow's own copilot-requests: write
permission — no token to create or store.
It is never required. A missing permission, a service outage, a network error — all land in the same place: the summary stays a placeholder and the release is still drafted. A missing paragraph is not worth failing a release over.
The placeholder is an HTML comment, so a draft published without one
simply has no opening paragraph, rather than a visible TODO.
Replace the whole comment with a paragraph or two to fill it in,
or delete the draft and re-run once the service is back.
The summary runs through the actions/ai-inference action,
which drives the Copilot CLI.
As of the built-in GITHUB_TOKEN support GitHub added for Actions,
that needs nothing beyond the copilot-requests: write permission
already declared on draft-release.yml — there is no secret to create,
store, or rotate.
The permission alone is not enough, though: draft-release.yml still
passes GH_TOKEN: ${{ github.token }} explicitly, at the workflow level.
An environment variable set on a nested composite action's own step —
release/draft calling ai-inference — does not reliably reach the
action it calls; only one set by the calling workflow cascades down
through every layer, which is what actually lets the Copilot CLI, several
layers deep, see the token.
This still depends on the repo's owner having an active Copilot entitlement. On an organization-owned repo, that also needs the "Allow use of Copilot CLI billed to the organization" policy enabled; the equivalent for a personal-account repo has not been exercised here yet, so treat that combination as unverified until a real run confirms it.
Almost nothing here is layer-specific.
Building once has a pleasant side effect:
because the release workflow no longer builds anything,
it needs no toolchain.
So the whole apparatus — the version calculation, the changelog generation,
and the release drafting — lives in the separate
TaffarelJr/.actions repo as composite actions,
and is shared by every consumer, .github included.
This repo's own draft-release.yml is a shell:
it checks out the history and calls the release/draft action,
and is identical in every repo in the chain.
A layer contributes two things, both in its own workflows:
- The packing step in
continuous-integration.yml, which produces the artifact — binaries plus aversion.txt— that a release later attaches. publish-release.yml, onrelease: published, which does whatever "publish" means for that repo: push a package, move a floating tag, deploy a site. A repo with nothing to do at that moment has no such file.
Everything else works without modification.