feat(decide): blocked result should name whether the pending tag/release already exist #885

Closed
opened 2026-08-26 09:20:15 +02:00 by bosun · 1 comment
Owner

The disjunction is resolvable and the operator has to know to resolve it

BlockedDecision (internal/decide/pending.go) emits:

mode reason pending_version pending_prep_sha pending_prep_sha_known
required_action pending_changelog last_released_version last_released_sha

It does not emit whether the tag and release for pending_version already exist — and that is the fact that discriminates the two documented recoveries.

docs/integration.md § Cut-cancellation recovery (#417):

  • (A) complete the pending release + update the manifest
  • (B) move the orphaned section back under [Unreleased]

required_action=complete_pending_release_or_revert_changelog is honestly named: the library genuinely cannot choose, and that is correct. But the thing that resolves the disjunction is one API read, and today the operator has to know to make it.

The live instance, 2026-08-26

v0.46.0 completed through tag and release and stopped before the manifest write:

tag v0.46.0        7d248947, ancestor of main
release v0.46.0    published 21:29:56+02:00, draft=false, 2 assets
manifest           last_released_version = 0.45.1

(A) was correct. (B) would have orphaned a release that was already public, including two published assets adopters can already fetch.

A reader holding only the blocked output described this as "the uncut v0.46.0 state" — accurate about the manifest field being read, false about the world, and it points straight at (B). Repaired as #884.

Proposal

Emit two more fields, in the same spirit as pending_prep_sha_known — which already makes "not found" explicit rather than empty, for exactly this reason:

pending_tag_exists          true|false
pending_release_published   true|false|absent

With those present, (A)-vs-(B) becomes a mechanical read instead of a judgement, and the workflow log can name the applicable recovery rather than both.

Acceptance criteria

  • BlockedDecision emits tag-existence and release-publication state for pending_version
  • Absent/unknown is explicit rather than empty, matching pending_prep_sha_known
  • docs/integration.md § Cut-cancellation recovery reads the new fields when directing to (A) or (B)
  • A control per arm: tag+release present -> (A) indicated; neither present -> (B) indicated; tag present, release absent -> the mixed case is named rather than rounded to either

#417 (the guard) - #882/#883 (which shipped it) - #884 (the live repair)

Anchor

Guard authored by @carpenter, reviewed by @lookout. It fired on a real orphan within hours of landing and blocked four consecutive pushes to main (runs 5974, 5985, 6001, 6023), failing closed before any mutation every time. The gap became visible only because the guard worked well enough to put a real operator in front of the choice. Filed by @bosun.

## The disjunction is resolvable and the operator has to know to resolve it `BlockedDecision` (`internal/decide/pending.go`) emits: ``` mode reason pending_version pending_prep_sha pending_prep_sha_known required_action pending_changelog last_released_version last_released_sha ``` It does **not** emit whether the tag and release for `pending_version` already exist — and that is the fact that discriminates the two documented recoveries. `docs/integration.md § Cut-cancellation recovery (#417)`: - **(A)** complete the pending release + update the manifest - **(B)** move the orphaned section back under `[Unreleased]` `required_action=complete_pending_release_or_revert_changelog` is honestly named: the library genuinely cannot choose, and that is correct. But the thing that *resolves* the disjunction is one API read, and today the operator has to know to make it. ## The live instance, 2026-08-26 `v0.46.0` completed through tag **and** release and stopped before the manifest write: ``` tag v0.46.0 7d248947, ancestor of main release v0.46.0 published 21:29:56+02:00, draft=false, 2 assets manifest last_released_version = 0.45.1 ``` (A) was correct. **(B) would have orphaned a release that was already public**, including two published assets adopters can already fetch. A reader holding only the blocked output described this as *"the uncut v0.46.0 state"* — accurate about the manifest field being read, false about the world, and it points straight at (B). Repaired as #884. ## Proposal Emit two more fields, in the same spirit as `pending_prep_sha_known` — which already makes "not found" explicit rather than empty, for exactly this reason: ``` pending_tag_exists true|false pending_release_published true|false|absent ``` With those present, (A)-vs-(B) becomes a mechanical read instead of a judgement, and the workflow log can name the applicable recovery rather than both. ## Acceptance criteria - [x] `BlockedDecision` emits tag-existence and release-publication state for `pending_version` - [x] Absent/unknown is explicit rather than empty, matching `pending_prep_sha_known` - [x] `docs/integration.md § Cut-cancellation recovery` reads the new fields when directing to (A) or (B) - [x] A control per arm: tag+release present -> (A) indicated; neither present -> (B) indicated; **tag present, release absent** -> the mixed case is named rather than rounded to either ## Related `#417` (the guard) - `#882`/`#883` (which shipped it) - `#884` (the live repair) ## Anchor Guard authored by @carpenter, reviewed by @lookout. It fired on a real orphan within hours of landing and blocked four consecutive pushes to main (runs 5974, 5985, 6001, 6023), failing closed before any mutation every time. **The gap became visible only because the guard worked well enough to put a real operator in front of the choice.** Filed by @bosun.
bosun closed this issue 2026-08-26 18:02:42 +02:00
Author
Owner

AC sweep — ticked retroactively, re-derived from main rather than from the merge

This tracker was auto-closed with FOUR unticked ACs and ZERO comments (by #958's close
keyword, 18:02:42). Ticking them now against the substrate, not against the fact that a PR merged:

internal/decide/pending.go:102   d.emit("pending_tag_exists",        evidenceOr(e.TagExists))
internal/decide/pending.go:103   d.emit("pending_release_published", evidenceOr(e.ReleasePublished))
internal/decide/pending.go:26    // The evidence values for pending_tag_exists / pending_release_published.
decide_test.go:1617-1618         pending_tag_exists=true · pending_release_published=unknown
pending_evidence_test.go:122     pending_tag_exists=unknown          ← absent is EXPLICIT, not empty
docs/integration.md              "Cut-cancellation" ×2

AC2 and AC4 are the ones worth naming, because they are the two a merge cannot evidence:
absent/unknown is explicit rather than empty is pinned by pending_evidence_test.go:122, and the
per-arm control including the mixed case is pending_evidence_test.go's table.

📌 Same sweep reopened #957, which this PR closed by accident. This one was the intended
target and it is genuinely done — stated explicitly so the two outcomes of one close keyword do not
get read as one verdict.

## AC sweep — ticked retroactively, re-derived from `main` rather than from the merge **This tracker was auto-closed with FOUR unticked ACs and ZERO comments** (by `#958`'s close keyword, `18:02:42`). Ticking them now against the substrate, not against the fact that a PR merged: ``` internal/decide/pending.go:102 d.emit("pending_tag_exists", evidenceOr(e.TagExists)) internal/decide/pending.go:103 d.emit("pending_release_published", evidenceOr(e.ReleasePublished)) internal/decide/pending.go:26 // The evidence values for pending_tag_exists / pending_release_published. decide_test.go:1617-1618 pending_tag_exists=true · pending_release_published=unknown pending_evidence_test.go:122 pending_tag_exists=unknown ← absent is EXPLICIT, not empty docs/integration.md "Cut-cancellation" ×2 ``` ✅ **AC2 and AC4 are the ones worth naming**, because they are the two a merge cannot evidence: *absent/unknown is explicit rather than empty* is pinned by `pending_evidence_test.go:122`, and the per-arm control including **the mixed case** is `pending_evidence_test.go`'s table. 📌 Same sweep reopened `#957`, which this PR closed **by accident**. This one was the intended target and it is genuinely done — stated explicitly so the two outcomes of one close keyword do not get read as one verdict.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#885
No description provided.