From d91c9eabc1d0648ea06356bfacf3907dfafa1969 Mon Sep 17 00:00:00 2001 From: Greg Baker Date: Thu, 10 Sep 2026 20:19:02 -0700 Subject: [PATCH 1/5] feat(actions): support explicit commit range verification for non-PR workflows --- .../verify-signed-commit-authors/action.yml | 14 +- .../verify-signed-commit-authors.mjs | 258 +++++++++++++++--- .../verify-signed-commit-authors.test.mjs | 168 +++++++++++- 3 files changed, 380 insertions(+), 60 deletions(-) diff --git a/.github/actions/verify-signed-commit-authors/action.yml b/.github/actions/verify-signed-commit-authors/action.yml index 7edf011..ccf6d3f 100644 --- a/.github/actions/verify-signed-commit-authors/action.yml +++ b/.github/actions/verify-signed-commit-authors/action.yml @@ -1,5 +1,5 @@ name: Verify Signed Commit Authors -description: Verify that every pull request commit is SSH-signed by an allowed key. +description: Verify that pull request commits or specified commit ranges are SSH-signed by an allowed key. inputs: enforce: @@ -8,7 +8,15 @@ inputs: required: false github-token: description: Token with contents:read access to fetch pull request commits. - required: true + default: ${{ github.token }} + required: false + head-sha: + description: Commit SHA to verify when not running on a pull request. + required: false + base-ref: + description: Base branch or commit ref to compare against (defaults to origin/main). + default: 'origin/main' + required: false runs: using: composite @@ -19,4 +27,6 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} SIGNED_COMMIT_ACTION_PATH: ${{ github.action_path }} SIGNED_COMMIT_ENFORCE: ${{ inputs.enforce }} + SIGNED_COMMIT_HEAD_SHA: ${{ inputs.head-sha }} + SIGNED_COMMIT_BASE_REF: ${{ inputs.base-ref }} run: node "$SIGNED_COMMIT_ACTION_PATH/verify-signed-commit-authors.mjs" diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs index 25c5a8d..021a7ba 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs @@ -25,14 +25,6 @@ try { function main() { const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); - const pr = readPullRequestPayload(); - const prNumber = validateInteger('pull request number', pr.number); - const prCommitCount = validateInteger( - 'pull request commit count', - pr.commits, - ); - const prHeadSha = validateSha('pull request head SHA', pr.head?.sha); - const prBaseSha = validateSha('pull request base SHA', pr.base?.sha); const allowedSignersPath = getAllowedSignersPath(); const activeSignerCount = countActiveSigners(allowedSignersPath); @@ -40,31 +32,72 @@ function main() { `Checking signatures against ${activeSignerCount} allowed SSH key entr${activeSignerCount === 1 ? 'y' : 'ies'}.`, ); - fetchPullRequestCommits({ - prBaseSha, - prCommitCount, - prHeadSha, - prNumber, - workspace, - }); + const pr = tryReadPullRequestPayload(); + if (pr) { + const rawHead = process.env.SIGNED_COMMIT_HEAD_SHA?.trim(); + if (rawHead && rawHead !== pr.head?.sha) { + fail( + `head-sha (${rawHead}) does not match pull request head SHA (${pr.head?.sha}). Cannot override target commit on a pull request.`, + ); + } - info(`Checking ${prCommitCount} commit(s) on PR #${prNumber}.`); - verifyPullRequestCommits({ + const prNumber = validateInteger('pull request number', pr.number); + const prCommitCount = validateInteger( + 'pull request commit count', + pr.commits, + ); + const prHeadSha = validateSha('pull request head SHA', pr.head?.sha); + const prBaseSha = validateSha('pull request base SHA', pr.base?.sha); + + fetchPullRequestCommits({ + prBaseSha, + prCommitCount, + prHeadSha, + prNumber, + workspace, + }); + + info(`Checking ${prCommitCount} commit(s) on PR #${prNumber}.`); + verifyPullRequestCommits({ + allowedSignersPath, + expectedCommitCount: prCommitCount, + prBaseSha, + prHeadSha, + workspace, + }); + return; + } + + const rawHead = process.env.SIGNED_COMMIT_HEAD_SHA?.trim(); + if (!rawHead) { + fail('No pull_request payload and no head-sha provided.'); + } + + const headSha = validateSha('head SHA', rawHead); + const baseRef = process.env.SIGNED_COMMIT_BASE_REF?.trim() || 'origin/main'; + if (baseRef.startsWith('-')) { + fail(`Invalid base ref "${baseRef}": must not start with a dash.`); + } + const SAFE_REF_PATTERN = /^[0-9a-zA-Z._\/-]+$/; + if (!SAFE_REF_PATTERN.test(baseRef)) { + fail(`Invalid base ref "${baseRef}": contains invalid characters.`); + } + + verifyCommitRange({ allowedSignersPath, - expectedCommitCount: prCommitCount, - prBaseSha, - prHeadSha, + baseRef, + headSha, workspace, }); } -function readPullRequestPayload() { - const eventPath = requiredEnv('GITHUB_EVENT_PATH'); - const payload = JSON.parse(readFileSync(eventPath, 'utf8')); - if (!payload.pull_request) { - fail('No pull_request payload.'); +function tryReadPullRequestPayload() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !existsSync(eventPath)) { + return null; } - return payload.pull_request; + const payload = JSON.parse(readFileSync(eventPath, 'utf8')); + return payload.pull_request ?? null; } function getAllowedSignersPath() { @@ -88,6 +121,75 @@ function countActiveSigners(allowedSignersPath) { return activeSignerLines.length; } +function ensureGitRepository(workspace) { + if (!existsSync(join(workspace, '.git'))) { + const init = git(['init'], workspace); + if (!init.ok) { + fail(`Could not initialize git repository: ${commandDetails(init)}`); + } + const repo = process.env.GITHUB_REPOSITORY; + if (repo) { + const remoteAdd = git( + ['remote', 'add', 'origin', `https://github.com/${repo}`], + workspace, + ); + if (!remoteAdd.ok) { + fail(`Could not add origin remote: ${commandDetails(remoteAdd)}`); + } + } + } +} + +function fetchFromOrigin(ref, workspace) { + const token = process.env.GITHUB_TOKEN; + if (!token) { + return; + } + const authHeader = Buffer.from(`x-access-token:${token}`, 'utf8').toString( + 'base64', + ); + const configArgs = [ + '-c', + `http.https://github.com/.extraheader=AUTHORIZATION: basic ${authHeader}`, + ]; + + if (FULL_SHA_PATTERN.test(ref)) { + git([...configArgs, 'fetch', '--no-tags', 'origin', ref], workspace); + return; + } + + const branch = ref + .replace(/^origin\//, '') + .replace(/^refs\/remotes\/origin\//, ''); + git( + [ + ...configArgs, + 'fetch', + '--no-tags', + 'origin', + `+refs/heads/${branch}:refs/remotes/origin/${branch}`, + ], + workspace, + ); +} + +function resolveBaseCommit(baseRef, workspace) { + let res = git(['rev-parse', '--verify', `${baseRef}^{commit}`], workspace); + if (res.ok) { + return {ok: true, sha: res.stdout.trim()}; + } + if (!baseRef.startsWith('refs/')) { + res = git( + ['rev-parse', '--verify', `refs/remotes/${baseRef}^{commit}`], + workspace, + ); + if (res.ok) { + return {ok: true, sha: res.stdout.trim()}; + } + } + return {error: res, ok: false}; +} + function fetchPullRequestCommits({ prBaseSha, prCommitCount, @@ -100,19 +202,7 @@ function fetchPullRequestCommits({ 'base64', ); - if (!existsSync(join(workspace, '.git'))) { - const init = git(['init'], workspace); - if (!init.ok) { - fail(`Could not initialize git repository: ${commandDetails(init)}`); - } - const remoteAdd = git( - ['remote', 'add', 'origin', `https://github.com/${requiredEnv('GITHUB_REPOSITORY')}`], - workspace, - ); - if (!remoteAdd.ok) { - fail(`Could not add origin remote: ${commandDetails(remoteAdd)}`); - } - } + ensureGitRepository(workspace); const fetchBase = git( [ @@ -179,6 +269,91 @@ function verifyPullRequestCommits({ ); } + verifyCommits(commits, allowedSignersPath, workspace); + + workflowCommand( + 'notice', + `All ${commits.length} PR commit(s) are signed by allowed SSH keys.`, + ); +} + +function verifyCommitRange({ + allowedSignersPath, + baseRef, + headSha, + workspace, +}) { + ensureGitRepository(workspace); + + let headExists = git(['cat-file', '-e', `${headSha}^{commit}`], workspace); + if (!headExists.ok && process.env.GITHUB_TOKEN) { + fetchFromOrigin(headSha, workspace); + headExists = git(['cat-file', '-e', `${headSha}^{commit}`], workspace); + } + if (!headExists.ok) { + fail( + `Could not find head commit ${headSha} in workspace: ${commandDetails(headExists)}`, + ); + } + + let baseResolve = resolveBaseCommit(baseRef, workspace); + if (!baseResolve.ok && process.env.GITHUB_TOKEN) { + fetchFromOrigin(baseRef, workspace); + baseResolve = resolveBaseCommit(baseRef, workspace); + } + if (!baseResolve.ok) { + fail( + `Could not resolve base ref "${baseRef}" to a commit: ${commandDetails(baseResolve.error)}`, + ); + } + const baseSha = validateSha('base SHA', baseResolve.sha); + + const revList = git( + ['rev-list', '--reverse', `${baseSha}..${headSha}`], + workspace, + ); + if (!revList.ok) { + fail( + `Could not list commits between ${baseSha} and ${headSha}: ${commandDetails(revList)}`, + ); + } + + const commits = revList.stdout + .trim() + .split(LINE_SPLIT_PATTERN) + .filter(Boolean); + + if (commits.length === 0) { + const isAncestor = git( + ['merge-base', '--is-ancestor', headSha, baseSha], + workspace, + ); + if (!isAncestor.ok) { + fail( + `Target commit ${headSha} and base ref ${baseRef} (${baseSha}) produced 0 commits, but ${headSha} is not an ancestor of ${baseSha}.`, + ); + } + info( + `Target commit ${headSha.slice(0, 12)} is already merged into ${baseRef} (${baseSha.slice(0, 12)}); no unmerged commits to verify.`, + ); + workflowCommand( + 'notice', + `Commit ${headSha.slice(0, 12)} is already merged into ${baseRef}.`, + ); + return; + } + + info( + `Checking ${commits.length} unmerged commit(s) between ${baseRef} (${baseSha.slice(0, 12)}) and ${headSha.slice(0, 12)}.`, + ); + verifyCommits(commits, allowedSignersPath, workspace); + workflowCommand( + 'notice', + `All ${commits.length} unmerged commit(s) are signed by allowed SSH keys.`, + ); +} + +function verifyCommits(commits, allowedSignersPath, workspace) { const failures = []; for (const commit of commits) { const shortSha = commit.slice(0, 12); @@ -211,11 +386,6 @@ function verifyPullRequestCommits({ `Signed commit author check failed for ${failures.length}/${commits.length} commit(s):\n${details}`, ); } - - workflowCommand( - 'notice', - `All ${commits.length} PR commit(s) are signed by allowed SSH keys.`, - ); } function verifyAllowedSignature(commit, allowedSignersPath, workspace) { diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs index e4f4211..7d1932f 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs @@ -108,7 +108,123 @@ test('warn-only mode logs failures without failing the workflow', () => { assert.match(result.stdout, /::warning::.*has no active signing keys/); }); -function runVerifier({allowedSigners = activeAllowedSigners, env = {}} = {}) { +test('accepts explicit head-sha commit range when not running on a pull request', () => { + const result = runVerifier({ + env: { + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking signatures against 1 allowed SSH key entry\./, + ); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between origin\/main \(111111111111\) and 333333333333\./, + ); + assert.match( + result.stdout, + /222222222222: allowed SSH signature for alice@example\.com using SHA256:alice/, + ); + assert.match( + result.stdout, + /333333333333: allowed SSH signature for alice@example\.com using SHA256:alice/, + ); + assert.match( + result.stdout, + /::notice::All 2 unmerged commit\(s\) are signed by allowed SSH keys\./, + ); +}); + +test('accepts explicit head-sha when commit is already merged into base-ref', () => { + const result = runVerifier({ + env: { + FAKE_GIT_EMPTY_REV_LIST: '1', + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: baseSha, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Target commit 111111111111 is already merged into origin\/main \(111111111111\); no unmerged commits to verify\./, + ); + assert.match( + result.stdout, + /::notice::Commit 111111111111 is already merged into origin\/main\./, + ); +}); + +test('rejects explicit head-sha when an unmerged commit has an invalid signature', () => { + const result = runVerifier({ + env: { + FAKE_GIT_DENY_COMMIT: secondCommit, + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /::error::Signed commit author check failed for 1\/2 commit\(s\):%0A- 333333333333: signature is not made by an allowed SSH signing key: signature key is not allowed - reject me/, + ); +}); + +test('rejects run when neither pull_request payload nor head-sha is provided', () => { + const result = runVerifier({ + event: null, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /::error::No pull_request payload and no head-sha provided\./, + ); +}); + +test('rejects mismatched head-sha on a pull request event', () => { + const result = runVerifier({ + env: { + SIGNED_COMMIT_HEAD_SHA: '4444444444444444444444444444444444444444', + }, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /Cannot override target commit on a pull request/, + ); +}); + +test('rejects invalid base-ref starting with dash', () => { + const result = runVerifier({ + env: { + SIGNED_COMMIT_BASE_REF: '--upload-pack', + SIGNED_COMMIT_HEAD_SHA: headSha, + }, + event: null, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /Invalid base ref "--upload-pack": must not start with a dash\./, + ); +}); + +function runVerifier({ + allowedSigners = activeAllowedSigners, + env = {}, + event, +} = {}) { const dir = mkdtempSync(join(tmpdir(), 'verify-signed-commit-authors-test-')); tempDirs.push(dir); @@ -134,24 +250,29 @@ function runVerifier({allowedSigners = activeAllowedSigners, env = {}} = {}) { mkdirSync(dirname(allowedSignersPath), {recursive: true}); writeFileSync(allowedSignersPath, allowedSigners); - const eventPath = join(dir, 'event.json'); - writeFileSync( - eventPath, - JSON.stringify({ - pull_request: { - base: {sha: baseSha}, - commits: 2, - head: {sha: headSha}, - number: 42, - }, - }), - ); + let eventPath; + if (event !== null) { + eventPath = join(dir, 'event.json'); + writeFileSync( + eventPath, + JSON.stringify( + event ?? { + pull_request: { + base: {sha: baseSha}, + commits: 2, + head: {sha: headSha}, + number: 42, + }, + }, + ), + ); + } writeToolStubs(binDir, dir); const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8', env: { - GITHUB_EVENT_PATH: eventPath, + ...(eventPath ? {GITHUB_EVENT_PATH: eventPath} : {}), GITHUB_REPOSITORY: 'rocicorp/.github', GITHUB_TOKEN: 'github-token', GITHUB_WORKSPACE: workspace, @@ -199,7 +320,26 @@ if (command === 'cat-file' && args[1] === '-e') { process.exit(0); } +if (command === 'rev-parse') { + if (process.env.FAKE_GIT_FAIL_REV_PARSE) { + process.stderr.write('fatal: bad revision\\n'); + process.exit(1); + } + process.stdout.write(${JSON.stringify(`${baseSha}\n`)}); + process.exit(0); +} + +if (command === 'merge-base') { + if (process.env.FAKE_GIT_FAIL_MERGE_BASE) { + process.exit(1); + } + process.exit(0); +} + if (command === 'rev-list') { + if (process.env.FAKE_GIT_EMPTY_REV_LIST) { + process.exit(0); + } process.stdout.write(${JSON.stringify(`${firstCommit}\n${secondCommit}\n`)}); process.exit(0); } From 65c3d89b298496913ff5e0b76a38c31bfcc44506 Mon Sep 17 00:00:00 2001 From: Greg Baker Date: Thu, 10 Sep 2026 20:47:08 -0700 Subject: [PATCH 2/5] work --- .../verify-signed-commit-authors.mjs | 43 ++++++- .../verify-signed-commit-authors.test.mjs | 108 +++++++++++++++++- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs index 021a7ba..a54b2f6 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs @@ -160,7 +160,8 @@ function fetchFromOrigin(ref, workspace) { const branch = ref .replace(/^origin\//, '') - .replace(/^refs\/remotes\/origin\//, ''); + .replace(/^refs\/remotes\/origin\//, '') + .replace(/^refs\/heads\//, ''); git( [ ...configArgs, @@ -173,14 +174,43 @@ function fetchFromOrigin(ref, workspace) { ); } +function isShallowRepository(workspace) { + const res = git(['rev-parse', '--is-shallow-repository'], workspace); + return res.ok && res.stdout.trim() === 'true'; +} + +function unshallowFromOrigin(workspace) { + const token = process.env.GITHUB_TOKEN; + if (!token) { + return; + } + const authHeader = Buffer.from(`x-access-token:${token}`, 'utf8').toString( + 'base64', + ); + git( + [ + '-c', + `http.https://github.com/.extraheader=AUTHORIZATION: basic ${authHeader}`, + 'fetch', + '--unshallow', + '--no-tags', + 'origin', + ], + workspace, + ); +} + function resolveBaseCommit(baseRef, workspace) { let res = git(['rev-parse', '--verify', `${baseRef}^{commit}`], workspace); if (res.ok) { return {ok: true, sha: res.stdout.trim()}; } if (!baseRef.startsWith('refs/')) { + const remoteRef = baseRef.startsWith('origin/') + ? `refs/remotes/${baseRef}` + : `refs/remotes/origin/${baseRef}`; res = git( - ['rev-parse', '--verify', `refs/remotes/${baseRef}^{commit}`], + ['rev-parse', '--verify', `${remoteRef}^{commit}`], workspace, ); if (res.ok) { @@ -285,6 +315,15 @@ function verifyCommitRange({ }) { ensureGitRepository(workspace); + if (isShallowRepository(workspace) && process.env.GITHUB_TOKEN) { + unshallowFromOrigin(workspace); + } + if (isShallowRepository(workspace)) { + fail( + 'Cannot verify commit range in a shallow repository because intermediate commits cannot be reliably discovered. Please ensure the repository is not shallow (e.g. actions/checkout with fetch-depth: 0) or provide GITHUB_TOKEN so the action can unshallow the repository.', + ); + } + let headExists = git(['cat-file', '-e', `${headSha}^{commit}`], workspace); if (!headExists.ok && process.env.GITHUB_TOKEN) { fetchFromOrigin(headSha, workspace); diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs index 7d1932f..92f899d 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs @@ -220,6 +220,84 @@ test('rejects invalid base-ref starting with dash', () => { ); }); +test('accepts plain branch name (e.g. "main") for base-ref', () => { + const result = runVerifier({ + env: { + FAKE_GIT_FAIL_REV_PARSE_REFS: 'main^{commit},refs/remotes/main^{commit}', + SIGNED_COMMIT_BASE_REF: 'main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between main \(111111111111\) and 333333333333\./, + ); +}); + +test('unshallows repository in commit-range mode when shallow', () => { + const result = runVerifier({ + env: { + FAKE_GIT_SHALLOW: '1', + GITHUB_TOKEN: 'github-token', + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between origin\/main \(111111111111\) and 333333333333\./, + ); + const calls = readFileSync(join(result.dir, 'tool-calls.jsonl'), 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); + const unshallowCall = calls.find( + call => call.command === 'git' && call.args.includes('--unshallow'), + ); + assert.ok(unshallowCall, 'Expected git fetch --unshallow to be called'); +}); + +test('fails closed when repository is shallow and cannot be unshallowed', () => { + const result = runVerifier({ + env: { + FAKE_GIT_ALWAYS_SHALLOW: '1', + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /::error::Cannot verify commit range in a shallow repository because intermediate commits cannot be reliably discovered\./, + ); +}); + +test('fails closed when repository is shallow and GITHUB_TOKEN is missing', () => { + const result = runVerifier({ + env: { + FAKE_GIT_SHALLOW: '1', + GITHUB_TOKEN: '', + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 1); + assert.match( + result.stdout, + /::error::Cannot verify commit range in a shallow repository because intermediate commits cannot be reliably discovered\./, + ); +}); + function runVerifier({ allowedSigners = activeAllowedSigners, env = {}, @@ -293,7 +371,7 @@ function writeToolStubs(binDir, dir) { writeExecutable( join(binDir, 'git'), `#!/usr/bin/env node -import {appendFileSync} from 'node:fs'; +import {appendFileSync, existsSync} from 'node:fs'; const allArgs = process.argv.slice(2); appendFileSync( @@ -313,6 +391,13 @@ if (command === 'remote' && args[1] === 'add') { } if (command === 'fetch') { + if (args.includes('--unshallow')) { + if (process.env.FAKE_GIT_FAIL_UNSHALLOW) { + process.stderr.write('fatal: could not unshallow\\n'); + process.exit(1); + } + appendFileSync(${JSON.stringify(join(dir, 'unshallow-called'))}, '1'); + } process.exit(0); } @@ -321,10 +406,31 @@ if (command === 'cat-file' && args[1] === '-e') { } if (command === 'rev-parse') { + if (args.includes('--is-shallow-repository')) { + if (process.env.FAKE_GIT_ALWAYS_SHALLOW) { + process.stdout.write('true\\n'); + process.exit(0); + } + if (process.env.FAKE_GIT_SHALLOW) { + const unshallowed = existsSync(${JSON.stringify(join(dir, 'unshallow-called'))}); + process.stdout.write((unshallowed ? 'false' : 'true') + '\\n'); + process.exit(0); + } + process.stdout.write('false\\n'); + process.exit(0); + } if (process.env.FAKE_GIT_FAIL_REV_PARSE) { process.stderr.write('fatal: bad revision\\n'); process.exit(1); } + const failRefs = (process.env.FAKE_GIT_FAIL_REV_PARSE_REFS || '') + .split(',') + .filter(Boolean); + const verifyTarget = args[args.indexOf('--verify') + 1]; + if (verifyTarget && failRefs.includes(verifyTarget)) { + process.stderr.write(\`fatal: Needed a single revision: \${verifyTarget}\\n\`); + process.exit(1); + } process.stdout.write(${JSON.stringify(`${baseSha}\n`)}); process.exit(0); } From 86dbddb3448b64ef35ea57a15936274e9edbdc89 Mon Sep 17 00:00:00 2001 From: Greg Baker Date: Thu, 10 Sep 2026 20:55:06 -0700 Subject: [PATCH 3/5] review --- .../verify-signed-commit-authors/action.yml | 2 +- .../verify-signed-commit-authors.mjs | 9 +++++++-- .../verify-signed-commit-authors.test.mjs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/actions/verify-signed-commit-authors/action.yml b/.github/actions/verify-signed-commit-authors/action.yml index ccf6d3f..dbff687 100644 --- a/.github/actions/verify-signed-commit-authors/action.yml +++ b/.github/actions/verify-signed-commit-authors/action.yml @@ -7,7 +7,7 @@ inputs: default: 'true' required: false github-token: - description: Token with contents:read access to fetch pull request commits. + description: Token with contents:read access to fetch pull request commits, unshallow repositories, or fetch missing commits. default: ${{ github.token }} required: false head-sha: diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs index a54b2f6..6b8410e 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs @@ -205,10 +205,15 @@ function resolveBaseCommit(baseRef, workspace) { if (res.ok) { return {ok: true, sha: res.stdout.trim()}; } - if (!baseRef.startsWith('refs/')) { - const remoteRef = baseRef.startsWith('origin/') + let remoteRef; + if (baseRef.startsWith('refs/heads/')) { + remoteRef = `refs/remotes/origin/${baseRef.slice('refs/heads/'.length)}`; + } else if (!baseRef.startsWith('refs/')) { + remoteRef = baseRef.startsWith('origin/') ? `refs/remotes/${baseRef}` : `refs/remotes/origin/${baseRef}`; + } + if (remoteRef) { res = git( ['rev-parse', '--verify', `${remoteRef}^{commit}`], workspace, diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs index 92f899d..42c2846 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs @@ -237,6 +237,23 @@ test('accepts plain branch name (e.g. "main") for base-ref', () => { ); }); +test('accepts fully qualified branch ref (e.g. "refs/heads/main") for base-ref', () => { + const result = runVerifier({ + env: { + FAKE_GIT_FAIL_REV_PARSE_REFS: 'refs/heads/main^{commit}', + SIGNED_COMMIT_BASE_REF: 'refs/heads/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between refs\/heads\/main \(111111111111\) and 333333333333\./, + ); +}); + test('unshallows repository in commit-range mode when shallow', () => { const result = runVerifier({ env: { From 0a36d76044dc157f4d90827f52b67255f2224610 Mon Sep 17 00:00:00 2001 From: Greg Baker Date: Thu, 10 Sep 2026 21:12:44 -0700 Subject: [PATCH 4/5] review --- .../verify-signed-commit-authors.mjs | 8 +++++- .../verify-signed-commit-authors.test.mjs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs index 6b8410e..da9f262 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs @@ -201,7 +201,13 @@ function unshallowFromOrigin(workspace) { } function resolveBaseCommit(baseRef, workspace) { - let res = git(['rev-parse', '--verify', `${baseRef}^{commit}`], workspace); + const primaryRef = + FULL_SHA_PATTERN.test(baseRef) || baseRef.startsWith('refs/') + ? baseRef + : baseRef.startsWith('origin/') + ? `refs/remotes/${baseRef}` + : `refs/heads/${baseRef}`; + let res = git(['rev-parse', '--verify', `${primaryRef}^{commit}`], workspace); if (res.ok) { return {ok: true, sha: res.stdout.trim()}; } diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs index 42c2846..42a35c3 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs @@ -254,6 +254,34 @@ test('accepts fully qualified branch ref (e.g. "refs/heads/main") for base-ref', ); }); +test('resolves plain branch name through refs/heads/ or refs/remotes/origin/ even if a tag has the same name', () => { + const result = runVerifier({ + env: { + FAKE_GIT_FAIL_REV_PARSE_REFS: 'main^{commit}', + SIGNED_COMMIT_BASE_REF: 'main', + SIGNED_COMMIT_HEAD_SHA: secondCommit, + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between main \(111111111111\) and 333333333333\./, + ); + const calls = readFileSync(join(result.dir, 'tool-calls.jsonl'), 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); + const revParseCalls = calls.filter( + call => call.command === 'git' && call.args[0] === 'rev-parse', + ); + assert.ok( + revParseCalls.every(call => !call.args.includes('main^{commit}')), + 'Expected no git rev-parse call for bare ambiguous main^{commit}', + ); +}); + test('unshallows repository in commit-range mode when shallow', () => { const result = runVerifier({ env: { From da06f607fea3faa34b0bd2a86b3f79fd66df625f Mon Sep 17 00:00:00 2001 From: Greg Baker Date: Thu, 10 Sep 2026 21:34:49 -0700 Subject: [PATCH 5/5] work --- .../verify-signed-commit-authors.mjs | 8 +++---- .../verify-signed-commit-authors.test.mjs | 22 ++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs index da9f262..3a819bb 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.mjs @@ -35,9 +35,9 @@ function main() { const pr = tryReadPullRequestPayload(); if (pr) { const rawHead = process.env.SIGNED_COMMIT_HEAD_SHA?.trim(); - if (rawHead && rawHead !== pr.head?.sha) { + if (rawHead) { fail( - `head-sha (${rawHead}) does not match pull request head SHA (${pr.head?.sha}). Cannot override target commit on a pull request.`, + 'head-sha cannot be specified on a pull request event; commits are determined from the pull request payload.', ); } @@ -554,11 +554,11 @@ function validateInteger(label, value) { } function validateSha(label, value) { - const text = String(value); + const text = String(value).trim(); if (!FULL_SHA_PATTERN.test(text)) { fail(`Invalid ${label}: ${text}`); } - return text; + return text.toLowerCase(); } function info(message) { diff --git a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs index 42a35c3..c29b0ff 100644 --- a/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs +++ b/.github/actions/verify-signed-commit-authors/verify-signed-commit-authors.test.mjs @@ -190,17 +190,17 @@ test('rejects run when neither pull_request payload nor head-sha is provided', ( ); }); -test('rejects mismatched head-sha on a pull request event', () => { +test('rejects head-sha on a pull request event', () => { const result = runVerifier({ env: { - SIGNED_COMMIT_HEAD_SHA: '4444444444444444444444444444444444444444', + SIGNED_COMMIT_HEAD_SHA: headSha, }, }); assert.equal(result.status, 1); assert.match( result.stdout, - /Cannot override target commit on a pull request/, + /::error::head-sha cannot be specified on a pull request event; commits are determined from the pull request payload\./, ); }); @@ -282,6 +282,22 @@ test('resolves plain branch name through refs/heads/ or refs/remotes/origin/ eve ); }); +test('accepts uppercase head-sha in commit-range mode', () => { + const result = runVerifier({ + env: { + SIGNED_COMMIT_BASE_REF: 'origin/main', + SIGNED_COMMIT_HEAD_SHA: secondCommit.toUpperCase(), + }, + event: null, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Checking 2 unmerged commit\(s\) between origin\/main \(111111111111\) and 333333333333\./, + ); +}); + test('unshallows repository in commit-range mode when shallow', () => { const result = runVerifier({ env: {