Skip to content

feat(fleet): pin the auto-publisher to a configured GitHub account - #443

Merged
swackhamer merged 1 commit into
mainfrom
feat/publish-github-user
Aug 5, 2026
Merged

feat(fleet): pin the auto-publisher to a configured GitHub account#443
swackhamer merged 1 commit into
mainfrom
feat/publish-github-user

Conversation

@swackhamer

Copy link
Copy Markdown
Collaborator

The failure

git push and gh pr create both authenticate as whichever account gh auth switch last 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:

fatal: unable to access 'https://github.com/swack-tools/oxidex.git/':
The requested URL returned error: 403 -- skipping PR creation

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 because sweep/tags-2026-08-04-2 was found sitting locally, 4 commits ahead of main, never pushed.

The change

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. gh reads it directly; git push reaches it through the credential.https://github.com.helper = !gh auth git-credential helper.

It's a username, not a secret — the token comes from the keyring, so config.toml gains no second credential.

The part that's easy to get wrong

Four callables are bound: 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. run_sweep defaults push_branch_fn/create_pr_fn to overlord_sweep.real_push_branch/real_create_pr, which call subprocess.run themselves with no env — and those two are git push and gh 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 reach run_sweep, and another asserting neither is forwarded when no user is configured so run_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 gh has 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_loop243 passed (233 baseline + 10 new), 0 failed. Verified against the real keyring: swackhamer resolves, an unknown account raises with the remedy in the message.

🤖 Generated with Claude Code

`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
@swackhamer
swackhamer merged commit ebe9931 into main Aug 5, 2026
9 checks passed
@swackhamer
swackhamer deleted the feat/publish-github-user branch August 5, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant