magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
8 min read

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:

  1. Nested modules first (libs/<name>/vX.Y.Z). Each has to be go get-able before anything can require it at a real version.
  2. Rewrite the root go.mod, pointing each require .../libs/<name> from v0.0.0 at the version just tagged.
  3. 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 +metadata build 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, and go get resolves the path rather than the tag. A v2.0.0 tag beside a go.mod still 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>.yaml already 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.

releaseversioningtagsgo-modulesworkflowpreflight
Last updated (e3ea13b9)
Earlier changes on this page (4)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. See the module reference.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Lease

One row of the lease ledger: a piece of work an orchestrating agent handed out, with its goal, the checkpoint it was cut against, and the paths it owns or must not touch. The ledger records; the agent guard is what reads those facts back when grading a write. See doctrine.

Conventions

Placeholders

Angle brackets mark a value you replace with your own - never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.