Releasing
You release from your machine. magus run release cuts the tags. Nothing in CI
does it for you, and nothing pushes on your behalf.
| Step | Where | What it does |
|---|---|---|
| Cut the tag | your machine, magus run release |
tags each module, rewrites the root go.mod |
| Push the tag | your machine, git push |
deliberate, separate, yours |
| Build the artifacts | release.yaml on GitHub |
archives, images, signatures |
Survey first
Run it with no arguments. It tags nothing and prints every module, where it sits, and the three legal next versions:
magus run release
release: magus - at v0.3.0, next: patch v0.3.1 | minor v0.4.0 | major v1.0.0
release: libs/gopherbuzz - unreleased, next: patch v0.0.1 | minor v0.1.0 | major v1.0.0
release: libs/testlayout - unreleased, next: patch v0.0.1 | minor v0.1.0 | major v1.0.0
magus computes the three candidates and stops there. Which one a change deserves depends on what breaks for a consumer, so you pick.
The module list is computed too, from every project carrying a manifest. A new module shows up the moment it becomes a project, and there is no list to maintain.
Release one module
Name it as <module>@<version>, using the key the survey prints:
magus run release:cd -- libs/testlayout@0.1.0
Drop the cd charm for a dry run. It runs every check, prints every action, and
changes nothing:
magus run release -- libs/testlayout@0.1.0
release: DRY RUN - add the cd charm to execute
release: tag libs/testlayout/v0.1.0
release: dry run only - nothing was tagged or written
Release several at once
Pass as many pairs as you like. Anything you leave unnamed gets surveyed and skipped:
magus run release:cd -- magus@0.4.0 libs/gopherbuzz@0.2.0
Each module keeps its own cadence, because Go already gives each its own tag namespace. Deriving every tag from one number would chain a library's public API to the root's release schedule.
The order it enforces
Releasing the root alongside a nested module has an order that is easy to get wrong by hand, so the target handles it:
- Nested modules first (
libs/<name>/vX.Y.Z). Each has to bego get-able before anything can require it at a real version. - Rewrite the root
go.mod, pointing eachrequire .../libs/<name>fromv0.0.0at the version just tagged. - Tag the root last (
vX.Y.Z), so its tag captures that rewrite.
Reverse it and the root tag ships a go.mod requiring versions that do not
exist, which breaks go get github.com/egladman/magus for everyone outside the
repo.
The replace directives stay untouched. replace is never transitive, so no
consumer downstream sees them, and they are the standard pattern for local
development inside one repo.
A module the root's go.mod does not require is tag-only, so step 2 skips it.
That covers a module in another language, and equally a Go module nothing imports:
libs/testlayout gets compiled into a golangci-lint binary, so the root has no
require line to point anywhere.
What it refuses
Every check runs before the first tag exists, so a rejected release cannot leave half the modules tagged:
- A version that is not a legal next one. Patch, minor, or major successor to
the current version. A prerelease or
+metadatabuild of one of those passes. - A tag that already exists, checked across every named module first.
- An unknown module key, so a typo cannot release nothing and report success.
- A major bump Go cannot resolve. From v2 on, Go wants the module path to end
in
/vN, andgo getresolves the path rather than the tag. Av2.0.0tag beside ago.modstill declaring the v1 path is uninstallable. Renaming a module means rewriting every import, so the release stops rather than guesses. - A dirty tree, under
cd:release: tree is dirty; commit before releasing. The dry run warns instead of failing, so you can rehearse.
The preflight
The refusals above ask whether the version is legal. The preflight asks a different question: whether the release that follows this tag actually works.
It runs on every magus run release that names a module, before any tag exists,
and prints one line per check: OK, FAIL, or NOTE for a finding worth seeing
that is not grounds to stop. Under cd a single FAIL refuses the release and
nothing is tagged; a NOTE never does. Without cd nothing is refused and the
transcript is the point:
magus run release -- magus@0.5.0
release: OK workflow upload glob - `dist/magus_*.tar.gz` is what .github/workflows/release.yaml uploads
release: OK workflow tag trigger - `v*` is what .github/workflows/release.yaml triggers on
release: OK branch collision - no branch is named `v0.5.0`
release: OK asset names - version `v0.5.0` names assets `dist/magus_*.tar.gz` matches on every platform release.yaml builds
release: OK release manifest - releases/v0.5.0.yaml is absent, as cut requires
release: OK changelog - CHANGELOG.md's [Unreleased] section has content for cut to move
release: OK workflow trigger - `v0.5.0` matches `v*` and starts the release
Timing is the whole point. Each gating check already had an owner, and each of them fired hours after the tag was pushed, in a workflow. A pushed tag is not retractable in any useful sense - the Go module proxy caches it within the hour - so a release that fails in CI burns the version number.
A branch using the tag's name
This one is a note, not a refusal. refs/heads and refs/tags are separate
namespaces precisely so a branch and a tag can share a name, and git supports that
rather than merely tolerating it:
release: NOTE branch collision - a branch named `v0.5.0` exists beside this tag. The
two namespaces are separate, so both are legal and magus reads tags by written name
either way.
cd proceeds. The v0.4.0 incident here looked like a collision problem but was not
one: magus asked git for %(refname:short), which renders an ambiguous tag as
tags/v0.4.0, and that was a bug in the query rather than an illegal repository
state. It is fixed, and refusing a release over a state git is designed for would
contradict the reason this preflight exists.
What does survive is a handling detail worth knowing before you push. While both refs exist a bare name is ambiguous, so push the qualified form:
git push origin refs/tags/v0.5.0 # unambiguous
git push origin v0.5.0 # error: src refspec v0.5.0 matches more than one
git rev-parse v0.5.0 likewise warns before answering. The survey mentions a
colliding candidate earlier too, beside the version it affects, because that is
where the number gets chosen.
The post-tag state, simulated
The preflight computes the version release-build would stamp once these tags
sit on HEAD, then computes every asset name that version produces and checks each
one against the glob release.yaml uploads with.
That is the v0.4.0 failure, caught a step earlier. Tagging the root and three
libraries on one commit left git describe choosing among four tags; it chose
libs/diagnostics/v0.1.0, every asset was named
magus_libs/diagnostics/v0.1.0_<os>_<arch>.tar.gz, and because a glob star does
not cross a /, dist/magus_*.tar.gz matched nothing. Every build job passed and
the release published zero assets.
A release that tags libraries only is reported rather than refused: no root tag
means release.yaml does not run, so there are no published assets to misname.
What publish will need
magus-utils cut runs in the publish job, after every binary is built, and
refuses on two things the preflight can see now:
releases/v<version>.yamlalready exists. Release manifests are immutable once committed.CHANGELOG.md's[Unreleased]section is empty. Write the entry before tagging, not after.
The workflow contract
release.yaml's upload glob and tag trigger are stated in magusfile.buzz as
RELEASE_ASSET_GLOB and RELEASE_TAG_REFSPEC, and the preflight checks that the
workflow still contains both. Edit one side alone and the rehearsal names it. The
preflight also confirms the root tag matches the trigger and that no module tag
does, so a library bump cannot start a release run.
Pushing
Nothing above pushes. Review the tags, then push when you mean it:
git push origin v0.4.0
Prereleases and the image channel
release takes no channel charm, because a version tag has only one channel.
Container images do have channels, and release.yaml picks one with tagged:
magus run image-build:cd,tagged
tagged reads the channel off the release tag HEAD sits on. A stable semver takes
latest; v0.5.0-rc.1 gets its version tag alone. The derivation lives in the
magusfile's channel(), not in YAML, so the image step and the prerelease flag on
the GitHub release cannot disagree about what is shipping.
Cutting a release candidate is therefore a tag and nothing else. You can still name
stable or unstable directly for a local build, but not beside tagged, which
already answers that question:
magus run image-build:cd,unstable # version tag only, no floating tag
magus run image-build:cd,stable # version tag plus latest
An untagged HEAD is refused rather than guessed at:
image: `tagged` reads the channel off the release tag HEAD sits on, and HEAD is not
tagged - `v0.4.0-3-gabc123` describes distance past the nearest tag rather than a
release.
What CI checks about the assets
Nothing in release.yaml checks the asset names, and that is deliberate: the
target already refuses to write one the upload could not collect. release-build
matches every name it is about to write against RELEASE_ASSET_GLOB and throws
before the first byte, and the preflight checks that same constant still appears in
release.yaml. The two links compose, so a step re-checking the built files
against the glob would restate a conclusion already reached twice.
There is also nothing left for such a step to find. Every asset in one job shares
one version, and <goos>, <goarch> and the variant suffix are fixed tokens with
no separator in them, so the glob selects all of a job's archives or none of them.
A partial match is not reachable.
One gap is left open on purpose. Tagging by hand skips the preflight, so it also
skips the check that RELEASE_ASSET_GLOB still matches the workflow. Tagging by
hand equally skips the version-legality, existing-tag, changelog and manifest
checks, so the answer is to cut releases with magus run release rather than to
reproduce one of its checks somewhere else.
Which tags the workflow reacts to
Only a root vX.Y.Z tag triggers the release workflow. release.yaml matches
v*, and libs/testlayout/v0.1.0 does not start with v, so pushing it
publishes no binaries and no images. A library tag exists to make the module
resolvable by go get, which needs no artifacts. Push it like any other tag and
consumers can require it.