bug(tokens): the documented release PAT scope omits write:issue — a write:repository-only token fails the rolling refresh on a REQUEST_CHANGES deadlock #1484

Closed
opened 2026-10-02 09:50:00 +02:00 by claude · 2 comments
Member

The docs tell adopters to mint the release PAT with only write:repository, and claim it "covers the manifest push + the PR-create/comment paths" (docs/integration.md decision matrix, hazard note, bot provisioning step 3, at-a-glance table, path-α step 2; docs/operations.md rotation step 1; arc42 §8.8; cmd/rt/credentials.go tier-3 output). On Forgejo, a PR comment is an issue-scope route, and write:repository carries no issue bits.

Evidence

Source, Forgejo v15.0.7 — the version this instance reports (GET /api/v1/version → 15.0.7+gitea-1.22.0):

routers/api/v1/api.go:1087   // Issue (requires issue scope)
                     :1092   m.Group("/issues", ...)      incl. /{index}/comments  GET + POST, /{index} PATCH
                     :1184   m.Group("/labels", ...)
                     :1199   }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
                     :634-760  m.Group("/user", ...) ... tokenRequiresScopes(AccessTokenScopeCategoryUser)
models/auth/access_token_scope.go:122
    accessTokenScopeWriteRepositoryBits = 1<<iota | accessTokenScopeReadRepositoryBits   <- no issue bits

The check runs before the handler (permissions/token_requires_scopes.go), so the refusal is 403 token does not have at least one of required scope(s): [read:issue] regardless of collaborator rights. Gitea v1.24.0 groups the same routes the same way.

Live read-back: release-bot posted pulls/952#issuecomment-101227 (2026-08-26, the rt post-cut stale-rolling-PR note). That POST passes only the issue-scope check, so release-bot's PAT carried issue scope as of that date — which is why no frankenbit adopter has hit this. Not graded: a throwaway write:repository-only token was not minted; the claim rests on source + this read-back.

What a write:repository-only PAT does today

Call Route / scope Behaviour when refused
rolling-PR deadlock notice (internal/prep/pr.go:100) GET /issues/{n}/comments — read:issue 🔴 FATAL: cmd/rt/prep.go:284 returns it, so the rolling refresh fails whenever a live REQUEST_CHANGES blocks the rolling PR
no-reviewer / failed-review-request disclosure (pr.go:316) POST /issues/{n}/comments — write:issue warning only — the disclosure the PR exists to carry is silently lost
stale rolling-PR close + note (close_stale_rolling_pr.go:88) PATCH /issues/{n}, POST .../comments — write:issue warning only — stale PR left open
push-whitelist preflight identity (preflight_push_whitelist.go:190) GET /user — read:user (tier 2 only) WARN + proceed — so the #260 preflight cannot grade the whitelist, and a non-whitelisted bot is found at the manifest push, mid-cut. The docs say this preflight "fails loud".

