Skip to content

Commit 65acdde

Browse files
agoldisclaude
andcommitted
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 <sha> into <sha>" 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdJrRhBLEH9Uwt97JMGVTR
1 parent 80ffa78 commit 65acdde

6 files changed

Lines changed: 479 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Collects Git commit info from git CLI
55

66
## Install
77

8-
Requires [Node](https://nodejs.org/en/) version 6 or above.
8+
Requires [Node](https://nodejs.org/en/) version 8 or above.
99

1010
```sh
1111
npm install --save @currents-dev/commit-info
@@ -37,6 +37,24 @@ Notes:
3737
- If a command fails, returns `null` for each property
3838
- If you need to debug, run with `DEBUG=commit-info` environment variable.
3939

40+
## Pull request builds
41+
42+
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/<number>/merge`, whose message is `Merge <sha> into <sha>`.
43+
44+
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:
45+
46+
- GitHub Actions: `pull_request.head.sha` in the event file (`GITHUB_EVENT_PATH`)
47+
- GitLab merged results pipelines: `CI_MERGE_REQUEST_SOURCE_BRANCH_SHA`
48+
- Azure Pipelines: `SYSTEM_PULLREQUEST_SOURCECOMMITID`
49+
- Travis CI: `TRAVIS_PULL_REQUEST_SHA`
50+
- Semaphore: `SEMAPHORE_GIT_PR_SHA`
51+
- Buildkite: `BUILDKITE_PULL_REQUEST_HEAD_COMMIT`
52+
- Bitbucket Pipelines: `BITBUCKET_COMMIT`
53+
54+
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 <sha>`, 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.
55+
56+
The `COMMIT_INFO_*` variables below still take priority. When `COMMIT_INFO_SHA` is set, the pull request's commit is not looked up.
57+
4058
## Fallback environment variables
4159

4260
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

‎package-lock.json‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@currents/commit-info",
33
"description": "Collects Git commit info from CI or from CLI",
4-
"version": "1.0.1-beta.0",
4+
"version": "1.1.0",
55
"author": "Gleb Bahmutov <gleb.bahmutov@gmail.com>",
66
"contributors": [
77
"Gleb Bahmutov <gleb.bahmutov@gmail.com>",
@@ -28,7 +28,7 @@
2828
}
2929
},
3030
"engines": {
31-
"node": ">=6"
31+
"node": ">=8"
3232
},
3333
"files": [
3434
"src/*.js",

‎src/index.js‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
getCommitInfoFromEnvironment,
1717
getGhaEventData
1818
} = require('./utils')
19+
const { getPullRequestHeadCommit } = require('./pull-request-head')
1920
const Promise = require('bluebird')
2021
const { mergeWith, or } = require('ramda')
2122

@@ -35,12 +36,22 @@ function commitInfo (folder) {
3536
process.env.GITHUB_EVENT_PATH,
3637
process.env.GITHUB_ACTIONS
3738
)
38-
}).then(info => {
39-
const envVariables = getCommitInfoFromEnvironment()
40-
debug('git commit: %o', info)
41-
debug('env commit: %o', envVariables)
42-
return mergeWith(or, envVariables, info)
4339
})
40+
.then(info => {
41+
// COMMIT_INFO_SHA names the commit to report, so it is used as is
42+
if (process.env.COMMIT_INFO_SHA) {
43+
return info
44+
}
45+
return getPullRequestHeadCommit(folder, info.sha, info.ghaEventData).then(
46+
head => Object.assign({}, info, head)
47+
)
48+
})
49+
.then(info => {
50+
const envVariables = getCommitInfoFromEnvironment()
51+
debug('git commit: %o', info)
52+
debug('env commit: %o', envVariables)
53+
return mergeWith(or, envVariables, info)
54+
})
4455
}
4556

4657
module.exports = {

‎src/pull-request-head-spec.js‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
'use strict'
2+
3+
/* eslint-env mocha */
4+
const assert = require('assert')
5+
const { execFileSync } = require('child_process')
6+
const fs = require('fs')
7+
const os = require('os')
8+
const path = require('path')
9+
const mockedEnv = require('mocked-env')
10+
const { commitInfo } = require('.')
11+
const { getPullRequestHeadCommit } = require('./pull-request-head')
12+
13+
const commitEnv = (name, email, date) => ({
14+
GIT_AUTHOR_NAME: name,
15+
GIT_AUTHOR_EMAIL: email,
16+
GIT_AUTHOR_DATE: date,
17+
GIT_COMMITTER_NAME: name,
18+
GIT_COMMITTER_EMAIL: email,
19+
GIT_COMMITTER_DATE: date
20+
})
21+
22+
const runGit = (env, cwd, args) =>
23+
execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], {
24+
cwd,
25+
encoding: 'utf8',
26+
env: Object.assign({}, process.env, env)
27+
}).trim()
28+
29+
const git = (cwd, ...args) =>
30+
runGit(
31+
commitEnv('Target Author', 'target@example.com', '1700000000 +0000'),
32+
cwd,
33+
args
34+
)
35+
36+
/**
37+
* An origin repo with a pull request (branch "feature") and two merges of it
38+
* into "main":
39+
* - refs/pull/1/merge, made by GitHub: parent 1 is main, parent 2 the pull request
40+
* - refs/heads/local-merge, made by Bitbucket Pipelines or Jenkins: parent 1
41+
* is the pull request, parent 2 main
42+
*/
43+
function createOrigin (root) {
44+
const origin = path.join(root, 'origin')
45+
git(root, 'init', '-q', origin)
46+
git(origin, 'checkout', '-q', '-b', 'main')
47+
// git before 2.29 (protocol v0) only serves advertised refs by default
48+
git(origin, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
49+
git(origin, 'commit', '-q', '--allow-empty', '-m', 'target 1')
50+
git(origin, 'checkout', '-q', '-b', 'feature')
51+
runGit(commitEnv('PR Author', 'pr@example.com', '1600000000 +0000'), origin, [
52+
'commit',
53+
'-q',
54+
'--allow-empty',
55+
'-m',
56+
'feat: add retries\n\nLonger description.'
57+
])
58+
const headSha = git(origin, 'rev-parse', 'HEAD')
59+
git(origin, 'checkout', '-q', 'main')
60+
git(origin, 'commit', '-q', '--allow-empty', '-m', 'target 2')
61+
const baseSha = git(origin, 'rev-parse', 'HEAD')
62+
const tree = git(origin, 'rev-parse', 'HEAD^{tree}')
63+
64+
const mergeSha = git(
65+
origin,
66+
...['commit-tree', tree, '-p', baseSha, '-p', headSha],
67+
...['-m', `Merge ${headSha} into ${baseSha}`]
68+
)
69+
git(origin, 'update-ref', 'refs/pull/1/merge', mergeSha)
70+
71+
const localMergeSha = git(
72+
origin,
73+
...['commit-tree', tree, '-p', headSha, '-p', baseSha],
74+
...['-m', 'Merge branch main']
75+
)
76+
git(origin, 'update-ref', 'refs/heads/local-merge', localMergeSha)
77+
78+
return { origin, headSha, baseSha, mergeSha, localMergeSha }
79+
}
80+
81+
const prCommit = sha => ({
82+
sha,
83+
author: 'PR Author',
84+
email: 'pr@example.com',
85+
timestamp: '1600000000',
86+
// same format as the message commit-info reads for the checked-out commit
87+
message: 'feat: add retries\n\nLonger description.\n'
88+
})
89+
90+
describe('getPullRequestHeadCommit', function () {
91+
this.timeout(20000)
92+
93+
let root, repo, workRoot, restoreEnvironment
94+
95+
/** Checks out `ref` of the origin in a new repo */
96+
function checkout (ref, depth) {
97+
const work = path.join(workRoot, 'work')
98+
git(workRoot, 'init', '-q', work)
99+
git(work, 'remote', 'add', 'origin', `file://${repo.origin}`)
100+
git(
101+
work,
102+
...['fetch', '-q', ...(depth ? [`--depth=${depth}`] : [])],
103+
...['origin', `+${ref}:refs/remotes/origin/checkout`]
104+
)
105+
git(work, 'checkout', '-q', '--detach', 'refs/remotes/origin/checkout')
106+
return work
107+
}
108+
109+
before(() => {
110+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-head-commit-'))
111+
repo = createOrigin(root)
112+
})
113+
114+
after(() => {
115+
fs.rmSync(root, { recursive: true, force: true })
116+
})
117+
118+
beforeEach(() => {
119+
workRoot = fs.mkdtempSync(path.join(root, 'test-'))
120+
// provider variables from the machine running the tests would change the result
121+
restoreEnvironment = mockedEnv(
122+
{ PATH: process.env.PATH, HOME: process.env.HOME },
123+
{ clear: true }
124+
)
125+
})
126+
127+
afterEach(() => {
128+
restoreEnvironment()
129+
})
130+
131+
it('fetches the pull request commit in a depth-1 checkout', async () => {
132+
const work = checkout('refs/pull/1/merge', 1)
133+
134+
assert.deepStrictEqual(
135+
await getPullRequestHeadCommit(work, repo.mergeSha, {
136+
headSha: repo.headSha
137+
}),
138+
prCommit(repo.headSha)
139+
)
140+
assert.strictEqual(git(work, 'rev-parse', 'HEAD'), repo.mergeSha)
141+
})
142+
143+
it('reads the pull request commit from a full clone without fetching', async () => {
144+
const work = checkout('refs/pull/1/merge')
145+
git(work, 'remote', 'set-url', 'origin', path.join(root, 'missing'))
146+
147+
assert.deepStrictEqual(
148+
await getPullRequestHeadCommit(work, repo.mergeSha, {
149+
headSha: repo.headSha
150+
}),
151+
prCommit(repo.headSha)
152+
)
153+
})
154+
155+
it('takes the sha from a provider variable', async () => {
156+
const work = checkout('refs/pull/1/merge', 1)
157+
process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA = repo.headSha
158+
159+
assert.deepStrictEqual(
160+
await getPullRequestHeadCommit(work, repo.mergeSha),
161+
prCommit(repo.headSha)
162+
)
163+
})
164+
165+
it('accepts the pull request commit as the first parent', async () => {
166+
const work = checkout('refs/heads/local-merge', 1)
167+
process.env.BITBUCKET_COMMIT = repo.headSha
168+
169+
assert.deepStrictEqual(
170+
await getPullRequestHeadCommit(work, repo.localMergeSha),
171+
prCommit(repo.headSha)
172+
)
173+
})
174+
175+
it('returns null when the checkout is not a merge of the pull request', async () => {
176+
// pull_request_target checks out the target branch
177+
const work = checkout('refs/heads/main', 1)
178+
179+
assert.strictEqual(
180+
await getPullRequestHeadCommit(work, repo.baseSha, {
181+
headSha: repo.headSha
182+
}),
183+
null
184+
)
185+
})
186+
187+
it('returns null when the checkout is the pull request commit', async () => {
188+
const work = checkout('refs/heads/feature', 1)
189+
process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA = repo.headSha
190+
191+
assert.strictEqual(await getPullRequestHeadCommit(work, repo.headSha), null)
192+
})
193+
194+
it('does not fetch when CURRENTS_DISABLE_HEAD_COMMIT_FETCH is set', async () => {
195+
const work = checkout('refs/pull/1/merge', 1)
196+
process.env.CURRENTS_DISABLE_HEAD_COMMIT_FETCH = 'true'
197+
198+
assert.strictEqual(
199+
await getPullRequestHeadCommit(work, repo.mergeSha, {
200+
headSha: repo.headSha
201+
}),
202+
null
203+
)
204+
assert.throws(() => git(work, 'cat-file', '-e', repo.headSha))
205+
})
206+
207+
it('returns null when the fetch fails', async () => {
208+
const work = checkout('refs/pull/1/merge', 1)
209+
git(work, 'remote', 'set-url', 'origin', path.join(root, 'missing'))
210+
211+
assert.strictEqual(
212+
await getPullRequestHeadCommit(work, repo.mergeSha, {
213+
headSha: repo.headSha
214+
}),
215+
null
216+
)
217+
})
218+
219+
it('returns null outside a git repository', async () => {
220+
assert.strictEqual(
221+
await getPullRequestHeadCommit(workRoot, repo.mergeSha, {
222+
headSha: repo.headSha
223+
}),
224+
null
225+
)
226+
})
227+
228+
describe('commitInfo', () => {
229+
let work
230+
231+
beforeEach(() => {
232+
work = checkout('refs/pull/1/merge', 1)
233+
const eventPath = path.join(workRoot, 'event.json')
234+
fs.writeFileSync(
235+
eventPath,
236+
JSON.stringify({
237+
pull_request: {
238+
title: 'Add retries',
239+
head: { ref: 'feature', sha: repo.headSha },
240+
base: { ref: 'main', sha: repo.baseSha }
241+
},
242+
sender: {}
243+
})
244+
)
245+
process.env.GITHUB_ACTIONS = 'true'
246+
process.env.GITHUB_EVENT_PATH = eventPath
247+
})
248+
249+
it('reports the pull request commit on a GitHub Actions merge checkout', async () => {
250+
const info = await commitInfo(work)
251+
252+
assert.deepStrictEqual(
253+
{
254+
sha: info.sha,
255+
message: info.message,
256+
author: info.author,
257+
email: info.email,
258+
timestamp: info.timestamp
259+
},
260+
prCommit(repo.headSha)
261+
)
262+
assert.strictEqual(info.ghaEventData.headSha, repo.headSha)
263+
})
264+
265+
it('gives COMMIT_INFO_* variables priority over the pull request commit', async () => {
266+
process.env.COMMIT_INFO_MESSAGE = 'message from env'
267+
268+
const info = await commitInfo(work)
269+
270+
assert.strictEqual(info.sha, repo.headSha)
271+
assert.strictEqual(info.message, 'message from env')
272+
assert.strictEqual(info.author, 'PR Author')
273+
})
274+
275+
it('reports the checked-out commit when COMMIT_INFO_SHA is set', async () => {
276+
process.env.COMMIT_INFO_SHA = repo.mergeSha
277+
278+
const info = await commitInfo(work)
279+
280+
assert.strictEqual(info.sha, repo.mergeSha)
281+
assert.strictEqual(
282+
info.message,
283+
`Merge ${repo.headSha} into ${repo.baseSha}\n`
284+
)
285+
})
286+
})
287+
})

0 commit comments

Comments
 (0)