From df7524a8c23865403f063266ccb376b21017b6ef Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:18:23 -0700 Subject: [PATCH 1/6] docs: use the @currents/commit-info package name in the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f66b01a..a2c93b3 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 => { From a00887cc20804822dbe919a923b190e0c91cde25 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:18:23 -0700 Subject: [PATCH 2/6] feat: add removeCredentials for remote URLs Same behavior as removeAuthFromGitUrl in the Currents server: keeps the port and scp-style SSH remotes, handles a password that contains @. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/remove-credentials-spec.js | 76 ++++++++++++++++++++++++++++++++++ src/remove-credentials.js | 45 ++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/remove-credentials-spec.js create mode 100644 src/remove-credentials.js 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 } From 0e854f4168c01a033db7f679e8fd4f819733275b Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:18:50 -0700 Subject: [PATCH 3/6] feat: read the commit from CI provider variables getCiCommitInfo() returns the sha, branch, message, author, email and remote that the CI provider's variables hold, with the same variables the Currents Playwright reporter reads. The remote has no credentials: GitLab's CI_REPOSITORY_URL holds the job token. detectCiProvider() returns the provider name. On Semaphore the remote is the clone URL in SEMAPHORE_GIT_URL. The reporter reads SEMAPHORE_GIT_REPO_SLUG (owner/repo), which the Currents server cannot build commit links from. On Bamboo the remote is bamboo_planRepository_repositoryUrl, the variable Bamboo sets; the reporter reads bamboo_planRepository_repositoryURL, which is never set. commitInfo() does not use these values yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- README.md | 21 ++ __snapshots__/commit-info-spec.js | 8 +- src/ci-provider.js | 57 ++++++ src/ci-spec.js | 329 ++++++++++++++++++++++++++++++ src/ci.js | 159 +++++++++++++++ src/index.js | 5 +- 6 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 src/ci-provider.js create mode 100644 src/ci-spec.js create mode 100644 src/ci.js diff --git a/README.md b/README.md index a2c93b3..00ba12e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,22 @@ Notes: - If a command fails, returns `null` for each property - If you need to debug, run with `DEBUG=commit-info` environment variable. +## 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). + ## 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 `. @@ -87,6 +103,11 @@ See [docker-example](docker-example) for a full example. 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. +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`. + For example ```js diff --git a/__snapshots__/commit-info-spec.js b/__snapshots__/commit-info-spec.js index 84decac..080b3ef 100644 --- a/__snapshots__/commit-info-spec.js +++ b/__snapshots__/commit-info-spec.js @@ -8,7 +8,9 @@ exports['commit-info no environment variables has certain api 1'] = [ "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "getCiCommitInfo", + "detectCiProvider" ] exports['commit-info no environment variables returns information 1'] = { @@ -41,7 +43,9 @@ exports['commit-info combination with environment variables has certain api 1'] "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "getCiCommitInfo", + "detectCiProvider" ] 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/index.js b/src/index.js index 0090fbe..3235d14 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ const { getGhaEventData } = require('./utils') const { getPullRequestHeadCommit } = require('./pull-request-head') +const { getCiCommitInfo, detectCiProvider } = require('./ci') const Promise = require('bluebird') const { mergeWith, or } = require('ramda') @@ -64,5 +65,7 @@ module.exports = { getRemoteOrigin, getSubject, getTimestamp, - getBody + getBody, + getCiCommitInfo, + detectCiProvider } From 0e5c3fe3395ab3a4397671e6885d3f8f807a04c4 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:20:07 -0700 Subject: [PATCH 4/6] fix: remove credentials from the remote and retry git on dubious ownership The remote from git and from COMMIT_INFO_REMOTE has no user name or password. git's output is cleaned before the debug line that prints it; with DEBUG=commit-info that line printed GitLab job tokens. git runs without a shell. On CI, read-only git commands that fail with "dubious ownership" run again with -c safe.directory=*, and an empty remote is read again the same way. CI means CI is set or a CI provider is detected, because Jenkins does not set CI. GOOGLE_CLOUD_PROJECT, GCP_PROJECT, GCLOUD_PROJECT and JENKINS_HOME do not count: developers often have them set in their shell. The pull request head fetch is not retried. commitInfo() returns the same values as 1.1.0, apart from the remote credentials and the repositories it can now read on CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- README.md | 16 ++- __snapshots__/commit-info-spec.js | 6 +- src/commit-info-repos-spec.js | 172 ++++++++++++++++++++++++++++ src/commit-info-spec.js | 65 ++++------- src/git-api-spec.js | 111 +++++++++++++++--- src/git-api.js | 111 ++++++++++++++---- src/index.js | 5 +- src/pull-request-head-spec.js | 14 +++ src/pull-request-head.js | 21 ++-- src/run-git-spec.js | 182 ++++++++++++++++++++++++++++++ src/run-git.js | 110 ++++++++++++++++++ 11 files changed, 719 insertions(+), 94 deletions(-) create mode 100644 src/commit-info-repos-spec.js create mode 100644 src/run-git-spec.js create mode 100644 src/run-git.js diff --git a/README.md b/README.md index 00ba12e..81b568b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ commitInfo(folder) // author // sha // timestamp (in seconds since epoch) - // remote + // remote (without credentials) }) ``` @@ -35,7 +35,8 @@ 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 @@ -53,6 +54,12 @@ Notes: 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 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 `. @@ -67,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. @@ -101,12 +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 080b3ef..a0a2449 100644 --- a/__snapshots__/commit-info-spec.js +++ b/__snapshots__/commit-info-spec.js @@ -10,7 +10,8 @@ exports['commit-info no environment variables has certain api 1'] = [ "getTimestamp", "getBody", "getCiCommitInfo", - "detectCiProvider" + "detectCiProvider", + "removeCredentials" ] exports['commit-info no environment variables returns information 1'] = { @@ -45,7 +46,8 @@ exports['commit-info combination with environment variables has certain api 1'] "getTimestamp", "getBody", "getCiCommitInfo", - "detectCiProvider" + "detectCiProvider", + "removeCredentials" ] exports['commit-info combination with environment variables returns information 1'] = { diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js new file mode 100644 index 0000000..2278f30 --- /dev/null +++ b/src/commit-info-repos-spec.js @@ -0,0 +1,172 @@ +'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` + +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: process.env.HOME }, 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-')) + 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..625fa55 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,85 @@ 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, restoreEnvironment + + const env = extra => + mockedEnv( + Object.assign( + { PATH: process.env.PATH, HOME: process.env.HOME }, + extra + ), + { clear: true } + ) + + beforeEach(() => { + root = fs.mkdtempSync(join(os.tmpdir(), 'git-api-')) + execFileSync('git', ['init', '-q', root]) + }) + + afterEach(() => { + restoreEnvironment() + fs.rmSync(root, { 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 3235d14..9c1ac0b 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ const { } = 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') @@ -49,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) @@ -67,5 +69,6 @@ module.exports = { getTimestamp, getBody, getCiCommitInfo, - detectCiProvider + 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/run-git-spec.js b/src/run-git-spec.js new file mode 100644 index 0000000..c7923f2 --- /dev/null +++ b/src/run-git-spec.js @@ -0,0 +1,182 @@ +'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, restoreEnvironment + + const env = extra => + mockedEnv( + Object.assign( + { + PATH: process.env.PATH, + HOME: process.env.HOME, + GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' + }, + extra + ), + { clear: true } + ) + + before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'run-git-')) + execFileSync('git', ['init', '-q', root]) + }) + + after(() => { + fs.rmSync(root, { 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 +} From 0d637dbeb79c181f3e7f2eb4f0c05b9813fe82f9 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 21:57:42 -0700 Subject: [PATCH 5/6] test: run the ownership tests with an empty HOME The GitHub Actions runner's global git config marks the test repositories as safe, so git never reported dubious ownership there and the tests that expect it failed on CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/commit-info-repos-spec.js | 8 +++++++- src/git-api-spec.js | 16 ++++++++-------- src/run-git-spec.js | 8 ++++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js index 2278f30..20e17d8 100644 --- a/src/commit-info-repos-spec.js +++ b/src/commit-info-repos-spec.js @@ -10,6 +10,10 @@ const path = require('path') const TOKEN = 'glcbt-64_SECRET_TOKEN' const GITLAB_REMOTE = `https://gitlab-ci-token:${TOKEN}@gitlab.com/org/repo.git` +// An empty HOME for commitInfo, because a global git config 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, @@ -33,7 +37,7 @@ function runCommitInfo (cwd, env) { const child = spawnSync(process.execPath, ['-e', script], { cwd, encoding: 'utf8', - env: Object.assign({ PATH: process.env.PATH, HOME: process.env.HOME }, env) + env: Object.assign({ PATH: process.env.PATH, HOME: home }, env) }) assert.strictEqual(child.status, 0, child.stderr) const lines = child.stdout.trim().split('\n') @@ -51,6 +55,8 @@ describe('commitInfo in real repositories', function () { 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') diff --git a/src/git-api-spec.js b/src/git-api-spec.js index 625fa55..073d3d1 100644 --- a/src/git-api-spec.js +++ b/src/git-api-spec.js @@ -120,25 +120,25 @@ describe('git-api', () => { const TOKEN = 'glcbt-64_SECRET_TOKEN' const { runGitCommandWithError, readRemoteOrigin } = require('./git-api') - let root, restoreEnvironment + let root, home, restoreEnvironment + // An empty HOME, because a global git config can mark every repository as + // safe, as the GitHub Actions runner does const env = extra => - mockedEnv( - Object.assign( - { PATH: process.env.PATH, HOME: process.env.HOME }, - extra - ), - { clear: true } - ) + mockedEnv(Object.assign({ PATH: process.env.PATH, HOME: home }, 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', () => { diff --git a/src/run-git-spec.js b/src/run-git-spec.js index c7923f2..fb42929 100644 --- a/src/run-git-spec.js +++ b/src/run-git-spec.js @@ -88,14 +88,16 @@ describe('describeGitError', () => { describe('execGit', function () { this.timeout(10000) - let root, restoreEnvironment + let root, home, restoreEnvironment + // An empty HOME, because a global git config can mark every repository as + // safe, as the GitHub Actions runner does const env = extra => mockedEnv( Object.assign( { PATH: process.env.PATH, - HOME: process.env.HOME, + HOME: home, GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }, extra @@ -105,11 +107,13 @@ describe('execGit', function () { 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(() => { From f8e9340d19f637e9d17a561ee452b795541506b4 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 22:02:07 -0700 Subject: [PATCH 6/6] test: ignore the system git config in the ownership tests The GitHub Actions runner image sets safe.directory=* in the system git config, so git still never reported dubious ownership there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/commit-info-repos-spec.js | 9 ++++++--- src/git-api-spec.js | 16 +++++++++++----- src/run-git-spec.js | 5 +++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js index 20e17d8..447c4e7 100644 --- a/src/commit-info-repos-spec.js +++ b/src/commit-info-repos-spec.js @@ -10,8 +10,8 @@ const path = require('path') const TOKEN = 'glcbt-64_SECRET_TOKEN' const GITLAB_REMOTE = `https://gitlab-ci-token:${TOKEN}@gitlab.com/org/repo.git` -// An empty HOME for commitInfo, because a global git config can mark every -// repository as safe, as the GitHub Actions runner does +// 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) => @@ -37,7 +37,10 @@ function runCommitInfo (cwd, env) { const child = spawnSync(process.execPath, ['-e', script], { cwd, encoding: 'utf8', - env: Object.assign({ PATH: process.env.PATH, HOME: home }, env) + 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') diff --git a/src/git-api-spec.js b/src/git-api-spec.js index 073d3d1..9c47244 100644 --- a/src/git-api-spec.js +++ b/src/git-api-spec.js @@ -122,12 +122,18 @@ describe('git-api', () => { let root, home, restoreEnvironment - // An empty HOME, because a global git config can mark every repository as - // safe, as the GitHub Actions runner does + // 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 }, extra), { - clear: true - }) + 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-')) diff --git a/src/run-git-spec.js b/src/run-git-spec.js index fb42929..661be5d 100644 --- a/src/run-git-spec.js +++ b/src/run-git-spec.js @@ -90,14 +90,15 @@ describe('execGit', function () { let root, home, restoreEnvironment - // An empty HOME, because a global git config can mark every repository as - // safe, as the GitHub Actions runner does + // 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