chore: prune orphaned -rc.N tags + automate self-bootstrap rc-tag lifecycle #153

Closed
opened 2026-06-27 01:37:30 +02:00 by quartermaster · 1 comment

The phenomenon

Operator surfaced 2026-06-27: the self-bootstrap re-pin discipline (AGENTS.md §2) tags each in-cycle re-pin commit as vX.Y.Z-rc.1. These tags exist purely to give the consumer's release.yml a stable ref to pin to between cuts. Once the cut for vX.Y.Z ships, the corresponding -rc.1 tag is superseded — no consumer references it; it's substrate dead-weight.

13 cycles of in-cycle re-pin discipline have produced 13 -rc.N tags. Only 1 is currently live; 12 are orphaned.

Inventory at 2026-06-27 01:36 UTC

v0.4.0-rc.1   shipped → v0.4.0   ORPHANED
v0.4.0-rc.2   shipped → v0.4.0   ORPHANED
v0.4.0-rc.3   shipped → v0.4.0   ORPHANED
v0.5.0-rc.1   shipped → v0.5.0   ORPHANED
v0.5.1-rc.1   NEVER SHIPPED      ORPHANED (the v0.5.1 sprint auto-bumped to v0.5.2 via feat: commit; rc.1 was tagged then abandoned — empirical artifact of the fix-vs-feat classification pattern)
v0.6.1-rc.1   shipped → v0.6.1   ORPHANED
v0.6.2-rc.1   shipped → v0.6.2   ORPHANED
v0.8.0-rc.1   shipped → v0.8.0   ORPHANED
v0.9.0-rc.1   shipped → v0.9.0   ORPHANED
v0.10.0-rc.1  shipped → v0.10.0  ORPHANED
v0.10.1-rc.1  shipped → v0.10.1  ORPHANED
v0.10.2-rc.1  shipped → v0.10.2  ORPHANED
v0.10.3-rc.1  shipped → v0.10.3  ORPHANED
v0.10.4-rc.1  current pin        LIVE (referenced by .forgejo/workflows/release.yml + cellblock)

Why the orphans exist

