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
17 changes: 13 additions & 4 deletions .github/workflows/smoke-codex.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ If all tests pass:
- Use the `add_labels` safe-output tool to add the label `smoke-codex` to the pull request
- Use the `remove_labels` safe-output tool to remove the label `smoke` from the pull request
- Use the `unassign_from_user` safe-output tool to unassign the user `githubactionagent` from the pull request (this is a fictitious user used for testing)
- Use the `add_smoked_label` safe-output action tool to add the label `smoked` to the pull request (call it with `{"labels": "smoked"}`)
- Use the `add_smoked_label` safe-output action tool to add the label `smoked` to the pull request (call it with `{"labels": "smoked", "number": "${{ github.event.pull_request.number }}"}`)

**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation. Failing to call any safe-output tool is the most common cause of safe-output workflow failures.

Expand Down
50 changes: 41 additions & 9 deletions actions/setup/js/push_signed_commits.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,47 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
return;
}

core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch`);
core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch (branch: ${branch}, repo: ${owner}/${repo})`);

try {
for (const sha of shas) {
// Get the current remote HEAD OID (updated each iteration)
const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd });
const expectedHeadOid = oidOut.trim().split(/\s+/)[0];
if (!expectedHeadOid) {
throw new Error(`Could not resolve remote HEAD OID for branch ${branch}`);
/** @type {string | undefined} */
let lastOid;
for (let i = 0; i < shas.length; i++) {
const sha = shas[i];
core.info(`pushSignedCommits: processing commit ${i + 1}/${shas.length} sha=${sha}`);

// Determine the expected HEAD OID for this commit.
// After the first signed commit, reuse the OID returned by the previous GraphQL
// mutation instead of re-querying ls-remote (works even if the branch is new).
let expectedHeadOid;
if (lastOid) {
expectedHeadOid = lastOid;
core.info(`pushSignedCommits: using chained OID from previous mutation: ${expectedHeadOid}`);
} else {
// First commit: check whether the branch already exists on the remote.
const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd });
expectedHeadOid = oidOut.trim().split(/\s+/)[0];
if (!expectedHeadOid) {
// Branch does not exist on the remote yet – createCommitOnBranch will create it.
// Use the local parent commit OID as the expected base.
core.info(`pushSignedCommits: branch ${branch} not yet on the remote, resolving parent OID for first commit`);
const { stdout: parentOut } = await exec.getExecOutput("git", ["rev-parse", `${sha}^`], { cwd });
expectedHeadOid = parentOut.trim();
if (!expectedHeadOid) {
throw new Error(`Could not resolve OID for new branch ${branch}`);
}
core.info(`pushSignedCommits: using parent OID for new branch: ${expectedHeadOid}`);
} else {
core.info(`pushSignedCommits: using remote HEAD OID from ls-remote: ${expectedHeadOid}`);
}
}

// Full commit message (subject + body)
const { stdout: msgOut } = await exec.getExecOutput("git", ["log", "-1", "--format=%B", sha], { cwd });
const message = msgOut.trim();
const headline = message.split("\n")[0];
const body = message.split("\n").slice(1).join("\n").trim();
core.info(`pushSignedCommits: commit message headline: "${headline}"`);

// File changes for this commit (supports Add/Modify/Delete/Rename/Copy)
const { stdout: nameStatusOut } = await exec.getExecOutput("git", ["diff", "--name-status", `${sha}^`, sha], { cwd });
Expand All @@ -85,6 +110,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
}
}

core.info(`pushSignedCommits: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`);

/** @type {any} */
const input = {
branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch },
Expand All @@ -93,14 +120,19 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
expectedHeadOid,
};

