From 268b52973cead4a216b62c942f5c830388e58a8b Mon Sep 17 00:00:00 2001 From: kostua16 Date: Sun, 28 Jun 2026 20:59:43 +0500 Subject: [PATCH] feat(auth): sync git credentials with switched account --- .github/workflows/test-action.yml | 28 ++++ README.md | 17 +++ action.yml | 6 + src/main.ts | 230 +++++++++++++++++++++++++++++- 4 files changed, 278 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index a3c2f78..d46ac97 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -89,6 +89,34 @@ jobs: - run: test "${{ steps.switch-account.outputs.gh-config-dir }}" != "${{ steps.switch-account-again.outputs.gh-config-dir }}" - run: test "$GH_CONFIG_DIR" != "$HOME/.config/gh" - run: gh auth status --hostname github.com + - name: Poison git auth config + run: | + git config --local credential.helper store + git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic stale" + git config --local url.https://x-access-token:stale@github.com/.insteadOf https://github.com/ + - name: Switch account updates raw git credentials + id: git-credentials + uses: ./ + env: + GH_TOKEN: stale-token + with: + token: ${{ github.token }} + switch-account: "true" + update-git-credentials: "true" + - run: test -n "$GIT_ASKPASS" + - run: test -x "$GIT_ASKPASS" + - run: test "$GIT_TERMINAL_PROMPT" = "0" + - run: test -n "$GIT_CONFIG_GLOBAL" + - run: test -f "$GIT_CONFIG_GLOBAL" + - run: test "$GIT_CONFIG_NOSYSTEM" = "1" + - run: test -n "$SETUP_GH_GIT_TOKEN" + - run: test "$("$GIT_ASKPASS" Username)" = "x-access-token" + - run: test "$("$GIT_ASKPASS" Password)" = "$SETUP_GH_GIT_TOKEN" + - run: test -z "$(git config --local --get-all credential.helper || true)" + - run: test -z "$(git config --local --get-all http.https://github.com/.extraheader || true)" + - run: test -z "$(git config --local --get-regexp '^url\..*\.insteadOf$' || true)" + - run: grep -q "helper =" "$GIT_CONFIG_GLOBAL" + - run: git ls-remote --get-url origin | grep -q '^https://github.com/' - name: Mock gh auth status failure run: | fake_bin="$(mktemp -d)" diff --git a/README.md b/README.md index 88937bf..96caceb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ jobs: later workflow steps. Each `switch-account: true` invocation creates its own config directory. The default is `false`. +- **`update-git-credentials`:** Whether to configure later raw `git` commands + in the same job to use the provided token through job-scoped askpass + credentials. This is useful with `switch-account: true` on self-hosted runners + where persisted Git config or checkout credentials may otherwise select an + older token. The default is `false`. + - **`skip-matching-version`:** If `false`, installation is skipped whenever any `gh` is already on `PATH`. If `true`, installation is skipped only when the installed version matches `gh-version`. The default is `false`. @@ -78,6 +84,17 @@ jobs: - **`github-server-url`:** The GitHub server URL to use when running `gh auth login`. Defaults to the current `github.server_url`. +To switch both `gh` and later raw `git` commands to the same PAT: + +```yml +- uses: kostua16/setup-gh@v1 + with: + token: ${{ secrets.GH_PAT }} + cli-token: ${{ secrets.GH_PAT }} + switch-account: true + update-git-credentials: true +``` + ### Outputs - **`gh-version`:** The available version of `gh`. This will be something like diff --git a/action.yml b/action.yml index c980dd8..f762ea0 100644 --- a/action.yml +++ b/action.yml @@ -30,6 +30,12 @@ inputs: another account is already authenticated. When true, this exports 'GH_TOKEN' and a fresh isolated 'GH_CONFIG_DIR' for later workflow steps. default: false + update-git-credentials: + description: > + Whether to configure later raw 'git' commands in the same job to use the + provided token through job-scoped askpass credentials. Intended for use + with 'switch-account: true' on self-hosted runners with stale Git config. + default: false skip-matching-version: description: > If false, skip installation when any 'gh' is already on PATH. If true, diff --git a/src/main.ts b/src/main.ts index 0fb01f2..60929b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,17 +2,21 @@ import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; import * as github from "@actions/github"; import { join } from "node:path"; -import { existsSync, mkdtempSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import semver from "semver"; import process from "node:process"; -import { $ } from "execa"; +import { $, execa } from "execa"; import { createUnauthenticatedAuth } from "@octokit/auth-unauthenticated"; const requestedVersion = core.getInput("gh-version") || core.getInput("version") || "latest"; const switchAccount = core.getBooleanInput("switch-account"); +const updateGitCredentials = core.getBooleanInput("update-git-credentials"); const skipMatchingVersion = core.getBooleanInput("skip-matching-version"); +const gitTokenEnvName = "SETUP_GH_GIT_TOKEN"; +const gitAuthConfigPattern = + "^(credential\\.helper|http(\\..*)?\\.extraheader|url\\..*\\.insteadof)$"; async function getInstalledVersion() { try { @@ -140,6 +144,10 @@ function createGhConfigDir() { return mkdtempSync(join(process.env.RUNNER_TEMP ?? tmpdir(), "setup-gh-")); } +function createGitCredentialDir() { + return mkdtempSync(join(process.env.RUNNER_TEMP ?? tmpdir(), "setup-gh-git-")); +} + function getGhConfigDir() { if (process.env.GH_CONFIG_DIR) return process.env.GH_CONFIG_DIR; if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "gh"); @@ -149,6 +157,212 @@ function getGhConfigDir() { return join(homedir(), ".config", "gh"); } +function escapeGitConfigSubsection(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function createAskpassScript(gitCredentialDir: string) { + const askpassPath = join( + gitCredentialDir, + process.platform === "win32" ? "git-askpass.cmd" : "git-askpass.sh", + ); + const script = + process.platform === "win32" + ? `@echo off +setlocal EnableExtensions +set "prompt=%~1" +echo(%prompt%| findstr /I "Username" >nul +if not errorlevel 1 ( + echo x-access-token + exit /b 0 +) +echo(%prompt%| findstr /I "Password" >nul +if not errorlevel 1 ( + echo(%${gitTokenEnvName}% + exit /b 0 +) +echo( +exit /b 0 +` + : `#!/usr/bin/env sh +case "$1" in + *Username*) printf '%s\\n' 'x-access-token' ;; + *Password*) printf '%s\\n' "\${${gitTokenEnvName}}" ;; + *) printf '\\n' ;; +esac +`; + + writeFileSync(askpassPath, script, { mode: 0o700 }); + chmodSync(askpassPath, 0o700); + return askpassPath; +} + +function createTempGlobalGitConfig(gitCredentialDir: string, serverUrl: URL) { + const gitConfigPath = join(gitCredentialDir, "gitconfig"); + const httpsBaseUrl = `${serverUrl.protocol}//${serverUrl.host}/`; + const sshUrlHost = serverUrl.host; + const sshScpHost = serverUrl.port ? serverUrl.hostname : serverUrl.host; + const config = [ + "[credential]", + "\thelper =", + "", + `[url "${escapeGitConfigSubsection(httpsBaseUrl)}"]`, + `\tinsteadOf = git@${sshScpHost}:`, + `\tinsteadOf = ssh://git@${sshUrlHost}/`, + "", + ].join("\n"); + + writeFileSync(gitConfigPath, config, { mode: 0o600 }); + return gitConfigPath; +} + +function sanitizeSecretBearingText(value: string) { + return value + .replace(/(https?:\/\/)(?:[^/\s@]+@)/gi, "$1***@") + .replace(/(x-access-token:)[^@/\s]+/gi, "$1***") + .replace(/(authorization:\s*(?:basic|bearer)\s+)\S+/gi, "$1***") + .replace(/(oauth_token[:=]\s*)\S+/gi, "$1***"); +} + +async function debugGitAuthConfigOrigins(label: string) { + if (!core.isDebug()) return; + + const result = await execa( + "git", + [ + "config", + "--show-origin", + "--show-scope", + "--name-only", + "--get-regexp", + gitAuthConfigPattern, + ], + { reject: false }, + ); + + if (result.exitCode === 1) { + core.debug(`${label}: no matching git auth config entries`); + return; + } + + if (result.exitCode !== 0) { + core.debug(`${label}: unable to inspect git auth config origins`); + return; + } + + for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) { + core.debug(`${label}: ${sanitizeSecretBearingText(line)}`); + } +} + +type GitConfigEntry = { + key: string; + value: string; +}; + +async function getLocalGitConfigEntries(pattern: string): Promise { + const result = await execa( + "git", + ["config", "--local", "--get-regexp", pattern], + { reject: false }, + ); + + if (result.exitCode === 1) return []; + if (result.exitCode !== 0) { + core.debug("Unable to inspect local git auth config entries"); + return []; + } + + return result.stdout + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const separator = line.search(/\s/); + if (separator === -1) return { key: line, value: "" }; + return { + key: line.slice(0, separator), + value: line.slice(separator).trim(), + }; + }); +} + +function isServerRelatedConfig(value: string, serverUrl: URL) { + const normalized = value.toLowerCase(); + return ( + normalized.includes(serverUrl.host.toLowerCase()) || + normalized.includes(serverUrl.hostname.toLowerCase()) + ); +} + +function shouldUnsetLocalGitAuthConfig(entry: GitConfigEntry, serverUrl: URL) { + const key = entry.key.toLowerCase(); + if (key === "credential.helper") return true; + if (key === "http.extraheader") return true; + if (key.startsWith("http.") && key.endsWith(".extraheader")) { + return isServerRelatedConfig(key, serverUrl); + } + if (key.startsWith("url.") && key.endsWith(".insteadof")) { + return isServerRelatedConfig(`${entry.key} ${entry.value}`, serverUrl); + } + return false; +} + +async function scrubLocalGitAuthConfig(serverUrl: URL) { + const workTreeResult = await execa( + "git", + ["rev-parse", "--is-inside-work-tree"], + { reject: false }, + ); + + if (workTreeResult.exitCode !== 0 || workTreeResult.stdout.trim() !== "true") { + core.debug("Skipping local git auth config cleanup outside a git worktree"); + return 0; + } + + const entries = await getLocalGitConfigEntries(gitAuthConfigPattern); + const keysToUnset = [ + ...new Set( + entries + .filter((entry) => shouldUnsetLocalGitAuthConfig(entry, serverUrl)) + .map((entry) => entry.key), + ), + ]; + + for (const key of keysToUnset) { + const result = await execa("git", ["config", "--local", "--unset-all", key], { + reject: false, + }); + if (result.exitCode !== 0 && result.exitCode !== 5) { + core.debug( + `Unable to unset local git config key ${sanitizeSecretBearingText(key)}`, + ); + } + } + + return keysToUnset.length; +} + +async function configureGitCredentials(serverUrl: URL, token: string) { + core.debug("update-git-credentials: enabled"); + await debugGitAuthConfigOrigins("git auth config before update"); + + const gitCredentialDir = createGitCredentialDir(); + const askpassPath = createAskpassScript(gitCredentialDir); + const gitConfigPath = createTempGlobalGitConfig(gitCredentialDir, serverUrl); + + core.exportVariable(gitTokenEnvName, token); + core.exportVariable("GIT_ASKPASS", askpassPath); + core.exportVariable("GIT_TERMINAL_PROMPT", "0"); + core.exportVariable("GIT_CONFIG_GLOBAL", gitConfigPath); + core.exportVariable("GIT_CONFIG_NOSYSTEM", "1"); + + const scrubbedLocalKeys = await scrubLocalGitAuthConfig(serverUrl); + core.debug(`git credential temp config: ${gitConfigPath}`); + core.debug(`git askpass script: ${askpassPath}`); + core.debug(`local git auth config keys scrubbed: ${scrubbedLocalKeys}`); + await debugGitAuthConfigOrigins("git auth config after update"); +} + function sanitizeAuthStatusOutput(output: string) { return output .split(/\r?\n/) @@ -206,8 +420,12 @@ let ghConfigDir = getGhConfigDir(); const token = core.getInput("token"); if (!token) { core.setOutput("auth", false); + if (updateGitCredentials) { + core.debug("update-git-credentials: skipped because no token was provided"); + } } else { - const { hostname } = new URL(core.getInput("github-server-url")); + const serverUrl = new URL(core.getInput("github-server-url")); + const { hostname } = serverUrl; core.setSecret(token); if (switchAccount) { @@ -228,5 +446,11 @@ if (!token) { `gh auth status --hostname ${hostname} failed after login; setting auth=false.`, ); } + + if (updateGitCredentials) { + await configureGitCredentials(serverUrl, token); + } else { + core.debug("update-git-credentials: disabled"); + } } core.setOutput("gh-config-dir", ghConfigDir);