From 0e32b438806d746f307cd4834e0d4d759f883a64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 22:32:33 +0000 Subject: [PATCH 1/5] Initial plan From cf6754594287422793fe953912c0e56844c3eaea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 22:49:43 +0000 Subject: [PATCH 2/5] fix: handle empty baseRef in pushSignedCommits for orphan branch first push When push_experiment_state.cjs creates a new orphan branch, checkoutOrCreateBranch() returns "" as baseRef. Previously pushSignedCommits passed this empty string directly to git rev-list as "..HEAD", which git interprets as an empty range (HEAD..HEAD), yielding zero commits and silently returning without pushing anything. Fix: when baseRef is empty, use "HEAD" instead of "..HEAD" so that all commits reachable from HEAD are enumerated. This enables experiment state to be persisted on the first push to a new orphan branch. Also adds a regression test that verifies the commit is attempted (GraphQL invoked) rather than silently skipped when baseRef is "". Fixes #30476 Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a58b208-67e3-4d8a-9676-a286da07c94b Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 5 ++- actions/setup/js/push_signed_commits.test.cjs | 41 +++++++++++++++++++ .../src/content/docs/agent-factory-status.mdx | 1 + .../docs/reference/frontmatter-full.md | 25 +++++++---- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 5b28dcfda41..e5a81eb9297 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -135,7 +135,10 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // correct sequencing even when commit dates are out of sync (e.g. after rebase --committer-date-is-author-date). // Using --parents emits each line as " [ ...]", which lets us detect merge commits // (more than one parent) in a single subprocess call without iterating each SHA individually. - const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${baseRef}..HEAD`], { cwd }); + // When baseRef is empty (orphan branch first push), list all commits reachable from HEAD instead + // of using the empty-string range "..HEAD" which git interprets as HEAD..HEAD (zero commits). + const revRange = baseRef ? `${baseRef}..HEAD` : "HEAD"; + const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", revRange], { cwd }); const revListLines = revListOut.trim().split("\n").filter(Boolean); const shas = revListLines.map(line => line.split(" ")[0]); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 5ff4e267147..8fa4e22192d 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -593,6 +593,47 @@ describe("push_signed_commits integration tests", () => { }); }); + // ────────────────────────────────────────────────────── + // Orphan branch – empty baseRef (push_experiment_state first push) + // ────────────────────────────────────────────────────── + + describe("orphan branch first push (empty baseRef)", () => { + it("should not return early when baseRef is empty string (regression: git rev-list empty-range was a no-op)", async () => { + // Simulate checkoutOrCreateBranch() returning "" for a brand-new orphan branch, + // which is exactly the scenario in push_experiment_state.cjs. + execGit(["checkout", "--orphan", "experiments/state"], { cwd: workDir }); + execGit(["read-tree", "--empty"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "state.json"), JSON.stringify({ runs: 1 })); + execGit(["add", "state.json"], { cwd: workDir }); + execGit(["commit", "-m", "Initial experiment state"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + // baseRef is "" - orphan branch first push. + // Before the fix this returned undefined immediately ("no new commits") because + // git rev-list ""..HEAD resolves to HEAD..HEAD (empty range). + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "experiments/state", + baseRef: "", + cwd: workDir, + }); + + // The fix: the commit must have been found and an attempt made to push it. + // GraphQL was invoked for the orphan commit (the mock always succeeds). + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + const callArg = githubClient.graphql.mock.calls[0][1].input; + expect(callArg.message.headline).toBe("Initial experiment state"); + expect(callArg.branch.branchName).toBe("experiments/state"); + + // Regression guard: must NOT have short-circuited with "no new commits". + expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("no new commits")); + }); + }); + // ────────────────────────────────────────────────────── // Fallback path – GraphQL fails → git push // ────────────────────────────────────────────────────── diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index d82cb6e1bd9..5f6b9e2e0ce 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -193,6 +193,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Smoke Update Cross-Repo PR](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-update-cross-repo-pr.md) | copilot | [![Smoke Update Cross-Repo PR](https://github.com/github/gh-aw/actions/workflows/smoke-update-cross-repo-pr.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-update-cross-repo-pr.lock.yml) | - | - | | [Smoke Workflow Call](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-workflow-call.md) | copilot | [![Smoke Workflow Call](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call.lock.yml) | - | - | | [Smoke Workflow Call with Inputs](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-workflow-call-with-inputs.md) | copilot | [![Smoke Workflow Call with Inputs](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call-with-inputs.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call-with-inputs.lock.yml) | - | - | +| [Stale PR Cleanup](https://github.com/github/gh-aw/blob/main/.github/workflows/stale-pr-cleanup.md) | copilot | [![Stale PR Cleanup](https://github.com/github/gh-aw/actions/workflows/stale-pr-cleanup.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/stale-pr-cleanup.lock.yml) | - | - | | [Stale Repository Identifier](https://github.com/github/gh-aw/blob/main/.github/workflows/stale-repo-identifier.md) | copilot | [![Stale Repository Identifier](https://github.com/github/gh-aw/actions/workflows/stale-repo-identifier.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/stale-repo-identifier.lock.yml) | - | - | | [Static Analysis Report](https://github.com/github/gh-aw/blob/main/.github/workflows/static-analysis-report.md) | claude | [![Static Analysis Report](https://github.com/github/gh-aw/actions/workflows/static-analysis-report.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/static-analysis-report.lock.yml) | - | - | | [Step Name Alignment](https://github.com/github/gh-aw/blob/main/.github/workflows/step-name-alignment.md) | claude | [![Step Name Alignment](https://github.com/github/gh-aw/actions/workflows/step-name-alignment.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/step-name-alignment.lock.yml) | `daily` | - | diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e427667333a..5859d2fe560 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -831,6 +831,15 @@ on: labels: [] # Array items: undefined + # Allow the bot-posted-menu / user-checks-box pattern: when a workflow posts a + # checkbox-menu comment as a GitHub App bot and a human maintainer edits it to + # tick a box (issue_comment:edited where actor ≠ comment.user.login), treat this + # as safe and skip the confused-deputy check. When false (default), the check + # applies to all issue_comment events. The Dependabot confused-deputy attack + # vector (issue_comment:created) is unaffected. + # (optional) + allow-bot-authored-trigger-comment: true + # Environment name that requires manual approval before the workflow can run. Must # match a valid environment configured in the repository settings. # (optional) @@ -1199,7 +1208,7 @@ experiments: # Storage backend for experiment state. 'repo' (default) persists state to a git # branch named 'experiments/{sanitizedWorkflowID}' (workflow ID lowercased with # hyphens removed, e.g. 'my-workflow' -> 'experiments/myworkflow') for durability - # across cache evictions. 'cache' uses GitHub Actions cache (legacy behavior). + # across cache evictions. 'cache' uses GitHub Actions cache (legacy behaviour). # Repo storage is recommended because experiment data is valuable and more durable # than cache. # (optional) @@ -3736,7 +3745,7 @@ safe-outputs: # Controls protected-file protection. String form: blocked (default), allowed, or # fallback-to-issue — or a GitHub Actions expression for reusable workflows. - # Object form: { policy, exclude } to customize the protected-file set. + # Object form: { policy, exclude } to customise the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -3749,7 +3758,7 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to - # parameterize the policy per caller. + # parameterise the policy per caller. protected-files: "example-value" # Option 3: Object form for granular control over the protected-file set. Use the @@ -3828,7 +3837,7 @@ safe-outputs: patch-format: "am" # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at - # runtime. Use in reusable workflow_call workflows to parameterize the transport + # runtime. Use in reusable workflow_call workflows to parameterise the transport # format per caller. patch-format: "example-value" @@ -4966,7 +4975,7 @@ safe-outputs: # Controls protected-file protection. String form: blocked (default), allowed, or # fallback-to-issue — or a GitHub Actions expression for reusable workflows. - # Object form: { policy, exclude } to customize the protected-file set. + # Object form: { policy, exclude } to customise the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -4979,7 +4988,7 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to - # parameterize the policy per caller. + # parameterise the policy per caller. protected-files: "example-value" # Option 3: Object form for granular control over the protected-file set. Use the @@ -5041,7 +5050,7 @@ safe-outputs: patch-format: "am" # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at - # runtime. Use in reusable workflow_call workflows to parameterize the transport + # runtime. Use in reusable workflow_call workflows to parameterise the transport # format per caller. patch-format: "example-value" @@ -5523,7 +5532,7 @@ safe-outputs: # Default values injected when the model omits a field # (optional) defaults: - # Behavior when no files match: 'error' (default) or 'ignore' + # Behaviour when no files match: 'error' (default) or 'ignore' # (optional) if-no-files: "error" From 7b6e3d6ae973e48a0a5dd987b8e0d595942f7c72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 22:53:09 +0000 Subject: [PATCH 3/5] fix: correct inaccurate git rev-list comment for empty baseRef Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a58b208-67e3-4d8a-9676-a286da07c94b Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index e5a81eb9297..3d94797e60b 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -136,7 +136,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // Using --parents emits each line as " [ ...]", which lets us detect merge commits // (more than one parent) in a single subprocess call without iterating each SHA individually. // When baseRef is empty (orphan branch first push), list all commits reachable from HEAD instead - // of using the empty-string range "..HEAD" which git interprets as HEAD..HEAD (zero commits). + // of using the empty-string range "..HEAD" which yields zero commits (the empty left operand + // resolves to an ambiguous ref rather than a real commit, so git returns an empty range). const revRange = baseRef ? `${baseRef}..HEAD` : "HEAD"; const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", revRange], { cwd }); const revListLines = revListOut.trim().split("\n").filter(Boolean); From bac1444f51f6ea33e802ba4ad14dd783685dc39f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:12:25 +0000 Subject: [PATCH 4/5] fix: detect empty baseRef upfront and use git push directly for orphan branch first push - Replace the `revRange = baseRef ? ... : "HEAD"` approach with an early-exit that bypasses the GraphQL path entirely when baseRef is "". - Orphan-branch root commits have no parent, so git rev-parse SHA^ would fail in production (throws in @actions/exec) and fall through to git push as a warning. Using an explicit early path makes the intent clear and avoids a spurious warning. - Update the regression test to assert GraphQL is NOT called, the direct git push path logs at info level, no warnings are emitted, and the commit lands on the remote. - Revert unrelated doc changes (agent-factory-status.mdx, frontmatter-full.md) that were accidentally staged in the previous commit. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/185a0df5-5018-4cd1-923f-266300c88e90 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 22 ++++++++++--- actions/setup/js/push_signed_commits.test.cjs | 33 +++++++++++-------- .../src/content/docs/agent-factory-status.mdx | 1 - .../docs/reference/frontmatter-full.md | 25 +++++--------- 4 files changed, 45 insertions(+), 36 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 3d94797e60b..54ef7fadf0c 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -131,15 +131,27 @@ async function readBlobAsBase64(blobHash, cwd) { * @returns {Promise} SHA of the commit that landed on the target branch */ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv }) { + // Orphan branch first push: baseRef is "" when push_experiment_state creates a brand-new + // branch for the first time (checkoutOrCreateBranch returns "" for new branches). + // The GraphQL createCommitOnBranch path cannot handle root commits (no parent to resolve), + // so skip it entirely and fall directly through to git push. + if (!baseRef) { + core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`); + await exec.exec("git", ["push", "origin", branch], { + cwd, + env: { ...process.env, ...(gitAuthEnv || {}) }, + }); + const { stdout: headOut } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], { cwd }); + const headSha = headOut.trim(); + core.info(`pushSignedCommits: git push completed for orphan branch, HEAD=${headSha}`); + return headSha; + } + // Collect the commits introduced (oldest-first) using topological order to ensure // correct sequencing even when commit dates are out of sync (e.g. after rebase --committer-date-is-author-date). // Using --parents emits each line as " [ ...]", which lets us detect merge commits // (more than one parent) in a single subprocess call without iterating each SHA individually. - // When baseRef is empty (orphan branch first push), list all commits reachable from HEAD instead - // of using the empty-string range "..HEAD" which yields zero commits (the empty left operand - // resolves to an ambiguous ref rather than a real commit, so git returns an empty range). - const revRange = baseRef ? `${baseRef}..HEAD` : "HEAD"; - const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", revRange], { cwd }); + const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${baseRef}..HEAD`], { cwd }); const revListLines = revListOut.trim().split("\n").filter(Boolean); const shas = revListLines.map(line => line.split(" ")[0]); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 8fa4e22192d..21a3ebd0d16 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -598,22 +598,24 @@ describe("push_signed_commits integration tests", () => { // ────────────────────────────────────────────────────── describe("orphan branch first push (empty baseRef)", () => { - it("should not return early when baseRef is empty string (regression: git rev-list empty-range was a no-op)", async () => { + it("should bypass GraphQL and use git push directly when baseRef is empty (orphan branch root commit)", async () => { // Simulate checkoutOrCreateBranch() returning "" for a brand-new orphan branch, // which is exactly the scenario in push_experiment_state.cjs. + // Orphan-branch first commits are root commits (no parent), so the GraphQL + // createCommitOnBranch path cannot resolve a parent OID. The fix detects + // !baseRef upfront and uses git push directly instead of attempting GraphQL. execGit(["checkout", "--orphan", "experiments/state"], { cwd: workDir }); execGit(["read-tree", "--empty"], { cwd: workDir }); fs.writeFileSync(path.join(workDir, "state.json"), JSON.stringify({ runs: 1 })); execGit(["add", "state.json"], { cwd: workDir }); execGit(["commit", "-m", "Initial experiment state"], { cwd: workDir }); + const expectedSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + global.exec = makeRealExec(workDir); const githubClient = makeMockGithubClient(); - // baseRef is "" - orphan branch first push. - // Before the fix this returned undefined immediately ("no new commits") because - // git rev-list ""..HEAD resolves to HEAD..HEAD (empty range). - await pushSignedCommits({ + const result = await pushSignedCommits({ githubClient, owner: "test-owner", repo: "test-repo", @@ -622,15 +624,20 @@ describe("push_signed_commits integration tests", () => { cwd: workDir, }); - // The fix: the commit must have been found and an attempt made to push it. - // GraphQL was invoked for the orphan commit (the mock always succeeds). - expect(githubClient.graphql).toHaveBeenCalledTimes(1); - const callArg = githubClient.graphql.mock.calls[0][1].input; - expect(callArg.message.headline).toBe("Initial experiment state"); - expect(callArg.branch.branchName).toBe("experiments/state"); + // GraphQL must NOT be called (orphan root commit has no parent to resolve). + expect(githubClient.graphql).not.toHaveBeenCalled(); + + // An info-level log (not a warning) should indicate the direct-push path. + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("empty baseRef detected")); + expect(mockCore.warning).not.toHaveBeenCalled(); + + // The commit must now be on the remote – state was NOT silently discarded. + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/experiments/state"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + expect(remoteOid).toBe(expectedSha); - // Regression guard: must NOT have short-circuited with "no new commits". - expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("no new commits")); + // Return value should be the HEAD SHA. + expect(result).toBe(expectedSha); }); }); diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index 5f6b9e2e0ce..d82cb6e1bd9 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -193,7 +193,6 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Smoke Update Cross-Repo PR](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-update-cross-repo-pr.md) | copilot | [![Smoke Update Cross-Repo PR](https://github.com/github/gh-aw/actions/workflows/smoke-update-cross-repo-pr.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-update-cross-repo-pr.lock.yml) | - | - | | [Smoke Workflow Call](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-workflow-call.md) | copilot | [![Smoke Workflow Call](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call.lock.yml) | - | - | | [Smoke Workflow Call with Inputs](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-workflow-call-with-inputs.md) | copilot | [![Smoke Workflow Call with Inputs](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call-with-inputs.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-workflow-call-with-inputs.lock.yml) | - | - | -| [Stale PR Cleanup](https://github.com/github/gh-aw/blob/main/.github/workflows/stale-pr-cleanup.md) | copilot | [![Stale PR Cleanup](https://github.com/github/gh-aw/actions/workflows/stale-pr-cleanup.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/stale-pr-cleanup.lock.yml) | - | - | | [Stale Repository Identifier](https://github.com/github/gh-aw/blob/main/.github/workflows/stale-repo-identifier.md) | copilot | [![Stale Repository Identifier](https://github.com/github/gh-aw/actions/workflows/stale-repo-identifier.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/stale-repo-identifier.lock.yml) | - | - | | [Static Analysis Report](https://github.com/github/gh-aw/blob/main/.github/workflows/static-analysis-report.md) | claude | [![Static Analysis Report](https://github.com/github/gh-aw/actions/workflows/static-analysis-report.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/static-analysis-report.lock.yml) | - | - | | [Step Name Alignment](https://github.com/github/gh-aw/blob/main/.github/workflows/step-name-alignment.md) | claude | [![Step Name Alignment](https://github.com/github/gh-aw/actions/workflows/step-name-alignment.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/step-name-alignment.lock.yml) | `daily` | - | diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 5859d2fe560..e427667333a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -831,15 +831,6 @@ on: labels: [] # Array items: undefined - # Allow the bot-posted-menu / user-checks-box pattern: when a workflow posts a - # checkbox-menu comment as a GitHub App bot and a human maintainer edits it to - # tick a box (issue_comment:edited where actor ≠ comment.user.login), treat this - # as safe and skip the confused-deputy check. When false (default), the check - # applies to all issue_comment events. The Dependabot confused-deputy attack - # vector (issue_comment:created) is unaffected. - # (optional) - allow-bot-authored-trigger-comment: true - # Environment name that requires manual approval before the workflow can run. Must # match a valid environment configured in the repository settings. # (optional) @@ -1208,7 +1199,7 @@ experiments: # Storage backend for experiment state. 'repo' (default) persists state to a git # branch named 'experiments/{sanitizedWorkflowID}' (workflow ID lowercased with # hyphens removed, e.g. 'my-workflow' -> 'experiments/myworkflow') for durability - # across cache evictions. 'cache' uses GitHub Actions cache (legacy behaviour). + # across cache evictions. 'cache' uses GitHub Actions cache (legacy behavior). # Repo storage is recommended because experiment data is valuable and more durable # than cache. # (optional) @@ -3745,7 +3736,7 @@ safe-outputs: # Controls protected-file protection. String form: blocked (default), allowed, or # fallback-to-issue — or a GitHub Actions expression for reusable workflows. - # Object form: { policy, exclude } to customise the protected-file set. + # Object form: { policy, exclude } to customize the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -3758,7 +3749,7 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to - # parameterise the policy per caller. + # parameterize the policy per caller. protected-files: "example-value" # Option 3: Object form for granular control over the protected-file set. Use the @@ -3837,7 +3828,7 @@ safe-outputs: patch-format: "am" # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at - # runtime. Use in reusable workflow_call workflows to parameterise the transport + # runtime. Use in reusable workflow_call workflows to parameterize the transport # format per caller. patch-format: "example-value" @@ -4975,7 +4966,7 @@ safe-outputs: # Controls protected-file protection. String form: blocked (default), allowed, or # fallback-to-issue — or a GitHub Actions expression for reusable workflows. - # Object form: { policy, exclude } to customise the protected-file set. + # Object form: { policy, exclude } to customize the protected-file set. # (optional) # This field supports multiple formats (oneOf): @@ -4988,7 +4979,7 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to 'blocked', 'allowed', or # 'fallback-to-issue' at runtime. Use in reusable workflow_call workflows to - # parameterise the policy per caller. + # parameterize the policy per caller. protected-files: "example-value" # Option 3: Object form for granular control over the protected-file set. Use the @@ -5050,7 +5041,7 @@ safe-outputs: patch-format: "am" # Option 2: GitHub Actions expression that resolves to 'am' or 'bundle' at - # runtime. Use in reusable workflow_call workflows to parameterise the transport + # runtime. Use in reusable workflow_call workflows to parameterize the transport # format per caller. patch-format: "example-value" @@ -5532,7 +5523,7 @@ safe-outputs: # Default values injected when the model omits a field # (optional) defaults: - # Behaviour when no files match: 'error' (default) or 'ignore' + # Behavior when no files match: 'error' (default) or 'ignore' # (optional) if-no-files: "error" From 71075c1258e6f5891893f5589203fa6d519c59df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:30:46 +0000 Subject: [PATCH 5/5] test: add 5 git-coverage tests identified by deep review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. baseRef as a full commit SHA — covers push_experiment_state real-world path where `git rev-parse HEAD` is stored and passed back as baseRef on the next run. 2. Binary file content — exercises the binary-safe readBlobAsBase64 listener path with actual non-UTF-8 bytes (PNG magic + 0x00/0xFF/0xFE) and verifies the base64 payload round-trips without corruption. 3. Multiple commits on orphan branch — ensures all commits land on remote when baseRef="" and the orphan has 2+ commits (not just 1). 4. Rename with executable bit — covers the previously-untested R-status + dstMode=100755 branch (line 247): warns about exec bit loss but continues via GraphQL rather than falling back. 5. Deleted submodule — covers the D-status + srcMode=160000 branch (line 226): when a previously-committed gitlink is removed, triggers git push fallback (only the ADDED submodule path was tested before). Agent-Logs-Url: https://github.com/github/gh-aw/sessions/89897c51-ee9a-4bfc-8733-655a8347ea4c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.test.cjs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 21a3ebd0d16..caf063d299d 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -1164,4 +1164,236 @@ describe("push_signed_commits integration tests", () => { expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("merge commit")); }); }); + + // ────────────────────────────────────────────────────── + // baseRef as a full commit SHA (push_experiment_state path) + // ────────────────────────────────────────────────────── + + describe("baseRef as a full commit SHA", () => { + it("should correctly compute rev-list range when baseRef is a 40-char SHA (push_experiment_state real-world path)", async () => { + // push_experiment_state.cjs records: baseRef = execGitSync(["rev-parse", "HEAD"]).trim() + // on a pre-existing branch, yielding a full SHA not a symbolic ref. + execGit(["checkout", "-b", "sha-baseref-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "state.json"), JSON.stringify({ run: 1 })); + execGit(["add", "state.json"], { cwd: workDir }); + execGit(["commit", "-m", "First state"], { cwd: workDir }); + execGit(["push", "-u", "origin", "sha-baseref-branch"], { cwd: workDir }); + + // Record the SHA of the current HEAD (simulates what push_experiment_state does) + const baseRefSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + + // Add a new commit that should be picked up by rev-list ..HEAD + fs.writeFileSync(path.join(workDir, "state.json"), JSON.stringify({ run: 2 })); + execGit(["add", "state.json"], { cwd: workDir }); + execGit(["commit", "-m", "Second state"], { cwd: workDir }); + execGit(["push", "origin", "sha-baseref-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "sha-baseref-branch", + baseRef: baseRefSha, // full 40-char SHA, not a branch ref + cwd: workDir, + }); + + // Only the new commit must be found and sent to GraphQL + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + const callArg = githubClient.graphql.mock.calls[0][1].input; + expect(callArg.message.headline).toBe("Second state"); + }); + }); + + // ────────────────────────────────────────────────────── + // Binary file content (readBlobAsBase64 binary-safety) + // ────────────────────────────────────────────────────── + + describe("binary file content", () => { + it("should base64-encode binary files without corruption (readBlobAsBase64 binary-safe path)", async () => { + // readBlobAsBase64 uses exec.exec with a listeners.stdout Buffer callback to avoid + // the UTF-8 decoding that exec.getExecOutput applies. This test verifies that binary + // bytes (including NUL, 0xFF, 0xFE, and bytes invalid in UTF-8) are preserved. + execGit(["checkout", "-b", "binary-branch"], { cwd: workDir }); + + // Arbitrary binary bytes that are NOT valid UTF-8. 0x89 0x50 0x4E 0x47 is the PNG + // magic header; 0x00 0xFF 0xFE are bytes that would be corrupted by UTF-8 decoding. + const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0xfe]); + fs.writeFileSync(path.join(workDir, "image.bin"), binaryContent); + execGit(["add", "image.bin"], { cwd: workDir }); + execGit(["commit", "-m", "Add binary file"], { cwd: workDir }); + execGit(["push", "-u", "origin", "binary-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "binary-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + const callArg = githubClient.graphql.mock.calls[0][1].input; + expect(callArg.fileChanges.additions).toHaveLength(1); + expect(callArg.fileChanges.additions[0].path).toBe("image.bin"); + + // Decode the base64 payload and verify every byte is intact + const decoded = Buffer.from(callArg.fileChanges.additions[0].contents, "base64"); + expect(decoded.equals(binaryContent)).toBe(true); + }); + }); + + // ────────────────────────────────────────────────────── + // Orphan branch with multiple commits (baseRef="") + // ────────────────────────────────────────────────────── + + describe("orphan branch with multiple commits (empty baseRef)", () => { + it("should push all commits when orphan branch has more than one commit", async () => { + // The single-commit orphan test verifies the happy path. This test ensures that + // git push (not just the first commit) lands all local commits on the remote. + execGit(["checkout", "--orphan", "experiments/multi"], { cwd: workDir }); + execGit(["read-tree", "--empty"], { cwd: workDir }); + + fs.writeFileSync(path.join(workDir, "state.json"), JSON.stringify({ run: 1 })); + execGit(["add", "state.json"], { cwd: workDir }); + execGit(["commit", "-m", "First experiment commit"], { cwd: workDir }); + + const firstSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + + fs.writeFileSync(path.join(workDir, "meta.json"), JSON.stringify({ ts: 42 })); + execGit(["add", "meta.json"], { cwd: workDir }); + execGit(["commit", "-m", "Second experiment commit"], { cwd: workDir }); + + const expectedSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + expect(expectedSha).not.toBe(firstSha); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + const result = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "experiments/multi", + baseRef: "", + cwd: workDir, + }); + + // Both commits must be present on the remote + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/experiments/multi"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + expect(remoteOid).toBe(expectedSha); + + // Return value is the HEAD SHA + expect(result).toBe(expectedSha); + + // GraphQL must never be called for orphan first push + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + }); + + // ────────────────────────────────────────────────────── + // Rename with executable bit (R-status + dstMode=100755) + // ────────────────────────────────────────────────────── + + describe("rename with executable bit", () => { + it("should warn about executable bit loss on renamed destination but continue with GraphQL", async () => { + // git diff-tree detects renames (diff.renames=true by default). + // When the renamed destination has mode 100755, production code (line 247) warns + // but does NOT fall back to git push. This path has no coverage without this test. + execGit(["checkout", "-b", "rename-exec-branch"], { cwd: workDir }); + + fs.writeFileSync(path.join(workDir, "script.sh"), "#!/bin/bash\necho hello\n"); + execGit(["add", "script.sh"], { cwd: workDir }); + execGit(["commit", "-m", "Add script.sh"], { cwd: workDir }); + execGit(["push", "-u", "origin", "rename-exec-branch"], { cwd: workDir }); + + // Rename and set executable bit on the destination + fs.renameSync(path.join(workDir, "script.sh"), path.join(workDir, "run.sh")); + fs.chmodSync(path.join(workDir, "run.sh"), 0o755); + execGit(["add", "-A"], { cwd: workDir }); + execGit(["commit", "-m", "Rename script.sh to run.sh with exec bit"], { cwd: workDir }); + execGit(["push", "origin", "rename-exec-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "rename-exec-branch", + baseRef: "rename-exec-branch^", + cwd: workDir, + }); + + // GraphQL must still be called – executable bit loss is a warning, not a fallback + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + // Warning about executable bit loss on the renamed destination + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("executable bit on run.sh will be lost in signed commit")); + + // Payload: original path deleted, new path added with correct content + const callArg = githubClient.graphql.mock.calls[0][1].input; + expect(callArg.fileChanges.deletions).toContainEqual({ path: "script.sh" }); + expect(callArg.fileChanges.additions.find(a => a.path === "run.sh")).toBeTruthy(); + const decoded = Buffer.from(callArg.fileChanges.additions.find(a => a.path === "run.sh").contents, "base64").toString(); + expect(decoded).toContain("echo hello"); + }); + }); + + // ────────────────────────────────────────────────────── + // Deleted submodule (D status + srcMode=160000) fallback + // ────────────────────────────────────────────────────── + + describe("deleted submodule fallback", () => { + it("should fall back to git push and warn when a submodule entry is deleted", async () => { + // The existing submodule test only covers ADDING a submodule. + // This test covers the D-status + srcMode=160000 code path at line 226, + // where a previously-committed gitlink entry is removed in a new commit. + execGit(["checkout", "-b", "submodule-delete-branch"], { cwd: workDir }); + + // Add a fake gitlink (submodule) via update-index, commit, and push + const headSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + execGit(["update-index", "--add", "--cacheinfo", `160000,${headSha},mysubmodule`], { cwd: workDir }); + execGit(["commit", "-m", "Add submodule"], { cwd: workDir }); + execGit(["push", "-u", "origin", "submodule-delete-branch"], { cwd: workDir }); + + // Now remove the submodule entry and commit + execGit(["update-index", "--remove", "mysubmodule"], { cwd: workDir }); + execGit(["commit", "-m", "Remove submodule"], { cwd: workDir }); + execGit(["push", "origin", "submodule-delete-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "submodule-delete-branch", + // Only replay the delete commit + baseRef: "submodule-delete-branch^", + cwd: workDir, + }); + + // GraphQL must NOT be called – deleted submodule triggers git push fallback + expect(githubClient.graphql).not.toHaveBeenCalled(); + // Warning about submodule detection + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("submodule change detected in mysubmodule")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("falling back to git push")); + + // All commits must be present on the remote via git push fallback + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/submodule-delete-branch"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + const localOid = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + expect(remoteOid).toBe(localOid); + }); + }); });