From 49a89eafcefea308a64a917571a8d6e3efea1d6b Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Sat, 22 Aug 2026 09:24:26 +0000 Subject: [PATCH] Authenticate GitHub with gh tokens per repo, so two accounts can share one box An ssh key can be registered on exactly one GitHub account, so a box with a personal and a work account could never authenticate as both -- the second registration is refused as a key already in use. Tokens have no such limit, and gh already stores one per account. The obvious move, `gh auth setup-git`, does not work here: its helper answers only for whichever account is active. With two accounts that is wrong half the time, and wrong in the worst way, because a read as the other account returns 404 rather than 403 -- indistinguishable from the repository not existing. So git resolves the account the way gh/gh already does: `gh.account` on the repo, or a username carried in the remote URL, then `gh auth token --user`. Read-only, never `gh auth switch`, which mutates shared state that concurrent sessions flip under each other. Identity is now one setting per repo, covering push, fetch, and every gh subcommand alike. The empty helper ahead of it resets the chain. Helpers accumulate across system, global and local config and are tried in order, so without it a system helper -- osxkeychain, or a leftover setup-git line -- answers first with the wrong account. Claude-Session: https://claude.ai/code/session_01AVTNMRmHsTJMauEwo2ryLv --- .github/workflows/ci.yml | 2 +- git/credential-gh-account | 64 +++++++++++++++++++++++++++++++++++++++ git/gitconfig | 12 ++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100755 git/credential-gh-account diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fef6f0..c2dbffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: fi - name: shellcheck - run: shellcheck -s bash install.sh gh/gh claude/build-settings + run: shellcheck -s bash install.sh gh/gh git/credential-gh-account claude/build-settings - name: The shells parse it run: | diff --git a/git/credential-gh-account b/git/credential-gh-account new file mode 100755 index 0000000..420bf7e --- /dev/null +++ b/git/credential-gh-account @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# git credential helper that resolves a GitHub account per repository, the way the gh wrapper +# next door already does: the repo's `gh.account`, or a username git carries in the remote URL. +# +# It exists because `gh auth git-credential` -- the helper `gh auth setup-git` installs -- answers +# only for whichever account is currently active. With one account that is invisible; with two it +# is wrong half the time, and wrong in the worst way: a read as the other account returns 404, not +# 403, so the failure looks like the repository not existing rather than the wrong identity. +# +# Resolution is read-only, via `gh auth token --user`, and never `gh auth switch`. Switching +# mutates the shared active account in hosts.yml, so two sessions working in repos belonging to +# different accounts flip it under each other mid-request. Same reasoning as gh/gh. + +set -uo pipefail + +die() { + printf 'credential-gh-account: %s\n' "$@" >&2 + exit 1 +} + +# store and erase have nothing to do: the tokens live in gh's hosts.yml and gh owns their +# lifecycle. Anything other than `get` succeeds silently rather than erroring, because git +# calls all three and a helper that fails on store turns every push into a warning. +[[ "${1:-}" == get ]] || exit 0 + +declare -A req=() +while IFS='=' read -r key value; do + [[ -z "$key" ]] && break + req["$key"]="$value" +done + +# Scoped deliberately. A helper that answered for every host would shadow whatever the box uses +# for GitLab or a private forge, and it has no tokens for those to offer in exchange. +[[ "${req[protocol]:-}" == https && "${req[host]:-}" == github.com ]] || exit 0 + +# gh may not be on PATH: git runs credential helpers with the environment of whatever invoked +# git, which for an editor or a scheduled job is not a login shell that sourced zshrc. +GH_BINARY="$(command -v gh 2>/dev/null)" +if [[ -z "$GH_BINARY" ]]; then + for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh /home/linuxbrew/.linuxbrew/bin/gh; do + [[ -x "$candidate" ]] && GH_BINARY="$candidate" && break + done +fi +[[ -n "$GH_BINARY" ]] || die "gh was not found, so no token can be resolved for github.com." \ + " Install it: brew install gh" + +# A username in the request wins: it came from the remote URL or an explicit credential.username, +# both of which are more specific than a repo-wide default. This is also what makes the first +# clone work, before there is a repo in which to set gh.account -- clone +# https://ldayton@github.com/... and the account comes along in the URL. +account="${req[username]:-}" +[[ -n "$account" ]] || account="$(git config --get gh.account 2>/dev/null)" +[[ -n "$account" ]] || die "no gh.account set for this repository, and no username in the URL." \ + " Set one: git config gh.account " \ + " Or clone: git clone https://@github.com/owner/repo.git" + +# GH_WRAPPER_DISABLE because gh on PATH is this repo's wrapper, and its policy hook has no +# business adjudicating a credential lookup that git is blocking on. +token="$(GH_WRAPPER_DISABLE=1 "$GH_BINARY" auth token --user "$account" 2>/dev/null)" +[[ -n "$token" ]] || die "gh.account=$account, but gh has no token stored for that account." \ + " Log in: gh auth login (choose $account)" \ + " Check: gh auth status" + +printf 'username=%s\npassword=%s\n' "$account" "$token" diff --git a/git/gitconfig b/git/gitconfig index 2650545..3108e2c 100644 --- a/git/gitconfig +++ b/git/gitconfig @@ -16,3 +16,15 @@ smudge = git-lfs smudge -- %f process = git-lfs filter-process required = true +# GitHub authentication goes through gh's stored tokens rather than an ssh key. A key can be +# registered on exactly one GitHub account, so two accounts on one machine cannot both use one; +# tokens have no such limit, and `gh auth login` on a fresh box replaces copying key material +# onto it. See git/credential-gh-account for how the account is chosen. +# +# The empty helper first is a reset, not an accident: helpers accumulate across system, global +# and local config and are tried in order, so without it a system helper (osxkeychain, or a +# leftover `gh auth setup-git` line) answers first and wins with the wrong account. Scoped to +# github.com, so helpers for every other host survive it. +[credential "https://github.com"] + helper = + helper = "!\"$HOME/source/dotfiles/git/credential-gh-account\""