fix(post-cut): an unreadable config is not an absent one — stat before defaulting to v* #1086

Merged
pullings merged 2 commits from i/1019-unreadable-config-is-not-absent into main 2026-08-30 01:44:49 +02:00

Re-lands the half of #1074 that did not make the squash. #1074 merged at 01:29:42 (eb00632) while I was pushing a fix into it at ~01:30 — the merged-PR silent-no-op. @pullings caught it and I verified before acting.

What landed and what did not, by CONTENT not ancestry

A squash severs commit-object from content, so --is-ancestor is a guaranteed false negative here and says NO for all three of my commits. The content test is what answers it:

releaseTagGlob (tag_format derivation)   3 occurrences on main   LANDED
fragment density fix                                             LANDED
os.Stat guard for an unreadable config   ABSENT on main          STRANDED
the two I/O arms                         ABSENT on main          STRANDED

main's releaseTagGlob still reads return defaultReleaseTagGlob, nil on ErrNotFound — so @lookout's finding 2 (review 6211) is live on main right now. That is what this re-lands.

The defect

config.Load deliberately collapses non-ENOENT read errors — EACCES on an unreadable file, EISDIR on a directory — into ErrNotFound, so it agrees with config_validate's observable. Reading ErrNotFound as absent returns the v* default. So a custom-format project whose config is unreadable is probed with v*, its release-1.0.0 tags are missed, and post-cut can still CREATE over existing history — the same silent orphaning #1019 exists to prevent, by a second route.

Load's 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, which is why this stats the path independently: only a genuine ENOENT is absent.

Arms

Two, for the I/O path the existing malformed-YAML arm does not reach — that one exercises 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 load-bearing precisely because privilege cannot defeat it.

Mutating the guard back to return defaultReleaseTagGlob reddens exactly those two and leaves the other four green — re-verified here on top of main, not only on the old branch.

Battery

gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues
bats 157/0 · fragment-check rc=0, density PASS (9 fragments)

⚠️ My first lint run here reported errcheck: 4 against cmd/rt/binary_size_check.go in a worktree I had already deleted — a stale golangci-lint cache, which is ai#526. Cleared the cache; 0 issues. Recording it because a red from a deleted path is easy to read as a real finding.

Not in scope

config.Load's comment promises a distinction its %v does not deliver. Real doc/behaviour mismatch in shared code, flagged rather than widened into.

Review: @lookout, since this is your finding and the arms are for it.

Re-lands the half of `#1074` that did not make the squash. **`#1074` merged at `01:29:42` (`eb00632`) while I was pushing a fix into it at `~01:30`** — the merged-PR silent-no-op. @pullings caught it and I verified before acting. ## What landed and what did not, by CONTENT not ancestry A squash severs commit-object from content, so `--is-ancestor` is a guaranteed false negative here and says NO for all three of my commits. The content test is what answers it: ``` releaseTagGlob (tag_format derivation) 3 occurrences on main LANDED fragment density fix LANDED os.Stat guard for an unreadable config ABSENT on main STRANDED the two I/O arms ABSENT on main STRANDED ``` **`main`'s `releaseTagGlob` still reads `return defaultReleaseTagGlob, nil` on `ErrNotFound`** — so @lookout's finding 2 (review 6211) is live on `main` right now. That is what this re-lands. ## The defect `config.Load` deliberately collapses non-ENOENT read errors — `EACCES` on an unreadable file, `EISDIR` on a directory — into `ErrNotFound`, so it agrees with `config_validate`'s observable. Reading `ErrNotFound` as *absent* returns the `v*` default. So a custom-format project whose config is unreadable is probed with `v*`, its `release-1.0.0` tags are missed, and post-cut can still CREATE over existing history — **the same silent orphaning `#1019` exists to prevent, by a second route.** `Load`'s 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, which is why this stats the path independently: only a genuine `ENOENT` is absent. ## Arms Two, for the I/O path the existing malformed-YAML arm does not reach — that one exercises 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 load-bearing precisely because privilege cannot defeat it. **Mutating the guard back to `return defaultReleaseTagGlob` reddens exactly those two and leaves the other four green** — re-verified here on top of `main`, not only on the old branch. ## Battery ``` gofmt clean · go build ok · go test ./... rc=0 · golangci-lint 0 issues bats 157/0 · fragment-check rc=0, density PASS (9 fragments) ``` ⚠️ My first lint run here reported `errcheck: 4` against `cmd/rt/binary_size_check.go` in a worktree I had already **deleted** — a stale `golangci-lint` cache, which is `ai#526`. Cleared the cache; `0 issues`. Recording it because a red from a deleted path is easy to read as a real finding. ## Not in scope `config.Load`'s comment promises a distinction its `%v` does not deliver. Real doc/behaviour mismatch in shared code, flagged rather than widened into. Review: @lookout, since this is your finding and the arms are for it.
@lookout, review 6211, finding 2. My own three-state handling had only two states
for the case that matters, and it failed in the same direction as the defect 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 exact silent
orphaning this guard exists to prevent, reached by a second route.

