diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 46b2d3ec758..ae530cf3258 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -151,6 +151,10 @@ async function main() { const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); core.info(`Working in repository: ${workspaceDir}`); + // Split targetRepo into owner and repo name here so they are available for + // the GitHub REST API seeding calls below (before the checkout block). + const [targetOwner, targetRepoName] = targetRepo.split("/"); + // Checkout or create the memory branch // Note: we do NOT disable sparse checkout here. Disabling sparse checkout on a // large repository forces git to materialize all tracked files into the working @@ -189,24 +193,72 @@ async function main() { throw fetchError; } - // Branch doesn't exist, create orphan branch - // baseRef stays "" — pushSignedCommits will create the branch via - // rest.git.createRef before the first GraphQL mutation. - core.info(`Branch ${branchName} does not exist, creating orphan branch...`); - execGitSync(["checkout", "--orphan", branchName], { stdio: "inherit" }); - // Reset the index to an empty tree. This is O(1) regardless of how many - // files the source branch contained, avoiding the ENOBUFS error that - // "git rm -rf ." (with stdio:pipe) causes on large repos (10K+ files). - execGitSync(["read-tree", "--empty"], { stdio: "pipe" }); - // Clean the working directory using Node.js so we never pipe large git - // output back through spawnSync buffers. - core.info("Cleaning working directory for orphan branch..."); - for (const entry of fs.readdirSync(workspaceDir)) { - if (entry !== ".git") { - fs.rmSync(path.join(workspaceDir, entry), { recursive: true, force: true }); + // Branch doesn't exist – attempt to seed it via the GitHub REST API so + // the seed commit is server-signed, satisfying "Require signed commits" + // branch protection rules. Commits created via the REST API with + // GITHUB_TOKEN are automatically signed by GitHub. + const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + try { + core.info(`Branch ${branchName} does not exist, seeding via GitHub REST API...`); + const { data: seedCommit } = await github.rest.git.createCommit({ + owner: targetOwner, + repo: targetRepoName, + message: `Initialize ${branchName}`, + tree: EMPTY_TREE_SHA, + parents: [], + }); + let useApiSeedSha = true; + try { + await github.rest.git.createRef({ + owner: targetOwner, + repo: targetRepoName, + ref: `refs/heads/${branchName}`, + sha: seedCommit.sha, + }); + } catch (createRefError) { + // GitHub returns HTTP 422 with "Reference already exists" when the + // branch was created concurrently between our fetch-check and this + // createRef call. Check for either the status code or the message + // text since different Octokit versions surface errors differently. + // Treat as success and use the existing branch instead. + const createRefErrMsg = createRefError instanceof Error ? createRefError.message : String(createRefError); + if (!/422|Reference already exists/i.test(createRefErrMsg)) { + throw createRefError; + } + core.info(`Branch ${branchName} was created concurrently (422 Reference already exists); using existing branch.`); + useApiSeedSha = false; } + // Fetch the newly seeded (or concurrently created) branch and check it out. + execGitSync(["fetch", repoUrl, `${branchName}:${branchName}`], { stdio: "pipe", suppressLogs: true }); + execGitSync(["checkout", branchName], { stdio: "inherit" }); + // Set baseRef to the seed commit SHA (or the existing branch HEAD for + // the 422 concurrent-creation case) so pushSignedCommits can use the + // GraphQL signed-commit path instead of the unsigned git push fallback. + baseRef = useApiSeedSha ? seedCommit.sha : execGitSync(["rev-parse", "HEAD"]).trim(); + core.info(`Seeded and checked out new branch ${branchName} via GitHub API (baseRef: ${baseRef})`); + } catch (seedError) { + // Fallback: API seeding failed (e.g. insufficient token permissions). + // Fall back to the original orphan-branch + git push path and emit a + // warning so the operator knows signed commits may not be produced. + core.warning(`Failed to seed branch ${branchName} via GitHub API, falling back to orphan branch: ${getErrorMessage(seedError)}`); + // baseRef stays "" — pushSignedCommits will use git push for this + // orphan-branch first push (unsigned, may be rejected by strict rulesets). + core.info(`Branch ${branchName} does not exist, creating orphan branch...`); + execGitSync(["checkout", "--orphan", branchName], { stdio: "inherit" }); + // Reset the index to an empty tree. This is O(1) regardless of how many + // files the source branch contained, avoiding the ENOBUFS error that + // "git rm -rf ." (with stdio:pipe) causes on large repos (10K+ files). + execGitSync(["read-tree", "--empty"], { stdio: "pipe" }); + // Clean the working directory using Node.js so we never pipe large git + // output back through spawnSync buffers. + core.info("Cleaning working directory for orphan branch..."); + for (const entry of fs.readdirSync(workspaceDir)) { + if (entry !== ".git") { + fs.rmSync(path.join(workspaceDir, entry), { recursive: true, force: true }); + } + } + core.info(`Created orphan branch: ${branchName}`); } - core.info(`Created orphan branch: ${branchName}`); } } catch (error) { core.setFailed(`Failed to checkout branch: ${getErrorMessage(error)}`); @@ -517,7 +569,6 @@ async function main() { // strict signed-commits ruleset that fallback will also be rejected — // that is expected behaviour: remove the unsupported file types and // re-run. - const [targetOwner, targetRepoName] = targetRepo.split("/"); // URL with embedded token used for the pull-on-retry merge step only; // pushSignedCommits authenticates via the git extraheader set by // actions/checkout (and the gitAuthEnv fallback for the git-push path). diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index da845acffc2..12610f386ef 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -1575,3 +1575,69 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio }); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// API branch seeding tests +// Verifies that push_repo_memory seeds new memory branches via the GitHub REST +// API (server-signed commits) before falling back to the orphan-branch path. +// ────────────────────────────────────────────────────────────────────────────── + +describe("push_repo_memory.cjs - API branch seeding for signed commits", () => { + it("should use EMPTY_TREE_SHA and parents:[] to create a root seed commit (source check)", () => { + const nodeFs = require("fs"); + const nodePath = require("path"); + const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + + // Must define the well-known empty-tree SHA as the seed tree + expect(scriptContent).toContain("4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + // Must call createCommit with an empty parents array (root/orphan commit) + expect(scriptContent).toContain("github.rest.git.createCommit"); + expect(scriptContent).toContain("parents: []"); + // Must create the branch ref pointing at the seed commit + expect(scriptContent).toContain("github.rest.git.createRef"); + // Must set baseRef to the seed commit SHA so pushSignedCommits can use the + // GraphQL signed-commit path instead of the unsigned git push fallback + expect(scriptContent).toContain("seedCommit.sha"); + }); + + it("should fall back to orphan branch with core.warning when API seeding fails (source check)", () => { + const nodeFs = require("fs"); + const nodePath = require("path"); + const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + + // Must emit a warning identifying the API seeding failure and fallback + expect(scriptContent).toContain("falling back to orphan branch"); + // The fallback must still create an orphan branch (original path preserved) + expect(scriptContent).toContain('"--orphan"'); + expect(scriptContent).toContain('"read-tree", "--empty"'); + }); + + it("should treat 422 Reference-already-exists as success during concurrent branch creation (source check)", () => { + const nodeFs = require("fs"); + const nodePath = require("path"); + const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + + // Must detect the 422 status code or the GitHub "Reference already exists" message + expect(scriptContent).toContain("422|Reference already exists"); + // Must not rethrow a 422 error – branch is used as-is after concurrent creation + expect(scriptContent).toContain("Reference already exists"); + }); + + it("should split targetOwner/targetRepoName before the checkout block (source check)", () => { + const nodeFs = require("fs"); + const nodePath = require("path"); + const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + + // targetOwner/targetRepoName must be declared before the checkout section + // so they are available for the GitHub REST API seeding calls + const splitIdx = scriptContent.indexOf('targetRepo.split("/")'); + const checkoutIdx = scriptContent.indexOf("Checking out branch:"); + expect(splitIdx).toBeGreaterThan(-1); + expect(checkoutIdx).toBeGreaterThan(-1); + expect(splitIdx).toBeLessThan(checkoutIdx); + }); +});