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..1a7d5f9 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,48 @@ +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 }}) + 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 + # 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 + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EXPECTED_COMMIT: ${{ matrix.expected }} + CURRENTS_DISABLE_HEAD_COMMIT_FETCH: ${{ matrix.disable-fetch }} diff --git a/README.md b/README.md index 9495ca2..f66b01a 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 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. + ## 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..adcfbb0 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-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@currents/commit-info", - "version": "1.0.1-beta.0", + "version": "1.1.0-beta.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..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.0.1-beta.0", + "version": "1.1.0-beta.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..8447e28 --- /dev/null +++ b/src/pull-request-head-spec.js @@ -0,0 +1,343 @@ +'use strict' + +/* eslint-env mocha */ +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') +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 + * 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') + 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) + + 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 => ({ + 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('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' + + 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 + ) + }) + + 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 + + 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..e16411d --- /dev/null +++ b/src/pull-request-head.js @@ -0,0 +1,153 @@ +'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, 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.length < 2 || !parents.includes(headSha)) { + debug('%s is not a merge with parent %s, skipping', checkoutSha, headSha) + 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 }