fix: work around git dubious ownership errors in container jobs (#317)

This commit is contained in:
Sascha Bratton
2026-07-02 13:41:07 -04:00
committed by GitHub
parent f3ceefdc7e
commit 928037783a
8 changed files with 833 additions and 40 deletions

View File

@@ -74,6 +74,63 @@ jobs:
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
test-container-without-token:
runs-on: ubuntu-latest
strategy:
matrix:
# bullseye: git 2.30 + Debian CVE-2022-24765 backport, old "unsafe repository" wording,
# pre-2.32 -> ignores GIT_CONFIG_GLOBAL -> exercises the HOME-only path
# bookworm: git 2.39, "dubious ownership" wording, honors GIT_CONFIG_GLOBAL
container: ['node:24-bullseye', 'node:24-bookworm']
locale: ['']
include:
# zh_CN: git translates the dubious-ownership message via gettext - proves
# detection works on non-English stderr regardless of the container's locale.
# A CJK locale is the most adversarial probe (multibyte, non-Latin) whose
# catalog actually translates this message (ja does not exist, ko lacks it)
- container: 'node:24-bookworm'
locale: 'zh_CN.UTF-8'
container: ${{ matrix.container }}
steps:
- uses: actions/checkout@v6
- name: Generate locale
if: matrix.locale != ''
run: |
apt-get update
apt-get install -y locales
echo '${{ matrix.locale }} UTF-8' >> /etc/locale.gen
locale-gen
- name: Verify dubious ownership is reproduced
run: |
if git status; then
echo "::error::git succeeded - environment no longer reproduces dubious ownership"
exit 1
fi
- name: Verify git message is localized
if: matrix.locale != ''
env:
LC_ALL: ${{ matrix.locale }}
run: |
if stderr=$(git status 2>&1 >/dev/null); then
echo "::error::git succeeded - environment no longer reproduces dubious ownership"
exit 1
fi
echo "$stderr"
if echo "$stderr" | grep -qE 'dubious ownership|unsafe repository'; then
echo "::error::git message is not translated - the locale variant would not test anything"
exit 1
fi
- uses: ./
id: filter
env:
LC_ALL: ${{ matrix.locale }}
with:
token: ''
filters: '.github/filters.yml'
- name: filter-test
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
test-wd-without-token:
runs-on: ubuntu-latest
steps:

View File

@@ -74,9 +74,14 @@ For more scenarios see [examples](#examples) section.
- It's recommended to quote your path expressions with `'` or `"`. Otherwise, you will get an error if it starts with `*`.
- Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary.
- Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04`
- Git `dubious ownership` errors in [container jobs](https://docs.github.com/en/actions/using-containerized-services/running-jobs-in-a-container) are handled automatically -
the action retries with a temporary `HOME` containing a `safe.directory` entry, the same technique used by [actions/checkout](https://github.com/actions/checkout).
Only if fetching relies on credentials stored in `HOME`-relative files (e.g. `~/.git-credentials` or `~/.netrc`),
mark the repository as safe yourself in a step before this action: `git config --global --add safe.directory "$GITHUB_WORKSPACE"`
## What's New
- Automatic workaround for git `dubious ownership` errors in container jobs
- New major release `v4` after update to Node 24 [Breaking change]
- Add `ref` input parameter
- Add `list-files: csv` format

113
__tests__/git-exec.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import {getExecOutput, ExecOutput} from '@actions/exec'
import {gitExec} from '../src/git'
import {ensureSafeDirectory, getGitEnv} from '../src/safe-directory'
jest.mock('@actions/exec')
jest.mock('../src/safe-directory', () => ({
...jest.requireActual('../src/safe-directory'),
ensureSafeDirectory: jest.fn(),
getGitEnv: jest.fn()
}))
const getExecOutputMock = getExecOutput as jest.MockedFunction<typeof getExecOutput>
const ensureSafeDirectoryMock = ensureSafeDirectory as jest.MockedFunction<typeof ensureSafeDirectory>
const getGitEnvMock = getGitEnv as jest.MockedFunction<typeof getGitEnv>
const SUCCESS_OUTPUT: ExecOutput = {exitCode: 0, stdout: 'ok', stderr: ''}
const DUBIOUS_OUTPUT: ExecOutput = {
exitCode: 128,
stdout: '',
stderr: "fatal: detected dubious ownership in repository at '/github/workspace'"
}
// clearMocks in jest.config.js does not remove queued mockResolvedValueOnce values or implementations
beforeEach(() => {
getExecOutputMock.mockReset()
ensureSafeDirectoryMock.mockReset()
getGitEnvMock.mockReset()
})
describe('gitExec', () => {
test('returns result of successful command without invoking the workaround', async () => {
getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT)
const result = await gitExec(['status'])
expect(result).toBe(SUCCESS_OUTPUT)
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({ignoreReturnCode: true}))
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
test('passes environment from getGitEnv to git', async () => {
const env = {HOME: '/temp/home'}
getGitEnvMock.mockReturnValue(env)
getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT)
await gitExec(['status'])
expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({env}))
})
test('retries once after dubious ownership error is worked around', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const result = await gitExec(['status'])
expect(result).toBe(SUCCESS_OUTPUT)
expect(ensureSafeDirectoryMock).toHaveBeenCalledWith(DUBIOUS_OUTPUT.stderr)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
for (const call of getExecOutputMock.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ignoreReturnCode: true}))
}
})
test('throws actionable error when retry still fails with dubious ownership', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(DUBIOUS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const promise = gitExec(['status'])
await expect(promise).rejects.toThrow(/detected dubious ownership/)
await expect(promise).rejects.toThrow(/safe\.directory/)
await expect(promise).rejects.toThrow(/--user/)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
})
test('throws without retry when workaround adds nothing new', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(false)
await expect(gitExec(['status'])).rejects.toThrow(/safe\.directory/)
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
})
test('returns non-dubious failure when ignoreReturnCode is set', async () => {
const failure: ExecOutput = {exitCode: 1, stdout: '', stderr: 'some error'}
getExecOutputMock.mockResolvedValueOnce(failure)
const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true})
expect(result).toBe(failure)
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
test('retries dubious ownership error even when ignoreReturnCode is set', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true})
expect(result).toBe(SUCCESS_OUTPUT)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
})
test('throws on non-dubious failure when ignoreReturnCode is not set', async () => {
getExecOutputMock.mockResolvedValueOnce({exitCode: 1, stdout: '', stderr: 'some error'})
await expect(gitExec(['fetch'])).rejects.toThrow("The process 'git fetch' failed with exit code 1")
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,256 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import {exec} from '@actions/exec'
import {
buildGitEnv,
cleanup,
createTempGitHome,
ensureSafeDirectory,
getGitEnv,
isDubiousOwnershipError,
parseRepositoryPath,
resolveTempBaseDir
} from '../src/safe-directory'
jest.mock('@actions/exec')
const execMock = exec as jest.MockedFunction<typeof exec>
const DUBIOUS_STDERR = "fatal: detected dubious ownership in repository at '/github/workspace'"
const UNSAFE_STDERR = "fatal: unsafe repository ('/github/workspace' is owned by someone else)"
describe('detection of dubious ownership errors', () => {
test('detects "detected dubious ownership" wording at exit code 128', () => {
expect(isDubiousOwnershipError(128, DUBIOUS_STDERR)).toBe(true)
})
test('detects older "unsafe repository" wording at exit code 128', () => {
expect(isDubiousOwnershipError(128, UNSAFE_STDERR)).toBe(true)
})
test('does not match other git errors at exit code 128', () => {
expect(isDubiousOwnershipError(128, 'fatal: not a git repository')).toBe(false)
})
test('does not match dubious ownership text at other exit codes', () => {
expect(isDubiousOwnershipError(1, DUBIOUS_STDERR)).toBe(false)
expect(isDubiousOwnershipError(0, DUBIOUS_STDERR)).toBe(false)
})
test('parseRepositoryPath extracts path from both wordings', () => {
expect(parseRepositoryPath(DUBIOUS_STDERR)).toBe('/github/workspace')
expect(parseRepositoryPath(UNSAFE_STDERR)).toBe('/github/workspace')
expect(parseRepositoryPath('fatal: not a git repository')).toBeUndefined()
})
})
describe('createTempGitHome', () => {
const scratchDirs: string[] = []
async function makeScratchDir(): Promise<string> {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-'))
scratchDirs.push(dir)
return dir
}
afterEach(async () => {
for (const dir of scratchDirs.splice(0)) {
await fs.promises.rm(dir, {recursive: true, force: true})
}
})
test('copies file referenced by GIT_CONFIG_GLOBAL and skips XDG fallback', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
const configFile = path.join(home, 'custom-gitconfig')
await fs.promises.writeFile(configFile, 'custom')
await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true})
await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg')
const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: configFile, HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('custom')
expect(fs.existsSync(path.join(tempHome, '.config', 'git', 'config'))).toBe(false)
})
test('does not throw when GIT_CONFIG_GLOBAL references missing file', async () => {
const base = await makeScratchDir()
const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: path.join(base, 'missing-gitconfig')})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('')
})
test('copies $HOME/.gitconfig', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
await fs.promises.writeFile(path.join(home, '.gitconfig'), 'home config')
const tempHome = await createTempGitHome(base, {HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('home config')
})
test('copies XDG fallback config only when XDG_CONFIG_HOME is unset', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true})
await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg config')
const tempHome = await createTempGitHome(base, {HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.config', 'git', 'config'), 'utf8')).toBe('xdg config')
const tempHomeWithXdg = await createTempGitHome(base, {HOME: home, XDG_CONFIG_HOME: path.join(home, '.config')})
scratchDirs.push(tempHomeWithXdg)
expect(fs.existsSync(path.join(tempHomeWithXdg, '.config', 'git', 'config'))).toBe(false)
})
test('creates an empty .gitconfig even when there is no config to copy', async () => {
const base = await makeScratchDir()
const tempHome = await createTempGitHome(base, {})
scratchDirs.push(tempHome)
expect(await fs.promises.readdir(tempHome)).toEqual(['.gitconfig'])
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('')
})
})
describe('buildGitEnv', () => {
test('overrides HOME and GIT_CONFIG_GLOBAL, preserves other variables, drops undefined values', () => {
const env = buildGitEnv('/temp/home', {
HOME: '/root',
GIT_CONFIG_GLOBAL: '/root/.gitconfig',
PATH: '/usr/bin',
UNDEFINED_VALUE: undefined
})
expect(env['HOME']).toBe('/temp/home')
expect(env['GIT_CONFIG_GLOBAL']).toBe(path.join('/temp/home', '.gitconfig'))
expect(env['PATH']).toBe('/usr/bin')
expect('UNDEFINED_VALUE' in env).toBe(false)
})
test('leaves GIT_CONFIG_GLOBAL unset when not present in the original environment', () => {
const env = buildGitEnv('/temp/home', {HOME: '/root', PATH: '/usr/bin'})
expect(env['HOME']).toBe('/temp/home')
expect('GIT_CONFIG_GLOBAL' in env).toBe(false)
})
})
describe('resolveTempBaseDir', () => {
test('prefers RUNNER_TEMP and falls back to os.tmpdir()', () => {
expect(resolveTempBaseDir({RUNNER_TEMP: '/runner/temp'})).toBe('/runner/temp')
expect(resolveTempBaseDir({RUNNER_TEMP: ''})).toBe(os.tmpdir())
expect(resolveTempBaseDir({})).toBe(os.tmpdir())
})
})
describe('ensureSafeDirectory', () => {
const envBackup = process.env
let runnerTemp: string
beforeEach(async () => {
runnerTemp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-runner-'))
process.env = {...envBackup}
process.env['RUNNER_TEMP'] = runnerTemp
process.env['HOME'] = runnerTemp
process.env['GITHUB_WORKSPACE'] = process.cwd()
delete process.env['GIT_CONFIG_GLOBAL']
delete process.env['XDG_CONFIG_HOME']
})
afterEach(async () => {
await cleanup()
await fs.promises.rm(runnerTemp, {recursive: true, force: true})
process.env = envBackup
})
test('activates temporary HOME and adds reported directories on first call', async () => {
expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-')
const added = await ensureSafeDirectory(DUBIOUS_STDERR)
expect(added).toBe(true)
expect(getGitEnv()).toEqual(
expect.objectContaining({
HOME: expect.stringContaining('paths-filter-git-home-')
})
)
// GIT_CONFIG_GLOBAL was not set in the original environment, so it must stay unset
expect(getGitEnv()).not.toHaveProperty('GIT_CONFIG_GLOBAL')
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', '/github/workspace'],
expect.objectContaining({
env: expect.objectContaining({HOME: expect.stringContaining('paths-filter-git-home-')})
})
)
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', process.cwd()],
expect.anything()
)
})
test('returns false when repeated stderr adds no new directory', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const callCount = execMock.mock.calls.length
const added = await ensureSafeDirectory(DUBIOUS_STDERR)
expect(added).toBe(false)
expect(execMock.mock.calls.length).toBe(callCount)
})
test('adds directory reported by a later error for a different path', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const added = await ensureSafeDirectory("fatal: detected dubious ownership in repository at '/other/repo'")
expect(added).toBe(true)
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', '/other/repo'],
expect.anything()
)
})
test('cleanup removes the temporary HOME and resets state', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const tempHome = getGitEnv()['HOME']
expect(tempHome).toContain('paths-filter-git-home-')
await cleanup()
expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-')
expect(fs.existsSync(tempHome)).toBe(false)
})
test('getGitEnv mirrors process.env and forces LC_ALL=C before activation', () => {
process.env['SOME_PRESERVED_VARIABLE'] = 'preserved'
process.env['LC_ALL'] = 'de_DE.UTF-8'
const env = getGitEnv()
expect(env['SOME_PRESERVED_VARIABLE']).toBe('preserved')
expect(env['HOME']).toBe(runnerTemp)
expect(env['LC_ALL']).toBe('C')
})
test('getGitEnv contains the temporary HOME and forces LC_ALL=C after activation', async () => {
process.env['LC_ALL'] = 'de_DE.UTF-8'
await ensureSafeDirectory(DUBIOUS_STDERR)
const env = getGitEnv()
expect(env['HOME']).toContain('paths-filter-git-home-')
expect(env['LC_ALL']).toBe('C')
})
})

230
dist/index.js vendored
View File

@@ -204,17 +204,46 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.HEAD = exports.NULL_SHA = void 0;
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.gitExec = exports.HEAD = exports.NULL_SHA = void 0;
const exec_1 = __nccwpck_require__(1514);
const core = __importStar(__nccwpck_require__(2186));
const file_1 = __nccwpck_require__(4014);
const safe_directory_1 = __nccwpck_require__(2126);
exports.NULL_SHA = '0000000000000000000000000000000000000000';
exports.HEAD = 'HEAD';
async function gitExec(args, options) {
var _a;
// ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below
const execute = async () => (0, exec_1.getExecOutput)('git', args, { ...options, ignoreReturnCode: true, env: (0, safe_directory_1.getGitEnv)() });
let result = await execute();
if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) {
if (await (0, safe_directory_1.ensureSafeDirectory)(result.stderr)) {
result = await execute();
}
if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) {
const firstLine = (_a = result.stderr
.split(/\r?\n/)
.find(line => line.trim().length > 0)) === null || _a === void 0 ? void 0 : _a.trim();
throw new Error(`${firstLine !== null && firstLine !== void 0 ? firstLine : 'Git failed due to dubious repository ownership'}\n` +
'The automatic safe.directory workaround was not sufficient. ' +
'Either run the container with the same user as the runner:\n' +
' container:\n' +
' options: --user 1001\n' +
'or mark the repository as safe in a step before this action:\n' +
' - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"');
}
}
if (result.exitCode !== 0 && !(options === null || options === void 0 ? void 0 : options.ignoreReturnCode)) {
throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`);
}
return result;
}
exports.gitExec = gitExec;
async function getChangesInLastCommit() {
core.startGroup(`Change detection in last commit`);
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout;
output = (await gitExec(['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -231,8 +260,7 @@ async function getChanges(base, head) {
let output = '';
try {
// Two dots '..' change detection - directly compares two versions
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout;
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -246,7 +274,7 @@ async function getChangesOnHead() {
core.startGroup(`Change detection on HEAD`);
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout;
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -262,7 +290,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
if (baseRef === undefined || headRef === undefined) {
return false;
}
return (await (0, exec_1.getExecOutput)('git', ['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0;
return (await gitExec(['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0;
}
let noMergeBase = false;
core.startGroup(`Searching for merge-base ${base}...${head}`);
@@ -270,12 +298,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
baseRef = await getLocalRef(base);
headRef = await getLocalRef(head);
if (!(await hasMergeBase())) {
await (0, exec_1.getExecOutput)('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]);
await gitExec(['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]);
if (baseRef === undefined || headRef === undefined) {
baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base));
headRef = headRef !== null && headRef !== void 0 ? headRef : (await getLocalRef(head));
if (baseRef === undefined || headRef === undefined) {
await (0, exec_1.getExecOutput)('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], {
await gitExec(['fetch', '--tags', '--depth=1', 'origin', base, head], {
ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it
});
baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base));
@@ -292,12 +320,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
let lastCommitCount = await getCommitCount();
while (!(await hasMergeBase())) {
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER);
await (0, exec_1.getExecOutput)('git', ['fetch', `--deepen=${depth}`, 'origin', base, head]);
await gitExec(['fetch', `--deepen=${depth}`, 'origin', base, head]);
const commitCount = await getCommitCount();
if (commitCount === lastCommitCount) {
core.info('No more commits were fetched');
core.info('Last attempt will be to fetch full history');
await (0, exec_1.getExecOutput)('git', ['fetch']);
await gitExec(['fetch']);
if (!(await hasMergeBase())) {
noMergeBase = true;
}
@@ -320,7 +348,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
core.startGroup(`Change detection ${diffArg}`);
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout;
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -345,7 +373,7 @@ async function listAllFilesAsAdded() {
core.startGroup('Listing all files tracked by git');
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['ls-files', '-z'])).stdout;
output = (await gitExec(['ls-files', '-z'])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -363,15 +391,15 @@ exports.listAllFilesAsAdded = listAllFilesAsAdded;
async function getCurrentRef() {
core.startGroup(`Get current git ref`);
try {
const branch = (await (0, exec_1.getExecOutput)('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
if (branch && branch !== 'HEAD') {
return branch;
}
const describe = await (0, exec_1.getExecOutput)('git', ['describe', '--tags', '--exact-match'], { ignoreReturnCode: true });
const describe = await gitExec(['describe', '--tags', '--exact-match'], { ignoreReturnCode: true });
if (describe.exitCode === 0) {
return describe.stdout.trim();
}
return (await (0, exec_1.getExecOutput)('git', ['rev-parse', exports.HEAD])).stdout.trim();
return (await gitExec(['rev-parse', exports.HEAD])).stdout.trim();
}
finally {
core.endGroup();
@@ -395,10 +423,10 @@ function isGitSha(ref) {
}
exports.isGitSha = isGitSha;
async function hasCommit(ref) {
return (await (0, exec_1.getExecOutput)('git', ['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).exitCode === 0;
return (await gitExec(['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).exitCode === 0;
}
async function getCommitCount() {
const output = (await (0, exec_1.getExecOutput)('git', ['rev-list', '--count', '--all'])).stdout;
const output = (await gitExec(['rev-list', '--count', '--all'])).stdout;
const count = parseInt(output);
return isNaN(count) ? 0 : count;
}
@@ -406,7 +434,7 @@ async function getLocalRef(shortName) {
if (isGitSha(shortName)) {
return (await hasCommit(shortName)) ? shortName : undefined;
}
const output = (await (0, exec_1.getExecOutput)('git', ['show-ref', shortName], { ignoreReturnCode: true })).stdout;
const output = (await gitExec(['show-ref', shortName], { ignoreReturnCode: true })).stdout;
const refs = output
.split(/\r?\n/g)
.map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/))
@@ -426,10 +454,10 @@ async function ensureRefAvailable(name) {
try {
let ref = await getLocalRef(name);
if (ref === undefined) {
await (0, exec_1.getExecOutput)('git', ['fetch', '--depth=1', '--no-tags', 'origin', name]);
await gitExec(['fetch', '--depth=1', '--no-tags', 'origin', name]);
ref = await getLocalRef(name);
if (ref === undefined) {
await (0, exec_1.getExecOutput)('git', ['fetch', '--depth=1', '--tags', 'origin', name]);
await gitExec(['fetch', '--depth=1', '--tags', 'origin', name]);
ref = await getLocalRef(name);
if (ref === undefined) {
throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`);
@@ -559,6 +587,7 @@ const github = __importStar(__nccwpck_require__(5438));
const filter_1 = __nccwpck_require__(3707);
const file_1 = __nccwpck_require__(4014);
const git = __importStar(__nccwpck_require__(3374));
const safe_directory_1 = __nccwpck_require__(2126);
const shell_escape_1 = __nccwpck_require__(4613);
const csv_escape_1 = __nccwpck_require__(7402);
async function run() {
@@ -594,6 +623,9 @@ async function run() {
catch (error) {
core.setFailed(getErrorMessage(error));
}
finally {
await (0, safe_directory_1.cleanup)();
}
}
function isPathInput(text) {
return !(text.includes('\n') || text.includes(':'));
@@ -817,6 +849,164 @@ function getErrorMessage(error) {
run();
/***/ }),
/***/ 2126:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.resolveTempBaseDir = exports.buildGitEnv = exports.createTempGitHome = exports.cleanup = exports.ensureSafeDirectory = exports.getGitEnv = exports.parseRepositoryPath = exports.isDubiousOwnershipError = void 0;
const fs = __importStar(__nccwpck_require__(7147));
const os = __importStar(__nccwpck_require__(2037));
const path = __importStar(__nccwpck_require__(1017));
const core = __importStar(__nccwpck_require__(2186));
const exec_1 = __nccwpck_require__(1514);
// Git >= 2.35.2 and distro backports of CVE-2022-24765 fail with exit code 128 when
// the repository is owned by a different user - typical for container jobs where the
// workspace is bind-mounted from the host. Older backports use the "unsafe repository" wording.
const DUBIOUS_OWNERSHIP_PATTERN = /detected dubious ownership|unsafe repository/;
const REPOSITORY_PATH_PATTERN = /(?:repository at|unsafe repository \()\s*'([^']+)'/;
let tempHomeDir;
let gitEnv;
const safeDirectories = new Set();
function isDubiousOwnershipError(exitCode, stderr) {
return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr);
}
exports.isDubiousOwnershipError = isDubiousOwnershipError;
function parseRepositoryPath(stderr) {
var _a;
return (_a = stderr.match(REPOSITORY_PATH_PATTERN)) === null || _a === void 0 ? void 0 : _a[1];
}
exports.parseRepositoryPath = parseRepositoryPath;
// Until the workaround is activated this mirrors process.env; afterwards it applies the
// temporary HOME redirect. In both cases LC_ALL=C is forced so git emits untranslated
// messages and isDubiousOwnershipError / parseRepositoryPath match regardless of the
// container's locale.
function getGitEnv() {
return { ...(gitEnv !== null && gitEnv !== void 0 ? gitEnv : cloneDefinedEnv(process.env)), LC_ALL: 'C' };
}
exports.getGitEnv = getGitEnv;
// Marks directories reported by git as safe, using a temporary HOME so no configuration
// outside this action is modified - same technique as actions/checkout.
// Returns false if there was no new directory to add.
async function ensureSafeDirectory(stderr) {
if (tempHomeDir === undefined) {
tempHomeDir = await createTempGitHome(resolveTempBaseDir(process.env), process.env);
gitEnv = buildGitEnv(tempHomeDir, process.env);
core.info('Git reported dubious ownership of the repository - this is typical for container jobs ' +
'where the workspace is owned by a different user. A temporary HOME with a copy of the global ' +
'git config and a safe.directory exception will be used for git commands executed by this action.');
}
let added = false;
for (const dir of [parseRepositoryPath(stderr), process.env.GITHUB_WORKSPACE, process.cwd()]) {
if (dir && !safeDirectories.has(dir)) {
await (0, exec_1.exec)('git', ['config', '--global', '--add', 'safe.directory', dir], { env: getGitEnv() });
safeDirectories.add(dir);
added = true;
}
}
return added;
}
exports.ensureSafeDirectory = ensureSafeDirectory;
async function cleanup() {
if (tempHomeDir !== undefined) {
try {
await fs.promises.rm(tempHomeDir, { recursive: true, force: true });
}
catch (error) {
// Cleanup failure is not fatal - RUNNER_TEMP is wiped when the job ends
}
}
tempHomeDir = undefined;
gitEnv = undefined;
safeDirectories.clear();
}
exports.cleanup = cleanup;
// Exported for tests
async function createTempGitHome(baseTempDir, env) {
const tempHome = await fs.promises.mkdtemp(path.join(baseTempDir, 'paths-filter-git-home-'));
const tempConfigPath = path.join(tempHome, '.gitconfig');
// The file must exist even when there is no config to copy - when $HOME/.gitconfig is missing,
// `git config --global` writes to an existing $XDG_CONFIG_HOME/git/config instead
await fs.promises.writeFile(tempConfigPath, '');
if (env.GIT_CONFIG_GLOBAL) {
await copyFileIfExists(env.GIT_CONFIG_GLOBAL, tempConfigPath);
}
else if (env.HOME) {
await copyFileIfExists(path.join(env.HOME, '.gitconfig'), tempConfigPath);
if (!env.XDG_CONFIG_HOME) {
// When XDG_CONFIG_HOME is unset, git falls back to $HOME/.config/git/config,
// which would become unreadable under the new HOME
await copyFileIfExists(path.join(env.HOME, '.config', 'git', 'config'), path.join(tempHome, '.config', 'git', 'config'));
}
}
return tempHome;
}
exports.createTempGitHome = createTempGitHome;
// Exported for tests
function buildGitEnv(tempHome, env) {
const newEnv = cloneDefinedEnv(env);
// A changed HOME redirects git of any version to the temp config. GIT_CONFIG_GLOBAL is redirected
// only when already set - on git >= 2.32 it replaces both global config files, so setting it
// unconditionally would hide an existing $XDG_CONFIG_HOME/git/config from git
newEnv['HOME'] = tempHome;
if (env.GIT_CONFIG_GLOBAL) {
newEnv['GIT_CONFIG_GLOBAL'] = path.join(tempHome, '.gitconfig');
}
return newEnv;
}
exports.buildGitEnv = buildGitEnv;
// Exported for tests
function resolveTempBaseDir(env) {
return env.RUNNER_TEMP || os.tmpdir();
}
exports.resolveTempBaseDir = resolveTempBaseDir;
function cloneDefinedEnv(env) {
const newEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
newEnv[key] = value;
}
}
return newEnv;
}
async function copyFileIfExists(source, destination) {
try {
await fs.promises.access(source, fs.constants.R_OK);
}
catch (error) {
return;
}
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
await fs.promises.copyFile(source, destination);
}
/***/ }),
/***/ 7351:

View File

@@ -1,15 +1,50 @@
import {getExecOutput} from '@actions/exec'
import {getExecOutput, ExecOutput} from '@actions/exec'
import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
import {ensureSafeDirectory, getGitEnv, isDubiousOwnershipError} from './safe-directory'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const HEAD = 'HEAD'
export async function gitExec(args: string[], options?: {ignoreReturnCode?: boolean}): Promise<ExecOutput> {
// ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below
const execute = async (): Promise<ExecOutput> =>
getExecOutput('git', args, {...options, ignoreReturnCode: true, env: getGitEnv()})
let result = await execute()
if (isDubiousOwnershipError(result.exitCode, result.stderr)) {
if (await ensureSafeDirectory(result.stderr)) {
result = await execute()
}
if (isDubiousOwnershipError(result.exitCode, result.stderr)) {
const firstLine = result.stderr
.split(/\r?\n/)
.find(line => line.trim().length > 0)
?.trim()
throw new Error(
`${firstLine ?? 'Git failed due to dubious repository ownership'}\n` +
'The automatic safe.directory workaround was not sufficient. ' +
'Either run the container with the same user as the runner:\n' +
' container:\n' +
' options: --user 1001\n' +
'or mark the repository as safe in a step before this action:\n' +
' - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"'
)
}
}
if (result.exitCode !== 0 && !options?.ignoreReturnCode) {
throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`)
}
return result
}
export async function getChangesInLastCommit(): Promise<File[]> {
core.startGroup(`Change detection in last commit`)
let output = ''
try {
output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
output = (await gitExec(['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -27,8 +62,7 @@ export async function getChanges(base: string, head: string): Promise<File[]> {
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -42,7 +76,7 @@ export async function getChangesOnHead(): Promise<File[]> {
core.startGroup(`Change detection on HEAD`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -58,7 +92,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
if (baseRef === undefined || headRef === undefined) {
return false
}
return (await getExecOutput('git', ['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
return (await gitExec(['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
}
let noMergeBase = false
@@ -67,12 +101,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
baseRef = await getLocalRef(base)
headRef = await getLocalRef(head)
if (!(await hasMergeBase())) {
await getExecOutput('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
await gitExec(['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
if (baseRef === undefined || headRef === undefined) {
baseRef = baseRef ?? (await getLocalRef(base))
headRef = headRef ?? (await getLocalRef(head))
if (baseRef === undefined || headRef === undefined) {
await getExecOutput('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], {
await gitExec(['fetch', '--tags', '--depth=1', 'origin', base, head], {
ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it
})
baseRef = baseRef ?? (await getLocalRef(base))
@@ -94,12 +128,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
let lastCommitCount = await getCommitCount()
while (!(await hasMergeBase())) {
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER)
await getExecOutput('git', ['fetch', `--deepen=${depth}`, 'origin', base, head])
await gitExec(['fetch', `--deepen=${depth}`, 'origin', base, head])
const commitCount = await getCommitCount()
if (commitCount === lastCommitCount) {
core.info('No more commits were fetched')
core.info('Last attempt will be to fetch full history')
await getExecOutput('git', ['fetch'])
await gitExec(['fetch'])
if (!(await hasMergeBase())) {
noMergeBase = true
}
@@ -123,7 +157,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
core.startGroup(`Change detection ${diffArg}`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -148,7 +182,7 @@ export async function listAllFilesAsAdded(): Promise<File[]> {
core.startGroup('Listing all files tracked by git')
let output = ''
try {
output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
output = (await gitExec(['ls-files', '-z'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -166,17 +200,17 @@ export async function listAllFilesAsAdded(): Promise<File[]> {
export async function getCurrentRef(): Promise<string> {
core.startGroup(`Get current git ref`)
try {
const branch = (await getExecOutput('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branch && branch !== 'HEAD') {
return branch
}
const describe = await getExecOutput('git', ['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
const describe = await gitExec(['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
if (describe.exitCode === 0) {
return describe.stdout.trim()
}
return (await getExecOutput('git', ['rev-parse', HEAD])).stdout.trim()
return (await gitExec(['rev-parse', HEAD])).stdout.trim()
} finally {
core.endGroup()
}
@@ -199,11 +233,11 @@ export function isGitSha(ref: string): boolean {
}
async function hasCommit(ref: string): Promise<boolean> {
return (await getExecOutput('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
return (await gitExec(['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
}
async function getCommitCount(): Promise<number> {
const output = (await getExecOutput('git', ['rev-list', '--count', '--all'])).stdout
const output = (await gitExec(['rev-list', '--count', '--all'])).stdout
const count = parseInt(output)
return isNaN(count) ? 0 : count
}
@@ -213,7 +247,7 @@ async function getLocalRef(shortName: string): Promise<string | undefined> {
return (await hasCommit(shortName)) ? shortName : undefined
}
const output = (await getExecOutput('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout
const output = (await gitExec(['show-ref', shortName], {ignoreReturnCode: true})).stdout
const refs = output
.split(/\r?\n/g)
.map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/))
@@ -237,10 +271,10 @@ async function ensureRefAvailable(name: string): Promise<string> {
try {
let ref = await getLocalRef(name)
if (ref === undefined) {
await getExecOutput('git', ['fetch', '--depth=1', '--no-tags', 'origin', name])
await gitExec(['fetch', '--depth=1', '--no-tags', 'origin', name])
ref = await getLocalRef(name)
if (ref === undefined) {
await getExecOutput('git', ['fetch', '--depth=1', '--tags', 'origin', name])
await gitExec(['fetch', '--depth=1', '--tags', 'origin', name])
ref = await getLocalRef(name)
if (ref === undefined) {
throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`)

View File

@@ -14,6 +14,7 @@ import {
} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import {cleanup as cleanupSafeDirectory} from './safe-directory'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
import {csvEscape} from './list-format/csv-escape'
@@ -55,6 +56,8 @@ async function run(): Promise<void> {
exportResults(results, listFiles)
} catch (error) {
core.setFailed(getErrorMessage(error))
} finally {
await cleanupSafeDirectory()
}
}

135
src/safe-directory.ts Normal file
View File

@@ -0,0 +1,135 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as core from '@actions/core'
import {exec} from '@actions/exec'
// Git >= 2.35.2 and distro backports of CVE-2022-24765 fail with exit code 128 when
// the repository is owned by a different user - typical for container jobs where the
// workspace is bind-mounted from the host. Older backports use the "unsafe repository" wording.
const DUBIOUS_OWNERSHIP_PATTERN = /detected dubious ownership|unsafe repository/
const REPOSITORY_PATH_PATTERN = /(?:repository at|unsafe repository \()\s*'([^']+)'/
let tempHomeDir: string | undefined
let gitEnv: {[key: string]: string} | undefined
const safeDirectories = new Set<string>()
export function isDubiousOwnershipError(exitCode: number, stderr: string): boolean {
return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr)
}
export function parseRepositoryPath(stderr: string): string | undefined {
return stderr.match(REPOSITORY_PATH_PATTERN)?.[1]
}
// Until the workaround is activated this mirrors process.env; afterwards it applies the
// temporary HOME redirect. In both cases LC_ALL=C is forced so git emits untranslated
// messages and isDubiousOwnershipError / parseRepositoryPath match regardless of the
// container's locale.
export function getGitEnv(): {[key: string]: string} {
return {...(gitEnv ?? cloneDefinedEnv(process.env)), LC_ALL: 'C'}
}
// Marks directories reported by git as safe, using a temporary HOME so no configuration
// outside this action is modified - same technique as actions/checkout.
// Returns false if there was no new directory to add.
export async function ensureSafeDirectory(stderr: string): Promise<boolean> {
if (tempHomeDir === undefined) {
tempHomeDir = await createTempGitHome(resolveTempBaseDir(process.env), process.env)
gitEnv = buildGitEnv(tempHomeDir, process.env)
core.info(
'Git reported dubious ownership of the repository - this is typical for container jobs ' +
'where the workspace is owned by a different user. A temporary HOME with a copy of the global ' +
'git config and a safe.directory exception will be used for git commands executed by this action.'
)
}
let added = false
for (const dir of [parseRepositoryPath(stderr), process.env.GITHUB_WORKSPACE, process.cwd()]) {
if (dir && !safeDirectories.has(dir)) {
await exec('git', ['config', '--global', '--add', 'safe.directory', dir], {env: getGitEnv()})
safeDirectories.add(dir)
added = true
}
}
return added
}
export async function cleanup(): Promise<void> {
if (tempHomeDir !== undefined) {
try {
await fs.promises.rm(tempHomeDir, {recursive: true, force: true})
} catch (error) {
// Cleanup failure is not fatal - RUNNER_TEMP is wiped when the job ends
}
}
tempHomeDir = undefined
gitEnv = undefined
safeDirectories.clear()
}
// Exported for tests
export async function createTempGitHome(
baseTempDir: string,
env: {[key: string]: string | undefined}
): Promise<string> {
const tempHome = await fs.promises.mkdtemp(path.join(baseTempDir, 'paths-filter-git-home-'))
const tempConfigPath = path.join(tempHome, '.gitconfig')
// The file must exist even when there is no config to copy - when $HOME/.gitconfig is missing,
// `git config --global` writes to an existing $XDG_CONFIG_HOME/git/config instead
await fs.promises.writeFile(tempConfigPath, '')
if (env.GIT_CONFIG_GLOBAL) {
await copyFileIfExists(env.GIT_CONFIG_GLOBAL, tempConfigPath)
} else if (env.HOME) {
await copyFileIfExists(path.join(env.HOME, '.gitconfig'), tempConfigPath)
if (!env.XDG_CONFIG_HOME) {
// When XDG_CONFIG_HOME is unset, git falls back to $HOME/.config/git/config,
// which would become unreadable under the new HOME
await copyFileIfExists(
path.join(env.HOME, '.config', 'git', 'config'),
path.join(tempHome, '.config', 'git', 'config')
)
}
}
return tempHome
}
// Exported for tests
export function buildGitEnv(tempHome: string, env: {[key: string]: string | undefined}): {[key: string]: string} {
const newEnv = cloneDefinedEnv(env)
// A changed HOME redirects git of any version to the temp config. GIT_CONFIG_GLOBAL is redirected
// only when already set - on git >= 2.32 it replaces both global config files, so setting it
// unconditionally would hide an existing $XDG_CONFIG_HOME/git/config from git
newEnv['HOME'] = tempHome
if (env.GIT_CONFIG_GLOBAL) {
newEnv['GIT_CONFIG_GLOBAL'] = path.join(tempHome, '.gitconfig')
}
return newEnv
}
// Exported for tests
export function resolveTempBaseDir(env: {[key: string]: string | undefined}): string {
return env.RUNNER_TEMP || os.tmpdir()
}
function cloneDefinedEnv(env: {[key: string]: string | undefined}): {[key: string]: string} {
const newEnv: {[key: string]: string} = {}
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
newEnv[key] = value
}
}
return newEnv
}
async function copyFileIfExists(source: string, destination: string): Promise<void> {
try {
await fs.promises.access(source, fs.constants.R_OK)
} catch (error) {
return
}
await fs.promises.mkdir(path.dirname(destination), {recursive: true})
await fs.promises.copyFile(source, destination)
}