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\""