feat(fleet): pin the auto-publisher to a configured GitHub account - #443
Merged
Conversation
`git push` and `gh pr create` both authenticate as whichever account
`gh auth switch` last made active. That is account-global machine state
no config file records and no sweep log reports, so the fleet's identity
could change without anything in the repo changing.
On 2026-08-04 that account was a read-only one. Two consecutive sweeps
built their branches, ran the workspace tests, and then died at the push:
fatal: unable to access 'https://github.com/swack-tools/oxidex.git/':
The requested URL returned error: 403 -- skipping PR creation
Every earlier round had logged 'no_news', which is indistinguishable from
success in the log, so the first rounds that actually produced work were
also the first to reveal the problem -- hours later, and only because
sweep/tags-2026-08-04-2 was found sitting unpushed.
Add an optional `[publish]` table:
[publish]
github_user = "swackhamer"
Omit it and nothing changes: publishing keeps using the ambient account.
Set it and the dispatcher resolves that account's token once at startup
via `gh auth token --user <name>` and hands it to every publish
subprocess as GH_TOKEN. It is a username, not a secret -- the token comes
from the keyring, so config.toml gains no second credential.
Bound callables are run_git, run_gh, push_branch_fn AND create_pr_fn.
The last two are the ones that matter: auto_publish_round's runners only
cover PR adoption and merging, while run_sweep defaults
push_branch_fn/create_pr_fn to overlord_sweep's real_push_branch/
real_create_pr, which shell out on their own. Those two ARE `git push`
and `gh pr create` -- binding only the runners would have looked correct
and fixed nothing.
Resolution happens only when auto-publish is on, and an account gh has no
token for exits 1 at startup with the `gh auth login` remedy, rather than
failing mid-sweep after the work is done.
Tests: 243 pass (10 new), including that omitting the table forwards
neither callable so run_sweep's own defaults still win.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| def test_no_user_configured_keeps_the_ambient_runners(self): | ||
| # Omitting the table must be a true no-op, not a silent behaviour | ||
| # change for every host that never sets it. | ||
| import parallel_model_fix_loop as p |
| "no configured user must mean no identity, not a bound one") | ||
|
|
||
| def test_token_is_resolved_for_the_named_account(self): | ||
| import parallel_model_fix_loop as p |
| def test_unknown_account_raises_rather_than_falling_back(self): | ||
| # The whole point: an unusable identity must fail loudly at | ||
| # startup, never silently degrade to the ambient account. | ||
| import parallel_model_fix_loop as p |
| "the error must carry the remedy, not just the symptom") | ||
|
|
||
| def test_empty_token_is_an_error(self): | ||
| import parallel_model_fix_loop as p |
| p.resolve_publish_token("swackhamer", run_fn=run_fn) | ||
|
|
||
| def test_missing_gh_binary_raises_publish_identity_error(self): | ||
| import parallel_model_fix_loop as p |
| # callables are checked because binding only run_git/run_gh would | ||
| # leave the actual `git push` and `gh pr create` -- the two that | ||
| # returned 403 -- still running as the ambient account. | ||
| import parallel_model_fix_loop as p |
| # run_sweep reads (ok, message) from push_branch_fn and a dict | ||
| # from create_pr_fn; a shape change here would surface as a | ||
| # confusing sweep failure rather than an auth one. | ||
| import parallel_model_fix_loop as p |
| # The regression that binding-only-the-runners would have left: | ||
| # push_branch_fn/create_pr_fn must reach run_sweep, or the sweep | ||
| # silently falls back to overlord_sweep's unbound versions. | ||
| import parallel_model_fix_loop as p |
|
|
||
| def test_sweep_callables_are_omitted_when_no_identity_is_configured(self): | ||
| # None must not be forwarded -- run_sweep's own defaults have to win. | ||
| import parallel_model_fix_loop as p |
| def test_bound_gh_runner_keeps_the_no_raise_contract(self): | ||
| # default_run_gh deliberately cannot raise; the bound variant is | ||
| # substituted for it and must not reintroduce an exception path. | ||
| import parallel_model_fix_loop as p |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The failure
git pushandgh pr createboth authenticate as whichever accountgh auth switchlast made active. That's account-global machine state — no config file records it, and nothing in a sweep's log reports it. The fleet's publishing identity could change without anything in the repo changing.On 2026-08-04 that account was read-only. Two consecutive sweeps built their branches, ran the workspace tests, and died at the push:
Every earlier round logged
no_news, which is indistinguishable from success. So the first rounds that actually produced work were also the first to expose the problem — discovered hours later only becausesweep/tags-2026-08-04-2was found sitting locally, 4 commits ahead of main, never pushed.The change
An optional
[publish]table:Omit it and nothing changes — publishing keeps using the ambient account. Set it and the dispatcher resolves that account's token once at startup via
gh auth token --user <name>and hands it to every publish subprocess asGH_TOKEN.ghreads it directly;git pushreaches it through thecredential.https://github.com.helper = !gh auth git-credentialhelper.It's a username, not a secret — the token comes from the keyring, so
config.tomlgains no second credential.The part that's easy to get wrong
Four callables are bound:
run_git,run_gh,push_branch_fnandcreate_pr_fn.The last two are the ones that matter.
auto_publish_round's runners only cover PR adoption and merging.run_sweepdefaultspush_branch_fn/create_pr_fntooverlord_sweep.real_push_branch/real_create_pr, which callsubprocess.runthemselves with no env — and those two aregit pushandgh pr create, the exact commands that returned 403. Binding only the runners would have looked correct and fixed nothing. There's a test asserting the bound callables reachrun_sweep, and another asserting neither is forwarded when no user is configured sorun_sweep's own defaults still win.Failure mode
Resolution happens only when auto-publish is on, so workers and one-shot debugging rounds are unaffected. An account
ghhas no token for exits 1 at startup carrying the remedy (gh auth login --user <name>), rather than failing mid-sweep after the work is done.Testing
python3 -m unittest test_parallel_model_fix_loop— 243 passed (233 baseline + 10 new), 0 failed. Verified against the real keyring:swackhamerresolves, an unknown account raises with the remedy in the message.🤖 Generated with Claude Code