core.info(`pushSignedCommits: calling createCommitOnBranch mutation (expectedHeadOid=${expectedHeadOid})`);
const result = await githubClient.graphql(
`mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`,
{ input }
);
const oid = result?.createCommitOnBranch?.commit?.oid;
core.info(`pushSignedCommits: signed commit created: ${oid}`);
const newOid = result && result.createCommitOnBranch && result.createCommitOnBranch.commit ? result.createCommitOnBranch.commit.oid : undefined;
if (typeof newOid !== "string" || newOid.length === 0) {
throw new Error("pushSignedCommits: GraphQL createCommitOnBranch did not return a valid commit OID");
}
lastOid = newOid;
core.info(`pushSignedCommits: signed commit created: ${lastOid}`);
}
core.info(`pushSignedCommits: all ${shas.length} commit(s) pushed as signed commits`);
} catch (graphqlError) {
Expand Down
79 changes: 79 additions & 0 deletions actions/setup/js/push_signed_commits.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,85 @@ describe("push_signed_commits integration tests", () => {
});
});

// ──────────────────────────────────────────────────────
// New branch – branch does not yet exist on remote
// ──────────────────────────────────────────────────────

describe("new branch (does not exist on remote)", () => {
it("should use parent OID when branch is not yet on remote (single commit)", async () => {
// Create a local branch with one commit but do NOT push it
execGit(["checkout", "-b", "new-unpushed-branch"], { cwd: workDir });
fs.writeFileSync(path.join(workDir, "new-file.txt"), "New file content\n");
execGit(["add", "new-file.txt"], { cwd: workDir });
execGit(["commit", "-m", "Add new-file.txt"], { cwd: workDir });

// Capture the local parent OID (main HEAD before the new commit)
const expectedParentOid = execGit(["rev-parse", "HEAD^"], { cwd: workDir }).stdout.trim();

global.exec = makeRealExec(workDir);
const githubClient = makeMockGithubClient();

await pushSignedCommits({
githubClient,
owner: "test-owner",
repo: "test-repo",
branch: "new-unpushed-branch",
baseRef: "origin/main",
cwd: workDir,
});

expect(githubClient.graphql).toHaveBeenCalledTimes(1);
const callArg = githubClient.graphql.mock.calls[0][1].input;
// expectedHeadOid must be the parent commit OID, not empty
expect(callArg.expectedHeadOid).toBe(expectedParentOid);
expect(callArg.branch.branchName).toBe("new-unpushed-branch");
expect(callArg.message.headline).toBe("Add new-file.txt");
// Verify the info log was emitted
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("not yet on the remote"));
});

it("should chain GraphQL OIDs for multiple commits on a new branch", async () => {
// Create a local branch with two commits but do NOT push it
execGit(["checkout", "-b", "new-multi-commit-branch"], { cwd: workDir });

fs.writeFileSync(path.join(workDir, "alpha.txt"), "Alpha\n");
execGit(["add", "alpha.txt"], { cwd: workDir });
execGit(["commit", "-m", "Add alpha.txt"], { cwd: workDir });

fs.writeFileSync(path.join(workDir, "beta.txt"), "Beta\n");
execGit(["add", "beta.txt"], { cwd: workDir });
execGit(["commit", "-m", "Add beta.txt"], { cwd: workDir });

// The parent OID of the first commit is main's HEAD (two commits back from current)
const expectedParentOid = execGit(["rev-parse", "HEAD^^"], { cwd: workDir }).stdout.trim();

global.exec = makeRealExec(workDir);
// Mock returns the same OID for all calls; second call must use that OID
const githubClient = makeMockGithubClient({ oid: "signed-oid-first" });

await pushSignedCommits({
githubClient,
owner: "test-owner",
repo: "test-repo",
branch: "new-multi-commit-branch",
baseRef: "origin/main",
cwd: workDir,
});

expect(githubClient.graphql).toHaveBeenCalledTimes(2);

// First call: expectedHeadOid is the parent commit OID (resolved via git rev-parse)
const firstCallArg = githubClient.graphql.mock.calls[0][1].input;
expect(firstCallArg.expectedHeadOid).toBe(expectedParentOid);
expect(firstCallArg.message.headline).toBe("Add alpha.txt");

// Second call: expectedHeadOid is the OID returned by the first GraphQL mutation
const secondCallArg = githubClient.graphql.mock.calls[1][1].input;
expect(secondCallArg.expectedHeadOid).toBe("signed-oid-first");
expect(secondCallArg.message.headline).toBe("Add beta.txt");
});
});

// ──────────────────────────────────────────────────────
// Fallback path – GraphQL fails → git push
// ──────────────────────────────────────────────────────
Expand Down
Loading