From 49243000746c1e9f4a00a03306559bcfb76df28b Mon Sep 17 00:00:00 2001 From: Peter Evans <18365890+peter-evans@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:58:41 +0000 Subject: [PATCH 01/11] feat: adopt checkout@v6 auth pattern using credentials files Replace the workaround that hid/restored checkout@v6 credential files with a proper implementation that aligns with actions/checkout@v6's new authentication approach. Changes: - Store credentials in separate config file in RUNNER_TEMP with UUID - Use git's includeIf.gitdir mechanism to conditionally include credentials - Support both host and Docker container paths for credential resolution - Add worktree path support for git worktrees - Maintain backwards compatibility with checkout@v4/v5 old-style auth GitCommandManager: - Add configFile parameter to config() method - Add tryConfigUnsetValue() for key-value specific unset - Add tryGetConfigValues() for multi-value config keys - Add tryGetConfigKeys() for regex pattern matching config keys GitConfigHelper: - Remove hacky hide/unhide credential file approach - Add getCredentialsConfigPath() for UUID-based credential file paths - Add configureIncludeIf() for setting up includeIf entries - Add removeIncludeIfCredentials() for cleanup - Retain setExtraheaderConfig() for restoring old-style persisted auth --- __test__/git-config-helper.int.test.ts | 75 ++++++- dist/index.js | 267 +++++++++++++++++++------ src/git-command-manager.ts | 71 ++++++- src/git-config-helper.ts | 264 +++++++++++++++++------- 4 files changed, 536 insertions(+), 141 deletions(-) diff --git a/__test__/git-config-helper.int.test.ts b/__test__/git-config-helper.int.test.ts index dbb2513baa..1f6a8d1ed2 100644 --- a/__test__/git-config-helper.int.test.ts +++ b/__test__/git-config-helper.int.test.ts @@ -1,5 +1,7 @@ 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' @@ -7,29 +9,92 @@ 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', async () => { + it('tests configure and removal of auth using credentials file', async () => { + const runnerTemp = process.env['RUNNER_TEMP']! const gitConfigHelper = await GitConfigHelper.create(git) await gitConfigHelper.configureToken('github-token') - expect(await git.configExists(extraheaderConfigKey)).toBeTruthy() - expect(await git.getConfigValue(extraheaderConfigKey)).toEqual( + + // 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( 'AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu' ) + // Verify includeIf entries were added to local config + const includeIfKeys = await git.tryGetConfigKeys('^includeIf\\.gitdir:') + expect(includeIfKeys.length).toBeGreaterThan(0) + await gitConfigHelper.close() - expect(await git.configExists(extraheaderConfigKey)).toBeFalsy() + + // 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 were removed + const includeIfKeysAfter = await git.tryGetConfigKeys( + '^includeIf\\.gitdir:' + ) + const credentialIncludes = [] + for (const key of includeIfKeysAfter) { + const values = await git.tryGetConfigValues(key) + for (const value of values) { + if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { + credentialIncludes.push(value) + } + } + } + expect(credentialIncludes.length).toBe(0) }) - it('tests save and restore of persisted auth', async () => { + it('tests save and restore of persisted auth (old-style)', async () => { const extraheaderConfigValue = 'AUTHORIZATION: basic ***persisted-auth***' await git.config(extraheaderConfigKey, extraheaderConfigValue) diff --git a/dist/index.js b/dist/index.js index 56100057fa..f1684e5c72 100644 --- a/dist/index.js +++ b/dist/index.js @@ -730,9 +730,15 @@ class GitCommandManager { return yield this.exec(args, { allowAllExitCodes: allowAllExitCodes }); }); } - config(configKey, configValue, globalConfig, add) { + config(configKey, configValue, globalConfig, add, configFile) { return __awaiter(this, void 0, void 0, function* () { - const args = ['config', globalConfig ? '--global' : '--local']; + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } if (add) { args.push('--add'); } @@ -964,6 +970,60 @@ 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('--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 }); @@ -1100,9 +1160,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 = ''; @@ -1110,7 +1170,8 @@ class GitConfigHelper { this.extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***'; this.extraheaderConfigValueRegex = '^AUTHORIZATION:'; this.persistedExtraheaderConfigValue = ''; - this.backedUpCredentialFiles = []; + // Path to the credentials config file in RUNNER_TEMP (new v6-style auth) + this.credentialsConfigPath = ''; this.git = git; this.workingDirectory = this.git.getWorkingDirectory(); } @@ -1190,16 +1251,15 @@ 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`; - // Backup checkout@v6 credential files if they exist - yield this.hideCredentialFiles(); - // Save and unset persisted extraheader credential in git config if it exists + // 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 this.persistedExtraheaderConfigValue = yield this.getAndUnset(); }); } restorePersistedAuth() { return __awaiter(this, void 0, void 0, function* () { - // Restore checkout@v6 credential files if they were backed up - yield this.unhideCredentialFiles(); + // Restore old-style extraheader config if it was persisted if (this.persistedExtraheaderConfigValue) { try { yield this.setExtraheaderConfig(this.persistedExtraheaderConfigValue); @@ -1213,69 +1273,165 @@ class GitConfigHelper { } configureToken(token) { return __awaiter(this, void 0, void 0, function* () { - // Encode and configure the basic credential for HTTPS access + // Encode 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}`; - yield this.setExtraheaderConfig(extraheaderConfigValue); + // 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); }); } 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(); }); } - setExtraheaderConfig(extraheaderConfigValue) { + /** + * 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* () { - // 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 - yield this.gitConfigStringReplace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); + // 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); + } }); } - hideCredentialFiles() { + /** + * Removes includeIf entries that point to git-credentials-*.config files + * and deletes the credentials config files. + */ + removeIncludeIfCredentials() { 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; - } + const credentialsPaths = new Set(); 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)`); + // 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) { + // Check if value matches git-credentials-.config pattern + if (this.isCredentialsConfigPath(value)) { + credentialsPaths.add(value); + yield this.git.tryConfigUnsetValue(key, value); + core.debug(`Removed includeIf entry: ${key} = ${value}`); + } } } } 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)}`); + // Ignore errors during cleanup + core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`); + } + // Delete credentials config files that are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP']; + if (runnerTemp) { + for (const credentialsPath of credentialsPaths) { + // Only remove files under RUNNER_TEMP for safety + if (credentialsPath.startsWith(runnerTemp)) { + try { + yield fs.promises.unlink(credentialsPath); + core.info(`Removed credentials config file: ${credentialsPath}`); + } + catch (e) { + core.debug(`Could not remove credentials file ${credentialsPath}: ${utils.getErrorMessage(e)}`); + } + } + } } }); } - unhideCredentialFiles() { + /** + * Tests if a path matches the git-credentials-*.config pattern. + */ + isCredentialsConfigPath(filePath) { + return /git-credentials-[0-9a-f-]+\.config$/i.test(filePath); + } + /** + * 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* () { - // 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)}`); - } + // 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 + 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}`); } - this.backedUpCredentialFiles = []; + content = content.replace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); + yield fs.promises.writeFile(gitConfigPath, content); }); } getAndUnset() { @@ -1294,21 +1450,6 @@ 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; diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 6270f19392..44d0177e7f 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -96,9 +96,15 @@ export class GitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise { - const args: string[] = ['config', globalConfig ? '--global' : '--local'] + const args: string[] = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } if (add) { args.push('--add') } @@ -350,6 +356,67 @@ export class GitCommandManager { return output.exitCode === 0 } + async tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--unset', configKey, configValue) + const output = await this.exec(args, {allowAllExitCodes: true}) + return output.exitCode === 0 + } + + async tryGetConfigValues( + configKey: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + 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 { + 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 { const output = await this.exec( ['config', '--local', '--get', 'remote.origin.url'], diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index e9299348af..c780644270 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -4,6 +4,7 @@ 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 @@ -13,7 +14,6 @@ interface GitRemote { export class GitConfigHelper { private git: GitCommandManager - private gitConfigPath = '' private workingDirectory: string private safeDirectoryConfigKey = 'safe.directory' private safeDirectoryAdded = false @@ -22,7 +22,8 @@ export class GitConfigHelper { private extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***' private extraheaderConfigValueRegex = '^AUTHORIZATION:' private persistedExtraheaderConfigValue = '' - private backedUpCredentialFiles: string[] = [] + // Path to the credentials config file in RUNNER_TEMP (new v6-style auth) + private credentialsConfigPath = '' private constructor(git: GitCommandManager) { this.git = git @@ -122,15 +123,14 @@ export class GitConfigHelper { async savePersistedAuth(): Promise { const serverUrl = new URL(`https://${this.getGitRemote().hostname}`) this.extraheaderConfigKey = `http.${serverUrl.origin}/.extraheader` - // Backup checkout@v6 credential files if they exist - await this.hideCredentialFiles() - // Save and unset persisted extraheader credential in git config if it exists + // 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 this.persistedExtraheaderConfigValue = await this.getAndUnset() } async restorePersistedAuth(): Promise { - // Restore checkout@v6 credential files if they were backed up - await this.unhideCredentialFiles() + // Restore old-style extraheader config if it was persisted if (this.persistedExtraheaderConfigValue) { try { await this.setExtraheaderConfig(this.persistedExtraheaderConfigValue) @@ -142,81 +142,220 @@ export class GitConfigHelper { } async configureToken(token: string): Promise { - // Encode and configure the basic credential for HTTPS access + // Encode 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}` - await this.setExtraheaderConfig(extraheaderConfigValue) - } - async removeAuth(): Promise { - await this.getAndUnset() - } + // Get or create the credentials config file path + const credentialsConfigPath = this.getCredentialsConfigPath() - private async setExtraheaderConfig( - extraheaderConfigValue: string - ): Promise { - // 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 + // 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 ) - // Replace the placeholder - await this.gitConfigStringReplace( + 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) } - private async hideCredentialFiles(): Promise { - // Temporarily hide checkout@v6 credential files to avoid duplicate auth headers - const runnerTemp = process.env['RUNNER_TEMP'] + async removeAuth(): Promise { + // 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) { - return + 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 { + // 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 includeIf entries that point to git-credentials-*.config files + * and deletes the credentials config files. + */ + private async removeIncludeIfCredentials(): Promise { + const credentialsPaths = new Set() 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)` - ) + // 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) { + // Check if value matches git-credentials-.config pattern + if (this.isCredentialsConfigPath(value)) { + credentialsPaths.add(value) + await this.git.tryConfigUnsetValue(key, value) + core.debug(`Removed includeIf entry: ${key} = ${value}`) + } } } } 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)}` - ) + // Ignore errors during cleanup + core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`) } - } - private async unhideCredentialFiles(): Promise { - // 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)}` - ) + // Delete credentials config files that are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP'] + if (runnerTemp) { + for (const credentialsPath of credentialsPaths) { + // Only remove files under RUNNER_TEMP for safety + if (credentialsPath.startsWith(runnerTemp)) { + try { + await fs.promises.unlink(credentialsPath) + core.info(`Removed credentials config file: ${credentialsPath}`) + } catch (e) { + core.debug( + `Could not remove credentials file ${credentialsPath}: ${utils.getErrorMessage(e)}` + ) + } + } } } - this.backedUpCredentialFiles = [] + } + + /** + * Tests if a path matches the git-credentials-*.config pattern. + */ + private isCredentialsConfigPath(filePath: string): boolean { + return /git-credentials-[0-9a-f-]+\.config$/i.test(filePath) + } + + /** + * 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 { + // 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 + 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( + this.extraheaderConfigPlaceholderValue, + extraheaderConfigValue + ) + await fs.promises.writeFile(gitConfigPath, content) } private async getAndUnset(): Promise { @@ -247,21 +386,4 @@ export class GitConfigHelper { } return configValue } - - private async gitConfigStringReplace( - find: string, - replace: string - ): Promise { - 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) - } } From 2df30281e153c8ff4f2f6cbbd496d88b2ca51787 Mon Sep 17 00:00:00 2001 From: Peter Evans <18365890+peter-evans@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:33:33 +0000 Subject: [PATCH 02/11] fix: add type annotation to fix TS2345 error in integration test The credentialIncludes array was missing a type annotation, causing TypeScript to infer it as never[] and reject string pushes. --- __test__/git-config-helper.int.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__test__/git-config-helper.int.test.ts b/__test__/git-config-helper.int.test.ts index 1f6a8d1ed2..8a53c192e2 100644 --- a/__test__/git-config-helper.int.test.ts +++ b/__test__/git-config-helper.int.test.ts @@ -82,7 +82,7 @@ describe('git-config-helper integration tests', () => { const includeIfKeysAfter = await git.tryGetConfigKeys( '^includeIf\\.gitdir:' ) - const credentialIncludes = [] + const credentialIncludes: string[] = [] for (const key of includeIfKeysAfter) { const values = await git.tryGetConfigValues(key) for (const value of values) { From 64240115db5d324fed9b6f63650e6af66074843a Mon Sep 17 00:00:00 2001 From: Peter Evans <18365890+peter-evans@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:59:11 +0000 Subject: [PATCH 03/11] fix: use --fixed-value flag when unsetting git config values The tryConfigUnsetValue method was passing file paths directly to `git config --unset`, which treats the value argument as an extended regular expression. File paths contain `.` characters that would match any character instead of literal periods, potentially causing incorrect matches. Adding the --fixed-value flag ensures the value is treated as a literal string, fixing credential config cleanup in the v6-style authentication. --- dist/index.js | 4 +++- src/git-command-manager.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index f1684e5c72..6dc7025f74 100644 --- a/dist/index.js +++ b/dist/index.js @@ -979,7 +979,9 @@ class GitCommandManager { else { args.push(globalConfig ? '--global' : '--local'); } - args.push('--unset', configKey, configValue); + // Use --fixed-value to treat configValue as a literal string, not a regex pattern. + // This is important for file paths which contain regex special characters like '.' + args.push('--fixed-value', '--unset', configKey, configValue); const output = yield this.exec(args, { allowAllExitCodes: true }); return output.exitCode === 0; }); diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 44d0177e7f..b0ed1aefd3 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -368,7 +368,7 @@ export class GitCommandManager { } else { args.push(globalConfig ? '--global' : '--local') } - args.push('--unset', configKey, configValue) + args.push('--fixed-value', '--unset', configKey, configValue) const output = await this.exec(args, {allowAllExitCodes: true}) return output.exitCode === 0 } From ca2f66fc96eb384f95f3334e65e26d794cf0f1cd Mon Sep 17 00:00:00 2001 From: Peter Evans <18365890+peter-evans@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:43:33 +0000 Subject: [PATCH 04/11] fix: only remove credentials created by this action instance Previously, removeIncludeIfCredentials() deleted all includeIf.gitdir entries matching git-credentials-*.config, regardless of which action created them. This broke subsequent workflow steps that relied on credentials persisted by other actions (e.g., actions/checkout@v6). Now the cleanup only removes the specific credentials file and config entries created by this action instance, leaving other actions' credentials intact. --- __test__/git-config-helper.int.test.ts | 22 +++++++++--- dist/index.js | 44 ++++++++++-------------- src/git-config-helper.ts | 47 +++++++++++--------------- 3 files changed, 54 insertions(+), 59 deletions(-) diff --git a/__test__/git-config-helper.int.test.ts b/__test__/git-config-helper.int.test.ts index 8a53c192e2..85996cc0e2 100644 --- a/__test__/git-config-helper.int.test.ts +++ b/__test__/git-config-helper.int.test.ts @@ -69,6 +69,18 @@ describe('git-config-helper integration tests', () => { 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 @@ -78,20 +90,20 @@ describe('git-config-helper integration tests', () => { ) expect(credentialsFilesAfter.length).toBe(0) - // Verify includeIf entries were removed + // Verify includeIf entries pointing to our specific credentials file were removed const includeIfKeysAfter = await git.tryGetConfigKeys( '^includeIf\\.gitdir:' ) - const credentialIncludes: string[] = [] + let credentialIncludesForThisActionAfter = 0 for (const key of includeIfKeysAfter) { const values = await git.tryGetConfigValues(key) for (const value of values) { - if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { - credentialIncludes.push(value) + if (value === credentialsPath) { + credentialIncludesForThisActionAfter++ } } } - expect(credentialIncludes.length).toBe(0) + expect(credentialIncludesForThisActionAfter).toBe(0) }) it('tests save and restore of persisted auth (old-style)', async () => { diff --git a/dist/index.js b/dist/index.js index 6dc7025f74..ecdd195476 100644 --- a/dist/index.js +++ b/dist/index.js @@ -979,8 +979,6 @@ class GitCommandManager { else { args.push(globalConfig ? '--global' : '--local'); } - // Use --fixed-value to treat configValue as a literal string, not a regex pattern. - // This is important for file paths which contain regex special characters like '.' args.push('--fixed-value', '--unset', configKey, configValue); const output = yield this.exec(args, { allowAllExitCodes: true }); return output.exitCode === 0; @@ -1363,12 +1361,16 @@ class GitConfigHelper { }); } /** - * Removes includeIf entries that point to git-credentials-*.config files - * and deletes the credentials config files. + * 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* () { - const credentialsPaths = new Set(); + // 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:'); @@ -1376,9 +1378,8 @@ class GitConfigHelper { // Get all values for this key const values = yield this.git.tryGetConfigValues(key); for (const value of values) { - // Check if value matches git-credentials-.config pattern - if (this.isCredentialsConfigPath(value)) { - credentialsPaths.add(value); + // 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}`); } @@ -1389,30 +1390,19 @@ class GitConfigHelper { // Ignore errors during cleanup core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`); } - // Delete credentials config files that are under RUNNER_TEMP + // Delete only our credentials config file const runnerTemp = process.env['RUNNER_TEMP']; - if (runnerTemp) { - for (const credentialsPath of credentialsPaths) { - // Only remove files under RUNNER_TEMP for safety - if (credentialsPath.startsWith(runnerTemp)) { - try { - yield fs.promises.unlink(credentialsPath); - core.info(`Removed credentials config file: ${credentialsPath}`); - } - catch (e) { - core.debug(`Could not remove credentials file ${credentialsPath}: ${utils.getErrorMessage(e)}`); - } - } + if (runnerTemp && this.credentialsConfigPath.startsWith(runnerTemp)) { + 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)}`); } } }); } - /** - * Tests if a path matches the git-credentials-*.config pattern. - */ - isCredentialsConfigPath(filePath) { - return /git-credentials-[0-9a-f-]+\.config$/i.test(filePath); - } /** * Sets extraheader config directly in .git/config (old-style auth). * Used only for restoring persisted credentials from checkout@v4/v5. diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index c780644270..4af25a0d33 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -271,11 +271,15 @@ export class GitConfigHelper { } /** - * Removes includeIf entries that point to git-credentials-*.config files - * and deletes the credentials config files. + * 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 { - const credentialsPaths = new Set() + // 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 @@ -285,9 +289,8 @@ export class GitConfigHelper { // Get all values for this key const values = await this.git.tryGetConfigValues(key) for (const value of values) { - // Check if value matches git-credentials-.config pattern - if (this.isCredentialsConfigPath(value)) { - credentialsPaths.add(value) + // 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}`) } @@ -298,32 +301,22 @@ export class GitConfigHelper { core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`) } - // Delete credentials config files that are under RUNNER_TEMP + // Delete only our credentials config file const runnerTemp = process.env['RUNNER_TEMP'] - if (runnerTemp) { - for (const credentialsPath of credentialsPaths) { - // Only remove files under RUNNER_TEMP for safety - if (credentialsPath.startsWith(runnerTemp)) { - try { - await fs.promises.unlink(credentialsPath) - core.info(`Removed credentials config file: ${credentialsPath}`) - } catch (e) { - core.debug( - `Could not remove credentials file ${credentialsPath}: ${utils.getErrorMessage(e)}` - ) - } - } + if (runnerTemp && this.credentialsConfigPath.startsWith(runnerTemp)) { + 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)}` + ) } } } - /** - * Tests if a path matches the git-credentials-*.config pattern. - */ - private isCredentialsConfigPath(filePath: string): boolean { - return /git-credentials-[0-9a-f-]+\.config$/i.test(filePath) - } - /** * Sets extraheader config directly in .git/config (old-style auth). * Used only for restoring persisted credentials from checkout@v4/v5. From d9ef76f1acf12c702bd1cf5c9e2c307781088c48 Mon Sep 17 00:00:00 2001 From: Peter Evans <18365890+peter-evans@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:06:08 +0000 Subject: [PATCH 05/11] fix(security): prevent path traversal in credentials file deletion Use path.resolve() to normalize paths before comparison in removeIncludeIfCredentials(). The previous startsWith() check was vulnerable to path traversal attacks where a path like "/tmp/runner/../../../etc/passwd" would pass the check but resolve outside RUNNER_TEMP. Also append path.sep to prevent false positives (e.g., /tmp/runner2 matching /tmp/runner). --- dist/index.js | 5 ++++- src/git-config-helper.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index ecdd195476..c0140abf5f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1392,7 +1392,10 @@ class GitConfigHelper { } // Delete only our credentials config file const runnerTemp = process.env['RUNNER_TEMP']; - if (runnerTemp && this.credentialsConfigPath.startsWith(runnerTemp)) { + 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}`); diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index 4af25a0d33..bee89c1e54 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -303,7 +303,12 @@ export class GitConfigHelper { // Delete only our credentials config file const runnerTemp = process.env['RUNNER_TEMP'] - if (runnerTemp && this.credentialsConfigPath.startsWith(runnerTemp)) { + 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( From f4cb22d67361911342e303d280dfb5d3e7e1a9d6 Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:16:08 -0300 Subject: [PATCH 06/11] Update cpr-command.yml Agente MOSSAD UM --- .github/workflows/cpr-example-command.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cpr-example-command.yml b/.github/workflows/cpr-example-command.yml index f0e37948e3..1f60d5e871 100644 --- a/.github/workflows/cpr-example-command.yml +++ b/.github/workflows/cpr-example-command.yml @@ -1,24 +1,24 @@ -name: Create Pull Request Example Command +name: Create Pull Request Command on: repository_dispatch: types: [cpr-example-command] jobs: createPullRequest: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@ # v8.0.1 - name: Make changes to pull request run: date +%s > report.txt - - name: Create Pull Request + - name: Create Pull Request if: id: cpr uses: ./ with: commit-message: Update report committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> - signoff: false + signoff: true title: '[Example] Update report' body: | Update report @@ -32,9 +32,9 @@ jobs: assignees: retepsnave reviewers: retepsnave milestone: 1 - draft: false + draft: true branch: example-patches - delete-branch: true + delete-branch: false - name: Check output run: | @@ -46,4 +46,4 @@ jobs: with: repository: ${{ github.event.client_payload.github.payload.repository.full_name }} comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - reaction-type: hooray + reaction-type: hoofoo From 6260a74402e2779c39b712b27c97bf89da21ae1d Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:18:01 -0300 Subject: [PATCH 07/11] Merge pull request #2 from leoroma1/revert-1-patch-1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atualização do "Revert 1 patch 1" --- .github/workflows/cpr-example-command.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cpr-example-command.yml b/.github/workflows/cpr-example-command.yml index 1f60d5e871..f0e37948e3 100644 --- a/.github/workflows/cpr-example-command.yml +++ b/.github/workflows/cpr-example-command.yml @@ -1,24 +1,24 @@ -name: Create Pull Request Command +name: Create Pull Request Example Command on: repository_dispatch: types: [cpr-example-command] jobs: createPullRequest: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@ # v8.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Make changes to pull request run: date +%s > report.txt - - name: Create Pull Request if: + - name: Create Pull Request id: cpr uses: ./ with: commit-message: Update report committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> - signoff: true + signoff: false title: '[Example] Update report' body: | Update report @@ -32,9 +32,9 @@ jobs: assignees: retepsnave reviewers: retepsnave milestone: 1 - draft: true + draft: false branch: example-patches - delete-branch: false + delete-branch: true - name: Check output run: | @@ -46,4 +46,4 @@ jobs: with: repository: ${{ github.event.client_payload.github.payload.repository.full_name }} comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - reaction-type: hoofoo + reaction-type: hooray From 7b506f06fd2899c1f62c70b85e2067c5bb54237f Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:18:14 -0300 Subject: [PATCH 08/11] Revert "Merge pull request #2 from leoroma1/revert-1-patch-1" This reverts commit 6260a74402e2779c39b712b27c97bf89da21ae1d. --- .github/workflows/cpr-example-command.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cpr-example-command.yml b/.github/workflows/cpr-example-command.yml index f0e37948e3..1f60d5e871 100644 --- a/.github/workflows/cpr-example-command.yml +++ b/.github/workflows/cpr-example-command.yml @@ -1,24 +1,24 @@ -name: Create Pull Request Example Command +name: Create Pull Request Command on: repository_dispatch: types: [cpr-example-command] jobs: createPullRequest: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@ # v8.0.1 - name: Make changes to pull request run: date +%s > report.txt - - name: Create Pull Request + - name: Create Pull Request if: id: cpr uses: ./ with: commit-message: Update report committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> - signoff: false + signoff: true title: '[Example] Update report' body: | Update report @@ -32,9 +32,9 @@ jobs: assignees: retepsnave reviewers: retepsnave milestone: 1 - draft: false + draft: true branch: example-patches - delete-branch: true + delete-branch: false - name: Check output run: | @@ -46,4 +46,4 @@ jobs: with: repository: ${{ github.event.client_payload.github.payload.repository.full_name }} comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - reaction-type: hooray + reaction-type: hoofoo From 52b78c9f8cb7e6392d77f5bfe97bc87710f14956 Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:23:38 -0300 Subject: [PATCH 09/11] Revert "build(deps-dev): bump js-yaml from 4.2.0 to 5.4.2 (#4468)" This reverts commit 11e8dc7c9cc95aabee9cfaa85faaec8b50907340. --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f9ace0ee7..a46867bd36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -821,9 +821,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, "funding": [ { @@ -5074,9 +5074,9 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, "funding": [ { @@ -7373,9 +7373,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", - "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "dev": true, "funding": [ { From 971b8f3ddf3b1a9c9315e7638473e0fe200f8664 Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:32:14 -0300 Subject: [PATCH 10/11] Revert "Support credential files" --- __test__/git-config-helper.int.test.ts | 87 +-------- dist/index.js | 256 ++++++------------------- src/git-command-manager.ts | 71 +------ src/git-config-helper.ts | 254 +++++++----------------- 4 files changed, 134 insertions(+), 534 deletions(-) diff --git a/__test__/git-config-helper.int.test.ts b/__test__/git-config-helper.int.test.ts index 85996cc0e2..dbb2513baa 100644 --- a/__test__/git-config-helper.int.test.ts +++ b/__test__/git-config-helper.int.test.ts @@ -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) diff --git a/dist/index.js b/dist/index.js index 29e11bbb86..a97e247924 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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) { + setExtraheaderConfig(extraheaderConfigValue) { 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); - } + // 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 + yield this.gitConfigStringReplace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); }); } - /** - * 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() { + hideCredentialFiles() { return __awaiter(this, void 0, void 0, function* () { - // Only clean up if this action instance created a credentials config file - if (!this.credentialsConfigPath) { + // Temporarily hide checkout@v6 credential files to avoid duplicate auth headers + const runnerTemp = process.env['RUNNER_TEMP']; + if (!runnerTemp) { 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}`); - } + 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) { - // Ignore errors during cleanup - core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`); + // If directory doesn't exist or we can't read it, just continue + core.debug(`Could not backup credential files: ${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)) { + }); + } + 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 { - yield fs.promises.unlink(this.credentialsConfigPath); - core.info(`Removed credentials config file: ${this.credentialsConfigPath}`); + 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.debug(`Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}`); + core.warning(`Failed to restore credential file ${backupPath}: ${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 - 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}`); - } - content = content.replace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); - yield fs.promises.writeFile(gitConfigPath, content); + 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; diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index b0ed1aefd3..6270f19392 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -96,15 +96,9 @@ export class GitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean, - configFile?: string + add?: boolean ): Promise { - 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 { - 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 { - 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 { - 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 { const output = await this.exec( ['config', '--local', '--get', 'remote.origin.url'], diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index bee89c1e54..e9299348af 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -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 { 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 { - // 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 { - // 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}` + await this.setExtraheaderConfig(extraheaderConfigValue) + } - // Get or create the credentials config file path - const credentialsConfigPath = this.getCredentialsConfigPath() + async removeAuth(): Promise { + await this.getAndUnset() + } - // 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 + private async setExtraheaderConfig( + extraheaderConfigValue: string + ): Promise { + // 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, - 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( + // Replace the placeholder + await this.gitConfigStringReplace( this.extraheaderConfigPlaceholderValue, extraheaderConfigValue ) - await fs.promises.writeFile(credentialsConfigPath, content) - - // Configure includeIf entries to reference the credentials config file - await this.configureIncludeIf(credentialsConfigPath) } - async removeAuth(): Promise { - // 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'] || '' + private async hideCredentialFiles(): Promise { + // Temporarily hide checkout@v6 credential files to avoid duplicate auth headers + 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 { - // 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 { - // 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}`) - } + 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) { - // Ignore errors during cleanup - core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`) + // If directory doesn't exist or we can't read it, just continue + core.debug( + `Could not backup credential files: ${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) - ) { + private async unhideCredentialFiles(): Promise { + // Restore checkout@v6 credential files that were backed up + for (const backupPath of this.backedUpCredentialFiles) { try { - await fs.promises.unlink(this.credentialsConfigPath) - core.info( - `Removed credentials config file: ${this.credentialsConfigPath}` - ) + 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.debug( - `Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}` + core.warning( + `Failed to restore credential file ${backupPath}: ${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 { - // 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 - 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( - this.extraheaderConfigPlaceholderValue, - extraheaderConfigValue - ) - await fs.promises.writeFile(gitConfigPath, content) + this.backedUpCredentialFiles = [] } private async getAndUnset(): Promise { @@ -384,4 +247,21 @@ export class GitConfigHelper { } return configValue } + + private async gitConfigStringReplace( + find: string, + replace: string + ): Promise { + 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) + } } From 569a5ede026c8db2025b52e4b4fb88d0d672f6e6 Mon Sep 17 00:00:00 2001 From: leoroma1 Date: Fri, 23 Oct 2026 13:33:22 -0300 Subject: [PATCH 11/11] Merge pull request #7 from leoroma1/revert-6-revert-5-support-credential-files Revert 6 revert 5 support credential files --- .github/workflows/cpr-example-command.yml | 16 +- __test__/git-config-helper.int.test.ts | 87 +++++++- dist/index.js | 256 +++++++++++++++++----- src/git-command-manager.ts | 71 +++++- src/git-config-helper.ts | 254 +++++++++++++++------ 5 files changed, 542 insertions(+), 142 deletions(-) diff --git a/.github/workflows/cpr-example-command.yml b/.github/workflows/cpr-example-command.yml index f0e37948e3..1f60d5e871 100644 --- a/.github/workflows/cpr-example-command.yml +++ b/.github/workflows/cpr-example-command.yml @@ -1,24 +1,24 @@ -name: Create Pull Request Example Command +name: Create Pull Request Command on: repository_dispatch: types: [cpr-example-command] jobs: createPullRequest: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@ # v8.0.1 - name: Make changes to pull request run: date +%s > report.txt - - name: Create Pull Request + - name: Create Pull Request if: id: cpr uses: ./ with: commit-message: Update report committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> - signoff: false + signoff: true title: '[Example] Update report' body: | Update report @@ -32,9 +32,9 @@ jobs: assignees: retepsnave reviewers: retepsnave milestone: 1 - draft: false + draft: true branch: example-patches - delete-branch: true + delete-branch: false - name: Check output run: | @@ -46,4 +46,4 @@ jobs: with: repository: ${{ github.event.client_payload.github.payload.repository.full_name }} comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - reaction-type: hooray + reaction-type: hoofoo diff --git a/__test__/git-config-helper.int.test.ts b/__test__/git-config-helper.int.test.ts index dbb2513baa..85996cc0e2 100644 --- a/__test__/git-config-helper.int.test.ts +++ b/__test__/git-config-helper.int.test.ts @@ -1,5 +1,7 @@ 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' @@ -7,29 +9,104 @@ 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', async () => { + it('tests configure and removal of auth using credentials file', async () => { + const runnerTemp = process.env['RUNNER_TEMP']! const gitConfigHelper = await GitConfigHelper.create(git) await gitConfigHelper.configureToken('github-token') - expect(await git.configExists(extraheaderConfigKey)).toBeTruthy() - expect(await git.getConfigValue(extraheaderConfigKey)).toEqual( + + // 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( '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() - expect(await git.configExists(extraheaderConfigKey)).toBeFalsy() + + // 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) }) - it('tests save and restore of persisted auth', async () => { + it('tests save and restore of persisted auth (old-style)', async () => { const extraheaderConfigValue = 'AUTHORIZATION: basic ***persisted-auth***' await git.config(extraheaderConfigKey, extraheaderConfigValue) diff --git a/dist/index.js b/dist/index.js index a97e247924..29e11bbb86 100644 --- a/dist/index.js +++ b/dist/index.js @@ -730,9 +730,15 @@ class GitCommandManager { return yield this.exec(args, { allowAllExitCodes: allowAllExitCodes }); }); } - config(configKey, configValue, globalConfig, add) { + config(configKey, configValue, globalConfig, add, configFile) { return __awaiter(this, void 0, void 0, function* () { - const args = ['config', globalConfig ? '--global' : '--local']; + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } if (add) { args.push('--add'); } @@ -964,6 +970,60 @@ 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 }); @@ -1100,9 +1160,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 = ''; @@ -1110,7 +1170,8 @@ class GitConfigHelper { this.extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***'; this.extraheaderConfigValueRegex = '^AUTHORIZATION:'; this.persistedExtraheaderConfigValue = ''; - this.backedUpCredentialFiles = []; + // Path to the credentials config file in RUNNER_TEMP (new v6-style auth) + this.credentialsConfigPath = ''; this.git = git; this.workingDirectory = this.git.getWorkingDirectory(); } @@ -1190,16 +1251,15 @@ 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`; - // Backup checkout@v6 credential files if they exist - yield this.hideCredentialFiles(); - // Save and unset persisted extraheader credential in git config if it exists + // 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 this.persistedExtraheaderConfigValue = yield this.getAndUnset(); }); } restorePersistedAuth() { return __awaiter(this, void 0, void 0, function* () { - // Restore checkout@v6 credential files if they were backed up - yield this.unhideCredentialFiles(); + // Restore old-style extraheader config if it was persisted if (this.persistedExtraheaderConfigValue) { try { yield this.setExtraheaderConfig(this.persistedExtraheaderConfigValue); @@ -1213,69 +1273,160 @@ class GitConfigHelper { } configureToken(token) { return __awaiter(this, void 0, void 0, function* () { - // Encode and configure the basic credential for HTTPS access + // Encode 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}`; - yield this.setExtraheaderConfig(extraheaderConfigValue); + // 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); }); } 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(); }); } - setExtraheaderConfig(extraheaderConfigValue) { + /** + * 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* () { - // 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 - yield this.gitConfigStringReplace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); + // 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); + } }); } - hideCredentialFiles() { + /** + * 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* () { - // Temporarily hide checkout@v6 credential files to avoid duplicate auth headers - const runnerTemp = process.env['RUNNER_TEMP']; - if (!runnerTemp) { + // Only clean up if this action instance created a credentials config file + if (!this.credentialsConfigPath) { return; } 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)`); + // 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) { - // If directory doesn't exist or we can't read it, just continue - core.debug(`Could not backup credential files: ${utils.getErrorMessage(e)}`); + // Ignore errors during cleanup + core.debug(`Error during includeIf cleanup: ${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) { + // 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 { - const originalPath = backupPath.replace(/\.bak$/, ''); - yield fs.promises.rename(backupPath, originalPath); - const fileName = path.basename(originalPath); - core.info(`Restored checkout credential file: ${fileName}`); + yield fs.promises.unlink(this.credentialsConfigPath); + core.info(`Removed credentials config file: ${this.credentialsConfigPath}`); } catch (e) { - core.warning(`Failed to restore credential file ${backupPath}: ${utils.getErrorMessage(e)}`); + core.debug(`Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}`); } } - this.backedUpCredentialFiles = []; + }); + } + /** + * 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 + 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}`); + } + content = content.replace(this.extraheaderConfigPlaceholderValue, extraheaderConfigValue); + yield fs.promises.writeFile(gitConfigPath, content); }); } getAndUnset() { @@ -1294,21 +1445,6 @@ 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; diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 6270f19392..b0ed1aefd3 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -96,9 +96,15 @@ export class GitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise { - const args: string[] = ['config', globalConfig ? '--global' : '--local'] + const args: string[] = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } if (add) { args.push('--add') } @@ -350,6 +356,67 @@ export class GitCommandManager { return output.exitCode === 0 } + async tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + 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 { + 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 { + 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 { const output = await this.exec( ['config', '--local', '--get', 'remote.origin.url'], diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index e9299348af..bee89c1e54 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -4,6 +4,7 @@ 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 @@ -13,7 +14,6 @@ interface GitRemote { export class GitConfigHelper { private git: GitCommandManager - private gitConfigPath = '' private workingDirectory: string private safeDirectoryConfigKey = 'safe.directory' private safeDirectoryAdded = false @@ -22,7 +22,8 @@ export class GitConfigHelper { private extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***' private extraheaderConfigValueRegex = '^AUTHORIZATION:' private persistedExtraheaderConfigValue = '' - private backedUpCredentialFiles: string[] = [] + // Path to the credentials config file in RUNNER_TEMP (new v6-style auth) + private credentialsConfigPath = '' private constructor(git: GitCommandManager) { this.git = git @@ -122,15 +123,14 @@ export class GitConfigHelper { async savePersistedAuth(): Promise { const serverUrl = new URL(`https://${this.getGitRemote().hostname}`) this.extraheaderConfigKey = `http.${serverUrl.origin}/.extraheader` - // Backup checkout@v6 credential files if they exist - await this.hideCredentialFiles() - // Save and unset persisted extraheader credential in git config if it exists + // 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 this.persistedExtraheaderConfigValue = await this.getAndUnset() } async restorePersistedAuth(): Promise { - // Restore checkout@v6 credential files if they were backed up - await this.unhideCredentialFiles() + // Restore old-style extraheader config if it was persisted if (this.persistedExtraheaderConfigValue) { try { await this.setExtraheaderConfig(this.persistedExtraheaderConfigValue) @@ -142,81 +142,218 @@ export class GitConfigHelper { } async configureToken(token: string): Promise { - // Encode and configure the basic credential for HTTPS access + // Encode 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}` - await this.setExtraheaderConfig(extraheaderConfigValue) - } - async removeAuth(): Promise { - await this.getAndUnset() - } + // Get or create the credentials config file path + const credentialsConfigPath = this.getCredentialsConfigPath() - private async setExtraheaderConfig( - extraheaderConfigValue: string - ): Promise { - // 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 + // 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 ) - // Replace the placeholder - await this.gitConfigStringReplace( + 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) } - private async hideCredentialFiles(): Promise { - // Temporarily hide checkout@v6 credential files to avoid duplicate auth headers - const runnerTemp = process.env['RUNNER_TEMP'] + async removeAuth(): Promise { + // 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 { + // 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 { + // Only clean up if this action instance created a credentials config file + if (!this.credentialsConfigPath) { 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)` - ) + // 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) { - // If directory doesn't exist or we can't read it, just continue - core.debug( - `Could not backup credential files: ${utils.getErrorMessage(e)}` - ) + // Ignore errors during cleanup + core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`) } - } - private async unhideCredentialFiles(): Promise { - // Restore checkout@v6 credential files that were backed up - for (const backupPath of this.backedUpCredentialFiles) { + // 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 { - const originalPath = backupPath.replace(/\.bak$/, '') - await fs.promises.rename(backupPath, originalPath) - const fileName = path.basename(originalPath) - core.info(`Restored checkout credential file: ${fileName}`) + await fs.promises.unlink(this.credentialsConfigPath) + core.info( + `Removed credentials config file: ${this.credentialsConfigPath}` + ) } catch (e) { - core.warning( - `Failed to restore credential file ${backupPath}: ${utils.getErrorMessage(e)}` + core.debug( + `Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}` ) } } - this.backedUpCredentialFiles = [] + } + + /** + * 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 { + // 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 + 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( + this.extraheaderConfigPlaceholderValue, + extraheaderConfigValue + ) + await fs.promises.writeFile(gitConfigPath, content) } private async getAndUnset(): Promise { @@ -247,21 +384,4 @@ export class GitConfigHelper { } return configValue } - - private async gitConfigStringReplace( - find: string, - replace: string - ): Promise { - 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) - } }