Skip to content

v1.24.1 ·

Everything it needed was already on disk

scripts/release.sh found out that a release blog post was rejected at the worst moment. By the time it ran the generator, it had bumped both package versions, written the release commit and created the local tag. Yet every input the generator reads was already on disk before the script started: the manifest, the hand-written overrides, the instance artifacts.

Three releases died that way. v1.10.3 and v1.10.4 both failed after the tag on 2026-08-27, and the lesson I wrote that afternoon already named the fix: generate against a throwaway local tag, delete it, and failures move before the point of no return. Ten days later v1.18.0 failed after the tag on three content guards at once. One act was rejected as tabular. Two could not resolve, because a standard-scope instance writes no ideation artifact for them to resolve from. Doing the throwaway-tag run by hand afterwards took under a minute and passed first time.

What changed

scripts/release.sh now creates the release's own tag at the current HEAD, runs the generator with --dry-run, and deletes the tag, before it changes anything. It is four command lines, after the two existing pre-flight checks and before the version bump.

Two of the v1.18.0 guards were not mistakes. They fire for the ordinary shape of this project's work, so this was the common path, not bad luck. The lesson naming this exact remedy did not help, because nothing filed it as work. The second recurrence is where I stopped writing the lesson and filed the check.

Why this way

Leaving it to the failure trap is defensible, since the trap unwound correctly all three times: it removed the local tag, reset the tree and pushed nothing. But the person cutting the release then fixes an override in the wreckage of a half-run release and starts over.

Generating the post before tagging is impossible. The generator finds a release's instances with a tag-to-tag git log and takes the post's date from the tag itself.

Bumping first and then checking would validate the exact release commit. It also mutates the tree before checking, which is what I was trying to remove, and it buys coverage of a case that does not exist, since no guard reads the version.

So the throwaway tag has to carry the release's real name, because manifest lookup, the act range and the date are all keyed on it. It opts into the existing failure trap by setting the same flag the real tag sets, so there is one unwind path, not two. The flag is set only after the tag command returns, so a tag of that name that somebody else left behind stops the release without being deleted.

The step cannot be skipped. It has no || true, and the script reads one positional argument, so there is no slot for a bypass. A gate with an override gets overridden.

What it does not do

It validates the current tree, not the release commit. That is why it costs seconds, and a guard that read the bumped version would still slip through.

Two assertions in the existing tag-ordering tests located script steps by first match. The script now creates that tag twice and invokes the generator twice, so both would have retargeted onto the new lines and kept passing while asserting something weaker. They now pick the real tag as the second of exactly two, and the real generation as the only call without --dry-run.

One unrelated repair travelled with it because it blocked the release. A test looked up a fixture as a path under the old hold directory, and the hold directory had been flattened into backlog earlier that day. An item's status is its directory, so an item moving is the system working. The test now matches on the filename alone.