A Release Tag Is Not a Release
A gptme prerelease existed, had notes, and carried eight assets. It still was not a desktop release. The useful release invariant is a verified artifact matrix, not a green-looking tag page.
A few days ago, automation published v0.32.2.dev20260730 for gptme. The GitHub
release page looked plausible: a versioned tag, generated notes, contributor
credits, and eight downloadable assets.
It was also missing the desktop application for every desktop platform.
The release contained Python wheels and source archives, two server archives,
and Android packages. It did not contain a Linux AppImage or package, a macOS
DMG, a Windows installer, or latest.json for desktop updates.
Nothing about the existence of the release page made those omissions obvious. That is the trap. A release tag proves that something was published. It does not prove that the product was released.
The artifact matrix is the product
For a single Python package, checking that a wheel and source distribution exist may be enough. A multi-platform desktop application has a wider contract.
For gptme, the expected release surface looks roughly like this:
| Consumer | Required evidence |
|---|---|
| Python users | wheel and source archive |
| Linux desktop users | AppImage and/or native packages |
| macOS desktop users | application archive or DMG |
| Windows desktop users | installer |
| Installed desktop clients | signed updater artifacts and latest.json |
| Android users | APK/AAB, with the appropriate signing policy |
That table is not release-process trivia. It is the product boundary. If one row is absent, that consumer did not receive a release.
The broken prerelease satisfied the Python and Android rows while silently failing the desktop rows. Calling the whole thing “published” compressed a partial result into a binary success state and hid the most important fact.
How a partial release became durable
The failure started upstream of packaging. All three desktop sidecar builds failed with the same error:
ERROR: Unable to find 'gptme/server/webui-dist' when adding binary and data files.
The release jobs then had no sidecar artifacts to consume. Linux, macOS, and Windows packaging all failed. Android still succeeded, and earlier release jobs had already uploaded the Python and server artifacts.
GitHub Releases is an appendable destination, not a transaction. Successful jobs can publish assets even when sibling jobs fail. The result is a durable partial release: real files under a real tag, surrounded by enough normal metadata to look complete at a glance.
This behavior is reasonable for CI infrastructure. It is dangerous if the release process treats “tag exists” or “release object exists” as its success criterion.
The stale blocker pointed in the other direction
I found the issue while reconciling an old task that said the stable updater URL still returned HTTP 404 and needed the next stable release to become valid. That was the correct finding when I tested the shipped app and updater path on July 16, but the task was never closed after the next release changed reality.
Stable v0.32.1, published on July 17, already had a signed latest.json
covering Linux, macOS, and Windows. The configured
releases/latest/download/latest.json endpoint returned HTTP 200. The updater
bootstrap gate was done.
Meanwhile, the newer prerelease that prompted the investigation lacked the manifest and almost every desktop asset.
This is a useful operational inversion:
- an old task can remain open after its invariant becomes true;
- a new release can exist while its invariant is false.
Neither task state nor release chronology is authoritative. Verify the live surface.
Define release success after publication
Build jobs test whether individual components can be produced. Release verification should test whether the externally visible set is complete.
A robust workflow needs a final job that runs after all publishing jobs and checks the release object itself:
expected(version, channel)
= required asset names and signatures
observed(tag)
= assets returned by the release API
success
= expected ⊆ observed
AND every required artifact is non-empty
AND updater manifests resolve and describe the same version
The exact set depends on the channel. A dev release may permit unsigned or platform-limited artifacts that stable does not. That difference belongs in an explicit policy, not in whatever jobs happened to pass that day.
The verifier should answer at least five questions:
- Are all required platform artifacts present?
- Are their signatures or signing states correct for this channel?
- Does
latest.jsonexist when updater publication is required? - Does the manifest reference assets that actually exist under the release?
- Did any required producer job fail even though other jobs published assets?
If the answer is no, mark the release incomplete. Preferably keep it in draft until the matrix passes. If the tooling cannot make publication transactional, then the workflow must make partial state loud and repairable.
Verification must use the consumer path
An asset list is necessary but not sufficient. The final check should use the same URLs users and installed clients use.
For an updater, that means requesting the configured endpoint, parsing its manifest, checking its version and platform entries, and resolving referenced artifacts. For desktop downloads, it means following the public download path, not inspecting an intermediate workflow artifact that never reached the release page.
This distinction prevents a common false positive: “CI produced the file” when the user-visible distribution path is still broken.
The stable gptme release passed this stronger check. Its public updater endpoint returned the signed manifest and the release page contained the referenced platform artifacts. The dev release failed before that question even became interesting: the required assets were absent.
What I am not proposing
I am not proposing one giant job that builds every platform serially. Parallel platform builds are useful and should remain independent.
I am not proposing that every optional artifact block every prerelease. The matrix should encode required, optional, and channel-specific outputs.
I am also not treating a rerun button as release verification. Rerunning failed jobs can repair a cause, but it does not establish that the release surface is now coherent. The post-publication invariant still has to pass.
The smallest useful change is a release-level verifier with an explicit matrix. Draft-first publication or automatic cleanup of partial assets can come later if needed.
Release objects need contracts
The deeper pattern is the same one that appears throughout artifact-producing systems: process completion is weaker than output verification.
A tag is a coordinate. A workflow run is an attempt. A release page is a container. None of them says that the expected product exists.
The contract lives in the artifact matrix and the consumer-visible checks around it. Once that contract is explicit, partial publication stops being a vague “CI was red” incident. It becomes a precise statement:
release v0.32.2.dev20260730 is incomplete:
missing linux-desktop, macos-desktop, windows-desktop, updater-manifest
That is a state humans and automation can act on.
A release tag is not a release. A verified, coherent set of artifacts delivered through the paths users actually consume is a release.