From 65acdde5ea600e28803169f9cda8315e277bc9da Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Wed, 23 Sep 2026 14:47:15 -0700 Subject: [PATCH 1/4] feat: report the pull request's commit when CI checks out a merge commit On pull request builds GitHub Actions checks out refs/pull/N/merge, so commitInfo returned "Merge into " with the merge commit's author. GitLab merged results pipelines, Azure Pipelines, Travis, Semaphore, Bitbucket Pipelines and Buildkite with the merge refspec do the same. The pull request's sha comes from the GitHub event file or the provider's environment variable. When that commit is a parent of the checked-out commit, its sha, message, author, email and timestamp are returned instead. In a depth-1 clone, the actions/checkout default, that one commit is fetched from origin with a 3s timeout. A failed fetch keeps the checked-out commit. CURRENTS_DISABLE_HEAD_COMMIT_FETCH=true skips the fetch. COMMIT_INFO_* variables still take priority, and COMMIT_INFO_SHA skips the lookup. engines.node goes to >=8 for async/await. Refs ENG-934 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdJrRhBLEH9Uwt97JMGVTR --- README.md | 20 ++- package-lock.json | 6 +- package.json | 4 +- src/index.js | 21 ++- src/pull-request-head-spec.js | 287 ++++++++++++++++++++++++++++++++++ src/pull-request-head.js | 152 ++++++++++++++++++ 6 files changed, 479 insertions(+), 11 deletions(-) create mode 100644 src/pull-request-head-spec.js create mode 100644 src/pull-request-head.js diff --git a/README.md b/README.md index 9495ca2..3b1a18b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Collects Git commit info from git CLI ## Install -Requires [Node](https://nodejs.org/en/) version 6 or above. +Requires [Node](https://nodejs.org/en/) version 8 or above. ```sh npm install --save @currents-dev/commit-info @@ -37,6 +37,24 @@ Notes: - If a command fails, returns `null` for each property - If you need to debug, run with `DEBUG=commit-info` environment variable. +## Pull request builds + +On pull request builds many CI providers check out a commit that merges the pull request into its target branch. GitHub Actions, for example, checks out `refs/pull//merge`, whose message is `Merge into `. + +When the checked-out commit is such a merge, `commitInfo` reports the pull request's last commit instead: its `sha`, `message`, `email`, `author` and `timestamp`. It finds that commit in: + +- GitHub Actions: `pull_request.head.sha` in the event file (`GITHUB_EVENT_PATH`) +- GitLab merged results pipelines: `CI_MERGE_REQUEST_SOURCE_BRANCH_SHA` +- Azure Pipelines: `SYSTEM_PULLREQUEST_SOURCECOMMITID` +- Travis CI: `TRAVIS_PULL_REQUEST_SHA` +- Semaphore: `SEMAPHORE_GIT_PR_SHA` +- Buildkite: `BUILDKITE_PULL_REQUEST_HEAD_COMMIT` +- Bitbucket Pipelines: `BITBUCKET_COMMIT` + +The commit is used only when it is a parent of the checked-out commit. If a shallow clone does not contain it (for example `actions/checkout` with the default `fetch-depth: 1`), it is fetched with `git fetch --depth=1 origin `, with a 3 second timeout. If the fetch fails, the checked-out commit is reported. Set `CURRENTS_DISABLE_HEAD_COMMIT_FETCH=true` to skip the fetch. + +The `COMMIT_INFO_*` variables below still take priority. When `COMMIT_INFO_SHA` is set, the pull request's commit is not looked up. + ## Fallback environment variables If getting the commit information using `git` fails for some reason, you can provide the commit information by setting the environment variables. This module will look at the following environment variables as a fallback diff --git a/package-lock.json b/package-lock.json index 8c26018..2e71245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@currents/commit-info", - "version": "1.0.1-beta.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@currents/commit-info", - "version": "1.0.1-beta.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "bluebird": "3.5.5", @@ -37,7 +37,7 @@ "stub-spawn-once": "2.3.0" }, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 6190462..a08daeb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@currents/commit-info", "description": "Collects Git commit info from CI or from CLI", - "version": "1.0.1-beta.0", + "version": "1.1.0", "author": "Gleb Bahmutov ", "contributors": [ "Gleb Bahmutov ", @@ -28,7 +28,7 @@ } }, "engines": { - "node": ">=6" + "node": ">=8" }, "files": [ "src/*.js", diff --git a/src/index.js b/src/index.js index dd8143d..0090fbe 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,7 @@ const { getCommitInfoFromEnvironment, getGhaEventData } = require('./utils') +const { getPullRequestHeadCommit } = require('./pull-request-head') const Promise = require('bluebird') const { mergeWith, or } = require('ramda') @@ -35,12 +36,22 @@ function commitInfo (folder) { process.env.GITHUB_EVENT_PATH, process.env.GITHUB_ACTIONS ) - }).then(info => { - const envVariables = getCommitInfoFromEnvironment() - debug('git commit: %o', info) - debug('env commit: %o', envVariables) - return mergeWith(or, envVariables, info) }) + .then(info => { + // COMMIT_INFO_SHA names the commit to report, so it is used as is + if (process.env.COMMIT_INFO_SHA) { + return info + } + return getPullRequestHeadCommit(folder, info.sha, info.ghaEventData).then( + head => Object.assign({}, info, head) + ) + }) + .then(info => { + const envVariables = getCommitInfoFromEnvironment() + debug('git commit: %o', info) + debug('env commit: %o', envVariables) + return mergeWith(or, envVariables, info) + }) } module.exports = { diff --git a/src/pull-request-head-spec.js b/src/pull-request-head-spec.js new file mode 100644 index 0000000..61449e7 --- /dev/null +++ b/src/pull-request-head-spec.js @@ -0,0 +1,287 @@ +'use strict' + +/* eslint-env mocha */ +const assert = require('assert') +const { execFileSync } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') +const mockedEnv = require('mocked-env') +const { commitInfo } = require('.') +const { getPullRequestHeadCommit } = require('./pull-request-head') + +const commitEnv = (name, email, date) => ({ + GIT_AUTHOR_NAME: name, + GIT_AUTHOR_EMAIL: email, + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_NAME: name, + GIT_COMMITTER_EMAIL: email, + GIT_COMMITTER_DATE: date +}) + +const runGit = (env, cwd, args) => + execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { + cwd, + encoding: 'utf8', + env: Object.assign({}, process.env, env) + }).trim() + +const git = (cwd, ...args) => + runGit( + commitEnv('Target Author', 'target@example.com', '1700000000 +0000'), + cwd, + args + ) + +/** + * An origin repo with a pull request (branch "feature") and two merges of it + * into "main": + * - refs/pull/1/merge, made by GitHub: parent 1 is main, parent 2 the pull request + * - refs/heads/local-merge, made by Bitbucket Pipelines or Jenkins: parent 1 + * is the pull request, parent 2 main + */ +function createOrigin (root) { + const origin = path.join(root, 'origin') + git(root, 'init', '-q', origin) + git(origin, 'checkout', '-q', '-b', 'main') + // git before 2.29 (protocol v0) only serves advertised refs by default + git(origin, 'config', 'uploadpack.allowReachableSHA1InWant', 'true') + git(origin, 'commit', '-q', '--allow-empty', '-m', 'target 1') + git(origin, 'checkout', '-q', '-b', 'feature') + runGit(commitEnv('PR Author', 'pr@example.com', '1600000000 +0000'), origin, [ + 'commit', + '-q', + '--allow-empty', + '-m', + 'feat: add retries\n\nLonger description.' + ]) + const headSha = git(origin, 'rev-parse', 'HEAD') + git(origin, 'checkout', '-q', 'main') + git(origin, 'commit', '-q', '--allow-empty', '-m', 'target 2') + const baseSha = git(origin, 'rev-parse', 'HEAD') + const tree = git(origin, 'rev-parse', 'HEAD^{tree}') + + const mergeSha = git( + origin, + ...['commit-tree', tree, '-p', baseSha, '-p', headSha], + ...['-m', `Merge ${headSha} into ${baseSha}`] + ) + git(origin, 'update-ref', 'refs/pull/1/merge', mergeSha) + + const localMergeSha = git( + origin, + ...['commit-tree', tree, '-p', headSha, '-p', baseSha], + ...['-m', 'Merge branch main'] + ) + git(origin, 'update-ref', 'refs/heads/local-merge', localMergeSha) + + return { origin, headSha, baseSha, mergeSha, localMergeSha } +} + +const prCommit = sha => ({ + sha, + author: 'PR Author', + email: 'pr@example.com', + timestamp: '1600000000', + // same format as the message commit-info reads for the checked-out commit + message: 'feat: add retries\n\nLonger description.\n' +}) + +describe('getPullRequestHeadCommit', function () { + this.timeout(20000) + + let root, repo, workRoot, restoreEnvironment + + /** Checks out `ref` of the origin in a new repo */ + function checkout (ref, depth) { + const work = path.join(workRoot, 'work') + git(workRoot, 'init', '-q', work) + git(work, 'remote', 'add', 'origin', `file://${repo.origin}`) + git( + work, + ...['fetch', '-q', ...(depth ? [`--depth=${depth}`] : [])], + ...['origin', `+${ref}:refs/remotes/origin/checkout`] + ) + git(work, 'checkout', '-q', '--detach', 'refs/remotes/origin/checkout') + return work + } + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-head-commit-')) + repo = createOrigin(root) + }) + + after(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + beforeEach(() => { + workRoot = fs.mkdtempSync(path.join(root, 'test-')) + // provider variables from the machine running the tests would change the result + restoreEnvironment = mockedEnv( + { PATH: process.env.PATH, HOME: process.env.HOME }, + { clear: true } + ) + }) + + afterEach(() => { + restoreEnvironment() + }) + + it('fetches the pull request commit in a depth-1 checkout', async () => { + const work = checkout('refs/pull/1/merge', 1) + + assert.deepStrictEqual( + await getPullRequestHeadCommit(work, repo.mergeSha, { + headSha: repo.headSha + }), + prCommit(repo.headSha) + ) + assert.strictEqual(git(work, 'rev-parse', 'HEAD'), repo.mergeSha) + }) + + it('reads the pull request commit from a full clone without fetching', async () => { + const work = checkout('refs/pull/1/merge') + git(work, 'remote', 'set-url', 'origin', path.join(root, 'missing')) + + assert.deepStrictEqual( + await getPullRequestHeadCommit(work, repo.mergeSha, { + headSha: repo.headSha + }), + prCommit(repo.headSha) + ) + }) + + it('takes the sha from a provider variable', async () => { + const work = checkout('refs/pull/1/merge', 1) + process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA = repo.headSha + + assert.deepStrictEqual( + await getPullRequestHeadCommit(work, repo.mergeSha), + prCommit(repo.headSha) + ) + }) + + it('accepts the pull request commit as the first parent', async () => { + const work = checkout('refs/heads/local-merge', 1) + process.env.BITBUCKET_COMMIT = repo.headSha + + assert.deepStrictEqual( + await getPullRequestHeadCommit(work, repo.localMergeSha), + prCommit(repo.headSha) + ) + }) + + it('returns null when the checkout is not a merge of the pull request', async () => { + // pull_request_target checks out the target branch + const work = checkout('refs/heads/main', 1) + + assert.strictEqual( + await getPullRequestHeadCommit(work, repo.baseSha, { + headSha: repo.headSha + }), + null + ) + }) + + it('returns null when the checkout is the pull request commit', async () => { + const work = checkout('refs/heads/feature', 1) + process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA = repo.headSha + + assert.strictEqual(await getPullRequestHeadCommit(work, repo.headSha), null) + }) + + it('does not fetch when CURRENTS_DISABLE_HEAD_COMMIT_FETCH is set', async () => { + const work = checkout('refs/pull/1/merge', 1) + process.env.CURRENTS_DISABLE_HEAD_COMMIT_FETCH = 'true' + + assert.strictEqual( + await getPullRequestHeadCommit(work, repo.mergeSha, { + headSha: repo.headSha + }), + null + ) + assert.throws(() => git(work, 'cat-file', '-e', repo.headSha)) + }) + + it('returns null when the fetch fails', async () => { + const work = checkout('refs/pull/1/merge', 1) + git(work, 'remote', 'set-url', 'origin', path.join(root, 'missing')) + + assert.strictEqual( + await getPullRequestHeadCommit(work, repo.mergeSha, { + headSha: repo.headSha + }), + null + ) + }) + + it('returns null outside a git repository', async () => { + assert.strictEqual( + await getPullRequestHeadCommit(workRoot, repo.mergeSha, { + headSha: repo.headSha + }), + null + ) + }) + + describe('commitInfo', () => { + let work + + beforeEach(() => { + work = checkout('refs/pull/1/merge', 1) + const eventPath = path.join(workRoot, 'event.json') + fs.writeFileSync( + eventPath, + JSON.stringify({ + pull_request: { + title: 'Add retries', + head: { ref: 'feature', sha: repo.headSha }, + base: { ref: 'main', sha: repo.baseSha } + }, + sender: {} + }) + ) + process.env.GITHUB_ACTIONS = 'true' + process.env.GITHUB_EVENT_PATH = eventPath + }) + + it('reports the pull request commit on a GitHub Actions merge checkout', async () => { + const info = await commitInfo(work) + + assert.deepStrictEqual( + { + sha: info.sha, + message: info.message, + author: info.author, + email: info.email, + timestamp: info.timestamp + }, + prCommit(repo.headSha) + ) + assert.strictEqual(info.ghaEventData.headSha, repo.headSha) + }) + + it('gives COMMIT_INFO_* variables priority over the pull request commit', async () => { + process.env.COMMIT_INFO_MESSAGE = 'message from env' + + const info = await commitInfo(work) + + assert.strictEqual(info.sha, repo.headSha) + assert.strictEqual(info.message, 'message from env') + assert.strictEqual(info.author, 'PR Author') + }) + + it('reports the checked-out commit when COMMIT_INFO_SHA is set', async () => { + process.env.COMMIT_INFO_SHA = repo.mergeSha + + const info = await commitInfo(work) + + assert.strictEqual(info.sha, repo.mergeSha) + assert.strictEqual( + info.message, + `Merge ${repo.headSha} into ${repo.baseSha}\n` + ) + }) + }) +}) diff --git a/src/pull-request-head.js b/src/pull-request-head.js new file mode 100644 index 0000000..4f90938 --- /dev/null +++ b/src/pull-request-head.js @@ -0,0 +1,152 @@ +'use strict' + +const debug = require('debug')('commit-info') +const execa = require('execa') + +// On pull request builds these providers check out a commit that merges the +// pull request into its target branch. The variables hold the pull request's +// own last commit. GitHub Actions has no such variable; its sha comes from the +// event file (ghaEventData.headSha). +const HEAD_SHA_ENV_VARS = [ + 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', // GitLab merged results pipelines + 'SYSTEM_PULLREQUEST_SOURCECOMMITID', // Azure Pipelines + 'TRAVIS_PULL_REQUEST_SHA', + 'SEMAPHORE_GIT_PR_SHA', + 'BUILDKITE_PULL_REQUEST_HEAD_COMMIT', + 'BITBUCKET_COMMIT' +] + +const FETCH_TIMEOUT_MS = 3000 +const FETCH_RETRY_DELAY_MS = 500 + +/** + * Returns the pull request's last commit when the checked-out commit is a + * merge of the pull request into its target branch, or null. + * + * With `actions/checkout` defaults the clone has depth 1 and does not contain + * that commit, so it is fetched from origin. A failed or timed-out fetch + * returns null. + * + * @returns {Promise<{sha, message, email, author, timestamp} | null>} + */ +async function getPullRequestHeadCommit (folder, checkoutSha, ghaEventData) { + try { + const headSha = getHeadSha(ghaEventData) + if (!checkoutSha || !headSha || headSha === checkoutSha) { + return null + } + + // Skips pull_request_target, where the checkout is the target branch, and + // any build where the variable does not describe the checked-out commit. + const parents = await getParents(folder, checkoutSha) + if (!parents.includes(headSha)) { + debug('%s is not a parent of %s, skipping', headSha, checkoutSha) + return null + } + + if ( + !await hasCommit(folder, headSha) && + !await fetchCommit(folder, headSha) + ) { + return null + } + + return await readCommit(folder, headSha) + } catch (e) { + debug('failed to read the pull request head commit: %o', e) + return null + } +} + +function getHeadSha (ghaEventData) { + if (ghaEventData && ghaEventData.headSha) { + return ghaEventData.headSha + } + const name = HEAD_SHA_ENV_VARS.find(key => process.env[key]) + return name ? process.env[name] : null +} + +async function git (folder, args, timeout) { + const { stdout } = await execa('git', args, { + cwd: folder, + timeout, + env: { GIT_TERMINAL_PROMPT: '0' } + }) + return stdout +} + +// Reads the parent lines stored in the commit object. `git log --format=%P` +// and `HEAD^2` return nothing in a depth-1 clone. +async function getParents (folder, sha) { + const commit = await git(folder, ['cat-file', 'commit', sha]) + const header = commit.split('\n\n')[0] + return header + .split('\n') + .filter(line => line.startsWith('parent ')) + .map(line => line.slice('parent '.length)) +} + +async function hasCommit (folder, sha) { + try { + await git(folder, ['cat-file', '-e', `${sha}^{commit}`]) + return true + } catch (e) { + return false + } +} + +async function fetchCommit (folder, sha) { + if (process.env.CURRENTS_DISABLE_HEAD_COMMIT_FETCH === 'true') { + debug('fetching %s is disabled', sha) + return false + } + + // --depth on a complete clone would make it shallow for the rest of the job + const isShallow = await git(folder, ['rev-parse', '--is-shallow-repository']) + if (isShallow !== 'true') { + return false + } + + const args = [ + '-c', + 'credential.interactive=false', + 'fetch', + '--depth=1', + '--no-tags', + '--no-recurse-submodules', + 'origin', + sha + ] + + // Processes sharing the checkout, such as Playwright workers, can fetch at + // the same time; the one that loses the race for .git/shallow.lock fails. + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await git(folder, args, FETCH_TIMEOUT_MS) + return true + } catch (e) { + debug('fetching %s failed (attempt %d): %o', sha, attempt, e) + if (await hasCommit(folder, sha)) { + return true + } + if (e.timedOut || attempt === 2) { + return false + } + await new Promise(resolve => setTimeout(resolve, FETCH_RETRY_DELAY_MS)) + } + } + return false +} + +async function readCommit (folder, sha) { + const output = await git(folder, [ + 'show', + '-s', + '--format=%an%x00%ae%x00%ct%x00%B', + sha + ]) + const [author, email, timestamp, message] = output.split('\0') + return { sha, author, email, timestamp, message } +} + +module.exports = { getPullRequestHeadCommit } From 1152644cfb044d70a56743e5db4fba7f5536f10a Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Wed, 23 Sep 2026 15:06:17 -0700 Subject: [PATCH 2/4] test: check the pull request commit on real GitHub checkouts A pull_request workflow runs commitInfo on the checkout actions/checkout makes: depth 1, full clone, git 2.25 in the Ubuntu 20.04 Playwright image, and with CURRENTS_DISABLE_HEAD_COMMIT_FETCH. Unit tests cover the 3s timeout against an origin that never answers, and three processes fetching the same commit at once. Version 1.1.0-beta.0 for the beta release. Refs ENG-934 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdJrRhBLEH9Uwt97JMGVTR --- .github/scripts/check-pull-request-commit.js | 35 +++++++++++++++++ .github/workflows/pull-request.yml | 41 ++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/pull-request-head-spec.js | 37 ++++++++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/check-pull-request-commit.js create mode 100644 .github/workflows/pull-request.yml diff --git a/.github/scripts/check-pull-request-commit.js b/.github/scripts/check-pull-request-commit.js new file mode 100644 index 0000000..574d450 --- /dev/null +++ b/.github/scripts/check-pull-request-commit.js @@ -0,0 +1,35 @@ +'use strict' + +// Runs commitInfo on the checkout that actions/checkout makes for a +// pull_request event: GitHub's merge commit refs/pull/N/merge. +// +// EXPECTED_COMMIT=head: commitInfo reports the pull request's last commit +// EXPECTED_COMMIT=checkout: commitInfo reports the merge commit, as before + +const assert = require('assert') +const { execFileSync } = require('child_process') +const { commitInfo } = require('../../src') + +const headSha = process.env.HEAD_SHA +const expected = process.env.EXPECTED_COMMIT || 'head' + +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8' }).replace(/\n$/, '') + +const checkoutSha = git('rev-parse', 'HEAD') +assert.notStrictEqual(checkoutSha, headSha, 'expected the merge commit') + +commitInfo() + .then(info => { + console.log(info) + const sha = expected === 'head' ? headSha : checkoutSha + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.message, git('show', '-s', '--pretty=%B', sha)) + assert.strictEqual(info.author, git('show', '-s', '--pretty=%an', sha)) + assert.strictEqual(info.email, git('show', '-s', '--pretty=%ae', sha)) + console.log(`commitInfo reported ${expected} commit ${sha}`) + }) + .catch(e => { + console.error(e) + process.exit(1) + }) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..a3671ba --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,41 @@ +name: Pull request commit +on: pull_request + +jobs: + commit-info: + name: commitInfo (${{ matrix.name }}) + runs-on: ubuntu-latest + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + include: + - name: default checkout + fetch-depth: 1 + expected: head + - name: full clone + fetch-depth: 0 + expected: head + - name: git 2.25 + fetch-depth: 1 + expected: head + container: mcr.microsoft.com/playwright:v1.28.1-focal + - name: fetch disabled + fetch-depth: 1 + expected: checkout + disable-fetch: 'true' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: ${{ matrix.fetch-depth }} + - uses: actions/setup-node@v4 + if: ${{ !matrix.container }} + with: + node-version: 16 + - run: git --version && node --version + - run: npm ci --ignore-scripts + - run: node .github/scripts/check-pull-request-commit.js + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EXPECTED_COMMIT: ${{ matrix.expected }} + CURRENTS_DISABLE_HEAD_COMMIT_FETCH: ${{ matrix.disable-fetch }} diff --git a/package-lock.json b/package-lock.json index 2e71245..adcfbb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@currents/commit-info", - "version": "1.1.0", + "version": "1.1.0-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@currents/commit-info", - "version": "1.1.0", + "version": "1.1.0-beta.0", "license": "MIT", "dependencies": { "bluebird": "3.5.5", diff --git a/package.json b/package.json index a08daeb..8f45d89 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@currents/commit-info", "description": "Collects Git commit info from CI or from CLI", - "version": "1.1.0", + "version": "1.1.0-beta.0", "author": "Gleb Bahmutov ", "contributors": [ "Gleb Bahmutov ", diff --git a/src/pull-request-head-spec.js b/src/pull-request-head-spec.js index 61449e7..3706cde 100644 --- a/src/pull-request-head-spec.js +++ b/src/pull-request-head-spec.js @@ -4,6 +4,7 @@ const assert = require('assert') const { execFileSync } = require('child_process') const fs = require('fs') +const net = require('net') const os = require('os') const path = require('path') const mockedEnv = require('mocked-env') @@ -225,6 +226,42 @@ describe('getPullRequestHeadCommit', function () { ) }) + it('gives up after 3 seconds when origin does not answer', async () => { + const sockets = [] + const server = net.createServer(socket => sockets.push(socket)) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + try { + const work = checkout('refs/pull/1/merge', 1) + const { port } = server.address() + git(work, 'remote', 'set-url', 'origin', `git://127.0.0.1:${port}/repo`) + + const started = Date.now() + const head = await getPullRequestHeadCommit(work, repo.mergeSha, { + headSha: repo.headSha + }) + const elapsed = Date.now() - started + + assert.strictEqual(head, null) + // one attempt: a timed-out fetch is not retried + assert.ok(elapsed >= 2900 && elapsed < 5000, `took ${elapsed}ms`) + } finally { + sockets.forEach(socket => socket.destroy()) + server.close() + } + }) + + it('returns the pull request commit to processes fetching at the same time', async () => { + const work = checkout('refs/pull/1/merge', 1) + + const heads = await Promise.all( + [1, 2, 3].map(() => + getPullRequestHeadCommit(work, repo.mergeSha, { headSha: repo.headSha }) + ) + ) + + heads.forEach(head => assert.deepStrictEqual(head, prCommit(repo.headSha))) + }) + describe('commitInfo', () => { let work From 5e02907ee1e0e7129d4126b905899216d94ca6ca Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Wed, 23 Sep 2026 15:08:44 -0700 Subject: [PATCH 3/4] test: trust the checkout in the git 2.25 container Refs ENG-934 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdJrRhBLEH9Uwt97JMGVTR --- .github/workflows/pull-request.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a3671ba..7ca4062 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -32,6 +32,9 @@ jobs: if: ${{ !matrix.container }} with: node-version: 16 + # the checkout belongs to another user than the container's root + - if: ${{ matrix.container }} + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - run: git --version && node --version - run: npm ci --ignore-scripts - run: node .github/scripts/check-pull-request-commit.js From 24087423e60789d5f89c32f45e0da76b6d3563e4 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Wed, 23 Sep 2026 16:29:46 -0700 Subject: [PATCH 4/4] fix: only replace a checked-out merge commit with the pull request's commit A one-parent commit that a build adds on top of the pull request is a real commit and is reported as is. The PR workflow gets contents: read, since it runs the pull request's code. Refs ENG-934 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdJrRhBLEH9Uwt97JMGVTR --- .github/workflows/pull-request.yml | 4 ++++ README.md | 2 +- src/pull-request-head-spec.js | 21 ++++++++++++++++++++- src/pull-request-head.js | 9 +++++---- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7ca4062..1a7d5f9 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,6 +1,10 @@ name: Pull request commit on: pull_request +# the job runs code from the pull request +permissions: + contents: read + jobs: commit-info: name: commitInfo (${{ matrix.name }}) diff --git a/README.md b/README.md index 3b1a18b..f66b01a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ When the checked-out commit is such a merge, `commitInfo` reports the pull reque - Buildkite: `BUILDKITE_PULL_REQUEST_HEAD_COMMIT` - Bitbucket Pipelines: `BITBUCKET_COMMIT` -The commit is used only when it is a parent of the checked-out commit. If a shallow clone does not contain it (for example `actions/checkout` with the default `fetch-depth: 1`), it is fetched with `git fetch --depth=1 origin `, with a 3 second timeout. If the fetch fails, the checked-out commit is reported. Set `CURRENTS_DISABLE_HEAD_COMMIT_FETCH=true` to skip the fetch. +The commit is used only when the checked-out commit is a merge and the commit is one of its parents. If a shallow clone does not contain it (for example `actions/checkout` with the default `fetch-depth: 1`), it is fetched with `git fetch --depth=1 origin `, with a 3 second timeout. If the fetch fails, the checked-out commit is reported. Set `CURRENTS_DISABLE_HEAD_COMMIT_FETCH=true` to skip the fetch. The `COMMIT_INFO_*` variables below still take priority. When `COMMIT_INFO_SHA` is set, the pull request's commit is not looked up. diff --git a/src/pull-request-head-spec.js b/src/pull-request-head-spec.js index 3706cde..8447e28 100644 --- a/src/pull-request-head-spec.js +++ b/src/pull-request-head-spec.js @@ -40,6 +40,7 @@ const git = (cwd, ...args) => * - refs/pull/1/merge, made by GitHub: parent 1 is main, parent 2 the pull request * - refs/heads/local-merge, made by Bitbucket Pipelines or Jenkins: parent 1 * is the pull request, parent 2 main + * and refs/heads/on-top, a one-parent commit a build added on the pull request */ function createOrigin (root) { const origin = path.join(root, 'origin') @@ -76,7 +77,14 @@ function createOrigin (root) { ) git(origin, 'update-ref', 'refs/heads/local-merge', localMergeSha) - return { origin, headSha, baseSha, mergeSha, localMergeSha } + const onTopSha = git( + origin, + ...['commit-tree', `${headSha}^{tree}`, '-p', headSha], + ...['-m', 'ci: format'] + ) + git(origin, 'update-ref', 'refs/heads/on-top', onTopSha) + + return { origin, headSha, baseSha, mergeSha, localMergeSha, onTopSha } } const prCommit = sha => ({ @@ -192,6 +200,17 @@ describe('getPullRequestHeadCommit', function () { assert.strictEqual(await getPullRequestHeadCommit(work, repo.headSha), null) }) + it('returns null when the checkout is a one-parent commit on the pull request', async () => { + const work = checkout('refs/heads/on-top', 1) + + assert.strictEqual( + await getPullRequestHeadCommit(work, repo.onTopSha, { + headSha: repo.headSha + }), + null + ) + }) + it('does not fetch when CURRENTS_DISABLE_HEAD_COMMIT_FETCH is set', async () => { const work = checkout('refs/pull/1/merge', 1) process.env.CURRENTS_DISABLE_HEAD_COMMIT_FETCH = 'true' diff --git a/src/pull-request-head.js b/src/pull-request-head.js index 4f90938..e16411d 100644 --- a/src/pull-request-head.js +++ b/src/pull-request-head.js @@ -36,11 +36,12 @@ async function getPullRequestHeadCommit (folder, checkoutSha, ghaEventData) { return null } - // Skips pull_request_target, where the checkout is the target branch, and - // any build where the variable does not describe the checked-out commit. + // Skips pull_request_target, where the checkout is the target branch, a + // commit the build added on top of the pull request, and any build where + // the variable does not describe the checked-out commit. const parents = await getParents(folder, checkoutSha) - if (!parents.includes(headSha)) { - debug('%s is not a parent of %s, skipping', headSha, checkoutSha) + if (parents.length < 2 || !parents.includes(headSha)) { + debug('%s is not a merge with parent %s, skipping', checkoutSha, headSha) return null }