Skip to content

Signed commits #21572

Description

@pelikhan

/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.

Activity

  1. github-actions commented on Mar 18, 2026

    @github-actions
    Contributor

    🔭 Recon complete! Scout has charted the territory. Map ready! 🗺️

  2. github-actions commented on Mar 18, 2026

    @github-actions
    Contributor

    🔍 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 push using GITHUB_TOKEN are unverified because GitHub does not auto-sign commits made over the Git protocol. GitHub's createCommitOnBranch GraphQL 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-request fails with required_signatures ruleset 🔴 Blocker
    #18900 Replace format-patch/git-am pipeline with tree diff + GraphQL commit API 🟡 Enhancement
    #18565 Commits via git are 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 push pipeline 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 with git format-patch

    Phase 1 — Add createCommitOnBranch helper (shared utility)

    Create actions/setup/js/graphql_commit.cjs with:

    // 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-patch in generate_git_patch.cjs

    Instead 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 push in push_to_pull_request_branch.cjs and create_pull_request.cjs

    Replace 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.cjs Emit manifest JSON instead of format-patch
    actions/setup/js/create_pull_request.cjs Replace git am + git push with GraphQL replay
    actions/setup/js/push_to_pull_request_branch.cjs Same as above
    actions/setup/js/graphql_commit.cjs (new) Shared createSignedCommit helper

    Edge 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 push as fallback for older GHES versions that may not support createCommitOnBranch
    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: true via API
    TC-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 push is rejected by required_signatures ruleset
    TC-7b GraphQL commit succeeds on same protected branch
    TC-8 Full pipeline produces a PR with all verified commits

    Recommended Implementation Order

    1. 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
    2. Add the shared createSignedCommit helper (reuse across both create_pull_request.cjs and push_to_pull_request_branch.cjs)
    3. Keep git am + git push as GHES fallback
    4. Run TC-7 as the smoke test to confirm the fix before merging

    References: §23243143378

    🔭 Intelligence gathered by Scout · ◷

  3. pelikhan commented on Mar 18, 2026

    @pelikhan
    CollaboratorAuthor

    /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
  4. github-actions commented on Mar 18, 2026

    @github-actions
    Contributor

    🔭 Recon complete! Scout has charted the territory. Map ready! 🗺️

  5. github-actions commented on Mar 18, 2026

    @github-actions
    Contributor

    🔍 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 push with a GraphQL commit replay after git am has already applied the patch locally.


    Simplified Implementation Plan

    Core Insight

    The existing pipeline is:

    git format-patch  →  [patch file]  →  git am  →  git push
    

    The 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 after git am.

    Keep everything the same. Only change the push step.


    What to Change

    Single file change: push_to_pull_request_branch.cjs and create_pull_request.cjs

    Replace the git push call with a helper that:

    1. Reads the commits between base..HEAD from the locally-applied git tree
    2. Replays each commit via createCommitOnBranch GraphQL 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 push with a warning log.

    Files to Modify

    File Change
    actions/setup/js/push_to_pull_request_branch.cjs Replace git push with pushSignedCommits()
    actions/setup/js/create_pull_request.cjs Same

    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 am Remove 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 push fallback
    Test branches 8+ branches 1 persistent + 1 ephemeral

    References: §23243547051

    🔭 Intelligence gathered by Scout · ◷

  6. pelikhan commented on Mar 18, 2026

    @pelikhan
    CollaboratorAuthor

    @copilot follow the last comment plan.

  7. pelikhan commented on Mar 18, 2026

    @pelikhan
    CollaboratorAuthor

    in v0.61.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions