Repository navigation
Signed commits #21572
Description
Activity
github-actions commented
on Mar 18, 2026 on Mar 18, 2026 – with GitHub ActionsContributorMore actions🔭 Recon complete! Scout has charted the territory. Map ready! 🗺️
github-actions commented
on Mar 18, 2026 on Mar 18, 2026 – with GitHub ActionsContributorMore actions🔍 Scout Research Report
Triggered by @pelikhan
Executive Summary
Found 4 open community issues requesting signed commits support. The root cause is clear: commits created via
git am+git pushusingGITHUB_TOKENare unverified because GitHub does not auto-sign commits made over the Git protocol. GitHub'screateCommitOnBranchGraphQL mutation automatically signs commits — no GPG key management required. Below is a consolidated implementation plan and integration test plan.
Related Issues Found
# Title Severity #21562 Enterprise blocker: create-pull-requestfails withrequired_signaturesruleset🔴 Blocker #18900 Replace format-patch/git-am pipeline with tree diff + GraphQL commit API 🟡 Enhancement #18565 Commits via gitare unverified; switch to GraphQL for commits🟡 Enhancement #20322 Commits made by AI do not have signature 🟡 Enhancement
Implementation Plan
Root Cause
Two files apply commits via the
git am+git pushpipeline which produces unsigned commits:actions/setup/js/create_pull_request.cjs— line 694 (git am), line 742 (git push)actions/setup/js/push_to_pull_request_branch.cjs— line 449 (git am), line 486 (git push)actions/setup/js/generate_git_patch.cjs— creates the patch withgit format-patch
Phase 1 — Add
createCommitOnBranchhelper (shared utility)Create
actions/setup/js/graphql_commit.cjswith:// Helper to commit files via GitHub GraphQL (auto-signed/verified) async function createSignedCommit({ githubClient, owner, repo, branch, expectedHeadOid, message, additions, deletions }) { const mutation = ` mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } } `; return githubClient.graphql(mutation, { input: { branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch }, message: { headline: message }, fileChanges: { additions, deletions }, expectedHeadOid, } }); }
Phase 2 — Replace
git format-patchingenerate_git_patch.cjsInstead of emitting a patch file, emit a commit manifest (
manifest.json) capturing each commit:// Use gh CLI to walk commits const shas = execGitSync(["rev-list", "--reverse", `${baseRef}..HEAD`], { cwd }) .trim().split("\n").filter(Boolean); const commits = []; for (const sha of shas) { const message = execGitSync(["log", "-1", "--format=%B", sha], { cwd }).trim(); const parents = execGitSync(["log", "-1", "--format=%P", sha], { cwd }) .trim().split(" ").filter(Boolean); const nameStatus = execGitSync(["diff", "--name-status", `${sha}^`, sha], { cwd }); const additions = [], deletions = []; for (const line of nameStatus.trim().split("\n").filter(Boolean)) { const [status, filePath] = line.split("\t"); if (status === "D") { deletions.push({ path: filePath }); } else { const content = execGitSync(["show", `${sha}:${filePath}`], { cwd, binary: true }); additions.push({ path: filePath, contents: Buffer.from(content).toString("base64") }); } } commits.push({ sha, message, isMerge: parents.length > 1, mergeParent: parents[1] ?? null, additions, deletions }); } fs.writeFileSync(manifestPath, JSON.stringify({ commits }, null, 2));
Phase 3 — Replace
git am+git pushinpush_to_pull_request_branch.cjsandcreate_pull_request.cjsReplace the apply-patch logic with a GraphQL replay loop:
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); for (const commit of manifest.commits) { // Get current HEAD OID via gh CLI const { stdout } = await exec.getExecOutput("gh", [ "api", `repos/${owner}/${repo}/git/ref/heads/${branchName}`, "--jq", ".object.sha" ], { env: { ...process.env, GH_TOKEN: token } }); const expectedHeadOid = stdout.trim(); if (!commit.isMerge) { // Regular commit → createCommitOnBranch (auto-signed) await createSignedCommit({ githubClient, owner, repo, branch: branchName, expectedHeadOid, message: commit.message, additions: commit.additions, deletions: commit.deletions }); } else { // Merge commit → gh api merge + optional fixup await exec.exec("gh", [ "api", `repos/${owner}/${repo}/merges`, "-X", "POST", "-f", `base=${branchName}`, "-f", `head=${commit.mergeParent}`, "-f", `commit_message=${commit.message}` ], { env: { ...process.env, GH_TOKEN: token } }); } }
Phase 4 — Create branch via API (avoids unsigned init commit)
Replace
git checkout -b ${branchName}with:gh api repos/{owner}/{repo}/git/refs \ -X POST \ -f ref="refs/heads/${branchName}" \ -f sha="$(git rev-parse HEAD)"Files to Modify
File Change actions/setup/js/generate_git_patch.cjsEmit manifest JSON instead of format-patch actions/setup/js/create_pull_request.cjsReplace git am+git pushwith GraphQL replayactions/setup/js/push_to_pull_request_branch.cjsSame as above actions/setup/js/graphql_commit.cjs(new)Shared createSignedCommithelperEdge Cases to Handle
- Binary files: already base64-encoded in additions array
- File renames: treat as deletion + addition pair
- Empty commits (
allow-empty): use a no-op fileChanges addition with empty contents - Large payloads: GraphQL has ~100MB limit per mutation; chunk files if needed
- GHES compatibility: keep
git am+git pushas fallback for older GHES versions that may not supportcreateCommitOnBranch
Integration Test Plan (using git + test branches)
Setup: Create a test repo + branches
# Create a fresh test directory mkdir /tmp/signed-commits-test && cd /tmp/signed-commits-test git init --initial-branch=main git commit --allow-empty -m "Initial commit" # Create branches for each test scenario git checkout -b test/simple-file-add # TC-1 git checkout main && git checkout -b test/multi-file # TC-2 git checkout main && git checkout -b test/binary-file # TC-3 git checkout main && git checkout -b test/file-delete # TC-4 git checkout main && git checkout -b test/file-rename # TC-5 git checkout main && git checkout -b test/merge-commit # TC-6 git checkout main && git checkout -b test/empty-commit # TC-7
TC-1: Simple single-file addition
# On test/simple-file-add branch echo "hello world" > hello.txt git add hello.txt git commit -m "Add hello.txt" # Generate manifest (new code) and verify node generate_git_patch.cjs # should produce manifest.json # Verify manifest contains 1 commit with 1 addition cat manifest.json | jq '.commits | length == 1' cat manifest.json | jq '.commits[0].additions[0].path == "hello.txt"' # Apply via GraphQL and verify signature # Use gh CLI to invoke createCommitOnBranch gh api graphql \ -f query='mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }' \ -f input[branch][repositoryNameWithOwner]="$GH_REPO" \ -f input[branch][branchName]="test/simple-file-add-signed" \ -f input[message][headline]="Add hello.txt" \ -f input[fileChanges][additions][0][path]="hello.txt" \ -f input[fileChanges][additions][0][contents]="$(base64 -w0 hello.txt)" \ -f input[expectedHeadOid]="$(git rev-parse HEAD)" # Verify commit is signed gh api repos/$GH_REPO/commits/$(git rev-parse test/simple-file-add-signed) --jq '.commit.verification.verified' # Expected: true
TC-2: Multiple file modifications
git checkout test/multi-file echo "file1" > file1.txt && echo "file2" > file2.txt && echo "file3" > file3.txt git add -A && git commit -m "Add 3 files" echo "modified" > file1.txt && echo "also modified" > file2.txt git add -A && git commit -m "Modify 2 files" # Manifest should show 2 commits cat manifest.json | jq '.commits | length == 2' # Verify both commits are signed after GraphQL replay
TC-3: Binary file (e.g., PNG)
git checkout test/binary-file # Create a small binary file printf '\x89PNG\r\n\x1a\n\x00' > image.png git add image.png && git commit -m "Add binary PNG" # Verify base64 encoding in manifest cat manifest.json | jq '.commits[0].additions[0].contents' | base64 -d | xxd | head -1 # Should show PNG header bytes
TC-4: File deletion
git checkout test/file-delete echo "to be deleted" > delete-me.txt git add delete-me.txt && git commit -m "Add file" # Now delete it git rm delete-me.txt && git commit -m "Delete file" # Manifest should show deletion cat manifest.json | jq '.commits[1].deletions[0].path == "delete-me.txt"'
TC-5: File rename
git checkout test/file-rename echo "content" > original.txt git add original.txt && git commit -m "Add original" git mv original.txt renamed.txt && git commit -m "Rename file" # Manifest should show deletion of original + addition of renamed
TC-6: Merge commit (conflict resolution)
# Create two branches with conflicting changes git checkout main && git checkout -b test/feature-a echo "feature-a content" > shared.txt git add shared.txt && git commit -m "Feature A changes" git checkout main && git checkout -b test/feature-b echo "feature-b content" > shared.txt git add shared.txt && git commit -m "Feature B changes" # Merge with resolution git checkout test/merge-commit git merge test/feature-a git merge test/feature-b || true # will conflict echo "resolved content" > shared.txt git add shared.txt && git commit -m "Merge feature-b: resolve conflicts" # Verify manifest isMerge=true for merge commit cat manifest.json | jq '.commits[-1].isMerge == true' # Replay via GraphQL using mergeBranch + fixup approach
TC-7: Required signatures enforcement (enterprise scenario)
# Enable branch protection requiring signed commits (via gh CLI) gh api repos/$GH_REPO/branches/test/signed-required/protection \ -X PUT \ -f required_signatures=true \ --input - <<'EOF' { "required_status_checks": null, "enforce_admins": false, "required_pull_request_reviews": null, "restrictions": null, "required_linear_history": false, "allow_force_pushes": false, "required_signatures": true } EOF # TC-7a: Verify git push FAILS (control test) echo "test" > test.txt git add test.txt && git commit -m "unsigned commit" git push origin HEAD:test/signed-required # Expected: REJECTED # TC-7b: Verify GraphQL commit SUCCEEDS gh api graphql \ -f query='mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }' \ ... # Expected: SUCCESS with verified=true
TC-8: End-to-end workflow test (full pipeline)
# Simulate the full agent workflow using gh CLI OWNER="$GH_OWNER" REPO="$GH_REPO" BRANCH="test/e2e-signed-$(date +%s)" # 1. Create branch via API gh api repos/$OWNER/$REPO/git/refs \ -X POST \ -f ref="refs/heads/$BRANCH" \ -f sha="$(gh api repos/$OWNER/$REPO/git/ref/heads/main --jq '.object.sha')" # 2. Create commits via GraphQL (simulating agent output) EXPECTED_OID=$(gh api repos/$OWNER/$REPO/git/ref/heads/$BRANCH --jq '.object.sha') gh api graphql \ -f query='...' \ -f input[branch][repositoryNameWithOwner]="$OWNER/$REPO" \ -f input[branch][branchName]="$BRANCH" \ -f input[message][headline]="Signed commit from test" \ -f input[fileChanges][additions][0][path]="README.md" \ -f input[fileChanges][additions][0][contents]="$(echo 'test content' | base64 -w0)" \ -f input[expectedHeadOid]="$EXPECTED_OID" # 3. Verify signed status gh api repos/$OWNER/$REPO/commits/$BRANCH --jq '.commit.verification.verified' # Expected: true # 4. Create PR gh pr create --base main --head "$BRANCH" --title "Test signed commits" --body "E2E test" # 5. Verify PR shows verified checkmark on all commits gh pr view --json commits --jq '.commits[].verification.verified'
Pass Criteria
Test Pass Condition TC-1 Commit shows verified: truevia APITC-2 All commits in multi-commit session are verified TC-3 Binary file contents match original after round-trip TC-4 Deleted file no longer exists in target branch TC-5 Renamed file is present at new path TC-6 Merge commit has correct resolution (verified) TC-7a git pushis rejected byrequired_signaturesrulesetTC-7b GraphQL commit succeeds on same protected branch TC-8 Full pipeline produces a PR with all verified commits Recommended Implementation Order
- Start with Enterprise blocker: create-pull-request safe output fails with org-level required_signatures ruleset #21562 — it's a confirmed enterprise blocker with a detailed implementation plan already written
- Add the shared
createSignedCommithelper (reuse across bothcreate_pull_request.cjsandpush_to_pull_request_branch.cjs) - Keep
git am+git pushas GHES fallback - Run TC-7 as the smoke test to confirm the fix before merging
References: §23243143378
/scout review the plan. Make it simpler if possible.
- keep creating the existing patch files
- backward compatible with existing strategy
- integration test should reuse the same branch to avoid polluting the repo too much
Reacted by github-actionsgithub-actions commented
on Mar 18, 2026 on Mar 18, 2026 – with GitHub ActionsContributorMore actions🔭 Recon complete! Scout has charted the territory. Map ready! 🗺️
github-actions commented
on Mar 18, 2026 on Mar 18, 2026 – with GitHub ActionsContributorMore actions🔍 Scout Research Report
Triggered by @pelikhan
Executive Summary
The original plan was over-engineered. With the constraints (keep patch files, backward compat, minimal branch pollution), the change reduces to one targeted change: replace
git pushwith a GraphQL commit replay aftergit amhas already applied the patch locally.
Simplified Implementation Plan
Core Insight
The existing pipeline is:
git format-patch → [patch file] → git am → git pushThe only step that produces unsigned commits is
git push. The fix is to replace that final push with a GraphQL replay that reads the local git tree aftergit am.Keep everything the same. Only change the push step.
What to Change
Single file change:
push_to_pull_request_branch.cjsandcreate_pull_request.cjsReplace the
git pushcall with a helper that:- Reads the commits between
base..HEADfrom the locally-applied git tree - Replays each commit via
createCommitOnBranchGraphQL mutation (auto-signed)
// NEW: replace git push with GraphQL signed push async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, token }) { const shas = execGitSync(["rev-list", "--reverse", `${baseRef}..HEAD`], { cwd }) .trim().split("\n").filter(Boolean); for (const sha of shas) { // Get current remote HEAD OID const { stdout } = await exec.getExecOutput("gh", [ "api", `repos/${owner}/${repo}/git/ref/heads/${branch}`, "--jq", ".object.sha" ], { env: { ...process.env, GH_TOKEN: token } }); const expectedHeadOid = stdout.trim(); const message = execGitSync(["log", "-1", "--format=%B", sha], { cwd }).trim(); const nameStatus = execGitSync(["diff", "--name-status", `${sha}^`, sha], { cwd }); const additions = [], deletions = []; for (const line of nameStatus.trim().split("\n").filter(Boolean)) { const [status, filePath] = line.split("\t"); if (status === "D") { deletions.push({ path: filePath }); } else { const content = fs.readFileSync(path.join(cwd, filePath)); additions.push({ path: filePath, contents: content.toString("base64") }); } } await githubClient.graphql(` mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }`, { input: { branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch }, message: { headline: message }, fileChanges: { additions, deletions }, expectedHeadOid, } }); } }
Fallback (backward compat): Wrap in try/catch — if GraphQL fails (e.g., old GHES), fall back to
git pushwith a warning log.Files to Modify
File Change actions/setup/js/push_to_pull_request_branch.cjsReplace git pushwithpushSignedCommits()actions/setup/js/create_pull_request.cjsSame No changes to
generate_git_patch.cjs— patch files continue to be generated as-is.
Simplified Integration Test Plan
One branch, sequential tests, cleaned up between runs.
BRANCH="test/signed-commits-$(date +%Y%m%d)" # Create branch once gh api repos/$OWNER/$REPO/git/refs \ -X POST \ -f ref="refs/heads/$BRANCH" \ -f sha="$(gh api repos/$OWNER/$REPO/git/ref/heads/main --jq '.object.sha')" # ── TC-1: Simple file add ────────────────────────────────────────────────── EXPECTED=$(gh api repos/$OWNER/$REPO/git/ref/heads/$BRANCH --jq '.object.sha') gh api graphql -f query='mutation($i: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $i) { commit { oid } } }' \ -f i[branch][repositoryNameWithOwner]="$OWNER/$REPO" \ -f i[branch][branchName]="$BRANCH" \ -f i[message][headline]="TC-1: add hello.txt" \ -f i[fileChanges][additions][0][path]="hello.txt" \ -f i[fileChanges][additions][0][contents]="$(echo 'hello' | base64 -w0)" \ -f i[expectedHeadOid]="$EXPECTED" # Verify signed gh api repos/$OWNER/$REPO/commits/$BRANCH --jq '.commit.verification.verified' # → true # ── TC-2: File delete ────────────────────────────────────────────────────── # (delete hello.txt on same branch — continues from TC-1) EXPECTED=$(gh api repos/$OWNER/$REPO/git/ref/heads/$BRANCH --jq '.object.sha') gh api graphql ... \ -f i[fileChanges][deletions][0][path]="hello.txt" \ -f i[expectedHeadOid]="$EXPECTED" # Verify file gone # ── TC-3: Binary file (round-trip) ──────────────────────────────────────── # Add PNG, verify base64 round-trip preserves bytes # ── TC-4: E2E pipeline (patch → am → GraphQL push) ─────────────────────── # Run full agent pipeline on same branch, verify PR commits have verified=true # ── TC-5: required_signatures guard (only needs 1 sub-branch, deleted after) ── gh api repos/$OWNER/$REPO/branches/test/signed-enforcement/protection -X PUT ... # git push → rejected; GraphQL → succeeds; cleanup branch gh api repos/$OWNER/$REPO/git/refs/heads/test/signed-enforcement -X DELETE # ── Cleanup ──────────────────────────────────────────────────────────────── gh api repos/$OWNER/$REPO/git/refs/heads/$BRANCH -X DELETE
5 test cases, 1 persistent branch + 1 ephemeral protection-test branch (deleted immediately after).
Key Differences vs. Original Plan
Original Simplified Patch file format Replace with JSON manifest Keep as-is git amRemove Keep as-is Push mechanism GraphQL replay (from manifest) GraphQL replay (from local git tree after git am)New files graphql_commit.cjs(new)Inline helper in existing files GHES fallback Mentioned but not designed try/catch → git pushfallbackTest branches 8+ branches 1 persistent + 1 ephemeral References: §23243547051
- Reads the commits between
@copilot follow the last comment plan.
- added a commit that references this issue
on Mar 18, 2026 in v0.61.1
/scout research open issues labeled with community and "sign" that handles signing commits.
Generate an implementation plan from the issue. Leverage "gh" Cli as much as possible. Generate an integration test plan using git and some test branches.