Skip to content
Merged
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
85 changes: 68 additions & 17 deletions actions/setup/js/push_repo_memory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -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).
Expand Down
66 changes: 66 additions & 0 deletions actions/setup/js/push_repo_memory.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading