Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,30 @@ 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`.

- **`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
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
230 changes: 227 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The auth-config pattern only matches the bare credential.helper key and omits URL-scoped credential helpers (e.g. credential.https://github.com.helper / credential.<host>.helper).

On self-hosted runners — the stated use case for update-git-credentials — stale GitHub-scoped credential helpers may be configured under a URL subsection. Such entries are neither surfaced by debugGitAuthConfigOrigins (which reuses this same pattern) nor removed by scrubLocalGitAuthConfig, because shouldUnsetLocalGitAuthConfig only special-cases the bare credential.helper. The empty helper = reset written to GIT_CONFIG_GLOBAL does not backstop this either: it is read before local config, so later-accumulated local URL-scoped helpers are never cleared. A stale credential.<host>.helper entry can therefore keep feeding an outdated token to raw git, bypassing the askpass credentials this feature installs.

Extending the pattern to match credential(?:\\..*)?\\.helper and adding a corresponding server-scoped branch in shouldUnsetLocalGitAuthConfig would close the gap.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


async function getInstalledVersion() {
try {
Expand Down Expand Up @@ -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");
Expand All @@ -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<GitConfigEntry[]> {
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/)
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Loading