diff --git a/README.md b/README.md index f66b01a..81b568b 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ Collects Git commit info from git CLI Requires [Node](https://nodejs.org/en/) version 8 or above. ```sh -npm install --save @currents-dev/commit-info +npm install --save @currents/commit-info ``` ## Use ```js -const {commitInfo} = require('@currents-dev/commit-info') +const {commitInfo} = require('@currents/commit-info') // default folder is current working directory commitInfo(folder) .then(info => { @@ -25,7 +25,7 @@ commitInfo(folder) // author // sha // timestamp (in seconds since epoch) - // remote + // remote (without credentials) }) ``` @@ -35,7 +35,30 @@ Notes: - Resolves with [Bluebird](https://github.com/petkaantonov/bluebird) promise. - Only uses Git commands, see [src/git-api.js](src/git-api.js) - If a command fails, returns `null` for each property -- If you need to debug, run with `DEBUG=commit-info` environment variable. +- `remote` never contains a user name or password, also when it comes from `COMMIT_INFO_REMOTE`. +- If you need to debug, run with `DEBUG=commit-info` environment variable. The debug output does not contain the remote's credentials. + +## CI provider variables + +`getCiCommitInfo()` reads the commit from the variables of the CI provider the process runs on. The branch comes from: + +| Provider | Branch | +| --- | --- | +| GitHub Actions | `GH_BRANCH`, `GITHUB_HEAD_REF` (pull requests), `GITHUB_REF_NAME`, or `GITHUB_REF` without `refs/heads/` or `refs/tags/` | +| GitLab | `CI_COMMIT_REF_NAME` | +| CircleCI | `CIRCLE_BRANCH` | +| Jenkins | `CHANGE_BRANCH` (multibranch pull requests), or `GIT_BRANCH` without `origin/`, `refs/remotes/origin/` or `refs/heads/` | +| Azure Pipelines | `SYSTEM_PULLREQUEST_SOURCEBRANCH` without `refs/heads/` (pull requests), or `BUILD_SOURCEBRANCHNAME`, which is only the last segment: `x` for `feature/x` | +| Bitbucket Pipelines | `BITBUCKET_BRANCH` | +| Buildkite | `BUILDKITE_BRANCH` | + +AWS CodeBuild gives no branch. The other properties and providers are in [src/ci.js](src/ci.js). + +## Containers + +git 2.35.2 and later refuse to read a repository owned by another user, with `fatal: unsafe repository` (2.35.2 to 2.37.x) or `fatal: detected dubious ownership in repository` (2.38.0 and later). This is common when a container runs as root on a checkout made by another user. On CI, the read-only git commands then run again with `-c safe.directory=*`. CI means that the `CI` variable is set to a value other than `false` or `0`, or that one of the CI providers in [src/ci-provider.js](src/ci-provider.js) is detected; Jenkins, for example, does not set `CI`. `GOOGLE_CLOUD_PROJECT`, `GCP_PROJECT`, `GCLOUD_PROJECT` and `JENKINS_HOME` do not count, because developers often have them set in their shell. `*` because the folder can be a subfolder of the repository. + +git 2.35.2 to 2.37.x ignore `safe.directory` on the command line, so there the git values are `null` (git 2.38.0 and later respect it); run `git config --global --add safe.directory '*'` in the container or set the `COMMIT_INFO_*` variables. ## Pull request builds @@ -51,7 +74,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 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 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. The fetch does not use the `safe.directory` retry. 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. @@ -85,7 +108,13 @@ See [docker-example](docker-example) for a full example. ## Individual methods In addition to `commitInfo` this module also exposes individual promise-returning -methods `getBranch`, `getMessage`, `getEmail`, `getAuthor`, `getSha`, `getTimestamp`, `getRemoteOrigin`. These methods do NOT use fallback environment variables. +methods `getBranch`, `getMessage`, `getEmail`, `getAuthor`, `getSha`, `getTimestamp`, `getRemoteOrigin`. These methods do NOT use fallback environment variables. `getRemoteOrigin` returns the remote without credentials. + +Other exports: + +- `getCiCommitInfo(env = process.env)`: the CI provider's values and the provider name, see [CI provider variables](#ci-provider-variables). The `remote` has no credentials. +- `detectCiProvider(env = process.env)`: the CI provider name, such as `githubActions`, or `null`. +- `removeCredentials(url)`: removes the user name and password from a URL, keeping the port. Returns other values, such as `git@github.com:o/r.git`, as they are. For example diff --git a/__snapshots__/commit-info-spec.js b/__snapshots__/commit-info-spec.js index 84decac..a0a2449 100644 --- a/__snapshots__/commit-info-spec.js +++ b/__snapshots__/commit-info-spec.js @@ -8,7 +8,10 @@ exports['commit-info no environment variables has certain api 1'] = [ "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "getCiCommitInfo", + "detectCiProvider", + "removeCredentials" ] exports['commit-info no environment variables returns information 1'] = { @@ -41,7 +44,10 @@ exports['commit-info combination with environment variables has certain api 1'] "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "getCiCommitInfo", + "detectCiProvider", + "removeCredentials" ] exports['commit-info combination with environment variables returns information 1'] = { diff --git a/src/ci-provider.js b/src/ci-provider.js new file mode 100644 index 0000000..2252462 --- /dev/null +++ b/src/ci-provider.js @@ -0,0 +1,57 @@ +'use strict' + +const anyKey = (env, pattern) => Object.keys(env).some(key => pattern.test(key)) + +// The order matters when the variables of two providers are set: the first +// match wins. It is the order the Currents clients detect providers in. +const CI_PROVIDERS = [ + ['appveyor', env => env.APPVEYOR], + ['azure', env => env.TF_BUILD && env.AZURE_HTTP_USER_AGENT], + ['awsCodeBuild', env => anyKey(env, /^CODEBUILD_/)], + ['bamboo', env => env.bamboo_buildNumber], + ['bitbucket', env => env.BITBUCKET_BUILD_NUMBER], + ['buildkite', env => env.BUILDKITE], + ['circle', env => env.CIRCLECI], + ['concourse', env => anyKey(env, /^CONCOURSE_/)], + ['codeFresh', env => env.CF_BUILD_ID], + ['drone', env => env.DRONE], + ['githubActions', env => env.GITHUB_ACTIONS], + [ + 'gitlab', + env => + env.GITLAB_CI || + (env.CI_SERVER_NAME && /^GitLab/.test(env.CI_SERVER_NAME)) + ], + ['goCD', env => env.GO_JOB_NAME], + [ + 'jenkins', + env => + env.JENKINS_URL || + env.JENKINS_HOME || + env.JENKINS_VERSION || + env.HUDSON_URL || + env.HUDSON_HOME + ], + [ + 'googleCloud', + env => + (env.BUILD_ID && env.PROJECT_ID && env.PROJECT_NUMBER) || + env.GCP_PROJECT || + env.GCLOUD_PROJECT || + env.GOOGLE_CLOUD_PROJECT + ], + ['semaphore', env => env.SEMAPHORE], + ['teamcity', env => env.TEAMCITY_VERSION], + ['travis', env => env.TRAVIS], + ['netlify', env => env.NETLIFY] +] + +/** + * Returns the name of the CI provider the process runs on, or null. + */ +function detectCiProvider (env = process.env) { + const found = CI_PROVIDERS.find(([, isProvider]) => isProvider(env)) + return found ? found[0] : null +} + +module.exports = { detectCiProvider } diff --git a/src/ci-spec.js b/src/ci-spec.js new file mode 100644 index 0000000..eaa8fcc --- /dev/null +++ b/src/ci-spec.js @@ -0,0 +1,329 @@ +'use strict' + +/* eslint-env mocha */ +const assert = require('assert') +const { getCiCommitInfo, detectCiProvider } = require('./ci') + +const SHA = '783b58db0b8048e19f362c35596553f27ad449d5' + +const GITLAB_URL = + 'https://gitlab-ci-token:glcbt-64_SECRET@gitlab.com/org/repo.git' + +// [name, environment, expected provider, expected fields] +const cases = [ + [ + 'GitHub Actions branch push', + { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'push', + GITHUB_REF: 'refs/heads/feature/x', + GITHUB_REF_NAME: 'feature/x', + GITHUB_SHA: SHA + }, + 'githubActions', + { branch: 'feature/x', sha: SHA } + ], + [ + 'GitHub Actions tag push', + { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'push', + GITHUB_REF: 'refs/tags/v1.2.0', + GITHUB_REF_NAME: 'v1.2.0', + GITHUB_REF_TYPE: 'tag', + GITHUB_SHA: SHA + }, + 'githubActions', + { branch: 'v1.2.0' } + ], + [ + 'GitHub Actions tag push without GITHUB_REF_NAME', + { GITHUB_ACTIONS: 'true', GITHUB_REF: 'refs/tags/v1.2.0' }, + 'githubActions', + { branch: 'v1.2.0' } + ], + [ + 'GitHub Actions pull_request', + { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_REF: 'refs/pull/12/merge', + GITHUB_REF_NAME: '12/merge', + GITHUB_HEAD_REF: 'feature/x', + GITHUB_BASE_REF: 'main', + GITHUB_SHA: SHA + }, + 'githubActions', + { branch: 'feature/x', sha: SHA } + ], + [ + 'GitHub Actions pull_request_target', + { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'pull_request_target', + GITHUB_REF: 'refs/heads/main', + GITHUB_REF_NAME: 'main', + GITHUB_HEAD_REF: 'feature/x', + GITHUB_BASE_REF: 'main' + }, + 'githubActions', + { branch: 'feature/x' } + ], + [ + 'GitHub Actions workflow_run (the default branch)', + { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'workflow_run', + GITHUB_REF: 'refs/heads/main', + GITHUB_REF_NAME: 'main', + GITHUB_HEAD_REF: '' + }, + 'githubActions', + { branch: 'main' } + ], + [ + 'GitHub Actions GH_BRANCH set by the user', + { + GITHUB_ACTIONS: 'true', + GH_BRANCH: 'release/2', + GITHUB_HEAD_REF: 'feature/x' + }, + 'githubActions', + { branch: 'release/2' } + ], + [ + 'GitLab branch pipeline', + { + GITLAB_CI: 'true', + CI_COMMIT_REF_NAME: 'feature/x', + CI_COMMIT_SHA: SHA, + CI_COMMIT_MESSAGE: 'feat: x', + GITLAB_USER_NAME: 'Jane', + GITLAB_USER_EMAIL: 'jane@example.com', + CI_REPOSITORY_URL: GITLAB_URL + }, + 'gitlab', + { + branch: 'feature/x', + sha: SHA, + message: 'feat: x', + author: 'Jane', + email: 'jane@example.com', + remote: 'https://gitlab.com/org/repo.git' + } + ], + [ + 'GitLab merge request pipeline', + { + GITLAB_CI: 'true', + CI_PIPELINE_SOURCE: 'merge_request_event', + CI_COMMIT_REF_NAME: 'feature/x', + CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/x', + CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' + }, + 'gitlab', + { branch: 'feature/x' } + ], + [ + 'CircleCI', + { + CIRCLECI: 'true', + CIRCLE_BRANCH: 'feature/x', + CIRCLE_SHA1: SHA, + CIRCLE_USERNAME: 'jane', + CIRCLE_REPOSITORY_URL: 'git@github.com:org/repo.git' + }, + 'circle', + { + branch: 'feature/x', + sha: SHA, + author: 'jane', + remote: 'git@github.com:org/repo.git' + } + ], + [ + 'Jenkins freestyle job with the Git plugin', + { + JENKINS_URL: 'https://ci.example.com/', + GIT_BRANCH: 'origin/feature/x', + GIT_COMMIT: SHA + }, + 'jenkins', + { branch: 'feature/x', sha: SHA } + ], + [ + 'Jenkins GIT_BRANCH as a remote ref', + { + JENKINS_URL: 'https://ci.example.com/', + GIT_BRANCH: 'refs/remotes/origin/feature/x' + }, + 'jenkins', + { branch: 'feature/x' } + ], + [ + 'Jenkins multibranch pull request', + { + JENKINS_URL: 'https://ci.example.com/', + BRANCH_NAME: 'PR-12', + CHANGE_ID: '12', + CHANGE_BRANCH: 'feature/x', + CHANGE_TARGET: 'main', + GIT_BRANCH: 'PR-12' + }, + 'jenkins', + { branch: 'feature/x' } + ], + [ + 'Azure Pipelines CI build of feature/x, BUILD_SOURCEBRANCHNAME is the last segment', + { + TF_BUILD: 'True', + AZURE_HTTP_USER_AGENT: 'agent', + BUILD_SOURCEBRANCH: 'refs/heads/feature/x', + BUILD_SOURCEBRANCHNAME: 'x', + BUILD_SOURCEVERSION: SHA, + BUILD_REPOSITORY_URI: 'https://org@dev.azure.com/org/p/_git/repo' + }, + 'azure', + { + branch: 'x', + sha: SHA, + remote: 'https://dev.azure.com/org/p/_git/repo' + } + ], + [ + 'Azure Pipelines pull request from feature/x', + { + TF_BUILD: 'True', + AZURE_HTTP_USER_AGENT: 'agent', + BUILD_REASON: 'PullRequest', + BUILD_SOURCEBRANCH: 'refs/pull/7/merge', + BUILD_SOURCEBRANCHNAME: 'merge', + SYSTEM_PULLREQUEST_SOURCEBRANCH: 'refs/heads/feature/x', + SYSTEM_PULLREQUEST_TARGETBRANCH: 'refs/heads/main', + SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI: 'https://github.com/org/repo' + }, + 'azure', + { branch: 'feature/x', remote: 'https://github.com/org/repo' } + ], + [ + 'Azure Pipelines tag build', + { + TF_BUILD: 'True', + AZURE_HTTP_USER_AGENT: 'agent', + BUILD_SOURCEBRANCH: 'refs/tags/v1.2.0', + BUILD_SOURCEBRANCHNAME: 'v1.2.0' + }, + 'azure', + { branch: 'v1.2.0' } + ], + [ + 'Bamboo', + { + bamboo_buildNumber: '5', + bamboo_planRepository_revision: SHA, + bamboo_planRepository_branch: 'feature/x', + bamboo_planRepository_username: 'jane', + bamboo_planRepository_repositoryUrl: 'https://github.com/org/repo.git' + }, + 'bamboo', + { + branch: 'feature/x', + sha: SHA, + author: 'jane', + remote: 'https://github.com/org/repo.git' + } + ], + [ + 'Bitbucket Pipelines', + { + BITBUCKET_BUILD_NUMBER: '5', + BITBUCKET_BRANCH: 'feature/x', + BITBUCKET_COMMIT: SHA + }, + 'bitbucket', + { branch: 'feature/x', sha: SHA } + ], + [ + 'Buildkite', + { + BUILDKITE: 'true', + BUILDKITE_BRANCH: 'feature/x', + BUILDKITE_COMMIT: SHA, + BUILDKITE_MESSAGE: 'feat: x', + BUILDKITE_BUILD_CREATOR: 'Jane', + BUILDKITE_BUILD_CREATOR_EMAIL: 'jane@example.com', + BUILDKITE_REPO: 'git@github.com:org/repo.git' + }, + 'buildkite', + { + branch: 'feature/x', + sha: SHA, + message: 'feat: x', + author: 'Jane', + email: 'jane@example.com', + remote: 'git@github.com:org/repo.git' + } + ], + [ + 'AWS CodeBuild, no branch', + { + CODEBUILD_BUILD_ID: 'p:1', + CODEBUILD_WEBHOOK_HEAD_REF: 'refs/heads/feature/x', + CODEBUILD_SOURCE_VERSION: SHA, + CODEBUILD_RESOLVED_SOURCE_VERSION: SHA, + CODEBUILD_SOURCE_REPO_URL: 'https://github.com/org/repo.git' + }, + 'awsCodeBuild', + { + branch: null, + sha: SHA, + remote: 'https://github.com/org/repo.git' + } + ], + [ + 'Semaphore', + { + SEMAPHORE: 'true', + SEMAPHORE_GIT_SHA: SHA, + SEMAPHORE_GIT_BRANCH: 'feature/x', + SEMAPHORE_GIT_URL: 'git@github.com:org/repo.git', + SEMAPHORE_GIT_REPO_SLUG: 'org/repo' + }, + 'semaphore', + { + branch: 'feature/x', + sha: SHA, + remote: 'git@github.com:org/repo.git' + } + ], + ['no CI', {}, null, { branch: null, sha: null, remote: null }] +] + +describe('getCiCommitInfo', () => { + cases.forEach(([name, env, provider, expected]) => { + it(name, () => { + const info = getCiCommitInfo(env) + assert.strictEqual(info.provider, provider) + assert.strictEqual(detectCiProvider(env), provider) + Object.keys(expected).forEach(field => { + assert.strictEqual(info[field], expected[field], field) + }) + }) + }) + + it('returns null for each field the provider does not set', () => { + assert.deepStrictEqual( + getCiCommitInfo({ BITBUCKET_BUILD_NUMBER: '5', BITBUCKET_BRANCH: 'x' }), + { + provider: 'bitbucket', + branch: 'x', + message: null, + email: null, + author: null, + sha: null, + remote: null, + timestamp: null + } + ) + }) +}) diff --git a/src/ci.js b/src/ci.js new file mode 100644 index 0000000..c0be561 --- /dev/null +++ b/src/ci.js @@ -0,0 +1,159 @@ +'use strict' + +const { detectCiProvider } = require('./ci-provider') +const { removeCredentials } = require('./remove-credentials') +const { getFields } = require('./utils') + +const join = (...pieces) => pieces.filter(Boolean).join('\n') + +// Each provider's commit values. Field names match commitInfo(). +const providerCommits = { + appveyor: env => ({ + sha: env.APPVEYOR_REPO_COMMIT, + // APPVEYOR_REPO_BRANCH is the target branch on a pull request + branch: + env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH || env.APPVEYOR_REPO_BRANCH, + message: join( + env.APPVEYOR_REPO_COMMIT_MESSAGE, + env.APPVEYOR_REPO_COMMIT_MESSAGE_EXTENDED + ), + author: env.APPVEYOR_REPO_COMMIT_AUTHOR, + email: env.APPVEYOR_REPO_COMMIT_AUTHOR_EMAIL + }), + /** @see https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html */ + awsCodeBuild: env => ({ + sha: env.CODEBUILD_RESOLVED_SOURCE_VERSION, + remote: env.CODEBUILD_SOURCE_REPO_URL + }), + /** @see https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables */ + azure: env => ({ + sha: env.BUILD_SOURCEVERSION, + branch: env.SYSTEM_PULLREQUEST_SOURCEBRANCH + ? env.SYSTEM_PULLREQUEST_SOURCEBRANCH.replace(/^refs\/heads\//, '') + : env.BUILD_SOURCEBRANCHNAME, + message: env.BUILD_SOURCEVERSIONMESSAGE, + author: env.BUILD_SOURCEVERSIONAUTHOR, + email: env.BUILD_REQUESTEDFOREMAIL, + remote: + env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI || env.BUILD_REPOSITORY_URI + }), + /** @see https://confluence.atlassian.com/bamboo/bamboo-variables-289277087.html */ + bamboo: env => ({ + sha: env.bamboo_planRepository_revision, + branch: env.bamboo_planRepository_branch, + author: env.bamboo_planRepository_username, + remote: env.bamboo_planRepository_repositoryUrl + }), + /** @see https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ */ + bitbucket: env => ({ + sha: env.BITBUCKET_COMMIT, + branch: env.BITBUCKET_BRANCH + }), + /** @see https://buildkite.com/docs/pipelines/environment-variables */ + buildkite: env => ({ + sha: env.BUILDKITE_COMMIT, + branch: env.BUILDKITE_BRANCH, + message: env.BUILDKITE_MESSAGE, + author: env.BUILDKITE_BUILD_CREATOR, + email: env.BUILDKITE_BUILD_CREATOR_EMAIL, + remote: env.BUILDKITE_REPO + }), + /** @see https://circleci.com/docs/variables/ */ + circle: env => ({ + sha: env.CIRCLE_SHA1, + branch: env.CIRCLE_BRANCH, + author: env.CIRCLE_USERNAME, + remote: env.CIRCLE_REPOSITORY_URL + }), + codeFresh: env => ({ + sha: env.CF_REVISION, + branch: env.CF_BRANCH, + message: env.CF_COMMIT_MESSAGE, + author: env.CF_COMMIT_AUTHOR + }), + drone: env => ({ + sha: env.DRONE_COMMIT_SHA, + branch: env.DRONE_SOURCE_BRANCH, + message: env.DRONE_COMMIT_MESSAGE, + author: env.DRONE_COMMIT_AUTHOR, + email: env.DRONE_COMMIT_AUTHOR_EMAIL, + remote: env.DRONE_GIT_HTTP_URL + }), + /** @see https://docs.github.com/en/actions/reference/variables-reference */ + githubActions: env => ({ + sha: env.GITHUB_SHA, + // GITHUB_HEAD_REF is set on pull_request and pull_request_target only + branch: + env.GH_BRANCH || + env.GITHUB_HEAD_REF || + env.GITHUB_REF_NAME || + (env.GITHUB_REF && env.GITHUB_REF.replace(/^refs\/(heads|tags)\//, '')) + }), + /** @see https://docs.gitlab.com/ee/ci/variables/predefined_variables.html */ + gitlab: env => ({ + sha: env.CI_COMMIT_SHA, + branch: env.CI_COMMIT_REF_NAME, + message: env.CI_COMMIT_MESSAGE, + author: env.GITLAB_USER_NAME, + email: env.GITLAB_USER_EMAIL, + // holds a job token: https://gitlab-ci-token:@host/o/r.git + remote: env.CI_REPOSITORY_URL + }), + googleCloud: env => ({ + sha: env.COMMIT_SHA, + branch: env.BRANCH_NAME + }), + /** + * CHANGE_BRANCH is the pull request branch in multibranch pipelines. + * GIT_BRANCH (Git plugin) names the remote branch, as in `origin/main`. + * @see https://plugins.jenkins.io/git/ + */ + jenkins: env => ({ + sha: env.GIT_COMMIT, + branch: + env.CHANGE_BRANCH || + (env.GIT_BRANCH && + env.GIT_BRANCH.replace( + /^(refs\/remotes\/|refs\/heads\/)?origin\//, + '' + ).replace(/^refs\/heads\//, '')) + }), + /** @see https://docs.semaphoreci.com/reference/env-vars */ + semaphore: env => ({ + sha: env.SEMAPHORE_GIT_SHA, + branch: env.SEMAPHORE_GIT_BRANCH, + remote: env.SEMAPHORE_GIT_URL + }), + travis: env => ({ + sha: env.TRAVIS_PULL_REQUEST_SHA || env.TRAVIS_COMMIT, + // TRAVIS_BRANCH is the target branch on a pull request + branch: env.TRAVIS_PULL_REQUEST_BRANCH || env.TRAVIS_BRANCH, + message: env.TRAVIS_COMMIT_MESSAGE + }), + netlify: env => ({ + sha: env.COMMIT_REF, + branch: env.BRANCH, + remote: env.REPOSITORY_URL + }) +} + +/** + * Reads the commit from the CI provider's variables. Fields the provider does + * not set are null; no provider sets the timestamp. The remote has no + * credentials. + * + * @returns {{provider: string|null, branch, message, email, author, sha, remote, timestamp}} + */ +function getCiCommitInfo (env = process.env) { + const provider = detectCiProvider(env) + const values = + provider && providerCommits[provider] ? providerCommits[provider](env) : {} + const commit = { provider } + getFields().forEach(field => { + commit[field] = values[field] || null + }) + commit.remote = removeCredentials(commit.remote) + return commit +} + +module.exports = { detectCiProvider, getCiCommitInfo } diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js new file mode 100644 index 0000000..447c4e7 --- /dev/null +++ b/src/commit-info-repos-spec.js @@ -0,0 +1,181 @@ +'use strict' + +/* eslint-env mocha */ +const assert = require('assert') +const { execFileSync, spawnSync } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const TOKEN = 'glcbt-64_SECRET_TOKEN' +const GITLAB_REMOTE = `https://gitlab-ci-token:${TOKEN}@gitlab.com/org/repo.git` + +// No global or system git config, because those can mark every repository +// as safe, as the GitHub Actions runner does +let home + +const git = (cwd, ...args) => + execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { + cwd, + encoding: 'utf8', + env: Object.assign({}, process.env, { + GIT_AUTHOR_NAME: 'Jane', + GIT_AUTHOR_EMAIL: 'jane@example.com', + GIT_COMMITTER_NAME: 'Jane', + GIT_COMMITTER_EMAIL: 'jane@example.com' + }) + }).trim() + +/** + * Runs commitInfo in a new process, so the CI variables of the machine that + * runs the tests do not apply. + * Resolves with the result and everything the process printed. + */ +function runCommitInfo (cwd, env) { + const script = `require(${JSON.stringify(__dirname)}).commitInfo() + .then(info => console.log(JSON.stringify(info)))` + const child = spawnSync(process.execPath, ['-e', script], { + cwd, + encoding: 'utf8', + env: Object.assign( + { PATH: process.env.PATH, HOME: home, GIT_CONFIG_NOSYSTEM: '1' }, + env + ) + }) + assert.strictEqual(child.status, 0, child.stderr) + const lines = child.stdout.trim().split('\n') + return { + info: JSON.parse(lines[lines.length - 1]), + output: child.stdout + child.stderr, + stderr: child.stderr + } +} + +describe('commitInfo in real repositories', function () { + this.timeout(20000) + + let root, repo, sha + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'commit-info-repos-')) + home = path.join(root, 'home') + fs.mkdirSync(home) + repo = path.join(root, 'repo') + git(root, 'init', '-q', '-b', 'main', repo) + git(repo, 'commit', '-q', '--allow-empty', '-m', 'feat: first') + git(repo, 'remote', 'add', 'origin', GITLAB_REMOTE) + fs.mkdirSync(path.join(repo, 'sub')) + sha = git(repo, 'rev-parse', 'HEAD') + }) + + after(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + afterEach(() => { + git(repo, 'checkout', '-q', 'main') + }) + + it('reads the branch from git', () => { + const { info, stderr } = runCommitInfo(repo, {}) + assert.strictEqual(info.branch, 'main') + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.message.trim(), 'feat: first') + assert.strictEqual(info.author, 'Jane') + assert.strictEqual(info.email, 'jane@example.com') + assert.strictEqual(stderr, '') + }) + + it('reports no branch for a detached checkout outside CI', () => { + git(repo, 'checkout', '-q', '--detach') + const { info, stderr } = runCommitInfo(repo, {}) + assert.strictEqual(info.branch, null) + assert.strictEqual(info.sha, sha) + assert.strictEqual(stderr, '') + }) + + it('prefers COMMIT_INFO_BRANCH to git and keeps it as it is', () => { + const { info } = runCommitInfo(repo, { + COMMIT_INFO_BRANCH: 'refs/heads/from-env' + }) + assert.strictEqual(info.branch, 'refs/heads/from-env') + }) + + describe('credentials', () => { + const gitlabEnv = { + GITLAB_CI: 'true', + CI: 'true', + CI_REPOSITORY_URL: GITLAB_REMOTE, + CI_COMMIT_REF_NAME: 'main', + DEBUG: 'commit-info' + } + + it('are not in the result or the debug output', () => { + const { info, output } = runCommitInfo(repo, gitlabEnv) + assert.strictEqual(info.remote, 'https://gitlab.com/org/repo.git') + assert(output.includes('git stdout:'), 'expected the debug output') + assert(!output.includes(TOKEN), output) + }) + + it('are removed from COMMIT_INFO_REMOTE', () => { + const { info, output } = runCommitInfo( + repo, + Object.assign({ COMMIT_INFO_REMOTE: GITLAB_REMOTE }, gitlabEnv) + ) + assert.strictEqual(info.remote, 'https://gitlab.com/org/repo.git') + assert(!output.includes(TOKEN), output) + }) + }) + + it('does not warn when the repository has no remote', () => { + const noRemote = path.join(root, 'no-remote') + git(root, 'init', '-q', noRemote) + git(noRemote, 'commit', '-q', '--allow-empty', '-m', 'feat: first') + const { info, stderr } = runCommitInfo(noRemote, { CI: 'true' }) + assert.strictEqual(info.remote, null) + assert.strictEqual(stderr, '') + }) + + it('has no values when git is not in PATH', () => { + const { info } = runCommitInfo(repo, { PATH: root }) + assert.strictEqual(info.sha, null) + assert.strictEqual(info.branch, null) + assert.strictEqual(info.remote, null) + }) + + describe('repository owned by another user', () => { + const otherOwner = { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' } + + it('is read from a subfolder on CI', () => { + const { info, stderr } = runCommitInfo( + path.join(repo, 'sub'), + Object.assign({ CI: 'true' }, otherOwner) + ) + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.branch, 'main') + assert.strictEqual(info.remote, 'https://gitlab.com/org/repo.git') + assert.strictEqual(stderr, '') + }) + + it('is read on Jenkins, which does not set CI', () => { + const { info, stderr } = runCommitInfo( + repo, + Object.assign( + { + JENKINS_URL: 'https://jenkins.example.com/', + GIT_COMMIT: 'jenkins-sha' + }, + otherOwner + ) + ) + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.branch, 'main') + assert.strictEqual(stderr, '') + }) + + it('is not read outside CI', () => { + const { info } = runCommitInfo(repo, otherOwner) + assert.strictEqual(info.sha, null) + }) + }) +}) diff --git a/src/commit-info-spec.js b/src/commit-info-spec.js index 75fd3dc..38f3c26 100644 --- a/src/commit-info-spec.js +++ b/src/commit-info-spec.js @@ -2,7 +2,7 @@ /* eslint-env mocha */ const { commitInfo } = require('.') -const { stubSpawnShellOnce } = require('stub-spawn-once') +const { stubSpawnOnce } = require('stub-spawn-once') const snapshot = require('snap-shot-it') const { gitCommands } = require('./git-api') const la = require('lazy-ass') @@ -17,7 +17,7 @@ describe('getBranch', () => { }) it('returns null for empty output', () => { - stubSpawnShellOnce(gitCommands.branch, 0, '', '') + stubSpawnOnce(gitCommands.branch, 0, '', '') return getBranch().then(branch => { la( branch === null, @@ -28,7 +28,7 @@ describe('getBranch', () => { }) it('returns null on git error', () => { - stubSpawnShellOnce(gitCommands.branch, 1, '', 'something wrong') + stubSpawnOnce(gitCommands.branch, 1, '', 'something wrong') return getBranch().then(branch => { la( branch === null, @@ -39,7 +39,7 @@ describe('getBranch', () => { }) it('returns null on git HEAD', () => { - stubSpawnShellOnce(gitCommands.branch, 0, 'HEAD', '') + stubSpawnOnce(gitCommands.branch, 0, 'HEAD', '') return getBranch().then(branch => { la( branch === null, @@ -68,29 +68,24 @@ describe('commit-info', () => { }) it('returns information', () => { - stubSpawnShellOnce(gitCommands.branch, 0, 'test-branch', '') - stubSpawnShellOnce(gitCommands.message, 0, 'important commit', '') - stubSpawnShellOnce(gitCommands.email, 0, 'me@foo.com', '') - stubSpawnShellOnce(gitCommands.author, 0, 'John Doe', '') - stubSpawnShellOnce(gitCommands.sha, 0, 'abc123', '') - stubSpawnShellOnce(gitCommands.timestamp, 0, '123', '') - stubSpawnShellOnce( - gitCommands.remoteOriginUrl, - 0, - 'git@github.com/repo', - '' - ) + stubSpawnOnce(gitCommands.branch, 0, 'test-branch', '') + stubSpawnOnce(gitCommands.message, 0, 'important commit', '') + stubSpawnOnce(gitCommands.email, 0, 'me@foo.com', '') + stubSpawnOnce(gitCommands.author, 0, 'John Doe', '') + stubSpawnOnce(gitCommands.sha, 0, 'abc123', '') + stubSpawnOnce(gitCommands.timestamp, 0, '123', '') + stubSpawnOnce(gitCommands.remoteOriginUrl, 0, 'git@github.com/repo', '') return commitInfo().then(snapshot) }) it('returns nulls for missing fields', () => { - stubSpawnShellOnce(gitCommands.branch, 0, 'test-branch', '') - stubSpawnShellOnce(gitCommands.message, 1, '', 'no message') - stubSpawnShellOnce(gitCommands.email, 0, 'me@foo.com', '') - stubSpawnShellOnce(gitCommands.author, 1, '', 'missing author') - stubSpawnShellOnce(gitCommands.sha, 0, 'abc123', '') - stubSpawnShellOnce(gitCommands.remoteOriginUrl, 1, '', 'no remote origin') - stubSpawnShellOnce(gitCommands.timestamp, 0, '123', '') + stubSpawnOnce(gitCommands.branch, 0, 'test-branch', '') + stubSpawnOnce(gitCommands.message, 1, '', 'no message') + stubSpawnOnce(gitCommands.email, 0, 'me@foo.com', '') + stubSpawnOnce(gitCommands.author, 1, '', 'missing author') + stubSpawnOnce(gitCommands.sha, 0, 'abc123', '') + stubSpawnOnce(gitCommands.remoteOriginUrl, 1, '', 'no remote origin') + stubSpawnOnce(gitCommands.timestamp, 0, '123', '') return commitInfo() .tap(info => { la(info.message === null, 'message should be null', info) @@ -129,23 +124,13 @@ describe('commit-info', () => { }) it('returns information', () => { - stubSpawnShellOnce(gitCommands.branch, 0, 'test-branch', '') - stubSpawnShellOnce( - gitCommands.message, - 1, - '', - 'could not get Git message' - ) - stubSpawnShellOnce(gitCommands.email, 1, '', 'could not get Git email') - stubSpawnShellOnce(gitCommands.author, 0, 'John Doe', '') - stubSpawnShellOnce(gitCommands.sha, 0, 'abc123', '') - stubSpawnShellOnce(gitCommands.timestamp, 0, '123', '') - stubSpawnShellOnce( - gitCommands.remoteOriginUrl, - 0, - 'git@github.com/repo', - '' - ) + stubSpawnOnce(gitCommands.branch, 0, 'test-branch', '') + stubSpawnOnce(gitCommands.message, 1, '', 'could not get Git message') + stubSpawnOnce(gitCommands.email, 1, '', 'could not get Git email') + stubSpawnOnce(gitCommands.author, 0, 'John Doe', '') + stubSpawnOnce(gitCommands.sha, 0, 'abc123', '') + stubSpawnOnce(gitCommands.timestamp, 0, '123', '') + stubSpawnOnce(gitCommands.remoteOriginUrl, 0, 'git@github.com/repo', '') return commitInfo().then(snapshot) }) }) diff --git a/src/git-api-spec.js b/src/git-api-spec.js index 53c8ce0..9c47244 100644 --- a/src/git-api-spec.js +++ b/src/git-api-spec.js @@ -3,10 +3,17 @@ const la = require('lazy-ass') const is = require('check-more-types') const chdir = require('chdir-promise') -const { stubSpawnShellOnce } = require('stub-spawn-once') +const { stubSpawnOnce } = require('stub-spawn-once') const Promise = require('bluebird') const snapshot = require('snap-shot-it') const { join } = require('path') +const assert = require('assert') +const { execFileSync } = require('child_process') +const fs = require('fs') +const os = require('os') +const util = require('util') +const createDebug = require('debug') +const mockedEnv = require('mocked-env') /* eslint-env mocha */ describe('git-api', () => { @@ -70,8 +77,8 @@ describe('git-api', () => { const { getSubject, getBody } = require('./git-api') it('gets subject and body', () => { - stubSpawnShellOnce(gitCommands.subject, 0, 'commit does this', '') - stubSpawnShellOnce(gitCommands.body, 0, 'more details', '') + stubSpawnOnce(gitCommands.subject, 0, 'commit does this', '') + stubSpawnOnce(gitCommands.body, 0, 'more details', '') return Promise.props({ subject: getSubject(), body: getBody() @@ -90,17 +97,12 @@ describe('git-api', () => { } = require('./git-api') it('works', () => { - stubSpawnShellOnce(gitCommands.message, 0, 'important commit', '') - stubSpawnShellOnce(gitCommands.email, 0, 'me@foo.com', '') - stubSpawnShellOnce(gitCommands.author, 0, 'John Doe', '') - stubSpawnShellOnce(gitCommands.sha, 0, 'abc123', '') - stubSpawnShellOnce(gitCommands.timestamp, 0, '123', '') - stubSpawnShellOnce( - gitCommands.remoteOriginUrl, - 0, - 'git@github.com/repo', - '' - ) + stubSpawnOnce(gitCommands.message, 0, 'important commit', '') + stubSpawnOnce(gitCommands.email, 0, 'me@foo.com', '') + stubSpawnOnce(gitCommands.author, 0, 'John Doe', '') + stubSpawnOnce(gitCommands.sha, 0, 'abc123', '') + stubSpawnOnce(gitCommands.timestamp, 0, '123', '') + stubSpawnOnce(gitCommands.remoteOriginUrl, 0, 'git@github.com/repo', '') return Promise.props({ message: getMessage(), @@ -112,4 +114,91 @@ describe('git-api', () => { }).then(snapshot) }) }) + describe('in a repository', function () { + this.timeout(10000) + + const TOKEN = 'glcbt-64_SECRET_TOKEN' + const { runGitCommandWithError, readRemoteOrigin } = require('./git-api') + + let root, home, restoreEnvironment + + // No global or system git config, because those can mark every repository + // as safe, as the GitHub Actions runner does + const env = extra => + mockedEnv( + Object.assign( + { PATH: process.env.PATH, HOME: home, GIT_CONFIG_NOSYSTEM: '1' }, + extra + ), + { + clear: true + } + ) + + beforeEach(() => { + root = fs.mkdtempSync(join(os.tmpdir(), 'git-api-')) + home = fs.mkdtempSync(join(os.tmpdir(), 'git-api-home-')) + execFileSync('git', ['init', '-q', root]) + }) + + afterEach(() => { + restoreEnvironment() + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(home, { recursive: true, force: true }) + }) + + it('returns no value and no error when the remote is not set', () => { + restoreEnvironment = env({}) + return runGitCommandWithError(gitCommands.remoteOriginUrl, root).then( + result => assert.deepStrictEqual(result, { value: null, error: null }) + ) + }) + + describe('readRemoteOrigin when another user owns the repository', () => { + let debugLines, restoreDebug + + beforeEach(() => { + execFileSync('git', [ + ...['-C', root, 'remote', 'add', 'origin'], + `https://gitlab-ci-token:${TOKEN}@gitlab.com/org/repo.git` + ]) + debugLines = [] + const namespaces = createDebug.disable() + const log = createDebug.log + createDebug.log = (...args) => debugLines.push(util.format(...args)) + createDebug.enable('commit-info') + restoreDebug = () => { + createDebug.log = log + createDebug.enable(namespaces) + } + }) + + afterEach(() => { + restoreDebug() + }) + + it('reads the empty value again on CI, without credentials', () => { + restoreEnvironment = env({ + CI: 'true', + GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' + }) + return readRemoteOrigin(root).then(result => { + assert.deepStrictEqual(result, { + value: 'https://gitlab.com/org/repo.git', + error: null + }) + const output = debugLines.join('\n') + assert(output.includes('https://gitlab.com/org/repo.git'), output) + assert(!output.includes(TOKEN), output) + }) + }) + + it('does not read it again outside CI', () => { + restoreEnvironment = env({ GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }) + return readRemoteOrigin(root).then(result => + assert.deepStrictEqual(result, { value: null, error: null }) + ) + }) + }) + }) }) diff --git a/src/git-api.js b/src/git-api.js index c63c9b9..62a879d 100644 --- a/src/git-api.js +++ b/src/git-api.js @@ -1,8 +1,14 @@ -const execa = require('execa') const debug = require('debug')('commit-info') const la = require('lazy-ass') const is = require('check-more-types') const Promise = require('bluebird') +const { removeCredentials } = require('./remove-credentials') +const { + execGit, + isCi, + describeGitError, + SAFE_DIRECTORY_ARGS +} = require('./run-git') // common git commands for getting basic info // https://git-scm.com/docs/git-show @@ -18,35 +24,60 @@ const gitCommands = { remoteOriginUrl: 'git config --get remote.origin.url' } -const prop = name => object => object[name] const returnNull = () => null const returnNullIfEmpty = value => value || null - -const debugError = (gitCommand, folder, e) => { - debug('got an error running command "%s" in folder "%s"', gitCommand, folder) - debug(e) -} - -const runGitCommand = (gitCommand, pathToRepo) => { +const keepValue = value => value + +// `git config --get` exits with 1 and prints nothing when the key is not set +const isMissingValue = e => + typeof e.code === 'number' && !String(e.stderr || '').trim() + +// The commands in gitCommands have no quoted arguments +const toArgs = gitCommand => gitCommand.split(' ').slice(1) + +/** + * Runs a read-only git command and resolves with `{ value, error }`. `value` + * is the trimmed stdout or null; `error` is set when git failed for another + * reason than a missing value. + * + * @param {string} gitCommand one of gitCommands + * @param {string} [pathToRepo] + * @param {(stdout: string) => string} [transform] runs before stdout is + * logged, so it can remove credentials + */ +const runGitCommandWithError = (gitCommand, pathToRepo, transform) => { la(is.unemptyString(gitCommand), 'missing git command', gitCommand) la(gitCommand.startsWith('git'), 'invalid git command', gitCommand) pathToRepo = pathToRepo || process.cwd() la(is.unemptyString(pathToRepo), 'missing repo path', pathToRepo) + transform = transform || keepValue debug('running git command: %s', gitCommand) debug('in folder %s', pathToRepo) - return Promise.try(() => execa.shell(gitCommand, { cwd: pathToRepo })) - .then(prop('stdout')) + return Promise.try(() => + execGit(pathToRepo, toArgs(gitCommand), { readOnly: true }) + ) + .then(transform) .tap(stdout => debug('git stdout:', stdout)) - .then(returnNullIfEmpty) + .then(stdout => ({ value: returnNullIfEmpty(stdout), error: null })) .catch(e => { - debugError(gitCommand, pathToRepo, e) - return returnNull() + debug( + 'got an error running command "%s" in folder "%s": %s', + gitCommand, + pathToRepo, + describeGitError(e) + ) + return { value: null, error: isMissingValue(e) ? null : e } }) } +const runGitCommand = (gitCommand, pathToRepo, transform) => + runGitCommandWithError(gitCommand, pathToRepo, transform).then( + result => result.value + ) + /* "gift" module returns "" for detached checkouts and our current command returns "HEAD" @@ -64,24 +95,59 @@ function getGitBranch (pathToRepo) { .catch(returnNull) } -const getMessage = runGitCommand.bind(null, gitCommands.message) +const bindCommand = gitCommand => pathToRepo => + runGitCommand(gitCommand, pathToRepo) + +const getMessage = bindCommand(gitCommands.message) + +const getSubject = bindCommand(gitCommands.subject) + +const getBody = bindCommand(gitCommands.body) -const getSubject = runGitCommand.bind(null, gitCommands.subject) +const getEmail = bindCommand(gitCommands.email) -const getBody = runGitCommand.bind(null, gitCommands.body) +const getAuthor = bindCommand(gitCommands.author) -const getEmail = runGitCommand.bind(null, gitCommands.email) +const getSha = bindCommand(gitCommands.sha) -const getAuthor = runGitCommand.bind(null, gitCommands.author) +const getTimestamp = bindCommand(gitCommands.timestamp) -const getSha = runGitCommand.bind(null, gitCommands.sha) +// Reads the repository's config when another user owns the repository +const remoteOriginUrlOfAnyOwner = gitCommands.remoteOriginUrl.replace( + /^git /, + `git ${SAFE_DIRECTORY_ARGS.join(' ')} ` +) -const getTimestamp = runGitCommand.bind(null, gitCommands.timestamp) +/** + * Reads the remote URL without credentials: a remote can hold a token, as in + * https://user:@host/o/r.git. Resolves with `{ value, error }`. + * + * In a repository owned by another user `git config` does not fail: it skips + * the repository's config and prints nothing. So on CI an empty result is read + * again with safe.directory=*. + */ +const readRemoteOrigin = pathToRepo => + runGitCommandWithError( + gitCommands.remoteOriginUrl, + pathToRepo, + removeCredentials + ).then(result => { + if (result.value || result.error || !isCi()) { + return result + } + return runGitCommandWithError( + remoteOriginUrlOfAnyOwner, + pathToRepo, + removeCredentials + ).then(retry => (retry.error ? result : retry)) + }) -const getRemoteOrigin = runGitCommand.bind(null, gitCommands.remoteOriginUrl) +const getRemoteOrigin = pathToRepo => + readRemoteOrigin(pathToRepo).then(result => result.value) module.exports = { runGitCommand, + runGitCommandWithError, getGitBranch, getSubject, getBody, @@ -91,5 +157,6 @@ module.exports = { getSha, getTimestamp, getRemoteOrigin, + readRemoteOrigin, gitCommands } diff --git a/src/index.js b/src/index.js index 0090fbe..9c1ac0b 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,8 @@ const { getGhaEventData } = require('./utils') const { getPullRequestHeadCommit } = require('./pull-request-head') +const { getCiCommitInfo, detectCiProvider } = require('./ci') +const { removeCredentials } = require('./remove-credentials') const Promise = require('bluebird') const { mergeWith, or } = require('ramda') @@ -48,6 +50,7 @@ function commitInfo (folder) { }) .then(info => { const envVariables = getCommitInfoFromEnvironment() + envVariables.remote = removeCredentials(envVariables.remote) debug('git commit: %o', info) debug('env commit: %o', envVariables) return mergeWith(or, envVariables, info) @@ -64,5 +67,8 @@ module.exports = { getRemoteOrigin, getSubject, getTimestamp, - getBody + getBody, + getCiCommitInfo, + detectCiProvider, + removeCredentials } diff --git a/src/pull-request-head-spec.js b/src/pull-request-head-spec.js index 8447e28..f662ed4 100644 --- a/src/pull-request-head-spec.js +++ b/src/pull-request-head-spec.js @@ -161,6 +161,20 @@ describe('getPullRequestHeadCommit', function () { ) }) + it('reads the pull request commit on CI when another user owns the repository', async () => { + const work = checkout('refs/pull/1/merge') + git(work, 'remote', 'set-url', 'origin', path.join(root, 'missing')) + process.env.CI = 'true' + process.env.GIT_TEST_ASSUME_DIFFERENT_OWNER = '1' + + 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 diff --git a/src/pull-request-head.js b/src/pull-request-head.js index e16411d..38bb776 100644 --- a/src/pull-request-head.js +++ b/src/pull-request-head.js @@ -1,7 +1,8 @@ 'use strict' const debug = require('debug')('commit-info') -const execa = require('execa') +const { execGit } = require('./run-git') +const { removeCredentialsFromText } = require('./remove-credentials') // 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 @@ -67,14 +68,7 @@ function getHeadSha (ghaEventData) { 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 -} +const git = (folder, args) => execGit(folder, args, { readOnly: true }) // Reads the parent lines stored in the commit object. `git log --format=%P` // and `HEAD^2` return nothing in a depth-1 clone. @@ -123,10 +117,15 @@ async function fetchCommit (folder, sha) { // 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) + await execGit(folder, args, { timeout: FETCH_TIMEOUT_MS }) return true } catch (e) { - debug('fetching %s failed (attempt %d): %o', sha, attempt, e) + debug( + 'fetching %s failed (attempt %d): %s', + sha, + attempt, + removeCredentialsFromText(String(e.stderr || e.message)) + ) if (await hasCommit(folder, sha)) { return true } diff --git a/src/remove-credentials-spec.js b/src/remove-credentials-spec.js new file mode 100644 index 0000000..8d2842a --- /dev/null +++ b/src/remove-credentials-spec.js @@ -0,0 +1,76 @@ +'use strict' + +/* eslint-env mocha */ +const assert = require('assert') +const { + removeCredentials, + removeCredentialsFromText +} = require('./remove-credentials') + +describe('removeCredentials', () => { + const urls = [ + [ + 'GitLab job token', + 'https://gitlab-ci-token:glcbt-64_SECRET@gitlab.com/o/r.git', + 'https://gitlab.com/o/r.git' + ], + [ + 'token as the user', + 'https://ghp_SECRET@github.com/o/r.git', + 'https://github.com/o/r.git' + ], + [ + 'port', + 'https://x-access-token:SECRET@github.example.com:8443/o/r.git', + 'https://github.example.com:8443/o/r.git' + ], + [ + 'Azure organization as the user', + 'https://org@dev.azure.com/org/p/_git/r', + 'https://dev.azure.com/org/p/_git/r' + ], + ['scp-style SSH', 'git@host:o/r.git', 'git@host:o/r.git'], + [ + 'SSH URL with a port', + 'ssh://git@host:2222/o/r.git', + 'ssh://host:2222/o/r.git' + ], + [ + 'password with "@"', + 'https://user:p@ss@host/o/r.git', + 'https://host/o/r.git' + ], + [ + 'encoded "@" in the password', + 'https://user:p%40ss@host/o/r.git', + 'https://host/o/r.git' + ], + ['no credentials', 'https://host/o/r.git', 'https://host/o/r.git'], + ['not a URL', 'org/repo', 'org/repo'] + ] + + urls.forEach(([name, url, expected]) => { + it(name, () => { + assert.strictEqual(removeCredentials(url), expected) + }) + }) + + it('returns empty values as they are', () => { + assert.strictEqual(removeCredentials(null), null) + assert.strictEqual(removeCredentials(undefined), undefined) + assert.strictEqual(removeCredentials(''), '') + }) +}) + +describe('removeCredentialsFromText', () => { + it('removes credentials from each URL in a message', () => { + assert.strictEqual( + removeCredentialsFromText( + "fatal: unable to access 'https://gitlab-ci-token:p@ss@gitlab.com/o/r.git/': 403" + + ' and https://ghp_SECRET@github.com/o/r' + ), + "fatal: unable to access 'https://gitlab.com/o/r.git/': 403" + + ' and https://github.com/o/r' + ) + }) +}) diff --git a/src/remove-credentials.js b/src/remove-credentials.js new file mode 100644 index 0000000..e33dcbd --- /dev/null +++ b/src/remove-credentials.js @@ -0,0 +1,45 @@ +'use strict' + +const { URL } = require('url') + +/** + * Removes the user name and password from a URL, as in + * `https://gitlab-ci-token:@gitlab.com/o/r.git`. Keeps the port. + * Returns other values, such as `git@github.com:o/r.git`, as they are. + * + * Matches `removeAuthFromGitUrl` in the Currents server. + * + * @param {string|null|undefined} url + * @returns {string|null|undefined} + */ +function removeCredentials (url) { + if (typeof url !== 'string' || !url) { + return url + } + try { + const parsed = new URL(url) + if (parsed.username || parsed.password) { + parsed.username = '' + parsed.password = '' + return parsed.toString() + } + return url + } catch (e) { + return url + } +} + +/** + * Removes the user name and password from every URL in a text, such as a git + * error message that names the remote. + */ +function removeCredentialsFromText (text) { + if (typeof text !== 'string') { + return text + } + // the user info ends at the last "@" before the host, so a password can + // contain "@" + return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s]*@/gi, '$1') +} + +module.exports = { removeCredentials, removeCredentialsFromText } diff --git a/src/run-git-spec.js b/src/run-git-spec.js new file mode 100644 index 0000000..661be5d --- /dev/null +++ b/src/run-git-spec.js @@ -0,0 +1,187 @@ +'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 { + execGit, + isCi, + isDubiousOwnership, + describeGitError +} = require('./run-git') + +describe('isCi', () => { + it('reads CI', () => { + assert.strictEqual(isCi({ CI: 'true' }), true) + assert.strictEqual(isCi({ CI: '1' }), true) + assert.strictEqual(isCi({}), false) + }) + + it('is false for CI=false and CI=0', () => { + assert.strictEqual(isCi({ CI: 'false' }), false) + assert.strictEqual(isCi({ CI: '0' }), false) + }) + + it('ignores gcloud and Jenkins variables that a developer shell sets', () => { + assert.strictEqual(isCi({ GOOGLE_CLOUD_PROJECT: 'my-project' }), false) + assert.strictEqual(isCi({ GCP_PROJECT: 'my-project' }), false) + assert.strictEqual(isCi({ GCLOUD_PROJECT: 'my-project' }), false) + assert.strictEqual(isCi({ JENKINS_HOME: '/var/jenkins_home' }), false) + }) + + it('is true on a CI provider that does not set CI', () => { + assert.strictEqual( + isCi({ JENKINS_URL: 'https://jenkins.example.com' }), + true + ) + }) +}) + +describe('isDubiousOwnership', () => { + it('matches the message of git 2.38.0 and later', () => { + assert( + isDubiousOwnership({ + stderr: "fatal: detected dubious ownership in repository at '/w/repo'" + }) + ) + }) + + it('matches the message of git 2.35.2 to 2.37.x', () => { + assert( + isDubiousOwnership({ + stderr: "fatal: unsafe repository ('/w/repo' is owned by someone else)" + }) + ) + }) + + it('does not match other errors', () => { + assert(!isDubiousOwnership({ stderr: 'fatal: not a git repository' })) + assert(!isDubiousOwnership(null)) + }) +}) + +describe('describeGitError', () => { + it('keeps the first line without credentials', () => { + const error = { + stderr: + "fatal: unable to access 'https://gitlab-ci-token:SECRET@gitlab.com/o/r.git/': 403\n" + + 'second line' + } + assert.strictEqual( + describeGitError(error), + "fatal: unable to access 'https://gitlab.com/o/r.git/': 403" + ) + }) + + it('uses the message when there is no stderr', () => { + assert.strictEqual( + describeGitError(new Error('spawn git ENOENT')), + 'spawn git ENOENT' + ) + }) +}) + +describe('execGit', function () { + this.timeout(10000) + + let root, home, restoreEnvironment + + // No global or system git config, because those can mark every repository + // as safe, as the GitHub Actions runner does + const env = extra => + mockedEnv( + Object.assign( + { + PATH: process.env.PATH, + HOME: home, + GIT_CONFIG_NOSYSTEM: '1', + GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' + }, + extra + ), + { clear: true } + ) + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'run-git-')) + home = fs.mkdtempSync(path.join(os.tmpdir(), 'run-git-home-')) + execFileSync('git', ['init', '-q', root]) + }) + + after(() => { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(home, { recursive: true, force: true }) + }) + + afterEach(() => { + restoreEnvironment() + }) + + const showTopLevel = options => + execGit(root, ['rev-parse', '--show-toplevel'], options) + + it('retries a read-only command on CI when another user owns the repository', async () => { + restoreEnvironment = env({ CI: 'true' }) + assert.strictEqual( + fs.realpathSync(await showTopLevel({ readOnly: true })), + fs.realpathSync(root) + ) + }) + + it('retries on a CI provider that does not set CI', async () => { + restoreEnvironment = env({ JENKINS_URL: 'https://jenkins.example.com' }) + assert.strictEqual( + fs.realpathSync(await showTopLevel({ readOnly: true })), + fs.realpathSync(root) + ) + }) + + it('retries in a Jenkins job', async () => { + restoreEnvironment = env({ + JENKINS_URL: 'https://jenkins.example.com/', + JENKINS_HOME: '/var/jenkins_home', + BUILD_ID: '12', + BUILD_NUMBER: '12' + }) + assert.strictEqual( + fs.realpathSync(await showTopLevel({ readOnly: true })), + fs.realpathSync(root) + ) + }) + + it('retries in a Google Cloud Build job', async () => { + restoreEnvironment = env({ + BUILD_ID: '7b3a9e2c-1f0d-4c3e-9a5b-2d8f6e4c1a0b', + PROJECT_ID: 'my-project', + PROJECT_NUMBER: '123456789012' + }) + assert.strictEqual( + fs.realpathSync(await showTopLevel({ readOnly: true })), + fs.realpathSync(root) + ) + }) + + it('does not retry in a shell with GOOGLE_CLOUD_PROJECT', async () => { + restoreEnvironment = env({ GOOGLE_CLOUD_PROJECT: 'my-project' }) + await assert.rejects(showTopLevel({ readOnly: true }), isDubiousOwnership) + }) + + it('does not retry in a shell with JENKINS_HOME', async () => { + restoreEnvironment = env({ JENKINS_HOME: '/var/jenkins_home' }) + await assert.rejects(showTopLevel({ readOnly: true }), isDubiousOwnership) + }) + + it('does not retry other commands, such as fetch', async () => { + restoreEnvironment = env({ CI: 'true' }) + await assert.rejects(showTopLevel(), isDubiousOwnership) + }) + + it('does not retry outside CI', async () => { + restoreEnvironment = env({}) + await assert.rejects(showTopLevel({ readOnly: true }), isDubiousOwnership) + }) +}) diff --git a/src/run-git.js b/src/run-git.js new file mode 100644 index 0000000..4b4990a --- /dev/null +++ b/src/run-git.js @@ -0,0 +1,110 @@ +'use strict' + +const execa = require('execa') +const debug = require('debug')('commit-info') +const { removeCredentialsFromText } = require('./remove-credentials') +const { detectCiProvider } = require('./ci-provider') + +// git 2.35.2 and later refuse a repository owned by another user, which is +// common in containers that mount the checkout. Messages: +// - "fatal: unsafe repository ('/w/repo' is owned by someone else)", git +// 2.35.2 to 2.37.x +// - "fatal: detected dubious ownership in repository at '/w/repo'", git 2.38.0 +// and later +const isDubiousOwnership = error => + Boolean(error) && + /dubious ownership|unsafe repository/.test( + String(error.stderr || error.message || '') + ) + +// detectCiProvider also matches these, which developers often have set in +// their shell: gcloud reads the project variables, and a Jenkins installation +// sets JENKINS_HOME. Jenkins jobs and Google Cloud Build set other variables +// that it matches. +const VARIABLES_SET_OUTSIDE_CI = [ + 'GOOGLE_CLOUD_PROJECT', + 'GCP_PROJECT', + 'GCLOUD_PROJECT', + 'JENKINS_HOME' +] + +const withoutKeys = (env, keys) => { + const copy = Object.assign({}, env) + keys.forEach(key => delete copy[key]) + return copy +} + +// A detected CI provider counts too: Jenkins does not set CI +const isCi = (env = process.env) => { + const ci = env.CI + return ( + (Boolean(ci) && ci !== 'false' && ci !== '0') || + Boolean(detectCiProvider(withoutKeys(env, VARIABLES_SET_OUTSIDE_CI))) + ) +} + +/** + * True when a read-only git command that failed should run again with + * `-c safe.directory=*`. + * + * Only on CI: on a developer machine the check protects against a repository + * another user planted. `*` and not the folder, because the folder can be a + * subfolder of the repository, which safe.directory does not match. + * + * git 2.35.2 to 2.37.x ignore `-c safe.directory`; they read the setting from + * the global and system config only, so the retry fails there too. + */ +const shouldRetryWithSafeDirectory = error => + isCi() && isDubiousOwnership(error) + +const SAFE_DIRECTORY_ARGS = ['-c', 'safe.directory=*'] + +/** + * Text of a git error for logs and warnings, without credentials. + */ +const describeGitError = error => { + if (!error) { + return '' + } + const text = String(error.stderr || error.message || error).trim() + return removeCredentialsFromText(text.split('\n')[0]) +} + +/** + * Runs `git ` in the folder and resolves with stdout. + * + * @param {string} folder + * @param {string[]} args + * @param {{timeout?: number, readOnly?: boolean}} options readOnly commands + * are retried on CI when git refuses the repository's owner + */ +async function execGit (folder, args, options = {}) { + const execaOptions = { + cwd: folder, + timeout: options.timeout, + env: { GIT_TERMINAL_PROMPT: '0' } + } + try { + const { stdout } = await execa('git', args, execaOptions) + return stdout + } catch (e) { + if (!options.readOnly || !shouldRetryWithSafeDirectory(e)) { + throw e + } + debug('git %s: dubious ownership, running with safe.directory=*', args[0]) + const { stdout } = await execa( + 'git', + SAFE_DIRECTORY_ARGS.concat(args), + execaOptions + ) + return stdout + } +} + +module.exports = { + execGit, + isCi, + describeGitError, + isDubiousOwnership, + SAFE_DIRECTORY_ARGS +}