Compare commits

..

4 commits

Author SHA1 Message Date
dependabot[bot]
a45d1fb447
build(deps): bump @tootallnate/once and jest-environment-jsdom (#4323)
Some checks are pending
CI / build (push) Waiting to run
CI / test (built) (push) Blocked by required conditions
CI / test (committed) (push) Blocked by required conditions
CI / commentTestSuiteHelp (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Removes [@tootallnate/once](https://github.com/TooTallNate/once). It's no longer used after updating ancestor dependency [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom). These dependencies need to be updated together.


Removes `@tootallnate/once`

Updates `jest-environment-jsdom` from 29.7.0 to 30.2.0
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.2.0/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: "@tootallnate/once"
  dependency-version: 
  dependency-type: indirect
- dependency-name: jest-environment-jsdom
  dependency-version: 30.2.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 06:14:22 +00:00
dependabot[bot]
3499eb6183
build(deps): bump the github-actions group with 2 updates (#4316)
Bumps the github-actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/upload-artifact` from 6 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

Updates `actions/download-artifact` from 7 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 12:34:50 +00:00
dependabot[bot]
3f3b473b8c
build(deps): bump minimatch (#4311)
Bumps  and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together.

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.5 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 06:50:02 +00:00
dependabot[bot]
6699836a21
build(deps-dev): bump the npm group with 2 updates (#4305)
Bumps the npm group with 2 updates: [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) and [prettier](https://github.com/prettier/prettier).


Updates `eslint-plugin-prettier` from 5.5.4 to 5.5.5
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.4...v5.5.5)

Updates `prettier` from 3.7.4 to 3.8.1
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-version: 5.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: prettier
  dependency-version: 3.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 12:35:41 +00:00
7 changed files with 959 additions and 931 deletions

View file

@ -29,11 +29,11 @@ jobs:
- run: npm run format-check
- run: npm run lint
- run: npm run test
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: action.yml
path: action.yml
@ -50,12 +50,12 @@ jobs:
with:
ref: main
- if: matrix.target == 'built' || github.event_name == 'pull_request'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: dist
path: dist
- if: matrix.target == 'built' || github.event_name == 'pull_request'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: action.yml
path: .
@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
with:
name: dist
path: dist

View file

@ -1,7 +1,5 @@
import {GitCommandManager} from '../lib/git-command-manager'
import {GitConfigHelper} from '../lib/git-config-helper'
import * as fs from 'fs'
import * as path from 'path'
const REPO_PATH = '/git/local/repos/test-base'
@ -9,104 +7,29 @@ const extraheaderConfigKey = 'http.https://127.0.0.1/.extraheader'
describe('git-config-helper integration tests', () => {
let git: GitCommandManager
let originalRunnerTemp: string | undefined
beforeAll(async () => {
git = await GitCommandManager.create(REPO_PATH)
})
beforeEach(async () => {
// Save original RUNNER_TEMP
originalRunnerTemp = process.env['RUNNER_TEMP']
// Create a temp directory for tests
const tempDir = await fs.promises.mkdtemp('/tmp/cpr-test-')
process.env['RUNNER_TEMP'] = tempDir
process.env['GITHUB_WORKSPACE'] = REPO_PATH
})
afterEach(async () => {
// Clean up RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP']
if (runnerTemp && runnerTemp.startsWith('/tmp/cpr-test-')) {
await fs.promises.rm(runnerTemp, {recursive: true, force: true})
}
// Restore original RUNNER_TEMP
if (originalRunnerTemp !== undefined) {
process.env['RUNNER_TEMP'] = originalRunnerTemp
} else {
delete process.env['RUNNER_TEMP']
}
})
it('tests save and restore with no persisted auth', async () => {
const gitConfigHelper = await GitConfigHelper.create(git)
await gitConfigHelper.close()
})
it('tests configure and removal of auth using credentials file', async () => {
const runnerTemp = process.env['RUNNER_TEMP']!
it('tests configure and removal of auth', async () => {
const gitConfigHelper = await GitConfigHelper.create(git)
await gitConfigHelper.configureToken('github-token')
// Verify credentials file was created in RUNNER_TEMP
const files = await fs.promises.readdir(runnerTemp)
const credentialsFiles = files.filter(
f => f.startsWith('git-credentials-') && f.endsWith('.config')
)
expect(credentialsFiles.length).toBe(1)
// Verify credentials file contains the auth token
const credentialsPath = path.join(runnerTemp, credentialsFiles[0])
const credentialsContent = await fs.promises.readFile(
credentialsPath,
'utf8'
)
expect(credentialsContent).toContain(
expect(await git.configExists(extraheaderConfigKey)).toBeTruthy()
expect(await git.getConfigValue(extraheaderConfigKey)).toEqual(
'AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu'
)
// Verify includeIf entries were added to local config
const includeIfKeys = await git.tryGetConfigKeys('^includeIf\\.gitdir:')
expect(includeIfKeys.length).toBeGreaterThan(0)
// Count credential includes pointing to this action's credentials file
let credentialIncludesForThisAction = 0
for (const key of includeIfKeys) {
const values = await git.tryGetConfigValues(key)
for (const value of values) {
if (value === credentialsPath) {
credentialIncludesForThisAction++
}
}
}
expect(credentialIncludesForThisAction).toBeGreaterThan(0)
await gitConfigHelper.close()
// Verify credentials file was removed
const filesAfter = await fs.promises.readdir(runnerTemp)
const credentialsFilesAfter = filesAfter.filter(
f => f.startsWith('git-credentials-') && f.endsWith('.config')
)
expect(credentialsFilesAfter.length).toBe(0)
// Verify includeIf entries pointing to our specific credentials file were removed
const includeIfKeysAfter = await git.tryGetConfigKeys(
'^includeIf\\.gitdir:'
)
let credentialIncludesForThisActionAfter = 0
for (const key of includeIfKeysAfter) {
const values = await git.tryGetConfigValues(key)
for (const value of values) {
if (value === credentialsPath) {
credentialIncludesForThisActionAfter++
}
}
}
expect(credentialIncludesForThisActionAfter).toBe(0)
expect(await git.configExists(extraheaderConfigKey)).toBeFalsy()
})
it('tests save and restore of persisted auth (old-style)', async () => {
it('tests save and restore of persisted auth', async () => {
const extraheaderConfigValue = 'AUTHORIZATION: basic ***persisted-auth***'
await git.config(extraheaderConfigKey, extraheaderConfigValue)

276
dist/index.js vendored
View file

@ -730,15 +730,9 @@ class GitCommandManager {
return yield this.exec(args, { allowAllExitCodes: allowAllExitCodes });
});
}
config(configKey, configValue, globalConfig, add, configFile) {
config(configKey, configValue, globalConfig, add) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
const args = ['config', globalConfig ? '--global' : '--local'];
if (add) {
args.push('--add');
}
@ -970,60 +964,6 @@ class GitCommandManager {
return output.exitCode === 0;
});
}
tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--fixed-value', '--unset', configKey, configValue);
const output = yield this.exec(args, { allowAllExitCodes: true });
return output.exitCode === 0;
});
}
tryGetConfigValues(configKey, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--get-all', configKey);
const output = yield this.exec(args, { allowAllExitCodes: true });
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim());
});
}
tryGetConfigKeys(pattern, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--name-only', '--get-regexp', pattern);
const output = yield this.exec(args, { allowAllExitCodes: true });
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim());
});
}
tryGetRemoteUrl() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.exec(['config', '--local', '--get', 'remote.origin.url'], { allowAllExitCodes: true });
@ -1160,9 +1100,9 @@ const fs = __importStar(__nccwpck_require__(9896));
const path = __importStar(__nccwpck_require__(6928));
const url_1 = __nccwpck_require__(7016);
const utils = __importStar(__nccwpck_require__(9277));
const uuid_1 = __nccwpck_require__(2048);
class GitConfigHelper {
constructor(git) {
this.gitConfigPath = '';
this.safeDirectoryConfigKey = 'safe.directory';
this.safeDirectoryAdded = false;
this.remoteUrl = '';
@ -1170,8 +1110,7 @@ class GitConfigHelper {
this.extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***';
this.extraheaderConfigValueRegex = '^AUTHORIZATION:';
this.persistedExtraheaderConfigValue = '';
// Path to the credentials config file in RUNNER_TEMP (new v6-style auth)
this.credentialsConfigPath = '';
this.backedUpCredentialFiles = [];
this.git = git;
this.workingDirectory = this.git.getWorkingDirectory();
}
@ -1251,15 +1190,16 @@ class GitConfigHelper {
return __awaiter(this, void 0, void 0, function* () {
const serverUrl = new url_1.URL(`https://${this.getGitRemote().hostname}`);
this.extraheaderConfigKey = `http.${serverUrl.origin}/.extraheader`;
// Save and unset persisted extraheader credential in git config if it exists (old-style auth)
// Note: checkout@v6 uses credentials files with includeIf, so we don't need to
// manipulate those - they work independently via git's include mechanism
// Backup checkout@v6 credential files if they exist
yield this.hideCredentialFiles();
// Save and unset persisted extraheader credential in git config if it exists
this.persistedExtraheaderConfigValue = yield this.getAndUnset();
});
}
restorePersistedAuth() {
return __awaiter(this, void 0, void 0, function* () {
// Restore old-style extraheader config if it was persisted
// Restore checkout@v6 credential files if they were backed up
yield this.unhideCredentialFiles();
if (this.persistedExtraheaderConfigValue) {
try {
yield this.setExtraheaderConfig(this.persistedExtraheaderConfigValue);
@ -1273,160 +1213,69 @@ class GitConfigHelper {
}
configureToken(token) {
return __awaiter(this, void 0, void 0, function* () {
// Encode the basic credential for HTTPS access
// Encode and configure the basic credential for HTTPS access
const basicCredential = Buffer.from(`x-access-token:${token}`, 'utf8').toString('base64');
core.setSecret(basicCredential);
const extraheaderConfigValue = `AUTHORIZATION: basic ${basicCredential}`;
// Get or create the credentials config file path
const credentialsConfigPath = this.getCredentialsConfigPath();
// Write placeholder to the separate credentials config file using git config.
// This approach avoids the credential being captured by process creation audit events,
// which are commonly logged. For more information, refer to
// https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
yield this.git.config(this.extraheaderConfigKey, this.extraheaderConfigPlaceholderValue, false, // globalConfig
false, // add
credentialsConfigPath);
// Replace the placeholder in the credentials config file
let content = (yield fs.promises.readFile(credentialsConfigPath)).toString();
const placeholderIndex = content.indexOf(this.extraheaderConfigPlaceholderValue);
if (placeholderIndex < 0 ||
placeholderIndex !=
content.lastIndexOf(this.extraheaderConfigPlaceholderValue)) {
throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`);
}
content = content.replace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue);
yield fs.promises.writeFile(credentialsConfigPath, content);
// Configure includeIf entries to reference the credentials config file
yield this.configureIncludeIf(credentialsConfigPath);
yield this.setExtraheaderConfig(extraheaderConfigValue);
});
}
removeAuth() {
return __awaiter(this, void 0, void 0, function* () {
// Remove old-style extraheader config if it exists
yield this.getAndUnset();
// Remove includeIf entries that point to git-credentials-*.config files
// and clean up the credentials config files
yield this.removeIncludeIfCredentials();
});
}
/**
* Gets or creates the path to the credentials config file in RUNNER_TEMP.
* @returns The absolute path to the credentials config file
*/
getCredentialsConfigPath() {
if (this.credentialsConfigPath) {
return this.credentialsConfigPath;
}
const runnerTemp = process.env['RUNNER_TEMP'] || '';
if (!runnerTemp) {
throw new Error('RUNNER_TEMP is not defined');
}
// Create a unique filename for this action instance
const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`;
this.credentialsConfigPath = path.join(runnerTemp, configFileName);
core.debug(`Credentials config path: ${this.credentialsConfigPath}`);
return this.credentialsConfigPath;
}
/**
* Configures includeIf entries in the local git config to reference the credentials file.
* Sets up entries for both host and container paths to support Docker container actions.
*/
configureIncludeIf(credentialsConfigPath) {
return __awaiter(this, void 0, void 0, function* () {
// Host git directory
const gitDir = yield this.git.getGitDirectory();
let hostGitDir = path.join(this.workingDirectory, gitDir);
hostGitDir = hostGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
// Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${hostGitDir}.path`;
yield this.git.config(hostIncludeKey, credentialsConfigPath);
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `includeIf.gitdir:${hostGitDir}/worktrees/*.path`;
yield this.git.config(hostWorktreeIncludeKey, credentialsConfigPath);
// Container paths for Docker container actions
const githubWorkspace = process.env['GITHUB_WORKSPACE'];
if (githubWorkspace) {
let relativePath = path.relative(githubWorkspace, this.workingDirectory);
relativePath = relativePath.replace(/\\/g, '/'); // Use forward slashes, even on Windows
const containerGitDir = path.posix.join('/github/workspace', relativePath, '.git');
// Container credentials config path
const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
// Configure container includeIf
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`;
yield this.git.config(containerIncludeKey, containerCredentialsPath);
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`;
yield this.git.config(containerWorktreeIncludeKey, containerCredentialsPath);
}
});
}
/**
* Removes the includeIf entry and credentials config file created by this action instance.
* Only cleans up the specific credentials file tracked in this.credentialsConfigPath,
* leaving credentials created by other actions (e.g., actions/checkout) intact.
*/
removeIncludeIfCredentials() {
return __awaiter(this, void 0, void 0, function* () {
// Only clean up if this action instance created a credentials config file
if (!this.credentialsConfigPath) {
return;
}
try {
// Get all includeIf.gitdir keys from local config
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:');
for (const key of keys) {
// Get all values for this key
const values = yield this.git.tryGetConfigValues(key);
for (const value of values) {
// Only remove entries pointing to our specific credentials file
if (value === this.credentialsConfigPath) {
yield this.git.tryConfigUnsetValue(key, value);
core.debug(`Removed includeIf entry: ${key} = ${value}`);
}
}
}
}
catch (e) {
// Ignore errors during cleanup
core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`);
}
// Delete only our credentials config file
const runnerTemp = process.env['RUNNER_TEMP'];
const resolvedCredentialsPath = path.resolve(this.credentialsConfigPath);
const resolvedRunnerTemp = runnerTemp ? path.resolve(runnerTemp) : '';
if (resolvedRunnerTemp &&
resolvedCredentialsPath.startsWith(resolvedRunnerTemp + path.sep)) {
try {
yield fs.promises.unlink(this.credentialsConfigPath);
core.info(`Removed credentials config file: ${this.credentialsConfigPath}`);
}
catch (e) {
core.debug(`Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}`);
}
}
});
}
/**
* Sets extraheader config directly in .git/config (old-style auth).
* Used only for restoring persisted credentials from checkout@v4/v5.
*/
setExtraheaderConfig(extraheaderConfigValue) {
return __awaiter(this, void 0, void 0, function* () {
// Configure a placeholder value. This approach avoids the credential being captured
// by process creation audit events, which are commonly logged. For more information,
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
// See https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts#L267-L274
yield this.git.config(this.extraheaderConfigKey, this.extraheaderConfigPlaceholderValue);
// Replace the placeholder in the local git config
const gitDir = yield this.git.getGitDirectory();
const gitConfigPath = path.join(this.workingDirectory, gitDir, 'config');
let content = (yield fs.promises.readFile(gitConfigPath)).toString();
const index = content.indexOf(this.extraheaderConfigPlaceholderValue);
if (index < 0 ||
index != content.lastIndexOf(this.extraheaderConfigPlaceholderValue)) {
throw new Error(`Unable to replace '${this.extraheaderConfigPlaceholderValue}' in ${gitConfigPath}`);
// Replace the placeholder
yield this.gitConfigStringReplace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue);
});
}
hideCredentialFiles() {
return __awaiter(this, void 0, void 0, function* () {
// Temporarily hide checkout@v6 credential files to avoid duplicate auth headers
const runnerTemp = process.env['RUNNER_TEMP'];
if (!runnerTemp) {
return;
}
content = content.replace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue);
yield fs.promises.writeFile(gitConfigPath, content);
try {
const files = yield fs.promises.readdir(runnerTemp);
for (const file of files) {
if (file.startsWith('git-credentials-') && file.endsWith('.config')) {
const sourcePath = path.join(runnerTemp, file);
const backupPath = `${sourcePath}.bak`;
yield fs.promises.rename(sourcePath, backupPath);
this.backedUpCredentialFiles.push(backupPath);
core.info(`Temporarily hiding checkout credential file: ${file} (will be restored after)`);
}
}
}
catch (e) {
// If directory doesn't exist or we can't read it, just continue
core.debug(`Could not backup credential files: ${utils.getErrorMessage(e)}`);
}
});
}
unhideCredentialFiles() {
return __awaiter(this, void 0, void 0, function* () {
// Restore checkout@v6 credential files that were backed up
for (const backupPath of this.backedUpCredentialFiles) {
try {
const originalPath = backupPath.replace(/\.bak$/, '');
yield fs.promises.rename(backupPath, originalPath);
const fileName = path.basename(originalPath);
core.info(`Restored checkout credential file: ${fileName}`);
}
catch (e) {
core.warning(`Failed to restore credential file ${backupPath}: ${utils.getErrorMessage(e)}`);
}
}
this.backedUpCredentialFiles = [];
});
}
getAndUnset() {
@ -1445,6 +1294,21 @@ class GitConfigHelper {
return configValue;
});
}
gitConfigStringReplace(find, replace) {
return __awaiter(this, void 0, void 0, function* () {
if (this.gitConfigPath.length === 0) {
const gitDir = yield this.git.getGitDirectory();
this.gitConfigPath = path.join(this.workingDirectory, gitDir, 'config');
}
let content = (yield fs.promises.readFile(this.gitConfigPath)).toString();
const index = content.indexOf(find);
if (index < 0 || index != content.lastIndexOf(find)) {
throw new Error(`Unable to replace '${find}' in ${this.gitConfigPath}`);
}
content = content.replace(find, replace);
yield fs.promises.writeFile(this.gitConfigPath, content);
});
}
}
exports.GitConfigHelper = GitConfigHelper;

1172
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -54,12 +54,12 @@
"eslint-plugin-github": "^4.10.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-jsdom": "^30.2.0",
"js-yaml": "^4.1.1",
"prettier": "^3.7.4",
"prettier": "^3.8.1",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"undici": "^6.23.0"

View file

@ -96,15 +96,9 @@ export class GitCommandManager {
configKey: string,
configValue: string,
globalConfig?: boolean,
add?: boolean,
configFile?: string
add?: boolean
): Promise<void> {
const args: string[] = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
const args: string[] = ['config', globalConfig ? '--global' : '--local']
if (add) {
args.push('--add')
}
@ -356,67 +350,6 @@ export class GitCommandManager {
return output.exitCode === 0
}
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--fixed-value', '--unset', configKey, configValue)
const output = await this.exec(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--get-all', configKey)
const output = await this.exec(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim())
}
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.exec(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim())
}
async tryGetRemoteUrl(): Promise<string> {
const output = await this.exec(
['config', '--local', '--get', 'remote.origin.url'],

View file

@ -4,7 +4,6 @@ import {GitCommandManager} from './git-command-manager'
import * as path from 'path'
import {URL} from 'url'
import * as utils from './utils'
import {v4 as uuid} from 'uuid'
interface GitRemote {
hostname: string
@ -14,6 +13,7 @@ interface GitRemote {
export class GitConfigHelper {
private git: GitCommandManager
private gitConfigPath = ''
private workingDirectory: string
private safeDirectoryConfigKey = 'safe.directory'
private safeDirectoryAdded = false
@ -22,8 +22,7 @@ export class GitConfigHelper {
private extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***'
private extraheaderConfigValueRegex = '^AUTHORIZATION:'
private persistedExtraheaderConfigValue = ''
// Path to the credentials config file in RUNNER_TEMP (new v6-style auth)
private credentialsConfigPath = ''
private backedUpCredentialFiles: string[] = []
private constructor(git: GitCommandManager) {
this.git = git
@ -123,14 +122,15 @@ export class GitConfigHelper {
async savePersistedAuth(): Promise<void> {
const serverUrl = new URL(`https://${this.getGitRemote().hostname}`)
this.extraheaderConfigKey = `http.${serverUrl.origin}/.extraheader`
// Save and unset persisted extraheader credential in git config if it exists (old-style auth)
// Note: checkout@v6 uses credentials files with includeIf, so we don't need to
// manipulate those - they work independently via git's include mechanism
// Backup checkout@v6 credential files if they exist
await this.hideCredentialFiles()
// Save and unset persisted extraheader credential in git config if it exists
this.persistedExtraheaderConfigValue = await this.getAndUnset()
}
async restorePersistedAuth(): Promise<void> {
// Restore old-style extraheader config if it was persisted
// Restore checkout@v6 credential files if they were backed up
await this.unhideCredentialFiles()
if (this.persistedExtraheaderConfigValue) {
try {
await this.setExtraheaderConfig(this.persistedExtraheaderConfigValue)
@ -142,218 +142,81 @@ export class GitConfigHelper {
}
async configureToken(token: string): Promise<void> {
// Encode the basic credential for HTTPS access
// Encode and configure the basic credential for HTTPS access
const basicCredential = Buffer.from(
`x-access-token:${token}`,
'utf8'
).toString('base64')
core.setSecret(basicCredential)
const extraheaderConfigValue = `AUTHORIZATION: basic ${basicCredential}`
// Get or create the credentials config file path
const credentialsConfigPath = this.getCredentialsConfigPath()
// Write placeholder to the separate credentials config file using git config.
// This approach avoids the credential being captured by process creation audit events,
// which are commonly logged. For more information, refer to
// https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
await this.git.config(
this.extraheaderConfigKey,
this.extraheaderConfigPlaceholderValue,
false, // globalConfig
false, // add
credentialsConfigPath
)
// Replace the placeholder in the credentials config file
let content = (await fs.promises.readFile(credentialsConfigPath)).toString()
const placeholderIndex = content.indexOf(
this.extraheaderConfigPlaceholderValue
)
if (
placeholderIndex < 0 ||
placeholderIndex !=
content.lastIndexOf(this.extraheaderConfigPlaceholderValue)
) {
throw new Error(
`Unable to replace auth placeholder in ${credentialsConfigPath}`
)
}
content = content.replace(
this.extraheaderConfigPlaceholderValue,
extraheaderConfigValue
)
await fs.promises.writeFile(credentialsConfigPath, content)
// Configure includeIf entries to reference the credentials config file
await this.configureIncludeIf(credentialsConfigPath)
await this.setExtraheaderConfig(extraheaderConfigValue)
}
async removeAuth(): Promise<void> {
// Remove old-style extraheader config if it exists
await this.getAndUnset()
// Remove includeIf entries that point to git-credentials-*.config files
// and clean up the credentials config files
await this.removeIncludeIfCredentials()
}
/**
* Gets or creates the path to the credentials config file in RUNNER_TEMP.
* @returns The absolute path to the credentials config file
*/
private getCredentialsConfigPath(): string {
if (this.credentialsConfigPath) {
return this.credentialsConfigPath
}
const runnerTemp = process.env['RUNNER_TEMP'] || ''
if (!runnerTemp) {
throw new Error('RUNNER_TEMP is not defined')
}
// Create a unique filename for this action instance
const configFileName = `git-credentials-${uuid()}.config`
this.credentialsConfigPath = path.join(runnerTemp, configFileName)
core.debug(`Credentials config path: ${this.credentialsConfigPath}`)
return this.credentialsConfigPath
}
/**
* Configures includeIf entries in the local git config to reference the credentials file.
* Sets up entries for both host and container paths to support Docker container actions.
*/
private async configureIncludeIf(
credentialsConfigPath: string
): Promise<void> {
// Host git directory
const gitDir = await this.git.getGitDirectory()
let hostGitDir = path.join(this.workingDirectory, gitDir)
hostGitDir = hostGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
// Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${hostGitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `includeIf.gitdir:${hostGitDir}/worktrees/*.path`
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
// Container paths for Docker container actions
const githubWorkspace = process.env['GITHUB_WORKSPACE']
if (githubWorkspace) {
let relativePath = path.relative(githubWorkspace, this.workingDirectory)
relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows
const containerGitDir = path.posix.join(
'/github/workspace',
relativePath,
'.git'
)
// Container credentials config path
const containerCredentialsPath = path.posix.join(
'/github/runner_temp',
path.basename(credentialsConfigPath)
)
// Configure container includeIf
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
await this.git.config(containerIncludeKey, containerCredentialsPath)
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`
await this.git.config(
containerWorktreeIncludeKey,
containerCredentialsPath
)
}
}
/**
* Removes the includeIf entry and credentials config file created by this action instance.
* Only cleans up the specific credentials file tracked in this.credentialsConfigPath,
* leaving credentials created by other actions (e.g., actions/checkout) intact.
*/
private async removeIncludeIfCredentials(): Promise<void> {
// Only clean up if this action instance created a credentials config file
if (!this.credentialsConfigPath) {
return
}
try {
// Get all includeIf.gitdir keys from local config
const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:')
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(key)
for (const value of values) {
// Only remove entries pointing to our specific credentials file
if (value === this.credentialsConfigPath) {
await this.git.tryConfigUnsetValue(key, value)
core.debug(`Removed includeIf entry: ${key} = ${value}`)
}
}
}
} catch (e) {
// Ignore errors during cleanup
core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`)
}
// Delete only our credentials config file
const runnerTemp = process.env['RUNNER_TEMP']
const resolvedCredentialsPath = path.resolve(this.credentialsConfigPath)
const resolvedRunnerTemp = runnerTemp ? path.resolve(runnerTemp) : ''
if (
resolvedRunnerTemp &&
resolvedCredentialsPath.startsWith(resolvedRunnerTemp + path.sep)
) {
try {
await fs.promises.unlink(this.credentialsConfigPath)
core.info(
`Removed credentials config file: ${this.credentialsConfigPath}`
)
} catch (e) {
core.debug(
`Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}`
)
}
}
}
/**
* Sets extraheader config directly in .git/config (old-style auth).
* Used only for restoring persisted credentials from checkout@v4/v5.
*/
private async setExtraheaderConfig(
extraheaderConfigValue: string
): Promise<void> {
// Configure a placeholder value. This approach avoids the credential being captured
// by process creation audit events, which are commonly logged. For more information,
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
// See https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts#L267-L274
await this.git.config(
this.extraheaderConfigKey,
this.extraheaderConfigPlaceholderValue
)
// Replace the placeholder in the local git config
const gitDir = await this.git.getGitDirectory()
const gitConfigPath = path.join(this.workingDirectory, gitDir, 'config')
let content = (await fs.promises.readFile(gitConfigPath)).toString()
const index = content.indexOf(this.extraheaderConfigPlaceholderValue)
if (
index < 0 ||
index != content.lastIndexOf(this.extraheaderConfigPlaceholderValue)
) {
throw new Error(
`Unable to replace '${this.extraheaderConfigPlaceholderValue}' in ${gitConfigPath}`
)
}
content = content.replace(
// Replace the placeholder
await this.gitConfigStringReplace(
this.extraheaderConfigPlaceholderValue,
extraheaderConfigValue
)
await fs.promises.writeFile(gitConfigPath, content)
}
private async hideCredentialFiles(): Promise<void> {
// Temporarily hide checkout@v6 credential files to avoid duplicate auth headers
const runnerTemp = process.env['RUNNER_TEMP']
if (!runnerTemp) {
return
}
try {
const files = await fs.promises.readdir(runnerTemp)
for (const file of files) {
if (file.startsWith('git-credentials-') && file.endsWith('.config')) {
const sourcePath = path.join(runnerTemp, file)
const backupPath = `${sourcePath}.bak`
await fs.promises.rename(sourcePath, backupPath)
this.backedUpCredentialFiles.push(backupPath)
core.info(
`Temporarily hiding checkout credential file: ${file} (will be restored after)`
)
}
}
} catch (e) {
// If directory doesn't exist or we can't read it, just continue
core.debug(
`Could not backup credential files: ${utils.getErrorMessage(e)}`
)
}
}
private async unhideCredentialFiles(): Promise<void> {
// Restore checkout@v6 credential files that were backed up
for (const backupPath of this.backedUpCredentialFiles) {
try {
const originalPath = backupPath.replace(/\.bak$/, '')
await fs.promises.rename(backupPath, originalPath)
const fileName = path.basename(originalPath)
core.info(`Restored checkout credential file: ${fileName}`)
} catch (e) {
core.warning(
`Failed to restore credential file ${backupPath}: ${utils.getErrorMessage(e)}`
)
}
}
this.backedUpCredentialFiles = []
}
private async getAndUnset(): Promise<string> {
@ -384,4 +247,21 @@ export class GitConfigHelper {
}
return configValue
}
private async gitConfigStringReplace(
find: string,
replace: string
): Promise<void> {
if (this.gitConfigPath.length === 0) {
const gitDir = await this.git.getGitDirectory()
this.gitConfigPath = path.join(this.workingDirectory, gitDir, 'config')
}
let content = (await fs.promises.readFile(this.gitConfigPath)).toString()
const index = content.indexOf(find)
if (index < 0 || index != content.lastIndexOf(find)) {
throw new Error(`Unable to replace '${find}' in ${this.gitConfigPath}`)
}
content = content.replace(find, replace)
await fs.promises.writeFile(this.gitConfigPath, content)
}
}