The self-bootstrap re-pin pattern (#82/#91/.../#146):

  1. Compose-script PR merges to main
  2. Tag vX.Y.Z-rc.1 at the merge SHA → consumer's release.yml re-pins @vX.Y.Z-rc.1
  3. Eventually a cut happens → release tag vX.Y.Z is created at the rolling-PR-merge commit (DIFFERENT SHA than the -rc.1)
  4. vX.Y.Z-rc.1 is now superseded; no live consumer references it
  5. Repeat for the next cycle, creating vX.Y.Z+1-rc.1

The orphan accumulates because we never DELETE the superseded -rc.1.

The empirical v0.5.1-rc.1 artifact (worth noting)

v0.5.1-rc.1 was tagged but v0.5.1 was never shipped. The v0.5.1 sprint produced a feat: commit which auto-bumped to v0.5.2 (same pattern that produced v0.10.2 instead of v0.10.1, v0.9.0 instead of v0.8.1, etc.). The -rc.1 tag was orphaned before its cut completed.

This pattern would also produce orphans going forward whenever the operator overrides the bump via the bump/* label (e.g., bump/patch on a feat: commit produces v0.X.Y not v0.X+1.0; the rc.1 was tagged with the intended-but-not-shipped name).

Proposed cleanups

(A) Historical: bulk-prune the 12 orphaned -rc tags now

git tag -d <orphan> + git push origin --delete <orphan>. Safe because:

  • No live consumer references them
  • They serve no functional purpose post-supersession
  • The historical commits they point at are still in main's history (the tag deletion doesn't lose the commit)

Substantive but small: ~12 tag deletions. The toolkit's release-counter in the Forgejo UI decreases from 14 to 2 (v0.10.4-rc.1 + cellblock's referenced refs).

(B) Going forward: automate the rc-tag lifecycle

Option 1 — Delete on cut: when the cut path completes successfully (cut tag vX.Y.Z is created), also delete vX.Y.Z-rc.1 (and any other vX.Y.Z-rc.N tags). The cut workflow has the auth to do this via the Forgejo API.

Option 2 — Delete on next re-pin: when the next vX.Y.Z+1-rc.1 is tagged, also delete the previous -rc.1. This requires the re-pin process to know which previous rc to delete; less clean than (1).

Option 3 — Leave them + accept the noise: keep the substrate-of-record but acknowledge the tag-list growth. Could move the rc tags to a non-default Forgejo "release type" if Forgejo distinguishes pre-releases prominently. (It does — they show as "Pre-release" labels, but they still appear in the releases list.)

My lean: (B)/Option 1 — cleanest. The cut workflow has the auth + the timing is unambiguous (post-cut, the rc.1 IS orphaned). ~10 lines of bash in _release.yml's cut path.

(C) Substantive alternative: eliminate rc tags entirely via build-bake (#148)

If #148 (build-bake toolkit_ref) ships, the consumer's release.yml still pins uses: ...@<ref>. The <ref> could be:

  • A -rc.1 tag (current discipline)
  • A direct commit SHA (@abc1234) — ugly but tag-clean
  • The previous release tag plus a main overlay — brittle

@<commit-SHA> is the substrate-cleanest option but introduces SHA-based pins which are less readable + harder to reason about for operators. Trade-off worth considering as part of #148's scope.

Composition

  • AGENTS.md §2: would gain a subsection documenting the rc-tag lifecycle + the auto-prune mechanism (if (B) ships)
  • #124 backstop: the check-self-bootstrap.sh reads toolkit_ref from consumer release.yml; the script's tag-resolution logic uses git rev-parse $PINNED_REF. If a tag is deleted while a consumer pins to it, the script fails loud ("FATAL: pinned ref '$PINNED_REF' does not resolve"). The pre-1.0 historical-prune (A) is safe ONLY if no historical pin still points at an orphan.
    • Verified: only v0.10.4-rc.1 is in toolkit-self's release.yml. cellblock pins @v0.10.4-rc.1 too. No historical pin references any orphan. ✓
  • #148 build-bake refactor: this tracker's (C) alternative is a sub-question for #148's scope; could be addressed together if there's appetite
  • #149/#150 Unicode cleanups: independent

Implementation surface

For (A) historical prune

  • scripts/prune-orphaned-rc-tags.sh (one-shot helper that lists orphan candidates + deletes after operator confirmation) OR just a one-liner for tag in $(git tag --list 'v*-rc*' | head -N); do git tag -d $tag && git push origin --delete $tag; done with the explicit list

For (B) automate

  • Add ~10 lines to _release.yml's cut path: after release-create succeeds, find + delete the ${CUT_VERSION}-rc.* tags via Forgejo API. Best-effort (failure doesn't break the cut)
  • bats coverage for the helper
  • AGENTS.md §2 amendment documenting the lifecycle

Estimated scope: ~30 lines + 1 PR cycle.

What this PR does NOT do

  • Does NOT touch the rc tag mechanism itself — the in-cycle re-pin still tags -rc.1 going forward; we just clean up after
  • Does NOT change consumer behavior — consumers still pin to a -rc.N tag between cuts
  • Does NOT cover the abandoned-rc case from bump-override — if a vX.Y.Z-rc.1 is tagged then bump-override produces a different version on cut, the orphan is created by abandonment, not by supersession. (B)/Option 1 would NOT catch this case; could be a follow-up

Refs

  • Operator engagement 2026-06-27: surfaced the question after the #149-#152 cleanup waves
  • Empirical artifact: 14 -rc.N tags in the repo at 2026-06-27 (12 superseded + 1 abandoned + 1 live)
  • Composition: #148 (build-bake refactor — alternative to rc tags), #124 (backstop — verifies pinned ref resolves), AGENTS.md §2 (re-pin discipline)
  • The v0.5.1-rc.1 artifact: empirical evidence of the abandoned-rc class; worth banking the "rc tag lifecycle has THREE outcomes: shipped, abandoned-by-skip, abandoned-by-override" framing
## The phenomenon Operator surfaced 2026-06-27: the self-bootstrap re-pin discipline (AGENTS.md §2) tags each in-cycle re-pin commit as `vX.Y.Z-rc.1`. These tags exist purely to give the consumer's `release.yml` a stable ref to pin to between cuts. **Once the cut for `vX.Y.Z` ships, the corresponding `-rc.1` tag is superseded** — no consumer references it; it's substrate dead-weight. 13 cycles of in-cycle re-pin discipline have produced 13 `-rc.N` tags. Only 1 is currently live; 12 are orphaned. ## Inventory at 2026-06-27 01:36 UTC ``` v0.4.0-rc.1 shipped → v0.4.0 ORPHANED v0.4.0-rc.2 shipped → v0.4.0 ORPHANED v0.4.0-rc.3 shipped → v0.4.0 ORPHANED v0.5.0-rc.1 shipped → v0.5.0 ORPHANED v0.5.1-rc.1 NEVER SHIPPED ORPHANED (the v0.5.1 sprint auto-bumped to v0.5.2 via feat: commit; rc.1 was tagged then abandoned — empirical artifact of the fix-vs-feat classification pattern) v0.6.1-rc.1 shipped → v0.6.1 ORPHANED v0.6.2-rc.1 shipped → v0.6.2 ORPHANED v0.8.0-rc.1 shipped → v0.8.0 ORPHANED v0.9.0-rc.1 shipped → v0.9.0 ORPHANED v0.10.0-rc.1 shipped → v0.10.0 ORPHANED v0.10.1-rc.1 shipped → v0.10.1 ORPHANED v0.10.2-rc.1 shipped → v0.10.2 ORPHANED v0.10.3-rc.1 shipped → v0.10.3 ORPHANED v0.10.4-rc.1 current pin LIVE (referenced by .forgejo/workflows/release.yml + cellblock) ``` ## Why the orphans exist The self-bootstrap re-pin pattern (#82/#91/.../#146): 1. Compose-script PR merges to main 2. Tag `vX.Y.Z-rc.1` at the merge SHA → consumer's `release.yml` re-pins `@vX.Y.Z-rc.1` 3. Eventually a cut happens → release tag `vX.Y.Z` is created at the rolling-PR-merge commit (DIFFERENT SHA than the -rc.1) 4. `vX.Y.Z-rc.1` is now superseded; no live consumer references it 5. Repeat for the next cycle, creating `vX.Y.Z+1-rc.1` The orphan accumulates because we never DELETE the superseded `-rc.1`. ## The empirical v0.5.1-rc.1 artifact (worth noting) `v0.5.1-rc.1` was tagged but `v0.5.1` was never shipped. The v0.5.1 sprint produced a `feat:` commit which auto-bumped to v0.5.2 (same pattern that produced v0.10.2 instead of v0.10.1, v0.9.0 instead of v0.8.1, etc.). The `-rc.1` tag was orphaned before its cut completed. This pattern would also produce orphans going forward whenever the operator overrides the bump via the `bump/*` label (e.g., `bump/patch` on a `feat:` commit produces v0.X.Y not v0.X+1.0; the rc.1 was tagged with the intended-but-not-shipped name). ## Proposed cleanups ### (A) Historical: bulk-prune the 12 orphaned -rc tags now `git tag -d <orphan>` + `git push origin --delete <orphan>`. Safe because: - No live consumer references them - They serve no functional purpose post-supersession - The historical commits they point at are still in main's history (the tag deletion doesn't lose the commit) Substantive but small: ~12 tag deletions. The toolkit's release-counter in the Forgejo UI decreases from 14 to 2 (v0.10.4-rc.1 + cellblock's referenced refs). ### (B) Going forward: automate the rc-tag lifecycle Option 1 — **Delete on cut**: when the cut path completes successfully (cut tag `vX.Y.Z` is created), also delete `vX.Y.Z-rc.1` (and any other `vX.Y.Z-rc.N` tags). The cut workflow has the auth to do this via the Forgejo API. Option 2 — **Delete on next re-pin**: when the next `vX.Y.Z+1-rc.1` is tagged, also delete the previous `-rc.1`. This requires the re-pin process to know which previous rc to delete; less clean than (1). Option 3 — **Leave them + accept the noise**: keep the substrate-of-record but acknowledge the tag-list growth. Could move the rc tags to a non-default Forgejo "release type" if Forgejo distinguishes pre-releases prominently. (It does — they show as "Pre-release" labels, but they still appear in the releases list.) **My lean: (B)/Option 1** — cleanest. The cut workflow has the auth + the timing is unambiguous (post-cut, the rc.1 IS orphaned). ~10 lines of bash in `_release.yml`'s cut path. ### (C) Substantive alternative: eliminate rc tags entirely via build-bake (#148) If [#148](https://git.frankenbit.de/frankenbit/release-toolkit/issues/148) (build-bake `toolkit_ref`) ships, the consumer's `release.yml` still pins `uses: ...@<ref>`. The `<ref>` could be: - A `-rc.1` tag (current discipline) - A direct commit SHA (`@abc1234`) — ugly but tag-clean - The previous release tag plus a `main` overlay — brittle `@<commit-SHA>` is the substrate-cleanest option but introduces SHA-based pins which are less readable + harder to reason about for operators. Trade-off worth considering as part of #148's scope. ## Composition - **AGENTS.md §2**: would gain a subsection documenting the rc-tag lifecycle + the auto-prune mechanism (if (B) ships) - **[#124 backstop](https://git.frankenbit.de/frankenbit/release-toolkit/issues/124)**: the check-self-bootstrap.sh reads `toolkit_ref` from consumer release.yml; the script's tag-resolution logic uses `git rev-parse $PINNED_REF`. If a tag is deleted while a consumer pins to it, the script fails loud ("FATAL: pinned ref '$PINNED_REF' does not resolve"). The pre-1.0 historical-prune (A) is safe ONLY if no historical pin still points at an orphan. - **Verified**: only `v0.10.4-rc.1` is in toolkit-self's release.yml. cellblock pins `@v0.10.4-rc.1` too. No historical pin references any orphan. ✓ - **[#148 build-bake refactor](https://git.frankenbit.de/frankenbit/release-toolkit/issues/148)**: this tracker's (C) alternative is a sub-question for #148's scope; could be addressed together if there's appetite - **[#149/#150 Unicode cleanups](https://git.frankenbit.de/frankenbit/release-toolkit/issues/149)**: independent ## Implementation surface ### For (A) historical prune - `scripts/prune-orphaned-rc-tags.sh` (one-shot helper that lists orphan candidates + deletes after operator confirmation) OR just a one-liner `for tag in $(git tag --list 'v*-rc*' | head -N); do git tag -d $tag && git push origin --delete $tag; done` with the explicit list ### For (B) automate - Add ~10 lines to `_release.yml`'s cut path: after release-create succeeds, find + delete the `${CUT_VERSION}-rc.*` tags via Forgejo API. Best-effort (failure doesn't break the cut) - bats coverage for the helper - AGENTS.md §2 amendment documenting the lifecycle Estimated scope: ~30 lines + 1 PR cycle. ## What this PR does NOT do - **Does NOT touch the rc tag mechanism itself** — the in-cycle re-pin still tags `-rc.1` going forward; we just clean up after - **Does NOT change consumer behavior** — consumers still pin to a `-rc.N` tag between cuts - **Does NOT cover the abandoned-rc case from bump-override** — if a `vX.Y.Z-rc.1` is tagged then bump-override produces a different version on cut, the orphan is created by abandonment, not by supersession. (B)/Option 1 would NOT catch this case; could be a follow-up ## Refs - **Operator engagement 2026-06-27**: surfaced the question after the #149-#152 cleanup waves - **Empirical artifact**: 14 `-rc.N` tags in the repo at 2026-06-27 (12 superseded + 1 abandoned + 1 live) - **Composition**: [#148](https://git.frankenbit.de/frankenbit/release-toolkit/issues/148) (build-bake refactor — alternative to rc tags), [#124](https://git.frankenbit.de/frankenbit/release-toolkit/issues/124) (backstop — verifies pinned ref resolves), AGENTS.md §2 (re-pin discipline) - **The v0.5.1-rc.1 artifact**: empirical evidence of the abandoned-rc class; worth banking the "rc tag lifecycle has THREE outcomes: shipped, abandoned-by-skip, abandoned-by-override" framing
Author
Owner

Operator confirmation 2026-06-27: agree to (B)/Option 1 (delete on cut). When engaged: ~10 lines in _release.yml's cut path post-release-create + bats coverage + AGENTS.md §2 amendment documenting the lifecycle. (A) historical-prune of the 12 superseded + 1 abandoned orphan can bundle with the same PR or ship separately.

**Operator confirmation 2026-06-27**: agree to (B)/Option 1 (delete on cut). When engaged: ~10 lines in `_release.yml`'s cut path post-release-create + bats coverage + AGENTS.md §2 amendment documenting the lifecycle. (A) historical-prune of the 12 superseded + 1 abandoned orphan can bundle with the same PR or ship separately.
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#153
No description provided.