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
14 changes: 14 additions & 0 deletions actions/setup/js/extra_empty_commit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMes
}
await exec.exec("git", ["remote", "add", "ci-trigger", remoteUrl]);

// Fetch and sync with the remote branch. This is required when the PR branch
// was created server-side via the GitHub API (e.g. via the createCommitOnBranch
// GraphQL mutation used by pushSignedCommits), because the remote branch tip
// then has a different SHA than the local branch tip. Without this sync, git
// would reject the subsequent push as non-fast-forward.
try {
await exec.exec("git", ["fetch", "ci-trigger", branchName]);
await exec.exec("git", ["reset", "--hard", `ci-trigger/${branchName}`]);
} catch {
// Non-fatal: if fetch/reset fails (e.g. branch not yet on remote), continue
// with the local HEAD and attempt the push anyway.
core.info(`Could not sync local branch with remote ${branchName} - will attempt push with local HEAD`);
Comment on lines +150 to +153
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The catch block for fetch/reset swallows the underlying error, which makes it hard to diagnose real sync failures (e.g., auth/network issues vs missing branch). Capture the thrown error and include its message in the log (and consider using core.warning since a failed sync is likely to lead to a failed push/CI not triggering).

Suggested change
} catch {
// Non-fatal: if fetch/reset fails (e.g. branch not yet on remote), continue
// with the local HEAD and attempt the push anyway.
core.info(`Could not sync local branch with remote ${branchName} - will attempt push with local HEAD`);
} catch (error) {
// Non-fatal: if fetch/reset fails (e.g. branch not yet on remote), continue
// with the local HEAD and attempt the push anyway.
const syncErrorMessage = error instanceof Error ? error.message : String(error);
core.warning(`Could not sync local branch with remote ${branchName} - will attempt push with local HEAD. Underlying error: ${syncErrorMessage}`);

Copilot uses AI. Check for mistakes.
}

// Create and push an empty commit
const message = commitMessage || "ci: trigger checks";
await exec.exec("git", ["commit", "--allow-empty", "-m", message]);
Expand Down
70 changes: 70 additions & 0 deletions actions/setup/js/extra_empty_commit.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,76 @@ describe("extra_empty_commit.cjs", () => {
expect(removeRemoteCalls.length).toBeGreaterThanOrEqual(1);
});

it("should fetch and reset to remote branch before committing", async () => {
await pushExtraEmptyCommit({
branchName: "api-created-branch",
repoOwner: "test-owner",
repoName: "test-repo",
});

const execCalls = mockExec.exec.mock.calls;

// Find the remote add call index so we can verify order
const addRemoteIdx = execCalls.findIndex(c => c[0] === "git" && c[1] && c[1][0] === "remote" && c[1][1] === "add");
expect(addRemoteIdx).toBeGreaterThanOrEqual(0);

// fetch should come after remote add
const fetchCall = execCalls.find(c => c[0] === "git" && c[1] && c[1][0] === "fetch" && c[1][1] === "ci-trigger");
expect(fetchCall).toBeDefined();
expect(fetchCall[1]).toEqual(["fetch", "ci-trigger", "api-created-branch"]);
const fetchIdx = execCalls.indexOf(fetchCall);
expect(fetchIdx).toBeGreaterThan(addRemoteIdx);

// reset --hard should come after fetch
const resetCall = execCalls.find(c => c[0] === "git" && c[1] && c[1][0] === "reset" && c[1][1] === "--hard");
expect(resetCall).toBeDefined();
expect(resetCall[1]).toEqual(["reset", "--hard", "ci-trigger/api-created-branch"]);
const resetIdx = execCalls.indexOf(resetCall);
expect(resetIdx).toBeGreaterThan(fetchIdx);

// commit should come after reset
const commitCall = execCalls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeDefined();
const commitIdx = execCalls.indexOf(commitCall);
expect(commitIdx).toBeGreaterThan(resetIdx);

// push should come after commit
const pushCall = execCalls.find(c => c[0] === "git" && c[1] && c[1][0] === "push");
expect(pushCall).toBeDefined();
const pushIdx = execCalls.indexOf(pushCall);
expect(pushIdx).toBeGreaterThan(commitIdx);
});

it("should succeed even when fetch/reset fails (branch not yet on remote)", async () => {
mockExec.exec.mockImplementation(async (cmd, args, options) => {
if (cmd === "git" && args && args[0] === "log" && options && options.listeners) {
options.listeners.stdout(Buffer.from("COMMIT:abc123\nfile.txt\n"));
return 0;
}
// Simulate fetch failing (branch does not yet exist on remote)
if (cmd === "git" && args && args[0] === "fetch") {
throw new Error("couldn't find remote ref api-created-branch");
}
return 0;
});

const result = await pushExtraEmptyCommit({
branchName: "api-created-branch",
repoOwner: "test-owner",
repoName: "test-repo",
});

// Push should still be attempted and succeed (mock returns 0 for push)
expect(result).toEqual({ success: true });
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not sync local branch"));

// commit and push should still have been called
const commitCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "commit");
expect(commitCall).toBeDefined();
const pushCall = mockExec.exec.mock.calls.find(c => c[0] === "git" && c[1] && c[1][0] === "push");
expect(pushCall).toBeDefined();
});

it("should use github.com by default when GITHUB_SERVER_URL is not set", async () => {
delete process.env.GITHUB_SERVER_URL;
delete require.cache[require.resolve("./extra_empty_commit.cjs")];
Expand Down
Loading