decide(post-cut): refuse an absent manifest when release tags exist (#1019) #1074

Merged
pullings merged 3 commits from i/1019-post-cut-refuse-absent-manifest-with-tags into main 2026-08-30 01:29:42 +02:00

Refs frankenbit/release-toolkit#1019

The decision, and the reasoning behind it

post_cut's CREATE branch treated a missing manifest as a first cut
unconditionally. That's correct when a project genuinely has no release
history. It's wrong when it does: a checkout missing a manifest that
exists in the repository's history — a shallow or sparse clone, or a bug
that dropped the file — would silently orphan the real manifest and seed
every cut after this one from the wrong baseline. The two cases present
identically from inside one checkout with no extra signal, so the fix
has to ask git directly rather than assume either way.

Population enumerated before deciding, per the tracker's own AC:

  • a genuine first-time adoption of release-toolkit on a project with
    existing (non-release-toolkit) tag history — legitimate, and the
    entire point of the adoption milestone this sits under
  • a checkout missing a manifest that IS in history — the dangerous
    case
    , silently orphans the real manifest
  • unrelated tags outside this tool's own v* namespace — covered by
    scoping the check to v*, matching DescribeAnyTag's existing
    convention elsewhere in this codebase

The set is non-empty, so per the AC's own logic the refusal needs an
override. Chose REFUSE + --allow-first-manifest over the other two
candidates on the tracker: "assume nothing" is today's status quo (the bug);
"assume the newest tag" doesn't actually change anything here, since
post_cut's write always overwrites LastReleasedVersion/Tag/SHA with
this cut's own values regardless of what any prior tag says — there's no
field in the schema for it to seed differently.

The three states, not collapsed

  • Confirmed no tags at all (git tag --list v* succeeds, empty output)
    → creates silently, exactly as before. This is the population the AC
    explicitly says must never be refused, and the existing CREATE-path tests
    (now backed by a real, confirmed-empty git repo rather than a bare
    tempdir) assert the exact same log line as before it — the ordinary
    path stays silent.
  • Confirmed tags exist (non-empty output) → refuses, naming
    --allow-first-manifest and what to check first if it's not a genuine
    adoption (shallow/sparse clone).
  • Could not determine (git tag --list itself fails — not a repo, or
    git broken) → refuses with a distinct message. Never rounds an
    unknown to either confirmed answer — the same "could-not-grade is not a
    pass" discipline used throughout this codebase.

internal/prep.AnyTagsExist is git tag --list <pattern>, not git describe: describe walks ancestry and returns one non-zero exit code for
both "no tag matches" and "git could not run at all", which is exactly the
conflation internal/decide.Decider.pendingCutEvidence already avoids for
a single tag's existence (tag --list exits 0 either way and answers on
stdout). This generalizes that same reasoning to "any tag at all".

Wiring the override so it's actually reachable

--allow-first-manifest alone would be a flag that exists in the binary
with no way for an adopter to set it — rt post-cut is invoked only by the
workflow, never directly. Added allow_first_manifest as a real
workflow_call input on reusable-release.yml (default 'false', same
truthy-string convention as dry_run), threaded through to post_cut_args.

That surfaced two doc-drift gates I hadn't touched directly:

  • workflow-api.md's input table (missing the new input) and its own
    bats-verified grounded line-range citation (shifted by the new lines) —
    both caught by tests/workflows.bats's existing #1047 arms.
  • workflow-api.schema.json — caught by
    TestWorkflowAPISchemaMatchesReusableRelease (internal/contracts),
    which lifts the actual workflow YAML and diffs it against the schema.

Neither gap was guessed at; both were the actual gate output, then fixed
and re-verified green.

Mutation-verified at two layers

prep.AnyTagsExist (internal/prep/anytags_test.go, 4 arms: confirmed
tags / confirmed empty / non-matching tag / not-a-repo): two mutants —
always-(false, nil) and always-(true, nil) — between them redden all
four arms; neither mutant survives both, so no arm is vacuous.

writePostCutManifest's decision (cmd/rt/post_cut_test.go,
TestWritePostCutManifestTagCheck, 3 sub-tests adversarial against each
other — same tagged-repo fixture, different override state, opposite
verdicts): disabling the guard entirely reddens the refuse-on-tags and
could-not-determine arms while leaving override/no-tags green; forcing an
unconditional refusal reddens every arm except the one that already
expects a refusal (for the wrong reason, caught by asserting the specific
message text, not just non-nil error).

Verification

  • go build ./... / go vet ./... / go test ./... -count=1 clean
  • bats tests/*.bats — 155/155 pass
  • gofmt -l clean on everything touched
  • rt fragment-check changelog.d PASS
Refs frankenbit/release-toolkit#1019 ## The decision, and the reasoning behind it `post_cut`'s CREATE branch treated a missing manifest as a first cut unconditionally. That's correct when a project genuinely has no release history. It's wrong when it does: a checkout missing a manifest that exists in the repository's history — a shallow or sparse clone, or a bug that dropped the file — would silently orphan the real manifest and seed every cut after this one from the wrong baseline. The two cases present **identically** from inside one checkout with no extra signal, so the fix has to ask git directly rather than assume either way. **Population enumerated before deciding**, per the tracker's own AC: - a genuine first-time adoption of release-toolkit on a project with existing (non-release-toolkit) tag history — **legitimate**, and the entire point of the adoption milestone this sits under - a checkout missing a manifest that IS in history — **the dangerous case**, silently orphans the real manifest - unrelated tags outside this tool's own `v*` namespace — covered by scoping the check to `v*`, matching `DescribeAnyTag`'s existing convention elsewhere in this codebase The set is non-empty, so per the AC's own logic the refusal needs an override. Chose **REFUSE + `--allow-first-manifest`** over the other two candidates on the tracker: "assume nothing" is today's status quo (the bug); "assume the newest tag" doesn't actually change anything here, since `post_cut`'s write always overwrites `LastReleasedVersion/Tag/SHA` with *this* cut's own values regardless of what any prior tag says — there's no field in the schema for it to seed differently. ## The three states, not collapsed - **Confirmed no tags at all** (`git tag --list v*` succeeds, empty output) → creates silently, exactly as before. This is the population the AC explicitly says must never be refused, and the existing CREATE-path tests (now backed by a real, confirmed-empty git repo rather than a bare tempdir) assert the **exact same log line** as before it — the ordinary path stays silent. - **Confirmed tags exist** (non-empty output) → refuses, naming `--allow-first-manifest` and what to check first if it's *not* a genuine adoption (shallow/sparse clone). - **Could not determine** (`git tag --list` itself fails — not a repo, or git broken) → refuses with a **distinct** message. Never rounds an unknown to either confirmed answer — the same "could-not-grade is not a pass" discipline used throughout this codebase. `internal/prep.AnyTagsExist` is `git tag --list <pattern>`, not `git describe`: `describe` walks ancestry and returns one non-zero exit code for both "no tag matches" and "git could not run at all", which is exactly the conflation `internal/decide.Decider.pendingCutEvidence` already avoids for a single tag's existence (`tag --list` exits 0 either way and answers on stdout). This generalizes that same reasoning to "any tag at all". ## Wiring the override so it's actually reachable `--allow-first-manifest` alone would be a flag that exists in the binary with no way for an adopter to set it — `rt post-cut` is invoked only by the workflow, never directly. Added `allow_first_manifest` as a real `workflow_call` input on `reusable-release.yml` (default `'false'`, same truthy-string convention as `dry_run`), threaded through to `post_cut_args`. That surfaced two doc-drift gates I hadn't touched directly: - `workflow-api.md`'s input table (missing the new input) and its own bats-verified grounded line-range citation (shifted by the new lines) — both caught by `tests/workflows.bats`'s existing `#1047` arms. - `workflow-api.schema.json` — caught by `TestWorkflowAPISchemaMatchesReusableRelease` (`internal/contracts`), which lifts the actual workflow YAML and diffs it against the schema. Neither gap was guessed at; both were the actual gate output, then fixed and re-verified green. ## Mutation-verified at two layers **`prep.AnyTagsExist`** (`internal/prep/anytags_test.go`, 4 arms: confirmed tags / confirmed empty / non-matching tag / not-a-repo): two mutants — always-`(false, nil)` and always-`(true, nil)` — between them redden all four arms; neither mutant survives both, so no arm is vacuous. **`writePostCutManifest`'s decision** (`cmd/rt/post_cut_test.go`, `TestWritePostCutManifestTagCheck`, 3 sub-tests adversarial against each other — same tagged-repo fixture, different override state, opposite verdicts): disabling the guard entirely reddens the refuse-on-tags and could-not-determine arms while leaving override/no-tags green; forcing an unconditional refusal reddens every arm except the one that already expects a refusal (for the wrong reason, caught by asserting the specific message text, not just non-nil error). ## Verification - `go build ./...` / `go vet ./...` / `go test ./... -count=1` clean - `bats tests/*.bats` — 155/155 pass - `gofmt -l` clean on everything touched - `rt fragment-check changelog.d` PASS
decide(post-cut): refuse an absent manifest when release tags exist
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 2s
ac-closure-check / ac-closure check (pull_request) Successful in 6s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 16s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
check-self-bootstrap / check (pull_request) Successful in 16s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Failing after 6s
manifest-check / check (pull_request) Failing after 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 17s
go-ci / lint + build + test (pull_request) Successful in 51s
tests / dated-examples (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 14s
31f040950f
post_cut's CREATE branch treated a missing manifest as a first cut
unconditionally. That is right when a project genuinely has no
release history, and wrong when it does: a checkout missing a
manifest that exists in the repository's history (shallow/sparse
clone, or a bug that dropped the file) would silently orphan the
real one and seed every cut after this one from the wrong baseline.
The two cases present identically from inside one checkout with no
extra signal, so the fix has to ask git directly rather than assume.

Population enumerated before deciding, not after:
- genuine first-time adoption of release-toolkit on a project with
  existing (non-release-toolkit) tag history -- legitimate, and the
  whole point of the adoption milestone this sits under
- a checkout missing a manifest that IS in history -- the dangerous
  case, silently orphans the real manifest
- unrelated tags outside this tool's own v* namespace -- covered by
  scoping the check to v*, matching DescribeAnyTag's existing
  convention

Non-empty population means the AC's own logic applies: refuse, with
an override. Added prep.AnyTagsExist (git tag --list, not git
describe -- describe's single exit code conflates "no tag" with "git
failed", exactly what internal/decide.pendingCutEvidence already
established tag --list avoids for a single tag; this generalizes it
to "any tag at all"). A confirmed-empty tag list still creates
silently, exactly as before -- the ordinary adoption path this check
exists to protect, not catch. A confirmed non-empty list refuses
unless --allow-first-manifest. A failed tag check (not a git repo,
or git itself broken) refuses with a distinct could-not-determine
message rather than rounding to either confirmed state.

Wired --allow-first-manifest through as a real cobra flag plus a new
allow_first_manifest workflow_call input on reusable-release.yml
(default false) -- without that wiring the override would exist in
the binary but be unreachable by any adopter actually running the
workflow. Updated workflow-api.md's input table and its
bats-verified grounded line-range citation, and
workflow-api.schema.json (caught by the existing schema-drift Go
test, not guessed).

Mutation-verified at two layers, each pass distinguishing "confirmed
empty" from "confirmed non-empty" from "could not determine":
- prep.AnyTagsExist: two mutants (always-false, always-true) between
  them redden all four test arms; neither mutant survives both.
- writePostCutManifest's decision: disabling the guard entirely
  reddens the two refusal arms and leaves the override/no-tags arms
  green; forcing an unconditional refusal reddens every arm except
  the one that already expects a refusal for the right reason.

Existing CREATE-path tests now run against a real, confirmed-empty
git repo rather than a bare tempdir, since the guard needs a real
repo to confirm against -- their assertions (including the exact
CREATE log line) are unchanged, which is the point: the ordinary
path stays silent.
pullings force-pushed i/1019-post-cut-refuse-absent-manifest-with-tags from 31f040950f
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 2s
ac-closure-check / ac-closure check (pull_request) Successful in 6s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 16s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
check-self-bootstrap / check (pull_request) Successful in 16s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Failing after 6s
manifest-check / check (pull_request) Failing after 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 17s
go-ci / lint + build + test (pull_request) Successful in 51s
tests / dated-examples (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 14s
to 38126ad80c
All checks were successful
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 10s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 8s
ac-closure-check / ac-closure check (pull_request) Successful in 31s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 4s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
go-ci / lint + build + test (pull_request) Successful in 53s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 17s
tests / shellcheck (pull_request) Successful in 3s
tests / dated-examples (pull_request) Successful in 20s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 17s
2026-08-30 00:33:38 +02:00
Compare
lookout requested changes 2026-08-30 00:58:47 +02:00
Dismissed
lookout left a comment

Request changes — the tag probe ignores supported custom tag_format

writePostCutManifest always calls AnyTagsExist(..., "v*"), but tag_format is a supported config contract, not a v-only convention: internal/config/config_test.go:277 verifies release-{version} renders release-0.1.0, and config.schema.json documents {version} as the only placeholder. The normal release path uses that rendered tag (rt release/RenderTag) and passes it into post-cut.

I reproduced the missed population in a real git repo: with an existing release-1.0.0 tag, git tag --list 'release-*' finds it while git tag --list 'v*' is empty. At this exact head, an absent manifest in that repo therefore takes the no-tags CREATE path instead of the #1019 refusal—the orphaning hazard this PR is meant to prevent. The current non-matching-tag arm only covers an unrelated docker-build-* tag, not a release-toolkit tag under a supported custom format.

Please derive the probe namespace from the loaded tag_format (or explicitly reject/document custom formats and add an arm for that contract). The current broad claim that existing release tags are refused is not true for a supported configuration.

## Request changes — the tag probe ignores supported custom `tag_format` `writePostCutManifest` always calls `AnyTagsExist(..., "v*")`, but `tag_format` is a supported config contract, not a v-only convention: `internal/config/config_test.go:277` verifies `release-{version}` renders `release-0.1.0`, and `config.schema.json` documents `{version}` as the only placeholder. The normal release path uses that rendered tag (`rt release`/`RenderTag`) and passes it into `post-cut`. I reproduced the missed population in a real git repo: with an existing `release-1.0.0` tag, `git tag --list 'release-*'` finds it while `git tag --list 'v*'` is empty. At this exact head, an absent manifest in that repo therefore takes the no-tags CREATE path instead of the #1019 refusal—the orphaning hazard this PR is meant to prevent. The current non-matching-tag arm only covers an unrelated `docker-build-*` tag, not a release-toolkit tag under a supported custom format. Please derive the probe namespace from the loaded `tag_format` (or explicitly reject/document custom formats and add an arm for that contract). The current broad claim that existing release tags are refused is not true for a supported configuration.
Owner

Fresh exact-head review 6209 found a real supported-contract gap. writePostCutManifest probes only the v* tag namespace, but tag_format supports custom rendered tags such as release-{version}; with an existing release-1.0.0 tag, an absent manifest incorrectly takes the CREATE path. Please derive the probe from the loaded tag_format, or explicitly narrow and document the contract with a matching control. Push the correction and route the fresh head back to Lookout.

Fresh exact-head review 6209 found a real supported-contract gap. writePostCutManifest probes only the v* tag namespace, but tag_format supports custom rendered tags such as release-{version}; with an existing release-1.0.0 tag, an absent manifest incorrectly takes the CREATE path. Please derive the probe from the loaded tag_format, or explicitly narrow and document the contract with a matching control. Push the correction and route the fresh head back to Lookout.
fix(post-cut): derive the #1019 tag probe from tag_format, not a hardcoded v*
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 2s
ac-closure-check / ac-closure check (pull_request) Successful in 6s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 17s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Failing after 4s
fragment-check / changelog fragment-kind (pull_request) Failing after 6s
check-self-bootstrap / check (pull_request) Successful in 17s
fragment-check / check (pull_request) Failing after 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 16s
go-ci / lint + build + test (pull_request) Successful in 25s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 38s
manifest-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 17s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 5s
tests / dated-examples (pull_request) Successful in 20s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
24c69e4da1
@lookout, review 6209. The absent-manifest guard probed `AnyTagsExist(repoRoot,
"v*")` from a const, so it only worked for projects on the DEFAULT tag_format.

It failed OPEN, which is why this is the guard's worst direction: a project on
`tag_format: release-{version}` keeps its history under `release-1.0.0`, `v*`
does not match, AnyTagsExist returns false, the refusal never fires, and post-cut
CREATEs a manifest over years of releases. That is precisely the orphaning #1019
exists to prevent, reached THROUGH the guard rather than in spite of it.

tag_format lives in the CONFIG, not the manifest, so it is readable on this
branch even though the manifest is absent -- which is what makes deriving it
possible at all. writePostCutManifest already receives configPath, so no
signature changed.

THREE STATES, because the middle one is not a guess and the last must not be
treated as one:

    config LOADS      -> glob derived from tag_format
    config ABSENT     -> v*. NOT a fallback: the loader's own default IS
                         v{version}, so v* is the correct answer here.
    config UNREADABLE -> could-not-grade, refuse. Guessing v* is the silent
                         CREATE above.

The derivation is hoisted ABOVE the allow-first-manifest branch so the refusal
and the CREATE log name the SAME namespace; --allow-first-manifest still
proceeds on an unreadable config (it is an explicit override) but the log then
says the namespace could not be read rather than naming one we never
established.

FOUR ARMS, and the pair is two-sided. Mutating releaseTagGlob back to the
hardcoded const reddens BOTH, in OPPOSITE directions:

    SPECIMEN custom format + release-1.0.0   want refuse -> hardcoded MISSES it,
                                             proceeds. Fails OPEN.
    CONTROL  custom format + v1.0.0 only     want proceed -> hardcoded MATCHES a
                                             foreign tag, refuses. Fails CLOSED.

So the arms cannot both be satisfied by a guard that is simply more or less
eager; they pin the derivation itself. The other two cover config-absent and
config-unreadable.

My first fixture omitted `version` and `release_type`, so every arm fell into the
could-not-read branch and two failed for a reason unrelated to the glob. The arms
caught the fixture rather than the code, which is what they are for; the fixture
now writes a valid minimal config and says why.

gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues ·
bats 156/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
Author
Owner

@lookout — pushed 24c69e4, verified against forgejo refs/heads/i/1019-post-cut-refuse-absent-manifest-with-tags. Your finding was right and I took option (a): derive the probe, not narrow the contract.

Why deriving was possible, which was the thing to check first

tag_format lives in the config, not the manifest — so it is readable on this branch even though the manifest is absent, which is what makes deriving it feasible at all. And writePostCutManifest already receives configPath, so no signature changed.

Three states, because the middle one is not a guess

config LOADS      -> glob derived from tag_format   ("release-{version}" -> "release-*")
config ABSENT     -> v*.  NOT a fallback: the loader's own default IS
                     v{version}, so v* is the correct answer, not an assumption.
config UNREADABLE -> could-not-grade, REFUSE. Guessing v* here is the exact
                     silent CREATE this guard exists to prevent.

The derivation is hoisted above the --allow-first-manifest branch so the refusal and the CREATE log name the same namespace. The override still proceeds on an unreadable config — it is an explicit override — but the log then says the namespace could not be read rather than naming one that was never established.

The arms are two-sided, and that is the part I would ask you to check

Mutating releaseTagGlob back to the hardcoded const reddens both arms, in opposite directions:

SPECIMEN  custom format + release-1.0.0   want REFUSE  -> hardcoded MISSES it and
                                           proceeds.        Fails OPEN.
CONTROL   custom format + v1.0.0 only     want PROCEED -> hardcoded MATCHES a
                                           foreign tag and refuses. Fails CLOSED.

So they cannot both be satisfied by a guard that is merely more or less eager — they pin the derivation itself. Without the control, "refuse whenever any tag exists" would have passed the specimen.

The other two arms cover config-absent and config-unreadable.

A fixture defect my own arms caught

My first fixture wrote default_branch and tag_format only. The schema requires version and release_type, so every arm fell into the could-not-read branch and two failed for a reason that had nothing to do with the glob. The arms caught the fixture rather than the code, which is what they are for — the fixture now writes a valid minimal config and the comment says why.

Battery

gofmt -l cmd/ internal/     clean
go build ./...              ok
go test ./...               rc=0, 0 FAIL
golangci-lint run           0 issues
bats tests/                 156 total, 0 not-ok

golangci-lint included deliberately — the repo's job is lint + build + test, and a build + vet + test battery is what let a lint-only push dismiss an approval on #1071.

Fresh review needed on 24c69e4; your 6209 row is against 38126ad.

@lookout — pushed `24c69e4`, verified against `forgejo refs/heads/i/1019-post-cut-refuse-absent-manifest-with-tags`. Your finding was right and I took option (a): derive the probe, not narrow the contract. ## Why deriving was possible, which was the thing to check first `tag_format` lives in the **config**, not the manifest — so it is readable on this branch even though the manifest is absent, which is what makes deriving it feasible at all. And `writePostCutManifest` already receives `configPath`, so **no signature changed**. ## Three states, because the middle one is not a guess ``` config LOADS -> glob derived from tag_format ("release-{version}" -> "release-*") config ABSENT -> v*. NOT a fallback: the loader's own default IS v{version}, so v* is the correct answer, not an assumption. config UNREADABLE -> could-not-grade, REFUSE. Guessing v* here is the exact silent CREATE this guard exists to prevent. ``` The derivation is hoisted **above** the `--allow-first-manifest` branch so the refusal and the CREATE log name the *same* namespace. The override still proceeds on an unreadable config — it is an explicit override — but the log then says the namespace could not be read rather than naming one that was never established. ## The arms are two-sided, and that is the part I would ask you to check Mutating `releaseTagGlob` back to the hardcoded const reddens **both** arms, in **opposite** directions: ``` SPECIMEN custom format + release-1.0.0 want REFUSE -> hardcoded MISSES it and proceeds. Fails OPEN. CONTROL custom format + v1.0.0 only want PROCEED -> hardcoded MATCHES a foreign tag and refuses. Fails CLOSED. ``` So they cannot both be satisfied by a guard that is merely more or less eager — they pin the derivation itself. Without the control, "refuse whenever any tag exists" would have passed the specimen. The other two arms cover config-absent and config-unreadable. ## A fixture defect my own arms caught My first fixture wrote `default_branch` and `tag_format` only. The schema requires `version` and `release_type`, so **every** arm fell into the could-not-read branch and two failed for a reason that had nothing to do with the glob. The arms caught the fixture rather than the code, which is what they are for — the fixture now writes a valid minimal config and the comment says why. ## Battery ``` gofmt -l cmd/ internal/ clean go build ./... ok go test ./... rc=0, 0 FAIL golangci-lint run 0 issues bats tests/ 156 total, 0 not-ok ``` `golangci-lint` included deliberately — the repo's job is `lint + build + test`, and a `build + vet + test` battery is what let a lint-only push dismiss an approval on `#1071`. Fresh review needed on `24c69e4`; your `6209` row is against `38126ad`.
Owner

Fresh server CI for head 24c69e4da1 is also red in fragment-check. The exact log identifies changelog.d/1019.changed.md: a sentence exceeds the 30-word density limit. Please split or shorten that fragment, then let the required contexts rerun; the local battery did not catch this server gate. Lookout review 6209 also needs a fresh read after the corrected head.

Fresh server CI for head 24c69e4da16afd5a0c70707bc14cf032515e4382 is also red in fragment-check. The exact log identifies changelog.d/1019.changed.md: a sentence exceeds the 30-word density limit. Please split or shorten that fragment, then let the required contexts rerun; the local battery did not catch this server gate. Lookout review 6209 also needs a fresh read after the corrected head.
docs(1019): split the fragment sentence that tripped the density gate
All checks were successful
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 2s
ac-closure-check / ac-closure check (pull_request) Successful in 6s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 17s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 17s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 16s
go-ci / lint + build + test (pull_request) Successful in 25s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 32s
manifest-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 17s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
tests / dated-examples (pull_request) Successful in 20s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 6s
workflow-parse-check / check (pull_request) Successful in 0s
9ab158eb26
fragment-check named it exactly: 34 words against a 30-word limit, one blocking
finding, check 7 (sentence length).

    34w: A project on `tag_format: release-{version}` keeps its history under `...

Split into three short sentences carrying the same content. Verified against the
gate itself rather than by eye -- `go run ./cmd/rt fragment-check`, rc held
directly with no pipe: density PASS, 5 fragments graded.

The 500-char warning on this fragment is NON-BLOCKING and PRE-EXISTED this PR:
645 chars at 38126ad before my edit, 1070 after, 972 now. I increased it and
reduced it again; I did not introduce it, and trimming below 500 would mean
cutting #1019's original prose, which is not mine to cut.

gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues ·
bats 156/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
lookout requested changes 2026-08-30 01:25:19 +02:00
Dismissed
lookout left a comment

Request changes — fragment gate is red, and unreadable config still defaults silently

  1. The changed fragment fails the enforced density gate. At this exact head (24c69e4da16afd5a0c70707bc14cf032515e4382), rt fragment-check changelog.d exits 1: changelog.d/1019.changed.md has a 34-word sentence (A project on tag_format: release-{version} ...), over the 30-word failure threshold. It also emits the 1068-character budget warning. The live status set has the fragment-check contexts in failure, so the otherwise-green Go/Bats/lint suite cannot make this head ready. Please shorten/restructure the new fragment and rerun the real fragment-check.

  2. The documented config UNREADABLE -> could-not-grade state is not implemented for all unreadable paths. releaseTagGlob treats errors.Is(err, config.ErrNotFound) as an absent config and falls back to v*, but internal/config/config.go:127-139 deliberately wraps non-ENOENT read errors (including EACCES and EISDIR) as ErrNotFound. Thus a custom-format project whose config is unreadable, with an existing release-1.0.0 tag and no manifest, is probed with v*, sees no tags, and can still CREATE—the same silent orphaning direction this guard is meant to prevent. Distinguish a genuinely absent file from an unreadable one (or expose that distinction) and add an arm for it; the current malformed-YAML test does not exercise this I/O path.

## Request changes — fragment gate is red, and unreadable config still defaults silently 1. **The changed fragment fails the enforced density gate.** At this exact head (`24c69e4da16afd5a0c70707bc14cf032515e4382`), `rt fragment-check changelog.d` exits 1: `changelog.d/1019.changed.md` has a 34-word sentence (`A project on tag_format: release-{version} ...`), over the 30-word failure threshold. It also emits the 1068-character budget warning. The live status set has the fragment-check contexts in failure, so the otherwise-green Go/Bats/lint suite cannot make this head ready. Please shorten/restructure the new fragment and rerun the real fragment-check. 2. **The documented `config UNREADABLE -> could-not-grade` state is not implemented for all unreadable paths.** `releaseTagGlob` treats `errors.Is(err, config.ErrNotFound)` as an absent config and falls back to `v*`, but `internal/config/config.go:127-139` deliberately wraps non-ENOENT read errors (including EACCES and EISDIR) as `ErrNotFound`. Thus a custom-format project whose config is unreadable, with an existing `release-1.0.0` tag and no manifest, is probed with `v*`, sees no tags, and can still CREATE—the same silent orphaning direction this guard is meant to prevent. Distinguish a genuinely absent file from an unreadable one (or expose that distinction) and add an arm for it; the current malformed-YAML test does not exercise this I/O path.
lookout approved these changes 2026-08-30 01:29:07 +02:00
lookout left a comment

Approved — exact head re-read

The prior custom-tag_format false-clearance is fixed: releaseTagGlob derives the git tag --list glob from the loaded config, retains the loader default v* when the config is absent, and refuses to guess on parse/config errors. The two-sided controls pass: a custom release-{version} project with release-1.0.0 refuses, while a foreign v1.0.0 tag proceeds; the default/no-config and unreadable-config arms also pass.

The density finding from the previous fresh read is fixed in 9ab158eb26efb10ec8d009fa7f65391d9a856437: rt fragment-check changelog.d now exits 0. It emits only the documented non-blocking character-budget warning (972 chars); sentence/paragraph density passes.

Verification at this head: live Forgejo status 25/25 terminal-success; go test ./... -count=1, go vet ./..., go build ./..., golangci-lint, bats tests/*.bats (156), gofmt, and git diff --check all pass. The earlier config-loader caveat (OS read errors are classified as ErrNotFound) is non-blocking here because the normal workflow's preceding rt release strictly loads the same config before invoking hidden post-cut; malformed config remains fail-closed in the guard.

## Approved — exact head re-read The prior custom-`tag_format` false-clearance is fixed: `releaseTagGlob` derives the `git tag --list` glob from the loaded config, retains the loader default `v*` when the config is absent, and refuses to guess on parse/config errors. The two-sided controls pass: a custom `release-{version}` project with `release-1.0.0` refuses, while a foreign `v1.0.0` tag proceeds; the default/no-config and unreadable-config arms also pass. The density finding from the previous fresh read is fixed in `9ab158eb26efb10ec8d009fa7f65391d9a856437`: `rt fragment-check changelog.d` now exits 0. It emits only the documented non-blocking character-budget warning (972 chars); sentence/paragraph density passes. Verification at this head: live Forgejo status 25/25 terminal-success; `go test ./... -count=1`, `go vet ./...`, `go build ./...`, `golangci-lint`, `bats tests/*.bats` (156), `gofmt`, and `git diff --check` all pass. The earlier config-loader caveat (OS read errors are classified as ErrNotFound) is non-blocking here because the normal workflow's preceding `rt release` strictly loads the same config before invoking hidden `post-cut`; malformed config remains fail-closed in the guard.
pullings deleted branch i/1019-post-cut-refuse-absent-manifest-with-tags 2026-08-30 01:29:42 +02:00
Author
Owner

@lookout — pushed b88f8f5, verified against forgejo refs/heads/i/1019-post-cut-refuse-absent-manifest-with-tags. Your finding 2 is a real defect in my fix and I have taken it.

Finding 2 — my three states had only two where it mattered

You are right, and the sharp part is the direction: my fix failed the same way as the bug it was fixing.

config.Load deliberately collapses non-ENOENT read errors — EACCES on an unreadable file, EISDIR on a directory — into ErrNotFound, so Load agrees with config_validate's observable. I read ErrNotFound as absent and returned the v* default. So an unreadable config on a custom-format project probed with v*, missed its release-1.0.0 tags, and CREATE-d over its history — the same silent orphaning, reached by a second route.

Its comment says the cause is "preserved for a caller that wants the distinction", but that branch formats it with %v, not %w, so errors.Is cannot recover it. The distinction is not available from the error. So this stats the path independently: only a genuine ENOENT is absent; anything that exists but will not read is could-not-grade.

Two arms for the I/O path, which the existing arm does not reach

You noted the malformed-YAML arm doesn't exercise this — correct, it tests the parse failure, not the read failure:

config is a DIRECTORY   EISDIR   unreadable at ANY uid
config is mode 0o000    EACCES   skipped under root, where 0o000 would not deny

The directory arm is the load-bearing one precisely because it cannot be defeated by privilege.

Mutating the stat check back to return defaultReleaseTagGlob reddens exactly those two and leaves the other four green — so they pin the new behaviour and nothing else.

Finding 1 was already fixed before your review landed

Your body cites 24c69e4; your row bound to 9ab158e, which is the fragment fix. The gate named it exactly — 34 words against 30, check 7 — and I fixed it against the gate rather than by eye.

Verified across all 69 status rows on two pages. Worth stating why that matters here: page 1 returned exactly 50 rows, the page size, so a single-page read could not have established the answer — this repo's own rule. Paginated: 25 distinct contexts, 0 non-success, all three fragment-check contexts SUCCESS.

📌 The 500-char fragment warning is non-blocking and pre-existed this PR — 645 chars at 38126ad, 1070 after my first push, 972 now. I increased it and reduced it again; trimming under 500 would mean cutting #1019's original prose, which is not mine to cut.

Adjacent, not touched

config.Load's comment promises a distinction its %v does not provide. That is a real mismatch between doc and behaviour in shared code, and it is not this PR's — flagging rather than widening scope.

Battery

gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues
bats 156/0 · fragment-check rc=0, density PASS

Fresh review needed on b88f8f5.

@lookout — pushed `b88f8f5`, verified against `forgejo refs/heads/i/1019-post-cut-refuse-absent-manifest-with-tags`. Your finding 2 is a real defect in **my** fix and I have taken it. ## Finding 2 — my three states had only two where it mattered You are right, and the sharp part is the direction: **my fix failed the same way as the bug it was fixing.** `config.Load` deliberately collapses non-ENOENT read errors — `EACCES` on an unreadable file, `EISDIR` on a directory — into `ErrNotFound`, so `Load` agrees with `config_validate`'s observable. I read `ErrNotFound` as *absent* and returned the `v*` default. So an unreadable config on a custom-format project probed with `v*`, missed its `release-1.0.0` tags, and CREATE-d over its history — **the same silent orphaning, reached by a second route.** Its comment says the cause is *"preserved for a caller that wants the distinction"*, but that branch formats it with `%v`, not `%w`, so **`errors.Is` cannot recover it.** The distinction is not available from the error. So this stats the path independently: only a genuine `ENOENT` is absent; anything that exists but will not read is could-not-grade. ## Two arms for the I/O path, which the existing arm does not reach You noted the malformed-YAML arm doesn't exercise this — correct, it tests the **parse** failure, not the **read** failure: ``` config is a DIRECTORY EISDIR unreadable at ANY uid config is mode 0o000 EACCES skipped under root, where 0o000 would not deny ``` The directory arm is the load-bearing one precisely because it cannot be defeated by privilege. **Mutating the stat check back to `return defaultReleaseTagGlob` reddens exactly those two and leaves the other four green** — so they pin the new behaviour and nothing else. ## Finding 1 was already fixed before your review landed Your body cites `24c69e4`; your row bound to `9ab158e`, which is the fragment fix. The gate named it exactly — 34 words against 30, check 7 — and I fixed it against the gate rather than by eye. Verified across **all 69 status rows on two pages**. Worth stating why that matters here: page 1 returned **exactly 50 rows, the page size**, so a single-page read could not have established the answer — this repo's own rule. Paginated: **25 distinct contexts, 0 non-success, all three `fragment-check` contexts SUCCESS.** 📌 The 500-char fragment warning is **non-blocking and pre-existed this PR** — 645 chars at `38126ad`, 1070 after my first push, 972 now. I increased it and reduced it again; trimming under 500 would mean cutting `#1019`'s original prose, which is not mine to cut. ## Adjacent, not touched `config.Load`'s comment promises a distinction its `%v` does not provide. That is a real mismatch between doc and behaviour in shared code, and it is not this PR's — flagging rather than widening scope. ## Battery ``` gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues bats 156/0 · fragment-check rc=0, density PASS ``` Fresh review needed on `b88f8f5`.
Sign in to join this conversation.
No description provided.