Not on a PAT path: manifest-pr (path γ, auto token), ac-closure-check (GITHUB_TOKEN), setup-bump-labels / review-eligibility (operator's own token).

Also stale

docs/integration.md (recover-pending-cut section) says reusable-recover-pending-cut.yml "is not yet in internal/bake/marker.go's canonical file set … until release-toolkit#1173 lands". #1173 landed and first shipped in v0.58.0; v0.57.3/v0.57.4 still bake 'v0.57.0' for that workflow.

Acceptance criteria

  • Every doc site that states the PAT scope names write:repository + write:issue (+ read:user on tier 2; the tier-3 API PAT needs write:repository + write:issue), with a table of which release-path call needs which scope and what fails without it — DONE @ #1488 (dc9df38). docs/integration.md: decision matrix, hazard-note scope table (scope / call / what fails without it), failure-mode entry, bot provisioning step 3, at-a-glance table, split-pair bound and path-α step 2. Also docs/operations.md rotation step 1, arc42 §8.8, the cli-surface.md contract row and the internal/credentials package comment. 29b541b fixes the section's anchor (#tokens-permissions).
  • rt credentials --check --probe-scopes makes one read-only issue-scope read with the API credential and exits 1 when the forge refuses it (401/403); a refused tier-2 GET /user WARNs and does not refuse; an unanswered read is UNGRADED, not a pass and not a refusal; its PASS names that a read cannot show write:issue — DONE @ #1488 (dc9df38). probeScopes in cmd/rt/credentials.go, with TestCredentialsProbe_* arms in cmd/rt/credentials_test.go covering: the tier-2 refusal (rc=1); a 401 named as a dead token, not a scope; a tier-2 GET /user refusal WARNing at rc=0; an unanswered read UNGRADED and not PASS; tier 1 skipped; and the PASS disclosure that a read cannot show write:issue. Client side: internal/forgejo/probe_issue_scope_test.go.
  • reusable-release.yml (and its .gitea twin) runs the probe in the credential step, before any cut work, and a bats arm pins that wiring — DONE @ #1488 (dc9df38). The credential step, which still precedes rt decide, passes --probe-scopes --owner --repo and FORGEJO_BASE_URL. The .gitea twin was regenerated (gitea-twin --check green). The bats arm is workflows: the credential grade probes the API credential's issue scope (#1484).
  • The regression arms are mutation-verified: a 403 that does not refuse, a probe built with the git credential, and a non-API 200 each redden an arm — DONE @ #1488. The author mutated each of the three (403 not refusing, prober(pair.Git), the non-array guard removed) and each turned an arm red. The bats wiring arm went red when --probe-scopes was removed. Review 7143 independently re-ran the three, plus an extra 404 mutation, against the code rather than trusting the claim.
  • The stale recover-pending-cut / marker.go sentence is corrected (v0.58.0 floor) — DONE @ #1488 (dc9df38). The recover-pending-cut section now names v0.58.0 as the floor. It explains that v0.57.3 and v0.57.4 contain the fold/restore change but bake 'v0.57.0', so they run the older recovery binary.
  • release-bot's CURRENT PAT is confirmed to pass the probe before the re-pin that ships it — the read-back above is from 2026-08-26, and a rotation since then without issue scope would redden the toolkit's own next release at the credential step — DONE 2026-10-02, before #1488 merged. The token then in use was refused GET /user (403 … [read:user]) and was re-minted. The new token answered GET /user with HTTP 200 as release-bot (comment 113683) and GET .../labels?limit=1 with HTTP 200 (comment 113688). The operator confirmed the org-level RELEASE_TOOLKIT_TOKEN secret holds it. Toolkit-self pins @main, so the merge itself ships the probe; there is no separate re-pin.

Leads, not claims

  • permissions/repo_access.go resolves permissions WithReducer(ctx.Reducer()), which suggests Forgejo v15 may support repo-restricted tokens. If so, the "each scope is a whole API category, so the split bounds the API surface only as far as the owning account's reach" sentence in the split-pair section is stale for Forgejo. Unverified.
  • ClosePR uses PATCH /issues/{n}; PATCH /pulls/{n} with state: closed would need only repository scope. The comments would still need issue scope, so it does not remove the requirement.
  • Same "a credential being present is not a capability" shape as #1482, one layer over (scope rather than push permission).
The docs tell adopters to mint the release PAT with **only `write:repository`**, and claim it "covers the manifest push + the PR-create/comment paths" (`docs/integration.md` decision matrix, hazard note, bot provisioning step 3, at-a-glance table, path-α step 2; `docs/operations.md` rotation step 1; arc42 §8.8; `cmd/rt/credentials.go` tier-3 output). **On Forgejo, a PR comment is an issue-scope route, and `write:repository` carries no issue bits.** ## Evidence **Source, Forgejo v15.0.7** — the version this instance reports (`GET /api/v1/version` → `15.0.7+gitea-1.22.0`): ``` routers/api/v1/api.go:1087 // Issue (requires issue scope) :1092 m.Group("/issues", ...) incl. /{index}/comments GET + POST, /{index} PATCH :1184 m.Group("/labels", ...) :1199 }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) :634-760 m.Group("/user", ...) ... tokenRequiresScopes(AccessTokenScopeCategoryUser) models/auth/access_token_scope.go:122 accessTokenScopeWriteRepositoryBits = 1<<iota | accessTokenScopeReadRepositoryBits <- no issue bits ``` The check runs before the handler (`permissions/token_requires_scopes.go`), so the refusal is `403 token does not have at least one of required scope(s): [read:issue]` regardless of collaborator rights. Gitea v1.24.0 groups the same routes the same way. **Live read-back:** `release-bot` posted [pulls/952#issuecomment-101227](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/952#issuecomment-101227) (2026-08-26, the `rt post-cut` stale-rolling-PR note). That POST passes only the issue-scope check, so release-bot's PAT carried issue scope **as of that date** — which is why no frankenbit adopter has hit this. *Not graded:* a throwaway `write:repository`-only token was not minted; the claim rests on source + this read-back. ## What a `write:repository`-only PAT does today | Call | Route / scope | Behaviour when refused | |---|---|---| | rolling-PR deadlock notice (`internal/prep/pr.go:100`) | `GET /issues/{n}/comments` — read:issue | 🔴 **FATAL**: `cmd/rt/prep.go:284` returns it, so the rolling refresh fails whenever a live `REQUEST_CHANGES` blocks the rolling PR | | no-reviewer / failed-review-request disclosure (`pr.go:316`) | `POST /issues/{n}/comments` — write:issue | warning only — the disclosure the PR exists to carry is silently lost | | stale rolling-PR close + note (`close_stale_rolling_pr.go:88`) | `PATCH /issues/{n}`, `POST .../comments` — write:issue | warning only — stale PR left open | | push-whitelist preflight identity (`preflight_push_whitelist.go:190`) | `GET /user` — **read:user** (tier 2 only) | WARN + proceed — so the #260 preflight cannot grade the whitelist, and a non-whitelisted bot is found at the manifest push, mid-cut. The docs say this preflight "fails loud". | Not on a PAT path: `manifest-pr` (path γ, auto token), `ac-closure-check` (`GITHUB_TOKEN`), `setup-bump-labels` / `review-eligibility` (operator's own token). ## Also stale `docs/integration.md` (recover-pending-cut section) says `reusable-recover-pending-cut.yml` "is not yet in `internal/bake/marker.go`'s canonical file set … until release-toolkit#1173 lands". #1173 landed and first shipped in **v0.58.0**; v0.57.3/v0.57.4 still bake `'v0.57.0'` for that workflow. ## Acceptance criteria - [x] Every doc site that states the PAT scope names `write:repository` + `write:issue` (+ `read:user` on tier 2; the tier-3 API PAT needs `write:repository` + `write:issue`), with a table of which release-path call needs which scope and what fails without it — DONE @ #1488 (`dc9df38`). `docs/integration.md`: decision matrix, hazard-note scope table (scope / call / what fails without it), failure-mode entry, bot provisioning step 3, at-a-glance table, split-pair bound and path-α step 2. Also `docs/operations.md` rotation step 1, arc42 §8.8, the `cli-surface.md` contract row and the `internal/credentials` package comment. `29b541b` fixes the section's anchor (`#tokens-permissions`). - [x] `rt credentials --check --probe-scopes` makes one read-only issue-scope read with the API credential and exits 1 when the forge refuses it (401/403); a refused tier-2 `GET /user` WARNs and does not refuse; an unanswered read is UNGRADED, not a pass and not a refusal; its PASS names that a read cannot show `write:issue` — DONE @ #1488 (`dc9df38`). `probeScopes` in `cmd/rt/credentials.go`, with `TestCredentialsProbe_*` arms in `cmd/rt/credentials_test.go` covering: the tier-2 refusal (rc=1); a 401 named as a dead token, not a scope; a tier-2 `GET /user` refusal WARNing at rc=0; an unanswered read UNGRADED and not PASS; tier 1 skipped; and the PASS disclosure that a read cannot show `write:issue`. Client side: `internal/forgejo/probe_issue_scope_test.go`. - [x] `reusable-release.yml` (and its `.gitea` twin) runs the probe in the credential step, before any cut work, and a bats arm pins that wiring — DONE @ #1488 (`dc9df38`). The credential step, which still precedes `rt decide`, passes `--probe-scopes --owner --repo` and `FORGEJO_BASE_URL`. The `.gitea` twin was regenerated (`gitea-twin --check` green). The bats arm is `workflows: the credential grade probes the API credential's issue scope (#1484)`. - [x] The regression arms are mutation-verified: a 403 that does not refuse, a probe built with the git credential, and a non-API 200 each redden an arm — DONE @ #1488. The author mutated each of the three (403 not refusing, `prober(pair.Git)`, the non-array guard removed) and each turned an arm red. The bats wiring arm went red when `--probe-scopes` was removed. Review 7143 independently re-ran the three, plus an extra 404 mutation, against the code rather than trusting the claim. - [x] The stale recover-pending-cut / marker.go sentence is corrected (v0.58.0 floor) — DONE @ #1488 (`dc9df38`). The recover-pending-cut section now names v0.58.0 as the floor. It explains that v0.57.3 and v0.57.4 contain the fold/restore change but bake `'v0.57.0'`, so they run the older recovery binary. - [x] `release-bot`'s CURRENT PAT is confirmed to pass the probe before the re-pin that ships it — the read-back above is from 2026-08-26, and a rotation since then without issue scope would redden the toolkit's own next release at the credential step — DONE 2026-10-02, before #1488 merged. The token then in use was refused `GET /user` (`403 … [read:user]`) and was re-minted. The new token answered `GET /user` with `HTTP 200` as `release-bot` (comment 113683) and `GET .../labels?limit=1` with `HTTP 200` (comment 113688). The operator confirmed the org-level `RELEASE_TOOLKIT_TOKEN` secret holds it. Toolkit-self pins `@main`, so the merge itself ships the probe; there is no separate re-pin. ## Leads, not claims - `permissions/repo_access.go` resolves permissions `WithReducer(ctx.Reducer())`, which suggests Forgejo v15 may support repo-restricted tokens. If so, the "each scope is a whole API category, so the split bounds the API surface only as far as the owning account's reach" sentence in the split-pair section is stale for Forgejo. Unverified. - `ClosePR` uses `PATCH /issues/{n}`; `PATCH /pulls/{n}` with `state: closed` would need only repository scope. The comments would still need issue scope, so it does not remove the requirement. - Same "a credential being present is not a capability" shape as #1482, one layer over (scope rather than push permission).
Author
Member

AC6 read-back, 2026-10-02. These are release-bot token reads run by the operator. I quote the responses only; no token value is involved.

  1. The token in use at the time, GET /api/v1/user:
    {"message":"token does not have at least one of required scope(s): [read:user]","url":"https://git.frankenbit.de/api/swagger"}
    HTTP 403
    
    This is the live confirmation of the /user → read:user claim. It also means that, on tier-2 cuts using that token, the #260 push-whitelist preflight could not resolve the push identity, so it WARNed and proceeded instead of grading (cmd/rt/preflight_push_whitelist.go:190-193). That is inferred from source plus this read-back; the run logs were not read.
  2. A re-read of GET /api/v1/user: HTTP 200, "login":"release-bot". Forgejo v15.0.7 has no route to add a scope to an existing token (web: new/delete/regenerate; API: create/delete), so this is presumably a newly minted token. release-bot shows last_login 2026-10-02T10:26:48+02:00.

AC6 stays open: the issue-scope read on the current token, GET /api/v1/repos/frankenbit/release-toolkit/labels?limit=1, has not been quoted yet. That is the read rt credentials --probe-scopes refuses on. If the token was re-minted, also confirm the org-level RELEASE_TOOLKIT_TOKEN secret holds the new token, because that is what the first push to main after #1488 merges will probe.

**AC6 read-back, 2026-10-02.** These are `release-bot` token reads run by the operator. I quote the responses only; no token value is involved. 1. The token in use at the time, `GET /api/v1/user`: ``` {"message":"token does not have at least one of required scope(s): [read:user]","url":"https://git.frankenbit.de/api/swagger"} HTTP 403 ``` This is the live confirmation of the `/user` → `read:user` claim. It also means that, on tier-2 cuts using that token, the #260 push-whitelist preflight could not resolve the push identity, so it WARNed and proceeded instead of grading (`cmd/rt/preflight_push_whitelist.go:190-193`). That is inferred from source plus this read-back; the run logs were not read. 2. A re-read of `GET /api/v1/user`: `HTTP 200`, `"login":"release-bot"`. Forgejo v15.0.7 has no route to add a scope to an existing token (web: new/delete/regenerate; API: create/delete), so this is presumably a newly minted token. `release-bot` shows `last_login` 2026-10-02T10:26:48+02:00. **AC6 stays open:** the issue-scope read on the current token, `GET /api/v1/repos/frankenbit/release-toolkit/labels?limit=1`, has not been quoted yet. That is the read `rt credentials --probe-scopes` refuses on. If the token was re-minted, also confirm the org-level `RELEASE_TOOLKIT_TOKEN` secret holds the new token, because that is what the first push to `main` after #1488 merges will probe.
Author
Member

AC6 issue-scope read-back, 2026-10-02. This is the same release-bot token that answered GET /user with HTTP 200 above. It was run by the operator; I quote the response only.

GET /api/v1/repos/frankenbit/release-toolkit/labels?limit=1 → HTTP 200, body a JSON array beginning [{"id":276,"name":"bump/major",…}. So the forge let the token through the issue-scope check.

  • That is the read rt credentials --check --probe-scopes refuses on, so this token passes the probe: issue scope: PASS.
  • With the GET /user 200 above, user scope: PASS too.
  • As the probe itself discloses, a read shows read:issue, not write:issue. The 2026-08-26 comment POST is the only write-level evidence, and it was made with an earlier token.

One item left for AC6: confirm that the org-level RELEASE_TOOLKIT_TOKEN Actions secret holds this token. The first push to main after #1488 merges probes the secret, not the operator's shell.

**AC6 issue-scope read-back, 2026-10-02.** This is the same `release-bot` token that answered `GET /user` with `HTTP 200` above. It was run by the operator; I quote the response only. `GET /api/v1/repos/frankenbit/release-toolkit/labels?limit=1` → **`HTTP 200`**, body a JSON array beginning `[{"id":276,"name":"bump/major",…}`. So the forge let the token through the issue-scope check. - That is the read `rt credentials --check --probe-scopes` refuses on, so this token passes the probe: `issue scope: PASS`. - With the `GET /user` 200 above, `user scope: PASS` too. - As the probe itself discloses, a read shows `read:issue`, not `write:issue`. The 2026-08-26 comment POST is the only write-level evidence, and it was made with an earlier token. **One item left for AC6:** confirm that the org-level `RELEASE_TOOLKIT_TOKEN` Actions secret holds this token. The first push to `main` after #1488 merges probes the secret, not the operator's shell.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1484
No description provided.