-
Notifications
You must be signed in to change notification settings - Fork 474
fix: wrap fork push sequences in withGitHubHostToken to prevent duplicate Authorization (HTTP 400) #48595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: wrap fork push sequences in withGitHubHostToken to prevent duplicate Authorization (HTTP 400) #48595
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,7 +30,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); | |
| const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); | ||
| const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); | ||
| const { renderTemplateFromFile, renderFilesList, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); | ||
| const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); | ||
| const { withGitHubHostToken } = require("./git_auth_helpers.cjs"); | ||
| const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs"); | ||
| const { isStagedMode } = require("./safe_output_helpers.cjs"); | ||
| const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); | ||
|
|
@@ -567,27 +567,18 @@ function enforcePullRequestLimits(patchContent, maxFiles = MAX_FILES) { | |
| * @param {string} [options.repo] - Repository name for the deleteRef call. | ||
| * @param {string} [options.remoteTarget] - Remote name or URL used for remote branch existence checks. | ||
| * @param {string} [options.remoteToken] - Optional token used for authenticated remote branch checks. | ||
| * @param {string} [options.cwd] - Optional working directory for git operations; scopes git config overrides to the correct checkout. | ||
| * @returns {Promise<string>} The (possibly renamed) branch name to use going forward. | ||
| */ | ||
| async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) { | ||
| const cwd = options.cwd; | ||
| let remoteBranchExists = false; | ||
| try { | ||
| const remoteTarget = options.remoteTarget || "origin"; | ||
| const checkRemoteBranch = async () => exec.getExecOutput("git", ["ls-remote", "--heads", remoteTarget, branchName]); | ||
| const checkRemoteBranch = async () => exec.getExecOutput("git", ["ls-remote", "--heads", remoteTarget, branchName], cwd ? { cwd } : {}); | ||
| let checkResult; | ||
| if (options.remoteToken) { | ||
| const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); | ||
| let previousExtraheaders = []; | ||
| let overrideApplied = false; | ||
| try { | ||
| previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, options.remoteToken); | ||
| overrideApplied = true; | ||
| checkResult = await checkRemoteBranch(); | ||
| } finally { | ||
| if (overrideApplied) { | ||
| await restorePersistedExtraheader(githubServerUrl, previousExtraheaders); | ||
| } | ||
| } | ||
| checkResult = await withGitHubHostToken(options.remoteToken, checkRemoteBranch, cwd); | ||
| } else { | ||
| checkResult = await checkRemoteBranch(); | ||
| } | ||
|
|
@@ -659,7 +650,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio | |
| const oldBranch = branchName; | ||
| const renamedBranch = `${branchName}-${extraHex}`; | ||
| // Rename local branch | ||
| await exec.exec("git", ["branch", "-m", oldBranch, renamedBranch]); | ||
| await exec.exec("git", ["branch", "-m", oldBranch, renamedBranch], cwd ? { cwd } : {}); | ||
| core.info(`Renamed branch to ${renamedBranch}`); | ||
| return renamedBranch; | ||
| } | ||
|
|
@@ -1638,14 +1629,16 @@ async function main(config = {}) { | |
| // fallback issue can include a compare URL. Genuine push failures are handled in | ||
| // the catch block below. | ||
| { | ||
| try { | ||
| const forkCwd = process.cwd(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This wrap-a-closure-in-withGitHubHostToken pattern is copy-pasted three times (bundle, patch, and empty-commit push paths), increasing the odds a future fix lands in only one of them. 💡 Why this mattersEach of the three push blocks (~1632-1660, 2026-2054, 2202-2229) repeats the same shape: capture Suggested refactor: extract a small helper, e.g. |
||
| const runBundlePush = async () => { | ||
| branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { | ||
| recreateRef, | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| remoteTarget: pushRemoteUrl || "origin", | ||
| remoteToken: headGitHubToken, | ||
| cwd: forkCwd, | ||
| }); | ||
|
|
||
| await pushSignedCommits({ | ||
|
|
@@ -1654,14 +1647,17 @@ async function main(config = {}) { | |
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: process.cwd(), | ||
| cwd: forkCwd, | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| }; | ||
| try { | ||
| await runBundlePush(); | ||
| core.info("Changes pushed to branch (from bundle)"); | ||
|
|
||
| // Count new commits on PR branch relative to base | ||
|
|
@@ -1683,20 +1679,22 @@ async function main(config = {}) { | |
| core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); | ||
| try { | ||
| await rewriteBundleBranchAsSingleCommit(baseBranch, exec); | ||
| await pushSignedCommits({ | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: process.cwd(), | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| const runRetryPush = async () => | ||
| pushSignedCommits({ | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: forkCwd, | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| await runRetryPush(); | ||
| core.info("Changes pushed to branch after bundle rewrite retry"); | ||
|
|
||
| try { | ||
|
|
@@ -2025,14 +2023,16 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead | |
| // fallback issue can include a compare URL. Genuine push failures are handled in | ||
| // the catch block below. | ||
| { | ||
| try { | ||
| const forkCwd = process.cwd(); | ||
| const runPatchPush = async () => { | ||
| branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { | ||
| recreateRef, | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| remoteTarget: pushRemoteUrl || "origin", | ||
| remoteToken: headGitHubToken, | ||
| cwd: forkCwd, | ||
| }); | ||
|
|
||
| await pushSignedCommits({ | ||
|
|
@@ -2041,14 +2041,17 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead | |
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: process.cwd(), | ||
| cwd: forkCwd, | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| }; | ||
| try { | ||
| await runPatchPush(); | ||
| core.info("Changes pushed to branch"); | ||
|
|
||
| // Count new commits on PR branch relative to base, used to restrict | ||
|
|
@@ -2196,29 +2199,34 @@ ${patchPreview}`; | |
| await exec.exec(`git commit --allow-empty -m "Initialize"`); | ||
| core.info("Created empty commit"); | ||
|
|
||
| branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { | ||
| recreateRef, | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| remoteTarget: pushRemoteUrl || "origin", | ||
| remoteToken: headGitHubToken, | ||
| }); | ||
| const forkCwd = process.cwd(); | ||
| const runEmptyPush = async () => { | ||
| branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { | ||
| recreateRef, | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| remoteTarget: pushRemoteUrl || "origin", | ||
| remoteToken: headGitHubToken, | ||
| cwd: forkCwd, | ||
| }); | ||
|
|
||
| await pushSignedCommits({ | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: process.cwd(), | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| await pushSignedCommits({ | ||
| githubClient: pushGithubClient, | ||
| owner: pushRepoParts.owner, | ||
| repo: pushRepoParts.repo, | ||
| branch: branchName, | ||
| baseRef: `origin/${baseBranch}`, | ||
| cwd: forkCwd, | ||
| pushRemoteUrl, | ||
| pushToken: headGitHubToken, | ||
| signedCommits, | ||
| resolvedTemporaryIds, | ||
| currentRepo: itemRepo, | ||
| validationConfig: config, | ||
| }); | ||
| }; | ||
| await runEmptyPush(); | ||
| core.info("Empty branch pushed successfully"); | ||
|
|
||
| // Count new commits (will be 1 from the Initialize commit) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inner
withGitHubHostTokencall here is nested inside the outer wrap added aroundrunBundlePush/runPatchPush/runEmptyPush, and both currently use the identical token — but nothing enforces that invariant.💡 Why this matters
handleRemoteBranchCollisionis now always invoked from inside an outerwithGitHubHostToken(headGitHubToken, ...)block (see lines ~1632-1660, 2026-2054, 2202-2229). Sinceoptions.remoteTokenis alwaysheadGitHubToken— the same value the outer wrap already applied — this inner call reads the outer override as its "previous" value and then restores that same override value when it finishes. That happens to be a harmless no-op today only because the two tokens coincide.If a future change ever passes a different token into
remoteToken(e.g. a per-call scoped token) while the outer wrap still usesheadGitHubToken, the innerrestorePersistedExtraheaderwould put the outer's overridden header back rather than the true pre-override upstream header, and the subsequent push (which runs after this inner call returns, still inside the outer callback) would silently run under the wrong or already-restored auth state — reintroducing the exact duplicate/incorrect-Authorization class of bug this PR fixes.Suggested fix: either (a) have
handleRemoteBranchCollisionskip its own token override entirely when called from within an already-active outer wrap (e.g. accept a flag or detect via a shared "active token" marker), or (b) add an explicit code comment + a unit test asserting nested calls with the same token are safe, so a future refactor that diverges the tokens fails a test instead of silently breaking auth.