Skip to content

v1.24.1 ·

Everything it needed was already on disk

The problem

scripts/release.sh discovers a rejected release blog post at the worst possible moment. By the time it runs the generator it has already bumped both package versions, written the release commit, and created the local tag — yet every input the generator reads was sitting on disk, unchanged, before the script started. The manifest, the hand-written overrides and the instance artifacts are all properties of the current tree. Nothing about them becomes knowable only after a tag exists.

Three releases have died that way. v1.10.3 and v1.10.4 both failed after the tag on 2026-08-27, and the lesson written that afternoon already named the remedy: generating a release's artifacts against a throwaway local tag, then deleting it, costs a minute and moves failures before the point of no return. Ten days later v1.18.0 failed after the tag on three content guards at once — one act rejected as tabular, and two acts that could not resolve because a standard-scope instance writes no ideation artifact for them to resolve from. Doing the throwaway-tag generation by hand afterwards took under a minute and passed first time.

The recurrence is the point. Two of those three v1.18.0 guards fire for the ordinary shape of this project's work rather than for a mistake, so this is not a rare path that got unlucky twice; it is the common path, and the next release meets it again. The project's own rule says that a fix understood well enough to write down should be captured as work in the same breath, and that the second recurrence is where you stop writing the lesson and file the check.

Nothing here is broken. The failure trap unwinds correctly every time: it removes the local tag, resets the tree, and pushes nothing. The guards were right on every occasion, including the three that stopped v1.18.0. The ordering they run in is right too — the generator resolves its act range with a tag-to-tag git log and derives the post's date from the tag itself, so it genuinely cannot run before a tag exists. The single missing piece is a tag that costs nothing to create and nothing to throw away, early enough that a rejection lands before the release has committed to anything.

How it could be solved

Four ways to stop a release dying after its own tag, and only one of them is cheap.

Leave it, and rely on the failure trap. This is the status quo, and it is not indefensible: the trap works. It deletes the local tag, resets the tree, pushes nothing, and it did that correctly on all three occasions. The cost is not correctness, it is that the person cutting the release now has to fix an override while standing in the wreckage of a half-run release, and then start again. Three occurrences in ten days, two of them on the same afternoon, is the argument against. So is the fact that the remedy was already written down as a lesson on the day of the first two and changed nothing, because a lesson is not a check.

Generate the post before tagging. The obvious reordering, and it is impossible. The generator resolves which instances a release covers with a tag-to-tag git log, and derives the post's date from the tag's own creation date. There is no post before there is a tag. That constraint is what created this whole situation, and an earlier release already went round the loop of trying to store the date instead of deriving it.

Bump first, then check. Move the version bump above the generation attempt so the check validates the exact commit that will ship. This is the only option that closes the gap completely, including a hypothetical guard that reads the bumped version. It is also the one that mutates the tree before checking, which is the property being removed. It buys coverage of a case that does not exist — no guard reads the version — by keeping the failure mode that does.

Create the tag, throw it away. Tag the current HEAD with the release's real name, generate with --dry-run into a temp directory, delete the tag. Seconds, no mutation, and the generator prints exactly the guard output it would have printed later. The catch is honest and worth stating plainly: it validates the tree as it stands rather than the release commit, so it cannot see anything that depends on the bump. That limit is precisely what makes it free.

The tag name is not a detail. It has to be the release's real name, because manifest lookup, the act range and the derived date are all keyed on it — a temporary tag called anything else would run the generator and validate nothing. Which means this option creates, for a few seconds, exactly the artifact whose premature existence caused the original problem. What makes that safe is the flag: the throwaway tag opts into the failure trap that already existed for the real one, so there is one unwind path rather than two, and it is set only after the tag command returns — so a tag somebody else left behind stops the release without being deleted.

There was a fifth option nobody argued for long: a separate command, or a CI job, that validates the blog trail on every push. That already exists. The full generator sweep runs in the ordinary test suite, and it is why a broken historical entry no longer hides until release day. It does not help here, because the thing being validated is the post for a tag that does not exist yet — which only the release knows the name of.

How AIDLC solves it

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

That ordering is the entire fix. Every input the generator reads is a property of the tree as it already sits: the manifest, the hand-written act overrides, the instance artifacts. None of it becomes knowable only once a tag exists. Yet until now the only attempt at generation was the one past the version bump, the release commit and the tag, and three releases died there — v1.10.3 and v1.10.4 on one afternoon, then v1.18.0 ten days later, on three content guards at once. The failure trap unwound each of them correctly. It should not have had to.

Two of those three v1.18.0 guards were not defects. They are the expected shape of a standard-scope instance, which writes no ideation artifact for acts one and two to resolve from, so any release whose instances are all standard or micro scope meets them. That is the common path in this project, not an unlucky one — which is why the lesson written on the day of the first two failures, naming this exact remedy, was not enough on its own. It said a throwaway tag costs a minute and moves failures before the point of no return, and then nothing filed it as work. A fix understood well enough to write down has to be captured in the same breath, and the second recurrence is where you stop writing the lesson.

Three decisions carry the change. The throwaway tag has to be the release's real name, because manifest lookup, act-range resolution and the derived post date are all keyed on it — a temporary tag under any other name would validate nothing. It opts into the existing failure trap by setting the same flag the real tag sets, so a rejection inside the dry run leaves no tag behind and there is no second unwind path to keep in step with the first. And the flag is set only after the tag command returns, so a tag of that name that somebody else left behind aborts the run without being deleted — it was never this script's to remove.

The step cannot be skipped. It carries no || true, and the script still reads exactly one positional argument, so there is no slot for a bypass to arrive in. A gate with an override gets overridden, which is the same reasoning the release-readiness check above it was built on.

What it deliberately does not do is validate the release commit. The dry run reads the current tree, which is what makes it cost seconds instead of an unwind, and it means a guard that depended on the bumped version would still slip through. No guard reads the version. Buying that coverage would mean bumping before the check — the thing being removed.

The coverage came with a lesson of its own. Two assertions in the existing tag-ordering tests located script steps by first match, and the script now creates that tag twice and invokes the generator twice. Both would have silently retargeted onto the new lines and kept passing while saying something weaker: one would have compared the throwaway tag against the dry run, the other would have been satisfied by a dry run that validates nothing that ships. They now resolve the real tag as the second of exactly two, and the real generation as the only invocation without --dry-run, with the counts asserted so a third occurrence fails loudly rather than being picked arbitrarily.

One unrelated repair travelled with this release because it blocked it. The sanitizer's corpus positive control looked its fixture up as hold/<filename>, and hold/ was flattened into backlog/ on main earlier the same day — so the test reported "the fixture item is gone" while the item sat one directory away with its content intact. An item's status is its directory, so an item moving is the system working; any assertion keyed on the pair is a guard against normal operation. It now matches on the filename alone. This is the third recorded firing of that corpus's fragility and the second in two days, and it was already a roadmap item before this release.