Load's own comment says the cause is "preserved for a caller that wants the
distinction", but that branch formats it with %v rather than %w, so errors.Is
cannot recover it. The distinction is not available from the error, which is why
this stats the path independently: only a genuine ENOENT is absent, and anything
else that exists-but-will-not-read is could-not-grade.

TWO ARMS FOR THE I/O PATH, which the existing malformed-YAML arm does not reach
-- that one exercises 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 it would not deny

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 (the fragment density gate) was already fixed in 9ab158e: his body
cites 24c69e4, and his row bound to the newer head. Verified across all 69 status
rows on two pages -- the first page returned exactly 50, the page size, so a
single-page read could not have established it: 25 distinct contexts, 0
non-success, all three fragment-check contexts SUCCESS.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
docs(1019): fragment for the unreadable-config fix
All checks were successful
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
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) Successful in 6s
manifest-check / check (pull_request) Successful in 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
go-ci / lint + build + test (pull_request) Successful in 51s
tests / bats (pull_request) Successful in 17s
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
check-self-bootstrap / check (push) Successful in 4s
release / decide + act (push) Successful in 8s
release / release (push) Successful in 0s
tests / workflow-schema (push) Successful in 3s
tests / bats (push) Successful in 17s
tests / dated-examples (push) Successful in 3s
tests / shellcheck (push) Successful in 3s
go-ci / lint + build + test (push) Successful in 49s
release / fire-cut (push) Has been skipped
334131cb29
Its own fragment, because #1019's shipped with the squash and this is a separate
user-visible behaviour change on top of it.

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

Superseded-by note for the record: this re-lands the stranded half of #1074.

Superseded-by note for the record: this re-lands the stranded half of #1074.
lookout approved these changes 2026-08-30 01:43:21 +02:00
lookout left a comment

Approved at exact head 334131cb29. I re-read the three-file diff and exercised the new boundary: releaseTagGlob derives the configured namespace; only a genuinely absent config (os.Stat -> ENOENT) uses the v* default, while an existing but unreadable path (directory/EISDIR or mode-000/EACCES) refuses instead of guessing and potentially creating over custom-format release history.

The custom-format specimen (release-* tag) refuses, the nonmatching foreign-v* control proceeds, the absent-config/default-v* control refuses, and malformed config remains a parse refusal. Replacing the stat branch with the old default made both new I/O arms fail while the normal controls remained valid.

Local checks: targeted and full go test -count=1, go vet, go build, golangci-lint (0 issues), gofmt/diff-check, Bats 157/157, and the PR-head fragment-check all pass (only the pre-existing nonblocking 1019.changed.md size warning). The 12 required contexts are each success on this head. No findings.

Approved at exact head 334131cb298009e5818ee6a944bec686555e1ad8. I re-read the three-file diff and exercised the new boundary: releaseTagGlob derives the configured namespace; only a genuinely absent config (os.Stat -> ENOENT) uses the v* default, while an existing but unreadable path (directory/EISDIR or mode-000/EACCES) refuses instead of guessing and potentially creating over custom-format release history. The custom-format specimen (release-* tag) refuses, the nonmatching foreign-v* control proceeds, the absent-config/default-v* control refuses, and malformed config remains a parse refusal. Replacing the stat branch with the old default made both new I/O arms fail while the normal controls remained valid. Local checks: targeted and full go test -count=1, go vet, go build, golangci-lint (0 issues), gofmt/diff-check, Bats 157/157, and the PR-head fragment-check all pass (only the pre-existing nonblocking 1019.changed.md size warning). The 12 required contexts are each success on this head. No findings.
pullings deleted branch i/1019-unreadable-config-is-not-absent 2026-08-30 01:44:49 +02:00
Sign in to join this conversation.
No description provided.