Skip to content

Commit b2d8af7

Browse files
authored
fix: configure git auth for GHES in incremental patch fetch (#19685)
1 parent 79d2b50 commit b2d8af7

File tree

2 files changed

+118
-0
lines changed

2 files changed

+118
-0
lines changed

actions/setup/js/generate_git_patch.cjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,26 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
138138
// We must explicitly fetch origin/branchName and fail if it doesn't exist.
139139

140140
debugLog(`Strategy 1 (incremental): Fetching origin/${branchName}`);
141+
// Configure git authentication using GITHUB_TOKEN and GITHUB_SERVER_URL.
142+
// This ensures the fetch works on GitHub Enterprise Server (GHES) where
143+
// the default credential helper may not be configured for the enterprise endpoint.
144+
// SECURITY: The header is set immediately before the fetch and removed in a finally
145+
// block to minimize the window during which the token is stored on disk. This is
146+
// important because clean_git_credentials.sh runs before the agent starts — any
147+
// credential written after that cleanup must be removed immediately after use so the
148+
// agent cannot read the token from .git/config.
149+
const githubToken = process.env.GITHUB_TOKEN;
150+
const githubServerUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
151+
const extraHeaderKey = `http.${githubServerUrl}/.extraheader`;
152+
let authHeaderSet = false;
141153
try {
154+
if (githubToken) {
155+
const tokenBase64 = Buffer.from(`x-access-token:${githubToken}`).toString("base64");
156+
execGitSync(["config", "--local", extraHeaderKey, `Authorization: basic ${tokenBase64}`], { cwd });
157+
authHeaderSet = true;
158+
debugLog(`Strategy 1 (incremental): Configured git auth for ${githubServerUrl}`);
159+
}
160+
142161
// Explicitly fetch origin/branchName to ensure we have the latest
143162
// Use "--" to prevent branch names starting with "-" from being interpreted as options
144163
execGitSync(["fetch", "origin", "--", `${branchName}:refs/remotes/origin/${branchName}`], { cwd });
@@ -154,6 +173,21 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
154173
error: errorMessage,
155174
patchPath: patchPath,
156175
};
176+
} finally {
177+
// SECURITY: Always remove the token from git config immediately after the fetch,
178+
// regardless of success or failure. This prevents the agent from reading the
179+
// credential out of .git/config for the remainder of its session.
180+
if (authHeaderSet) {
181+
try {
182+
execGitSync(["config", "--local", "--unset-all", extraHeaderKey], { cwd });
183+
debugLog(`Strategy 1 (incremental): Removed git auth header`);
184+
} catch {
185+
// Non-fatal: the header may already be absent or the repo state changed.
186+
// However, a persistent failure here may leave the credential in .git/config
187+
// for the remainder of the agent session and should be investigated.
188+
debugLog(`Strategy 1 (incremental): Warning - failed to remove git auth header`);
189+
}
190+
}
157191
}
158192
} else {
159193
// FULL MODE (for create_pull_request):

actions/setup/js/git_patch_integration.test.cjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,90 @@ describe("git patch integration tests", () => {
610610
}
611611
});
612612

613+
/**
614+
* Sets GITHUB_WORKSPACE, DEFAULT_BRANCH, GITHUB_TOKEN, and GITHUB_SERVER_URL for
615+
* a test, then restores the original values (or deletes them if they were unset).
616+
* Returns a restore function to call in `finally`.
617+
*/
618+
function setTestEnv(workspaceDir) {
619+
const saved = {
620+
GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE,
621+
DEFAULT_BRANCH: process.env.DEFAULT_BRANCH,
622+
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
623+
GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL,
624+
};
625+
process.env.GITHUB_WORKSPACE = workspaceDir;
626+
process.env.DEFAULT_BRANCH = "main";
627+
process.env.GITHUB_TOKEN = "ghs_test_token_for_cleanup_verification";
628+
process.env.GITHUB_SERVER_URL = "https://github.example.com";
629+
return () => {
630+
for (const [key, value] of Object.entries(saved)) {
631+
if (value === undefined) {
632+
delete process.env[key];
633+
} else {
634+
process.env[key] = value;
635+
}
636+
}
637+
};
638+
}
639+
640+
it("should remove auth extraheader from git config after a successful fetch", async () => {
641+
// Set up a feature branch, push a first commit, then add a second commit so
642+
// incremental mode has something new to patch.
643+
execGit(["checkout", "-b", "auth-cleanup-success"], { cwd: workingRepo });
644+
fs.writeFileSync(path.join(workingRepo, "auth.txt"), "auth test\n");
645+
execGit(["add", "auth.txt"], { cwd: workingRepo });
646+
execGit(["commit", "-m", "Auth cleanup base commit"], { cwd: workingRepo });
647+
execGit(["push", "-u", "origin", "auth-cleanup-success"], { cwd: workingRepo });
648+
649+
// Add a second commit that will become the incremental patch
650+
fs.writeFileSync(path.join(workingRepo, "auth2.txt"), "auth test 2\n");
651+
execGit(["add", "auth2.txt"], { cwd: workingRepo });
652+
execGit(["commit", "-m", "Auth cleanup new commit"], { cwd: workingRepo });
653+
654+
// Delete the tracking ref so generateGitPatch has to re-fetch
655+
execGit(["update-ref", "-d", "refs/remotes/origin/auth-cleanup-success"], { cwd: workingRepo });
656+
657+
const restore = setTestEnv(workingRepo);
658+
try {
659+
const result = await generateGitPatch("auth-cleanup-success", "main", { mode: "incremental" });
660+
661+
expect(result.success).toBe(true);
662+
663+
// Verify the extraheader was removed from git config
664+
const configCheck = spawnSync("git", ["config", "--local", "--get", "http.https://github.example.com/.extraheader"], { cwd: workingRepo, encoding: "utf8" });
665+
// exit status 1 means the key does not exist — that is what we want
666+
expect(configCheck.status).toBe(1);
667+
} finally {
668+
restore();
669+
}
670+
});
671+
672+
it("should remove auth extraheader from git config even when fetch fails", async () => {
673+
// Create a local-only branch (fetch will fail because it's not on origin)
674+
execGit(["checkout", "-b", "auth-cleanup-failure"], { cwd: workingRepo });
675+
fs.writeFileSync(path.join(workingRepo, "auth-fail.txt"), "auth fail test\n");
676+
execGit(["add", "auth-fail.txt"], { cwd: workingRepo });
677+
execGit(["commit", "-m", "Auth cleanup failure test commit"], { cwd: workingRepo });
678+
// Do NOT push — so the fetch fails
679+
680+
const restore = setTestEnv(workingRepo);
681+
try {
682+
const result = await generateGitPatch("auth-cleanup-failure", "main", { mode: "incremental" });
683+
684+
// The fetch must fail since origin/auth-cleanup-failure doesn't exist
685+
expect(result.success).toBe(false);
686+
expect(result.error).toContain("Cannot generate incremental patch");
687+
688+
// Verify the extraheader was removed even though the fetch failed
689+
const configCheck = spawnSync("git", ["config", "--local", "--get", "http.https://github.example.com/.extraheader"], { cwd: workingRepo, encoding: "utf8" });
690+
// exit status 1 means the key does not exist — that is what we want
691+
expect(configCheck.status).toBe(1);
692+
} finally {
693+
restore();
694+
}
695+
});
696+
613697
it("should include all commits in full mode even when origin/branch exists", async () => {
614698
// Create a feature branch with first commit
615699
execGit(["checkout", "-b", "full-mode-branch"], { cwd: workingRepo });

0 commit comments

Comments
 (0)