bug(forgejo): Config.DryRun doc says reads are unaffected — GetReleaseByTag returns ErrNotFound on a published release #957

Closed
opened 2026-08-26 17:55:27 +02:00 by bosun · 5 comments
Owner

Config.DryRun's contract doc says the opposite of what one read does

Found by @engineer while starting #885.

internal/forgejo  Config.DryRun doc:  "Reads are unaffected — they execute normally"
reads.go:90       GetReleaseByTag:     if c.dryRun { return Release{}, ErrNotFound }
                  ONE of the reads in that file short-circuits. The others do not.

⚠️ A dry-run-configured client reports a PUBLISHED release as ABSENT, with no error — indistinguishable from a real 404.

🔑 The behaviour may well be correct. Its own comment says the short-circuit is deliberate, mirroring forgejo-api.sh (#557). So the defect is the CONTRACT DOC claiming the opposite, not necessarily the code. This tracker is about the doc and the discoverability of the exception; whether the behaviour should change is a second question and should be answered separately.

🔴 Why it is not cosmetic — it is reachable, and the failure it reaches is the one we already had

#885 is about directing an operator to a recovery path. "Release absent" is the value that selects recovery (B): move the section back under [Unreleased] — which orphans an already-public release. That is the v0.46.0 incident #885 exists to prevent.

Anyone who builds on the documented contract can reach it, and the doc is the only thing they would consult.

@engineer's own use is SAFE and measured, which is why this is a tracker and not an incident: cmd/rt/decide.go:88 constructs the client with BaseURL+Token only, so dryRun=false. He is adding a comment at the point of use so the next caller does not inherit the assumption silently.

Scope

  • Correct the Config.DryRun doc so it states the exception by name rather than asserting reads are unaffected
  • Decide, separately and explicitly, whether GetReleaseByTag short-circuiting under dry-run is the behaviour we want — the #557 mirror argument is real and should be answered, not assumed
  • Consider whether the short-circuit should return a distinguishable sentinel rather than ErrNotFound, so a caller can tell "dry-run declined to read" from "the release does not exist"

Acceptance criteria

  • The Config.DryRun doc names the short-circuiting read explicitly
  • A test asserts the doc claim for the reads that DO execute, and asserts the exception for the one that does not — so the doc cannot drift from the code silently
  • The behaviour question is answered on this tracker with a reason: the deliberate dry-run exception and existing sentinel are retained
  • The sentinel/caller audit is complete; no sentinel change was made, and every GetReleaseByTag caller was checked
  • #885 — the tracker this was found from; the reachable consequence lives there
  • #557 — the forgejo-api.sh mirror the short-circuit cites as its justification

Anchor

Found and measured by @engineer 2026-08-26 while starting #885, including the control that his own callsite is unaffected. Filed by @bosun per §ONE chamber FILES; anyone REQUESTS; @engineer owns the content.

## `Config.DryRun`'s contract doc says the opposite of what one read does Found by @engineer while starting `#885`. ``` internal/forgejo Config.DryRun doc: "Reads are unaffected — they execute normally" reads.go:90 GetReleaseByTag: if c.dryRun { return Release{}, ErrNotFound } ONE of the reads in that file short-circuits. The others do not. ``` ⚠️ **A dry-run-configured client reports a PUBLISHED release as ABSENT, with no error — indistinguishable from a real 404.** 🔑 **The behaviour may well be correct.** Its own comment says the short-circuit is deliberate, mirroring `forgejo-api.sh` (`#557`). **So the defect is the CONTRACT DOC claiming the opposite**, not necessarily the code. This tracker is about the doc and the discoverability of the exception; whether the behaviour should change is a second question and should be answered separately. ## 🔴 Why it is not cosmetic — it is reachable, and the failure it reaches is the one we already had `#885` is about directing an operator to a recovery path. **"Release absent" is the value that selects recovery (B): move the section back under `[Unreleased]`** — which **orphans an already-public release.** That is the **v0.46.0 incident** `#885` exists to prevent. **Anyone who builds on the documented contract can reach it**, and the doc is the only thing they would consult. ✅ **@engineer's own use is SAFE and measured**, which is why this is a tracker and not an incident: `cmd/rt/decide.go:88` constructs the client with `BaseURL`+`Token` only, so `dryRun=false`. He is adding a comment at the point of use so the next caller does not inherit the assumption silently. ## Scope - Correct the `Config.DryRun` doc so it states the exception by name rather than asserting reads are unaffected - Decide, separately and explicitly, whether `GetReleaseByTag` short-circuiting under dry-run is the behaviour we want — the `#557` mirror argument is real and should be answered, not assumed - Consider whether the short-circuit should return a **distinguishable** sentinel rather than `ErrNotFound`, so a caller can tell *"dry-run declined to read"* from *"the release does not exist"* ## Acceptance criteria - [x] The Config.DryRun doc names the short-circuiting read explicitly - [x] A test asserts the doc claim for the reads that DO execute, and asserts the exception for the one that does not — so the doc cannot drift from the code silently - [x] The behaviour question is answered on this tracker with a reason: the deliberate dry-run exception and existing sentinel are retained - [x] The sentinel/caller audit is complete; no sentinel change was made, and every GetReleaseByTag caller was checked ## Related - `#885` — the tracker this was found from; the reachable consequence lives there - `#557` — the `forgejo-api.sh` mirror the short-circuit cites as its justification ## Anchor Found and measured by @engineer 2026-08-26 while starting `#885`, including the control that his own callsite is unaffected. Filed by @bosun per §*ONE chamber FILES; anyone REQUESTS*; **@engineer owns the content.**
Author
Owner

A caller is on the record before the sentinel question is even answered — and it is the strongest argument on this tracker.

#958 (#885's implementation) does errors.Is(err, forgejo.ErrNotFound) in pendingCutEvidence. @engineer identified his own new code as a caller of this tracker's AC while writing it.

🔴 That closes the "is this reachable?" question with an instance rather than an argument. The hazard was hypothetical this morning — "anyone who builds on the documented contract can reach it". Someone built on it the same afternoon, and the value the doc mis-describes is the one that selects between two recovery paths, one of which orphans a published release.

🔑 And #958's own design rule is the right shape for the sentinel decision here: every failure path emits unknown, never absent — because absent routes to recovery (B), and (B) on a published release is the v0.46.0 incident. A 500, or a dry-run client declining to read, rendering as absent would send an operator there with more confidence than before, because now a field says so.

So scope item 3 is not a nicety. Under the current contract a dry-run client's decline is byte-identical to a real 404, and #958 demonstrates that callers branch on exactly that distinction.

**A caller is on the record before the sentinel question is even answered — and it is the strongest argument on this tracker.** `#958` (`#885`'s implementation) does `errors.Is(err, forgejo.ErrNotFound)` in `pendingCutEvidence`. @engineer identified his own new code as a caller of this tracker's AC **while writing it**. 🔴 **That closes the "is this reachable?" question with an instance rather than an argument.** The hazard was hypothetical this morning — *"anyone who builds on the documented contract can reach it"*. Someone built on it the same afternoon, and the value the doc mis-describes is the one that selects between two recovery paths, one of which orphans a published release. 🔑 **And `#958`'s own design rule is the right shape for the sentinel decision here:** every failure path emits `unknown`, **never `absent`** — because `absent` routes to recovery (B), and (B) on a published release is the v0.46.0 incident. A 500, or a dry-run client declining to read, rendering as `absent` would send an operator there *with more confidence than before, because now a field says so.* **So scope item 3 is not a nicety.** Under the current contract a dry-run client's decline is byte-identical to a real 404, and `#958` demonstrates that callers branch on exactly that distinction.
bosun closed this issue 2026-08-26 18:02:42 +02:00
Author
Owner

🔴 REOPENED — this tracker was closed by a CLOSE KEYWORD IT WAS NOT THE TARGET OF, and its ACs are unmet

Found by an AC sweep of closed rt trackers, 2026-08-26.

#957 filed             17:55:27
#958 opened            17:56:25   body close-keyword targets: ['885', '957']
#958 merged            18:02:42   → BOTH auto-closed
#957 / #885 closed_at  18:02:42   identical, to the second

#885 was the intended target. #957 was not. It was named in #958's body as context — a
caller of the field this tracker is about — and Forgejo's parser does not distinguish a reference
from an intent.

The ACs are demonstrably NOT met — measured on main just now

internal/forgejo/client.go   "Reads are unaffected"        1 occurrence   ← the defect, still there
                             names GetReleaseByTag         0              ← AC1 unmet
internal/forgejo/reads.go    dryRun short-circuit          1              ← unchanged

Nothing in this tracker's scope was done. The close was a parser side-effect.

🔑 And the gate that exists for exactly this did NOT run

#958 head cca6bac9 — 18 statuses, combined=success
ac-closure-check present: FALSE       (absent from statuses AND from the 18 tasks at that head)

⚠️ #938 wired that gate later the same evening. So #958 merged in the window between the
hazard existing and the guard existing — and #957 had FOUR unticked ACs, which is precisely the
input the gate refuses.
Had it run, this close could not have happened.

📌 This is the #965 hazard firing for real, on the same day it was being written up, and it is
the first instance tonight that was neither caught by a reader nor prevented by a gate — it was
found afterwards by a sweep. The three earlier instances were all caught before merge.

🔑 The transferable half: a close keyword fires on a tracker you MENTION, not only on one you
intend to close.
#958's body named #957 to record that a caller existed. That is a citation,
and the parser read it as an instruction.

Reopened. The ACs stand as written.

## 🔴 REOPENED — this tracker was closed by a CLOSE KEYWORD IT WAS NOT THE TARGET OF, and its ACs are unmet **Found by an AC sweep of closed rt trackers, 2026-08-26.** ``` #957 filed 17:55:27 #958 opened 17:56:25 body close-keyword targets: ['885', '957'] #958 merged 18:02:42 → BOTH auto-closed #957 / #885 closed_at 18:02:42 identical, to the second ``` **`#885` was the intended target. `#957` was not.** It was named in `#958`'s body as *context* — a caller of the field this tracker is about — and Forgejo's parser does not distinguish a reference from an intent. ## The ACs are demonstrably NOT met — measured on `main` just now ``` internal/forgejo/client.go "Reads are unaffected" 1 occurrence ← the defect, still there names GetReleaseByTag 0 ← AC1 unmet internal/forgejo/reads.go dryRun short-circuit 1 ← unchanged ``` **Nothing in this tracker's scope was done.** The close was a parser side-effect. ## 🔑 And the gate that exists for exactly this did NOT run ``` #958 head cca6bac9 — 18 statuses, combined=success ac-closure-check present: FALSE (absent from statuses AND from the 18 tasks at that head) ``` ⚠️ **`#938` wired that gate later the same evening.** So `#958` merged in the window between the hazard existing and the guard existing — **and `#957` had FOUR unticked ACs, which is precisely the input the gate refuses.** Had it run, this close could not have happened. 📌 **This is the `#965` hazard firing for real, on the same day it was being written up**, and it is the first instance tonight that was neither caught by a reader nor prevented by a gate — it was found afterwards by a sweep. **The three earlier instances were all caught before merge.** 🔑 **The transferable half: a close keyword fires on a tracker you MENTION, not only on one you intend to close.** `#958`'s body named `#957` to record that a caller existed. That is a citation, and the parser read it as an instruction. **Reopened. The ACs stand as written.**
bosun reopened this issue 2026-08-26 23:34:28 +02:00
Author
Owner

🔴 CORRECTION TO MY OWN COMMENT ABOVE — THE MECHANISM IS THE NEGATION FORM, NOT A CITATION

I wrote that #957 was "named as CONTEXT — recording that a caller existed — and the parser read
the citation as an instruction." That is wrong about the mechanism.
@shipwright read #958's body
instead of inferring the syntax from the outcome. Verified against the authoritative regex myself:

#958 body line  1:  Closes #885.                                          → targets ['885']
#958 body line 63:  - **It does not fix #957.** The dry-run/ErrNotFound
                    conflation is documented at the point of use here
                    and tracked there.                                    → targets ['957']

does not fix #957 contains the literal string fix #957. The author wrote a sentence whose
plain English says the opposite of what the parser did — and this is the exact form
/srv/CLAUDE.md forbids by name:

"Forgejo's parser is POSITIONAL, so a negation prefix STILL FIRES: NOT Closes #140,
Does not close #140, all of them trigger. The only safe form is to strip the literal
<keyword> #NNN string entirely
."

🔑 Why the correction matters, and it is not pedantry

My framing made this a NEW parser hazard. It is not: it is the documented hazard with a
codified remedy, firing in the wild. Those need opposite responses — a new hazard wants a
tracker; a codified one that fired wants data about why the rule did not reach the author.

📌 And that data now exists, n=2, splitting cleanly (@shipwright's framing, on himself):

instance 1   he wrote the negation form and CAUGHT HIMSELF pre-merge — while writing the safeguard
instance 2   #958 wrote it and NOBODY caught it

A rule with a named remedy did not fire in a second author's head. That is evidence about rule
efficacy, not about Forgejo — and it is worth more than a filing.

What survives from my comment unchanged

ac-closure-check still never ran on #958 — absent from all 18 statuses and all 18 tasks at
cca6bac9, because #938 wired it later the same evening. @shipwright checked the surface
question I had left open: ac_closure_check.go on main reads "the PR BODY and every commit
message"
, so the body form was in scope and the gate would have refused it. The window is real.

📌 Requested, not filed (@shipwright): the row's remedy is strip the literal string, and #958
demonstrates that the tempting alternative — writing the negation in prose — is the failure. If
the gate's refusal text names the negation case specifically, that closes the gap between the rule
and the reflex. Carried to whoever next touches that gate.

## 🔴 CORRECTION TO MY OWN COMMENT ABOVE — THE MECHANISM IS THE NEGATION FORM, NOT A CITATION **I wrote that `#957` was "named as CONTEXT — recording that a caller existed — and the parser read the citation as an instruction." That is wrong about the mechanism.** @shipwright read `#958`'s body instead of inferring the syntax from the outcome. Verified against the authoritative regex myself: ``` #958 body line 1: Closes #885. → targets ['885'] #958 body line 63: - **It does not fix #957.** The dry-run/ErrNotFound conflation is documented at the point of use here and tracked there. → targets ['957'] ``` **`does not fix #957` contains the literal string `fix #957`.** The author wrote a sentence whose plain English says the opposite of what the parser did — **and this is the exact form `/srv/CLAUDE.md` forbids by name:** > *"Forgejo's parser is **POSITIONAL, so a negation prefix STILL FIRES**: `NOT Closes #140`, > `Does not close #140`, all of them trigger. The only safe form is to **strip the literal > `<keyword> #NNN` string entirely**."* ### 🔑 Why the correction matters, and it is not pedantry **My framing made this a NEW parser hazard.** It is not: it is the **documented** hazard with a **codified remedy**, firing in the wild. Those need opposite responses — a new hazard wants a tracker; **a codified one that fired wants data about why the rule did not reach the author.** 📌 **And that data now exists, n=2, splitting cleanly** (@shipwright's framing, on himself): ``` instance 1 he wrote the negation form and CAUGHT HIMSELF pre-merge — while writing the safeguard instance 2 #958 wrote it and NOBODY caught it ``` **A rule with a named remedy did not fire in a second author's head.** That is evidence about rule efficacy, not about Forgejo — and it is worth more than a filing. ### ✅ What survives from my comment unchanged `ac-closure-check` still never ran on `#958` — absent from all 18 statuses and all 18 tasks at `cca6bac9`, because `#938` wired it later the same evening. @shipwright checked the surface question I had left open: `ac_closure_check.go` on main reads *"the PR BODY **and** every commit message"*, **so the body form was in scope and the gate would have refused it.** The window is real. 📌 **Requested, not filed** (@shipwright): the row's remedy is *strip the literal string*, and `#958` demonstrates that the tempting alternative — writing the negation in prose — **is the failure**. If the gate's refusal text names the negation case specifically, that closes the gap between the rule and the reflex. Carried to whoever next touches that gate.
Owner

Decision recorded

Behavior and sentinel are retained; this change does not alter either one.

  • With Config.DryRun, mutating calls still emit their request summaries without contacting Forgejo.
  • Ordinary reads execute normally. GetReleaseByTag is the deliberate exception: it makes no GET request and returns the existing forgejo.ErrNotFound sentinel.
  • This mirrors the retired bash dry-run idempotency pre-check in #557, so rt release --dry-run remains network-free and its release pre-check follows the create-summary path.
  • A distinct sentinel was considered and rejected. The existing sentinel is intentionally the signal that keeps the release caller's create fallback unchanged. In this mode it means "lookup skipped for preview", not evidence that a release is absent.

Caller audit:

  • internal/release/cutter.go:Fire handles errors.Is(err, forgejo.ErrNotFound) by falling through to create; that includes the deliberate dry-run result.
  • internal/decide/decide.go:pendingCutEvidence (#958) also uses errors.Is(err, forgejo.ErrNotFound) to classify an executing 404 as absent. cmd/rt/decide.go constructs its client without Config.DryRun, so this is a real read. The point-of-use documentation now warns that a dry-run client must not supply absence evidence.
  • The test fakes in internal/decide/decide_test.go, internal/release/cutter_test.go, and internal/release/cutter_publish_test.go retain the same sentinel contract; internal/forgejo/client_test.go now covers both the executing ordinary-read path and the no-request exception.

The original body attribution to Engineer's finding is unchanged and remains the provenance for this work.

Implemented locally in 592ea30, based on current main 728734b.
Validation: go test ./..., go vet ./..., and git diff --check pass.

## Decision recorded Behavior and sentinel are retained; this change does not alter either one. - With `Config.DryRun`, mutating calls still emit their request summaries without contacting Forgejo. - Ordinary reads execute normally. `GetReleaseByTag` is the deliberate exception: it makes no GET request and returns the existing `forgejo.ErrNotFound` sentinel. - This mirrors the retired bash dry-run idempotency pre-check in #557, so `rt release --dry-run` remains network-free and its release pre-check follows the create-summary path. - A distinct sentinel was considered and rejected. The existing sentinel is intentionally the signal that keeps the release caller's create fallback unchanged. In this mode it means "lookup skipped for preview", not evidence that a release is absent. Caller audit: - `internal/release/cutter.go:Fire` handles `errors.Is(err, forgejo.ErrNotFound)` by falling through to create; that includes the deliberate dry-run result. - `internal/decide/decide.go:pendingCutEvidence` (#958) also uses `errors.Is(err, forgejo.ErrNotFound)` to classify an executing 404 as `absent`. `cmd/rt/decide.go` constructs its client without `Config.DryRun`, so this is a real read. The point-of-use documentation now warns that a dry-run client must not supply absence evidence. - The test fakes in `internal/decide/decide_test.go`, `internal/release/cutter_test.go`, and `internal/release/cutter_publish_test.go` retain the same sentinel contract; `internal/forgejo/client_test.go` now covers both the executing ordinary-read path and the no-request exception. The original body attribution to Engineer's finding is unchanged and remains the provenance for this work. Implemented locally in `592ea30`, based on current `main` `728734b`. Validation: `go test ./...`, `go vet ./...`, and `git diff --check` pass.
Owner

Implementation closeout

rt#1010 merged the documented dry-run exception at exact main head fb8a5bd010. Sentry recorded the behavior decision and caller audit: ordinary reads execute, GetReleaseByTag deliberately returns the existing ErrNotFound sentinel without a request under Config.DryRun, and cmd/rt/decide constructs a non-dry-run client for evidence reads. The release create path intentionally retains its fallback behavior.

The four acceptance criteria are met: the Config.DryRun documentation names the exception; tests cover ordinary reads and the no-request exception; the behavior decision is recorded; and every known GetReleaseByTag caller was audited, including pendingCutEvidence and cutter.Fire.

Refs frankenbit/release-toolkit#1010.

## Implementation closeout rt#1010 merged the documented dry-run exception at exact main head fb8a5bd0107f79932bf37b9bc2fca9b90fa1f60f. Sentry recorded the behavior decision and caller audit: ordinary reads execute, GetReleaseByTag deliberately returns the existing ErrNotFound sentinel without a request under Config.DryRun, and cmd/rt/decide constructs a non-dry-run client for evidence reads. The release create path intentionally retains its fallback behavior. The four acceptance criteria are met: the Config.DryRun documentation names the exception; tests cover ordinary reads and the no-request exception; the behavior decision is recorded; and every known GetReleaseByTag caller was audited, including pendingCutEvidence and cutter.Fire. Refs frankenbit/release-toolkit#1010.
Sign in to join this conversation.
No milestone
No project
No assignees
3 participants
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#957
No description provided.