diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index e93417720d..01397d7bcd 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -245,6 +245,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} @@ -450,13 +451,20 @@ jobs: { "description": "Add the 'smoked' label to the current pull request (can only be called once)", "inputSchema": { - "additionalProperties": true, + "additionalProperties": false, "properties": { - "payload": { - "description": "JSON-encoded payload to pass to the action", + "labels": { + "description": "The labels' name to be added. Must be separated with line breaks if there're multiple labels.", + "type": "string" + }, + "number": { + "description": "The number of the issue or pull request.", "type": "string" } }, + "required": [ + "labels" + ], "type": "object" }, "name": "add_smoked_label" @@ -1561,7 +1569,8 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - payload: ${{ steps.process_safe_outputs.outputs.action_add_smoked_label_payload }} + labels: ${{ fromJSON(steps.process_safe_outputs.outputs.action_add_smoked_label_payload).labels }} + number: ${{ fromJSON(steps.process_safe_outputs.outputs.action_add_smoked_label_payload).number }} - name: Upload safe output items if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index cd0316b115..45ec1e316c 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -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. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 481de1bf24..bbb8593c94 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -44,15 +44,39 @@ 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) @@ -60,6 +84,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c 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 }); @@ -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 }, @@ -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) { diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 67e933976c..dde736dec4 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -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 // ──────────────────────────────────────────────────────