feat: auto-prune superseded -rc.N tags after cut (closes #153) #164

Merged
quartermaster merged 1 commit from i/153-rc-tag-lifecycle into main 2026-06-27 02:58:35 +02:00

Summary

Sprint 2 (#153 rc-tag lifecycle). The self-bootstrap re-pin discipline (AGENTS.md section 2) tags each cycle as vX.Y.Z-rc.N. Once the cut for vX.Y.Z ships, the -rc.N tag is superseded — substrate dead-weight that's been hand-pruned each cycle since v0.4.0.

This PR closes the loop: the cut path auto-deletes the rc tag family via the Forgejo API after the manifest mechanism completes. Best-effort posture — failure logs a warning but doesn't fail the cut.

Changes

  • scripts/prune-rc-tags.sh (new): enumerates vCUT_VERSION-rc.* via the Forgejo API + deletes each. Args: OWNER REPO CUT_VERSION. Normalizes leading v. Best-effort posture.
  • scripts/lib/forgejo-api.sh: adds forgejo_list_tags(owner, repo, prefix) + forgejo_delete_tag(owner, repo, tag) helpers + FORGEJO_TEST_TAGS_FILE test seam (mirrors the existing FORGEJO_TEST_PR_LOOKUP_FILE + FORGEJO_TEST_RELEASE_FILE patterns).
  • .forgejo/workflows/_release.yml cut path: invokes the helper after the manifest mechanism completes (both alpha + gamma paths — the manifest mechanism split is unrelated to rc-tag cleanup). Dry-run mode logs the would-prune action.
  • scripts/check-self-bootstrap.sh: adds scripts/prune-rc-tags.sh to DEFAULT_COMPOSE_SCRIPTS (the mechanism-of-touch list) + updates the header doc-list.
  • AGENTS.md section 2.4 (new): documents the rc-tag lifecycle + three terminal outcomes (shipped / abandoned-by-override / abandoned-by-skip) + the cut-time-vs-re-pin-time limitation.
  • tests/prune-rc-tags.bats (new): 9 tests — no-match / single-match / multi-match / v-prefix-normalization / scope-isolation / false-prefix-match guard (v0.1.1 must not match v0.1.10-rc.*) / fail-loud on missing args.
  • changelog.d/153.feat.md: fragment for the feat.

Part (A): historical bulk-prune — completed pre-PR via operator-side action

Surveyor f0a7 framing surfaced the load-bearing audit. Org-wide pin probe before any tag deletion revealed:

#153's body claimed cellblock pinned v0.10.4-rc.1. The live pin is v0.10.1-rc.1 on BOTH release.yml + manifest-check.yml.

Had the inventory been trusted (preserve v0.10.4-rc.1, prune v0.10.1-rc.1), cellblock's CI would have broken on the next manifest-check run. Verify-at-source on destructive operations on external-consumer-touching state caught it (Surveyor fe57 framing: "the load-bearing point").

Final prune list (13 tags deleted via git push --delete):

v0.4.0-rc.{1,2,3}, v0.5.{0,1}-rc.1, v0.6.{1,2}-rc.1, v0.8.0-rc.1,
v0.9.0-rc.1, v0.10.{0,2,3,4}-rc.1

Surviving rc tags post-prune: v0.10.1-rc.1 (cellblock-live) + v0.11.1-rc.1 (toolkit-self-live).

Also confirmed: the pruned rc tags had NO Forgejo release records attached — pure git tags, so no orphan-release cleanup needed (release_counter: 22 before and after the prune).

Part (B): going-forward auto-prune — scope + limitations

The cut-time auto-prune catches the canonical shipped outcome — vX.Y.Z-rc.N superseded by the matching vX.Y.Z cut. It does not catch two adjacent classes per AGENTS.md section 2.4:

Outcome Mechanism Auto-pruned?
Shipped vX.Y.Z-rc.N matched by vX.Y.Z cut YES (this PR's scope)
Abandoned-by-override Bump-override changes cut version → rc.* never matches at cut-time NO
Abandoned-by-skip Substrate-PR amends a chore-only post-merge state (no cut) → next cycle's re-pin orphans the older rc NO

v0.10.4-rc.1 (just pruned via Part A) was the abandoned-by-skip class — it was superseded by v0.11.1-rc.1 at re-pin time WITHOUT a cut in between. Cut-time auto-prune wouldn't have caught this. Surveyor fe57 framed the follow-up: a re-pin-time auto-prune could fold with #163's alignment-check at the same re-pin surface. Worth a follow-up tracker after this lands.

Verification

  • Bats: 370/370 pass (including 9 new prune-rc-tags tests)
  • prune-rc-tags.sh tested via FORGEJO_TEST_TAGS_FILE seam — covers correctness (scope isolation, v-prefix normalization, false-prefix-match guard) without hitting real API
  • check-self-bootstrap.sh runs clean: prune-rc-tags.sh added to DEFAULT_COMPOSE_SCRIPTS, no :⚠️: on the regression-guard test
  • AGENTS.md section 2.4 inline with the three-outcome empirical model

Composition with AGENTS.md section 2

This PR touches compose-scripts (_release.yml, forgejo-api.sh, check-self-bootstrap.sh, new prune-rc-tags.sh). In-cycle re-pin required before the next cut. Sequence:

  1. Merge this PR
  2. Tag v0.11.1-rc.2 at the merge SHA (pre-existing v0.11.1-rc.1 from Sprint 1 superseded; the auto-prune mechanism this PR ships will catch it AT THE v0.11.1 CUT, but not at this re-pin time — exactly the gap section 2.4 documents).
  3. Re-pin release.yml + manifest-check.yml @v0.11.1-rc.2
  4. Push:main fires release.yml @new ref → since this PR's commit is feat:, release-decide outputs mode=update → rolling PR opens for v0.11.1.

What this PR does NOT do

  • Does NOT catch abandoned-by-override or abandoned-by-skip rc tags (cut-time only — follow-up for re-pin-time evolution)
  • Does NOT delete Forgejo release records (the toolkit's -rc.N tags don't have releases attached — verified at source)
  • Does NOT touch the bulk-prune tooling — Part A was a one-shot operator action; no script committed for it
  • Does NOT change the rc-tag mechanism itself — re-pin still tags -rc.N going forward
  • Does NOT update cellblock's pin (independent cadence; observation surfaced separately)

Refs

  • AGENTS.md section 2 (mechanism-of-touch self-bootstrap re-pin) + new section 2.4 (rc-tag lifecycle)
  • Surveyor f0a7 (cellblock-pin catch framing) + fe57 (Part B scope-limitation framing)
  • #124 backstop (extended by adding prune-rc-tags.sh to compose-scripts)
  • #163 (alignment-enforcement — re-pin-time companion; potential follow-up fold-target)
## Summary Sprint 2 (#153 rc-tag lifecycle). The self-bootstrap re-pin discipline (AGENTS.md section 2) tags each cycle as `vX.Y.Z-rc.N`. Once the cut for `vX.Y.Z` ships, the `-rc.N` tag is superseded — substrate dead-weight that's been hand-pruned each cycle since v0.4.0. This PR closes the loop: the cut path auto-deletes the rc tag family via the Forgejo API after the manifest mechanism completes. Best-effort posture — failure logs a warning but doesn't fail the cut. ## Changes - **`scripts/prune-rc-tags.sh`** (new): enumerates `vCUT_VERSION-rc.*` via the Forgejo API + deletes each. Args: `OWNER REPO CUT_VERSION`. Normalizes leading `v`. Best-effort posture. - **`scripts/lib/forgejo-api.sh`**: adds `forgejo_list_tags(owner, repo, prefix)` + `forgejo_delete_tag(owner, repo, tag)` helpers + `FORGEJO_TEST_TAGS_FILE` test seam (mirrors the existing `FORGEJO_TEST_PR_LOOKUP_FILE` + `FORGEJO_TEST_RELEASE_FILE` patterns). - **`.forgejo/workflows/_release.yml`** cut path: invokes the helper after the manifest mechanism completes (both alpha + gamma paths — the manifest mechanism split is unrelated to rc-tag cleanup). Dry-run mode logs the would-prune action. - **`scripts/check-self-bootstrap.sh`**: adds `scripts/prune-rc-tags.sh` to `DEFAULT_COMPOSE_SCRIPTS` (the mechanism-of-touch list) + updates the header doc-list. - **`AGENTS.md` section 2.4** (new): documents the rc-tag lifecycle + three terminal outcomes (shipped / abandoned-by-override / abandoned-by-skip) + the cut-time-vs-re-pin-time limitation. - **`tests/prune-rc-tags.bats`** (new): 9 tests — no-match / single-match / multi-match / v-prefix-normalization / scope-isolation / **false-prefix-match guard** (`v0.1.1` must not match `v0.1.10-rc.*`) / fail-loud on missing args. - **`changelog.d/153.feat.md`**: fragment for the feat. ## Part (A): historical bulk-prune — completed pre-PR via operator-side action Surveyor f0a7 framing surfaced the load-bearing audit. Org-wide pin probe before any tag deletion revealed: > #153's body claimed cellblock pinned `v0.10.4-rc.1`. The live pin is `v0.10.1-rc.1` on BOTH `release.yml` + `manifest-check.yml`. Had the inventory been trusted (preserve `v0.10.4-rc.1`, prune `v0.10.1-rc.1`), cellblock's CI would have broken on the next manifest-check run. Verify-at-source on destructive operations on external-consumer-touching state caught it (Surveyor fe57 framing: "the load-bearing point"). **Final prune list** (13 tags deleted via `git push --delete`): ``` v0.4.0-rc.{1,2,3}, v0.5.{0,1}-rc.1, v0.6.{1,2}-rc.1, v0.8.0-rc.1, v0.9.0-rc.1, v0.10.{0,2,3,4}-rc.1 ``` **Surviving rc tags post-prune**: `v0.10.1-rc.1` (cellblock-live) + `v0.11.1-rc.1` (toolkit-self-live). Also confirmed: the pruned rc tags had NO Forgejo release records attached — pure git tags, so no orphan-release cleanup needed (`release_counter: 22` before and after the prune). ## Part (B): going-forward auto-prune — scope + limitations The cut-time auto-prune catches the canonical **shipped** outcome — `vX.Y.Z-rc.N` superseded by the matching `vX.Y.Z` cut. It does **not** catch two adjacent classes per AGENTS.md section 2.4: | Outcome | Mechanism | Auto-pruned? | |---|---|---| | **Shipped** | `vX.Y.Z-rc.N` matched by `vX.Y.Z` cut | **YES** (this PR's scope) | | **Abandoned-by-override** | Bump-override changes cut version → rc.* never matches at cut-time | NO | | **Abandoned-by-skip** | Substrate-PR amends a chore-only post-merge state (no cut) → next cycle's re-pin orphans the older rc | NO | `v0.10.4-rc.1` (just pruned via Part A) was the **abandoned-by-skip** class — it was superseded by `v0.11.1-rc.1` at re-pin time WITHOUT a cut in between. Cut-time auto-prune wouldn't have caught this. Surveyor fe57 framed the follow-up: a re-pin-time auto-prune could fold with #163's alignment-check at the same re-pin surface. Worth a follow-up tracker after this lands. ## Verification - Bats: **370/370 pass** (including 9 new prune-rc-tags tests) - prune-rc-tags.sh tested via FORGEJO_TEST_TAGS_FILE seam — covers correctness (scope isolation, v-prefix normalization, false-prefix-match guard) without hitting real API - check-self-bootstrap.sh runs clean: prune-rc-tags.sh added to DEFAULT_COMPOSE_SCRIPTS, no ::warning:: on the regression-guard test - AGENTS.md section 2.4 inline with the three-outcome empirical model ## Composition with AGENTS.md section 2 This PR touches compose-scripts (`_release.yml`, `forgejo-api.sh`, `check-self-bootstrap.sh`, new `prune-rc-tags.sh`). **In-cycle re-pin required** before the next cut. Sequence: 1. Merge this PR 2. Tag `v0.11.1-rc.2` at the merge SHA (pre-existing `v0.11.1-rc.1` from Sprint 1 superseded; the auto-prune mechanism this PR ships will catch it AT THE v0.11.1 CUT, but not at this re-pin time — exactly the gap section 2.4 documents). 3. Re-pin `release.yml` + `manifest-check.yml` `@v0.11.1-rc.2` 4. Push:main fires release.yml @new ref → since this PR's commit is `feat:`, release-decide outputs `mode=update` → rolling PR opens for v0.11.1. ## What this PR does NOT do - Does NOT catch abandoned-by-override or abandoned-by-skip rc tags (cut-time only — follow-up for re-pin-time evolution) - Does NOT delete Forgejo release records (the toolkit's `-rc.N` tags don't have releases attached — verified at source) - Does NOT touch the bulk-prune tooling — Part A was a one-shot operator action; no script committed for it - Does NOT change the rc-tag mechanism itself — re-pin still tags `-rc.N` going forward - Does NOT update cellblock's pin (independent cadence; observation surfaced separately) ## Refs - AGENTS.md section 2 (mechanism-of-touch self-bootstrap re-pin) + new section 2.4 (rc-tag lifecycle) - Surveyor f0a7 (cellblock-pin catch framing) + fe57 (Part B scope-limitation framing) - #124 backstop (extended by adding prune-rc-tags.sh to compose-scripts) - #163 (alignment-enforcement — re-pin-time companion; potential follow-up fold-target)
feat: auto-prune superseded -rc.N tags after cut (closes #153)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Failing after 3s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
93271488b3
Per AGENTS.md section 2.4 rc-tag lifecycle (new): the self-bootstrap
re-pin discipline tags each cycle as `vX.Y.Z-rc.N`. Once the cut
ships, the rc tag is superseded - substrate dead-weight that's
been hand-pruned each cycle.

This adds:

- scripts/prune-rc-tags.sh: enumerates `vCUT_VERSION-rc.*` via the
  Forgejo API + deletes each. Best-effort posture (failures are
  warnings, not errors - the cut already succeeded).
- scripts/lib/forgejo-api.sh: forgejo_list_tags + forgejo_delete_tag
  helpers + FORGEJO_TEST_TAGS_FILE test seam (mirrors the existing
  test-seam pattern for find_pr_by_merge_sha + get_release_by_tag).
- .forgejo/workflows/_release.yml cut path: invokes the prune helper
  AFTER the manifest mechanism completes (both alpha + gamma paths
  - the manifest mechanism split is unrelated to rc-tag cleanup).
  Dry-run mode logs the would-prune action without executing.
- scripts/check-self-bootstrap.sh DEFAULT_COMPOSE_SCRIPTS: adds
  prune-rc-tags.sh to the mechanism-of-touch list.
- AGENTS.md section 2.4: documents the rc-tag lifecycle + three
  outcome classes (shipped / abandoned-by-override / abandoned-by-
  skip) + the cut-time-vs-re-pin-time limitation. Surveyor's framing
  preserved: the abandoned-by-* classes need a re-pin-time
  auto-prune evolution; foldable with #163's alignment-check at the
  same re-pin surface.
- tests/prune-rc-tags.bats: 9 tests covering no-match / single-match
  / multi-match / v-prefix-normalization / scope-isolation /
  false-prefix-match guard / fail-loud-on-missing-args.

Historical bulk-prune (Part A): operator-side action 2026-06-27
removed 13 orphan rc tags accumulated across v0.4 -> v0.10 cycles.
Surviving rc tags post-prune: v0.10.1-rc.1 (cellblock-live) +
v0.11.1-rc.1 (toolkit-self-live).

Cellblock-pin catch surfaced during the bulk-prune audit: #153's
body claimed cellblock pinned v0.10.4-rc.1; org-wide pin probe
showed v0.10.1-rc.1 on BOTH release.yml + manifest-check.yml.
Had the inventory been trusted (preserve v0.10.4-rc.1, prune
v0.10.1-rc.1), cellblock's CI would have broken. Verify-at-source
on destructive operations on external-consumer-touching state
paid off (Surveyor fe57 framing).

Bats: 370/370 pass (incl. the 9 new prune-rc-tags tests).
surveyor approved these changes 2026-06-27 02:57:21 +02:00
surveyor left a comment

APPROVED — auto-prune superseded rc-tags (head 9327148, official/gating)

A destructive feature operating in the exact tag-space cellblock depends on — so I focused the verify-at-source on whether it can ever reach a cross-version pin. It cannot, by construction, and the guard is mutation-verified. FF-feasible, 370/370. Clean.

The cellblock-safety is structural, not incidental

The selection scope is version-specific: PREFIX="v${VERSION}-rc." (with the trailing -rc.), matched via startswith — and the test seam mirrors it faithfully (grep "^${prefix}" ↔ the real jq 'select(.name | startswith($p))', same axis). So a v0.11.1 cut enumerates v0.11.1-rc.* only; cellblock's v0.10.1-rc.1 has a different prefix and is structurally unreachable. The exact incident-class the prune-audit caught (deleting a cross-version live pin) can't occur here — not because the code remembers to check, but because the prefix is version-scoped.

Mutation-verified: dropping the -rc. (→ PREFIX="v${VERSION}") reds the #153 false-prefix-match guard test (v0.1.1 then prunes v0.1.10-rc.1). So the trailing separator is genuinely load-bearing for cross-version safety, and the test discriminates it. That's the right test to be the safety anchor — it proves the adjacent-prefix case (v0.1.1 vs v0.1.10), and the cross-minor case (v0.11.1 vs v0.10.1) is even more clearly disjoint.

Posture + wiring

  • Best-effort: set -uo pipefail (no -e), delete failures → ::warning:: + continue, exit 0 always. Correct — the cut already shipped by the time this runs, so tag-cleanup failure must not retro-fail a successful cut.
  • Dry-run aware: forgejo_list_tags returns empty under FORGEJO_API_DRY_RUN → nothing enumerated → nothing deleted; the _release.yml step plumbs dry_run through. So a dry-run cut won't delete tags. ✓
  • Wiring: post-manifest-mechanism, CUT_VERSION: steps.decide.outputs.version passed. ✓

§2.4 limitation honesty

The three-outcome taxonomy is exactly the honest-scope framing: shipped (caught), abandoned-by-override and abandoned-by-skip (both explicitly marked NOT caught, with the re-pin-time-auto-prune evolution path). v0.10.4-rc.1 is named as the abandoned-by-skip worked-instance. That's documenting which terminal states the cut-time hook covers and which it doesn't — not claiming completeness it doesn't have. The follow-up tracker for re-pin-time prune (foldable with #163 — both re-pin-time substrate-hygiene) is the right deferral.

Notes (non-blocking)

  • Expected red on check-self-bootstrap is correct: prune-rc-tags.sh is a new compose-script in the DEFAULT list, so HEAD differs from the pinned ref until the post-merge re-pin — the documented expected-red-on-compose-script-PR, not a defect.
  • Activation lag: this auto-prune activates on the next cut after the re-pin that carries it — not the imminent v0.11.1 cut, which still runs the pinned v0.11.1-rc.1 _release.yml (no auto-prune yet). So v0.11.1 won't self-prune v0.11.1-rc.1; the first live auto-prune is the cut after #164's re-pin. Worth expecting, not fixing.

This IS the feat that triggers the v0.11.1 cut → and that cut carries the #139 paths-ignore empirical close (the pinned v0.11.1-rc.1 has the paths-ignore). I'll verify both at source when it fires: the #139 observation (does the chore(manifest) push trigger a run?) and the rolling-PR version-consistency. Clean to self-merge. 🎯

## APPROVED — auto-prune superseded rc-tags (head 9327148, official/gating) A destructive feature operating in the exact tag-space cellblock depends on — so I focused the verify-at-source on whether it can ever reach a cross-version pin. It cannot, by construction, and the guard is mutation-verified. FF-feasible, 370/370. Clean. ### The cellblock-safety is structural, not incidental ✅ The selection scope is version-specific: `PREFIX="v${VERSION}-rc."` (with the trailing `-rc.`), matched via `startswith` — and the test seam mirrors it faithfully (`grep "^${prefix}"` ↔ the real `jq 'select(.name | startswith($p))'`, same axis). So a v0.11.1 cut enumerates `v0.11.1-rc.*` only; cellblock's `v0.10.1-rc.1` has a different prefix and is **structurally unreachable**. The exact incident-class the prune-audit caught (deleting a cross-version live pin) can't occur here — not because the code remembers to check, but because the prefix is version-scoped. **Mutation-verified**: dropping the `-rc.` (→ `PREFIX="v${VERSION}"`) reds the `#153 false-prefix-match guard` test (`v0.1.1` then prunes `v0.1.10-rc.1`). So the trailing separator is genuinely load-bearing for cross-version safety, and the test discriminates it. That's the right test to be the safety anchor — it proves the adjacent-prefix case (v0.1.1 vs v0.1.10), and the cross-minor case (v0.11.1 vs v0.10.1) is even more clearly disjoint. ### Posture + wiring ✅ - **Best-effort**: `set -uo pipefail` (no `-e`), delete failures → `::warning::` + continue, exit 0 always. Correct — the cut already shipped by the time this runs, so tag-cleanup failure must not retro-fail a successful cut. - **Dry-run aware**: `forgejo_list_tags` returns empty under `FORGEJO_API_DRY_RUN` → nothing enumerated → nothing deleted; the `_release.yml` step plumbs `dry_run` through. So a dry-run cut won't delete tags. ✓ - **Wiring**: post-manifest-mechanism, `CUT_VERSION: steps.decide.outputs.version` passed. ✓ ### §2.4 limitation honesty ✅ The three-outcome taxonomy is exactly the honest-scope framing: **shipped** (caught), **abandoned-by-override** and **abandoned-by-skip** (both explicitly marked NOT caught, with the re-pin-time-auto-prune evolution path). v0.10.4-rc.1 is named as the abandoned-by-skip worked-instance. That's documenting *which* terminal states the cut-time hook covers and which it doesn't — not claiming completeness it doesn't have. The follow-up tracker for re-pin-time prune (foldable with #163 — both re-pin-time substrate-hygiene) is the right deferral. ### Notes (non-blocking) - **Expected red on check-self-bootstrap** is correct: `prune-rc-tags.sh` is a new compose-script in the DEFAULT list, so HEAD differs from the pinned ref until the post-merge re-pin — the documented expected-red-on-compose-script-PR, not a defect. - **Activation lag**: this auto-prune activates on the *next* cut after the re-pin that carries it — *not* the imminent v0.11.1 cut, which still runs the pinned `v0.11.1-rc.1` `_release.yml` (no auto-prune yet). So v0.11.1 won't self-prune `v0.11.1-rc.1`; the first live auto-prune is the cut after #164's re-pin. Worth expecting, not fixing. This IS the feat that triggers the v0.11.1 cut → and that cut carries the **#139 paths-ignore empirical close** (the pinned `v0.11.1-rc.1` has the paths-ignore). I'll verify both at source when it fires: the #139 observation (does the `chore(manifest)` push trigger a run?) and the rolling-PR version-consistency. Clean to self-merge. 🎯
Sign in to join this conversation.
No description provided.