Skip to content

Why a release is published complete

A release with binaries is made by two tools. One decides the version and cuts the tag; another compiles, signs and packages. They cannot both go first.

The build tool takes its version from the tag — that is how the number reaches the binary — so it can only run after the tag exists. Which means a release object created at tagging time is created before its binaries do.

That window looks harmless and is not.

The failure is worse than a missing file

A release that exists with nothing on it answers yes to the only question a consumer knows how to ask.

Something wanting version 1.2.0 asks the forge whether that release is there, is told it is, fetches an asset and gets a 404. The failure surfaces as a missing file, or a corrupted download, or a broken install script — anywhere except the place it came from, which is that the release was published early.

Two live breakages were recorded against exactly this before it was fixed: an image build that fetched a binary from a release moments after it appeared, and an update path in a CLI that did the same thing.

The window is small. It is also exactly when automation reaches for a release, because automation is what notices a new one immediately.

So the release is created last, not first

The tag still has to be cut early — it is what starts the build, and a forge will not attach a release to a tag that does not exist. What moves is the release object.

default branch   tag the commit that landed, and stop
tag pipeline     build, sign, upload the artefacts
                 create the release, carrying them

Nothing observable exists between the second and third step. The tag is not a release; nothing is watching it in the way things watch releases.

Attaching afterwards is not the same thing

The obvious alternative — create the release, then attach — reopens the window it closes. The guarantee worth making is about what is observable, not about how many requests are sent:

  • Where a forge can create a release with its assets in one operation, it does.
  • Where it cannot, it creates the release as a draft, attaches, then publishes. A draft is not visible without write access, so from a consumer's side the effect is identical.

Colophon asks for that guarantee where the platform offers it, and says so in its output when the platform does not, rather than claiming a promise it did not get.

The bytes are checked, because nothing else checks them

A forge records where an asset lives without fetching it. That is a deliberate choice on its part — making a request on a caller's behalf to a host it does not control is a capability nobody asked for — but it means a release can be published complete, correct by every contract involved, and have every link on it return 404.

That is the same bug wearing a different hat. So colophon resolves each asset before it publishes anything, which it can do honestly because it is not a stranger to the address: it is publishing to a registry the build just uploaded to.

This also retires a check that used to work. Does the release have assets? was a reasonable way to tell a good release from a broken one when the failure was an empty release. It is no longer, because a release with unreachable links has a full set of them. Fetch one instead of counting them.

What this costs, and what it does not

The artefacts go to a package registry rather than through CI job artifacts. That is not incidental: passing a build between jobs means moving it twice, and for a six-platform Go binary that is several hundred megabytes each way, against a shared runner fleet.

Uploading once to durable, versioned, addressable storage and then linking to it is both cheaper and more honest — the release points at where the bytes actually live, rather than at a copy made to satisfy the release.