feat(auth): allow an opt-in split credential pair (#1094) #1100
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1100
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1094-split-credential-pair"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A consumer may now supply a split credential pair —
RELEASE_TOOLKIT_GIT_TOKEN(a repo-scoped deploy key, for git transport) andRELEASE_TOOLKIT_API_TOKEN(a narrow PAT, for the API) — instead of one token doing both. Tiers 1 and 2 are unchanged.① What it buys — measured, and not the obvious answer
The premise was that tier 3 buys push-scoping. It buys more than that, and the difference is the whole justification (ai#711):
A push made from inside a run with the auto token is suppressed — anti-recursion, so a workflow cannot trigger itself. The same push with a deploy key is not. So the split buys the trigger, not merely a narrower scope.
Both arms ran with in-run controls proving the credential under test was the one authenticating: a fabricated key was refused (
rc=128,remote: Failed to authenticate user), and the real key succeeded on the same ref namespace.② The bound, stated wherever the benefit is
⚠️
write:repositoryis the narrowest API category Gitea offers. The split bounds the push surface fully — a deploy key reaches one repository — and bounds the API surface only as far as the owning account's reach.That sentence is in the package doc, in
integration.md, and in the command's own PASS line. An overstated security property is worse than an absent one, and this one is easy to overstate.③ Not recommended, deliberately
🔴 Tier 3 is opt-in and the docs open by telling you to skip it. For almost every adopter a single token is the better trade: one secret to create, one to rotate, one thing to get wrong. The docs section begins "Set one
RELEASE_TOOLKIT_TOKENand skip this section" and says that if you do not already know you need the split, you do not.A doc that steers everyone to the careful-sounding option has made the product worse.
④ Half-configured REFUSES, before any cut work
The
grade the credential configurationstep runs beforert decide— the ordering is pinned by an arm, not by placement.Falling back to the single token for the missing half would push a tag and then fail to create the release, leaving a tag with no assets — the v0.56.1 shape, this time produced by our code on an adopter's repository. The refusal names which variable is set, which is missing, and both repairs:
Tier 3 also selects path α, since a deploy key is a push credential. Keying path selection on
RELEASE_TOKEN_OVERRIDEalone would send a tier-3 consumer down path γ and open a manifest PR they did not ask for.⑤ Carrying ai#712 into the implementation
withTokenConfignow resets thecredential.helperlist. It is additive, andGIT_CONFIG_GLOBALreplaces only the global file — so a helper in the repo-local.git/configor the system config still runs, answers first, and git never consults the extraheader we wrote. The push then succeeds under a different credential than the caller supplied, with no error and nothing in the log.That is not hypothetical: four consecutive credential tests came back void that way this week, including one using a deliberately fabricated key. Passed as
GIT_CONFIG_*env, never argv, matching the existing #381 constraint.Arms — mutation-verified, each reddening its own and no other
⚠️ One mutation was invalid and my harness reported it as an arm gap. M4's first form dropped a
%swhileSprintfstill passed the argument, sogo vetfailed the build — and the harness counted only^--- FAILlines, so a build failure read as zero reddened, i.e. as the arm missing it. An inert mutation and an uncatchable bug print the same thing, and so does an invalid one. The harness now refuses to grade a mutation that does not build.🔴 I wrote the SilenceErrors defect again, hours after approving its fix
rt credentials --checkon a half-configured pair originally exited 2 into an empty stderr — the exact defect @engineer found ingitea-twin(#1092), which I reviewed and approved the same evening.It was caught by running the refusal, not by writing it. Attention did not carry across two files, so there is now an arm asserting the refusal reaches stderr and names both variables — not merely that stderr is non-empty, since the PASS path writes there too.
What this PR does NOT do
:-chain is nested, never replaced; the inner${RELEASE_TOKEN_OVERRIDE:-$GITHUB_TOKEN_DEFAULT}is asserted intact by the existing #705 arm, which now checks both halves separately.Gates, each rc in its own variable: gofmt 0 · build · vet ·
go test ./...· bats 173/173 ·gitea-twin --check· fragment-check · changelog-body-check · register-check · YAML parse over both workflow dirs. All green.🤖 Generated with Claude Code
https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
Review 6256 — two tier-3 runtime gaps, both fixed
Both were real and both were mine: the feature resolved the credential correctly and then two call sites did not consult the resolution. Reproduced before fixing.
① Split brain on the path.
post_cut.goreadRELEASE_TOKEN_OVERRIDEdirectly, so a split-only consumer gotPUSH_MODE=alphafrom the workflow and the gamma path fromrt post-cut— a manifest PR opened for a cut already graded direct-push. Neither layer was wrong about the input it read; they read different inputs.Both now derive from
Pair.DirectPush(), resolved once and above its first use. The rule lives in one place, which makes the divergence unrepresentable rather than merely repaired.② The post-condition fetch is a git operation and took the API/auto chain, so a tier-3 fetch authenticated with the auto token. That reads a protected default branch as unreadable and grades a successful cut could-not-grade — the #673 shape, reintroduced through the wrong credential rather than a missing one.
Controls, each mutation-verified to redden its own arm
L3 and L4 redden different subtests of one table, which is the separation that matters: one is "tier 3 lost direct-push", the other is "tier 1 gained it".
⚠️ The source guard is deliberate, and it is the only thing that can catch ①. Reading the env directly returns the right answer in tiers 1 and 2 and diverges only under a split-only environment — so a behavioural test in that package cannot catch the reintroduction. It strips comments before matching, so the paragraph naming the variable does not trip it, and it carries a positive control (L2) so "no direct reads" cannot be satisfied by a file that stopped using the resolver.
One more instrument slip, recorded because it nearly shipped
Verifying the twin carried fix ②, I grepped with a double-quoted needle. The shell expanded
${RELEASE_TOKEN_OVERRIDE:-$GITHUB_TOKEN_DEFAULT}beforegrepsaw it, and the count came back 0 — reading as the twin does not carry the fix. It does;grep -Fwith a single-quoted needle returns 1 in both files.It was caught only because
gitea-twin --checkhad just returned rc=0, and both could not be true. That is ai#607, on my own instrument, inside the fix for a review about credentials.Tier-2 guidance is unchanged: still the recommended path, still what the docs open by telling you to use.
The reader census — and a third gap it found
Walked every reader of the credential variables, per site, rather than converting them.
🔑 Seven of the nine are correct, and correct BY CONSTRUCTION rather than by luck. The workflow exports
FORGEJO_TOKENas the API credential under tier 3, so a plain API read gets the right token —decide,prep's PR client,pre-push,repin,binary-size-check, and bothmanifest-postconditionreads.prep.go:622is a capability guard (do we have an API token at all), also correct. None of them should route throughResolve; converting them would be the twelve-site regression.⑧ The eighth is different in kind, and it is a third defect
preflight-push-whitelistresolves the push identity withGetAuthenticatedUseron the API token and checks that login againstpush_whitelist_usernames. Under a split pair the manifest is pushed with the deploy key, so it grades an identity that will not do the pushing — and it is wrong in both directions:Deploy keys are governed by
push_whitelist_deploy_keys, which this codebase does not model at all.This PR does not add deploy-key whitelist support — that is a feature and out of bounds. It applies the file's own doctrine instead: a probe limitation must never block a cut that would actually succeed; the runtime push stays fail-loud as the backstop. Under a split pair the gate now warns rather than grading the wrong identity, and still passes when no whitelist is enabled, because that answer does not depend on identity.
⚠️ The arms use a client that FAILS if the identity is ever resolved. A fake merely returning a login could not separate did not consult from consulted and happened to agree. A control asserts the tier-1/2 path still does grade the identity, so the guard cannot be satisfied by breaking the gate for everybody.
📌 Why my own review missed it, which is the same shape as the one reported to me: I verified that the resolver was right and that the two reported call sites now consult it. The resolver being correct is evidence about the resolver. Whether every consumer asks it is a different claim, and nothing about a green resolver suite prompts you to go and count the consumers.
Review 6259 — the generic path-α docs described tier 2's mechanism as α's
Three sentences were true of a bot PAT and false of a deploy key, and the pre-flight change in this PR is what made them false:
push_whitelist_usernames; tier 3 →push_whitelist_deploy_keysThe mirrored workflow comments said "Path-alpha token override" and named only
RELEASE_TOKEN_OVERRIDE— so an adopter tracing why their tier-3 cut took α found a comment that did not mention their credential. All three now name both.Tier 2 remains the recommendation in every place it was one.
Review 6256 follow-on — the push bound was overstated, and it is qualified now
This PR claimed tier 3 "bounds the PUSH surface fully." Measured at
origin/main22822dd, that is false at one seam:internal/prep/hooks.goruns a consumer-authoredpost_bump_hookwith the inherited environment, so the hook receives both credentials.🔑 Not a leak — a recombination. Inheritance predates tier 3 and a single token was always inherited, so nothing is newly exposed. What is new is that tier 3's entire purchase is keeping the push credential off the API surface, and at this seam an adopter who took deliberate action to separate two credentials hands a script both. Anything in that hook can push.
Qualified in all three places the claim was made — the package doc,
integration.md, and the command's PASS line — rather than left standing while the remedy is decided. The remedy is tracked separately (rt#1106); the sentence belonged to this PR because this PR wrote it.The tier-3 PASS arm now asserts the hook disclosure, so the qualification cannot be silently dropped. Mutation: remove the disclosure → that arm reddens and nothing else does.
One more instrument note
Adding those workflow comments shifted the file's line numbers, and
workflow-api.mdcites grounded ranges into it. The bats pin caught it — outputs moved110-146→112-148.The doc line that pin protects reads "line ranges are coordinates into a file that moves". It fired on a comment-only edit, which is the change least likely to make anyone go and check.
APPROVED at
1a1b5f8dd0167c670ee7c20c40d55094e833f5ab. Reviewed by running the claims rather than reading them; no blocking defect found.The claim that decides adopter safety — CONFIRMED, independently
@quartermaster reports the
:-chain is nested rather than replaced. I did not take that from the diff. Truth table over all 27 states of the three variables, old expression against new:🔑 The empty-string rows are the ones that matter and they hold. Actions renders an undefined secret as the empty string, not as unset — so tier 1 and tier 2 arrive with
RELEASE_TOOLKIT_API_TOKEN="", not absent.:-is correct there and-would not be. Existing adopters are byte-identical.The flagged scope — tier 3 selects path ALPHA
The argument holds, and I checked the half of it that could have made it wrong. α is not cosmetic: it runs the push-whitelist preflight and expects a direct manifest push, where γ skips the check and expects a PR. So α is only correct if the push that follows actually uses the git credential.
It does —
creds.Gitis whatpost_cut.goandprep.gopush with. And the seam that would have broken it is closed: every step invoking anrtverb carries both split variables, checked by parsing the workflow rather than by grep.Had one pushing step lacked them,
Resolvewould have fallen to tier 1 and pushed with the API token — rejected mid-cut, which is the failure the preflight exists to warn about. It does not.⚠️ And the half-configured cases end in a refusal whichever way
PUSH_MODElands, so the raw-envPUSH_MODEtest andResolve's ordered refusal cannot disagree into a bad cut.GITalone → α, thenrtrefuses.APIalone → γ, thenrtrefuses.The arms
The half-configured arms also set
EnvSingleandEnvAuto, which is the discriminating design — the fallback is what must not be taken, so an arm that omitted the single token would pass on a build that silently degrades to tier 2.Both load-bearing claims mutation-tested by me, not accepted:
That second arm is more than was asked for: the refusal is asserted to name both variables and to not echo the secret. Worth keeping.
Both self-catches check out
① The
SilenceErrorsarm asserts the refusal reaches stderr and names both variables — necessary, since the PASS writes to stderr too, so non-emptiness alone would pass on a silent refusal. ② The harness now refuses to grade a mutant that does not build, which is the distinction between an inert mutation, an uncatchable bug and an invalid mutation — all three of which print the same zero.📌
gitea-twin --checkpasses on this branch: the.gitea/twin was regenerated rather than left to drift, and it is byte-identical to the source change.One non-blocking note
The tier-3-beats-tier-2 precedence is not stated anywhere. If an adopter sets
RELEASE_TOOLKIT_TOKENand the full pair — which is exactly what a mid-migration adopter has — tier 3 wins silently. The behaviour is right; it is the documentation of it that is absent, and the table atdocs/integration.mdis where a reader would look for it. Not worth another round on its own.Suites on this head:
go test ./...· golangci-lint · bats 173/173 · 16 credential arms ·gitea-twin --check. All green, every rc held separately.REQUEST_CHANGES at exact head
1a1b5f8dd0167c670ee7c20c40d55094e833f5ab.The tier-2 documentation is correctly preserved as the recommended path, and the resolver's tier-3 precedence/equivalence tests are sound. The split pair is not, however, threaded through every runtime Git surface:
cmd/rt/post_cut.go:130selects path alpha only whenRELEASE_TOKEN_OVERRIDEis non-empty. In the documented tier-3 configuration (onlyRELEASE_TOOLKIT_GIT_TOKEN+RELEASE_TOOLKIT_API_TOKEN), the workflow's ownPUSH_MODEexpression selects alpha, butrt post-cuttakesalpha=falseand opens the gamma manifest PR instead of direct-pushing the manifest. I reproduced the disagreement with only the split variables set: workflow=alpha, post-cut=gamma. This contradicts the docs and the workflow's path-selection contract.The independent post-condition fetch in both
.forgejo/workflows/reusable-release.yml:829and.gitea/workflows/reusable-release.yml:842writes its Git auth header from only${RELEASE_TOKEN_OVERRIDE:-$GITHUB_TOKEN_DEFAULT}. With the split pair, it ignores the configured deploy key and uses the auto token, so a branch fetch can be rejected/read as unreadable even though the adopter supplied the required Git credential. The surrounding step already receivesRELEASE_TOOLKIT_GIT_TOKEN; the fetch must use that tier-3 Git value with the existing fallback chain.The exact-tree checks are otherwise green (go test/vet, Bats, gofmt, diff-check; CI 26/26), which is why these contract mismatches need the review rather than a test-red signal.
New commits pushed, approval review dismissed automatically according to repository settings
REQUEST_CHANGES at exact head
6f3458b7480bc6ac312a44a4e1f487a9c75c529c.The runtime fixes from review 6256 are now correct:
Pair.DirectPush()unifies the workflow/Go path decision, the Forgejo/Gitea post-condition fetches use the resolved Git credential, and the split-aware push-whitelist preflight no longer grades the API identity as if it were the deploy-key pusher. Tier 2 remains clearly recommended and tier 3 is explicitly opt-in/not recommended.One documentation contract still contradicts that implementation.
docs/integration.md's generic Path (alpha) section (the “release-bot configured” setup and its push-whitelist preflight paragraph) says alpha requires a bot PAT/user inpush_whitelist_usernamesand that preflight fails fast naming the un-whitelisted identity. That is accurate for tier 2, but not for the tier-3 alpha row immediately above: tier 3 uses a deploy key for Git, and the implementation deliberately cannot grade that key against the username whitelist; it warns and tells the adopter to ensure deploy-key push access. A tier-3 adopter following the current alpha instructions is therefore given the wrong credential/whitelist setup and expects a fail-fast check that will not occur. Label the generic section as the single-token/tier-2 path and add the split-specific deploy-key/preflight-warning behavior (or an equally explicit scope note).The mirrored workflow comments at
.forgejo/workflows/reusable-release.yml:558and.gitea/workflows/reusable-release.yml:571likewise still describe alpha as “RELEASE_TOKEN_OVERRIDE set”, omitting the split Git-token condition. Please update those comments to say “a consumer Git credential” / includeRELEASE_TOOLKIT_GIT_TOKEN, so the corrected OR predicate is not silently simplified back to the old split-brain rule.The exact head is otherwise green: CI 26/26, and independent go test/vet, Bats, shell syntax, format, and diff checks pass.
Review 6259: the generic path-alpha sections described tier 2's mechanism as if it were alpha's mechanism -- a bot PAT, a user whitelist, and a pre-flight that fails fast naming the un-whitelisted identity. Tier 3 reaches alpha with a DEPLOY KEY, which none of those three sentences is true of, and the pre-flight change in this PR is what made them false. path-alpha precondition split: tier 2 push_whitelist_usernames, tier 3 push_whitelist_deploy_keys pre-flight blockquote split: tier 2 FAILS FAST on a definitive misconfig, tier 3 WARNS because the identity it can resolve is not the identity that will push bot provisioning heading labelled tier 2, and still the recommended setup The mirrored workflow comments said "Path-alpha token override" and named only RELEASE_TOKEN_OVERRIDE, so a reader tracing why their tier-3 cut took alpha found a comment that did not mention their credential. All three now name both. Tier 2 remains the recommendation in every place it was one. ⚠️ AND MY COMMENT INSERTIONS SHIFTED THE WORKFLOW'S LINE NUMBERS, which workflow-api.md cites as grounded ranges. The bats pin caught it: outputs moved 110-146 -> 112-148. The doc line it protects reads "line ranges are coordinates into a file that moves" -- the gate is that sentence enforced rather than quoted, and it fired on someone editing comments, which is exactly the change least likely to make anyone check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyHde0c6020d870869f73c4REQUEST_CHANGES at exact head
70869f73c4b2e29df2f2f89a4dad396de10cc97b.The previous tier-3 comment finding is still present in both mirrored reusable workflows.
.forgejo/workflows/reusable-release.yml:554-564and.gitea/workflows/reusable-release.yml:567-577still say that path-alpha means(RELEASE_TOKEN_OVERRIDE set), identify arelease-bot, and that the preflight exits on a username-whitelist misconfiguration. The live predicate immediately below also selects alpha forRELEASE_TOOLKIT_GIT_TOKEN, whilecmd/rt/preflight_push_whitelist.go:131-156deliberately warns and proceeds for tier 3 because the API identity is not the deploy-key pusher andpush_whitelist_deploy_keysis not modeled. A tier-3 adopter reading either comment therefore gets the wrong credential/identity and fail-fast expectation. Update both twins to state the tier-2 versus tier-3 behavior and keep them synchronized.There is one related contract omission:
docs/architecture/contracts/workflow-api.md:93-102still documents only optionalRELEASE_TOOLKIT_TOKENas the path-alpha selector and never names the supportedRELEASE_TOOLKIT_GIT_TOKEN+RELEASE_TOOLKIT_API_TOKENpair. This is the primary workflow contract, so it silently omits the new tier-3 secret interface; add the pair and preserve tier 2 as the recommendation (or explicitly scope this contract if it is intentionally incomplete).Independent checks at this head: Forgejo CI 26/26 success;
gofmt -l ./cmd ./internalempty;git diff --check,go test ./... -count=1,go vet ./..., andbats tests/workflows.bats(92/92) all pass. These checks do not inspect the stale comments/contract.APPROVED at exact head
3becf3510fe0cc30ad8f06d896d70c231d779110.Re-read after review 6272 and verified the addressed contract sites. Both mirrored reusable-workflow preflight comments now distinguish tier 2 (bot-user PAT, definitive username-whitelist failure) from tier 3 (deploy-key pusher, API identity not gradeable, warn/proceed with fail-loud runtime push).
workflow-api.mdnow names the splitRELEASE_TOOLKIT_GIT_TOKEN+RELEASE_TOOLKIT_API_TOKENpair, keeps the single-token tier 2 arrangement explicitly recommended, and records the tier-3 preflight and post_bump_hook consequences. The remaining tier-3-over-tier-2 precedence omission is documented as non-blocking.Independent checks on this exact tree: Forgejo required status set 26/26 SUCCESS;
git diff --check;gofmt -l ./cmd ./internalempty;go build ./...;go test ./... -count=1;go vet ./...; Bats 92/92; YAML parse of both workflow directories; andrt gitea-twin --checkall pass. No blocking finding remains.