chore(deps): update actions/checkout action to v5.0.1
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
| actions/checkout | action | patch | v5.0.0 -> v5.0.1 |
Release Notes
actions/checkout (actions/checkout)
v5.0.1
What's Changed
- Port v6 cleanup to v5 by @ericsciple in #2301
Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1
Configuration
📅 Schedule: Branch creation - Tuesday through Thursday ( * * * * 2-4 ) (UTC), Automerge - At any time (no schedule defined).
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
- [ ] If you want to rebase/retry this PR, check this box
This PR was generated by Mend Renovate. View the repository job log.
openai debug - [puLL-Merge] - actions/[email protected]
Diff
diff --git __test__/git-auth-helper.test.ts __test__/git-auth-helper.test.ts
index 7633704cc..9acba5463 100644
--- __test__/git-auth-helper.test.ts
+++ __test__/git-auth-helper.test.ts
@@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => {
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
})
+ const removeAuth_removesV6StyleCredentials =
+ 'removeAuth removes v6 style credentials'
+ it(removeAuth_removesV6StyleCredentials, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentials)
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Manually create v6-style credentials that would be left by v6
+ const credentialsFileName =
+ 'git-credentials-12345678-1234-1234-1234-123456789abc.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to local git config (simulating v6 configuration)
+ const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n`
+ )
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Mock the git methods to handle v6 cleanup
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockResolvedValue([
+ `includeIf.gitdir:${hostGitDir}/.path`,
+ 'includeIf.gitdir:/github/workspace/.git/.path'
+ ])
+
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(async (key: string) => {
+ if (key === `includeIf.gitdir:${hostGitDir}/.path`) {
+ return [credentialsFilePath]
+ }
+ if (key === 'includeIf.gitdir:/github/workspace/.git/.path') {
+ return [`/github/runner_temp/${credentialsFileName}`]
+ }
+ return []
+ })
+
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ // Remove the includeIf section
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert includeIf entries removed from local git config
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials config file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_removesV6StyleCredentialsFromSubmodules =
+ 'removeAuth removes v6 style credentials from submodules'
+ it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentialsFromSubmodules)
+
+ // Create fake submodule config paths
+ const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
+ const submodule1ConfigPath = path.join(submodule1Dir, 'config')
+ await fs.promises.mkdir(submodule1Dir, {recursive: true})
+ await fs.promises.writeFile(submodule1ConfigPath, '')
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file
+ const credentialsFileName =
+ 'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to submodule config
+ const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ submodule1ConfigPath,
+ `[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+
+ // Verify submodule config has includeIf entry
+ let submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(
+ 0
+ )
+ expect(
+ submoduleConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Mock getSubmoduleConfigPaths
+ const mockGetSubmoduleConfigPaths =
+ git.getSubmoduleConfigPaths as jest.Mock<any, any>
+ mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
+
+ // Mock tryGetConfigKeys for submodule
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockImplementation(
+ async (pattern: string, globalConfig?: boolean, configPath?: string) => {
+ if (configPath === submodule1ConfigPath) {
+ return [`includeIf.gitdir:${submodule1GitDir}/.path`]
+ }
+ return []
+ }
+ )
+
+ // Mock tryGetConfigValues for submodule
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(
+ async (key: string, globalConfig?: boolean, configPath?: string) => {
+ if (
+ configPath === submodule1ConfigPath &&
+ key === `includeIf.gitdir:${submodule1GitDir}/.path`
+ ) {
+ return [credentialsFilePath]
+ }
+ return []
+ }
+ )
+
+ // Mock tryConfigUnsetValue for submodule
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert submodule includeIf entries removed
+ submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_skipsV6CleanupWhenEnvVarSet =
+ 'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set'
+ it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => {
+ // Arrange
+ await setup(removeAuth_skipsV6CleanupWhenEnvVarSet)
+
+ // Set the skip environment variable
+ process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1'
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file in RUNNER_TEMP
+ const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const credentialsContent =
+ '[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n'
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf section to local git config (separate from http.* config)
+ const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n`
+ await fs.promises.appendFile(localGitConfigPath, includeIfSection)
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert v5 cleanup still happened (http.* removed)
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(
+ gitConfigContent.indexOf('http.https://github.com/.extraheader')
+ ).toBeLessThan(0)
+
+ // Assert v6 cleanup was skipped - includeIf should still be present
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Assert credentials file still exists (wasn't deleted)
+ await fs.promises.stat(credentialsFilePath) // File should still exist
+
+ // Assert debug message was logged
+ expect(core.debug).toHaveBeenCalledWith(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+
+ // Cleanup
+ delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ })
+
const removeGlobalConfig_removesOverride =
'removeGlobalConfig removes override'
it(removeGlobalConfig_removesOverride, async () => {
@@ -796,6 +1073,18 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(),
version: jest.fn()
}
diff --git __test__/git-directory-helper.test.ts __test__/git-directory-helper.test.ts
index 22e9ae6d4..1627b842e 100644
--- __test__/git-directory-helper.test.ts
+++ __test__/git-directory-helper.test.ts
@@ -499,6 +499,18 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(async () => {
return true
}),
diff --git dist/index.js dist/index.js
index f3ae6f3ea..b0add8ac4 100644
--- dist/index.js
+++ dist/index.js
@@ -411,8 +411,50 @@ class GitAuthHelper {
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
yield this.removeGitConfig(this.tokenConfigKey);
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'];
+ if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') {
+ core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP');
+ return;
+ }
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set();
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = yield this.removeIncludeIfCredentials();
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ }
+ }
+ catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`);
+ }
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP'];
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ yield io.rmRF(credentialsPath);
+ }
+ catch (err) {
+ core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`);
+ }
+ }
+ }
+ }
+ catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`);
+ }
});
}
removeGitConfig(configKey_1) {
@@ -430,6 +472,49 @@ class GitAuthHelper {
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
});
}
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ removeIncludeIfCredentials(configPath) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentialsPaths = new Set();
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
+ configPath);
+ for (const key of keys) {
+ // Get all values for this key
+ const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
+ configPath);
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value);
+ yield this.git.tryConfigUnsetValue(key, value, false, configPath);
+ }
+ }
+ }
+ }
+ }
+ catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
+ }
+ return Array.from(credentialsPaths);
+ });
+ }
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ testCredentialsConfigPath(path) {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
+ }
}
@@ -706,6 +791,16 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch');
});
}
+ getSubmoduleConfigPaths(recursive) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive);
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
+ return configPaths;
+ });
+ }
getWorkingDirectory() {
return this.workingDirectory;
}
@@ -836,6 +931,20 @@ 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.execGit(args, true);
+ return output.exitCode === 0;
+ });
+ }
tryDisableAutomaticGarbageCollection() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true);
@@ -855,6 +964,46 @@ class GitCommandManager {
return stdout;
});
}
+ 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.execGit(args, 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.execGit(args, true);
+ if (output.exitCode !== 0) {
+ return [];
+ }
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim());
+ });
+ }
tryReset() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['reset', '--hard', 'HEAD'], true);
diff --git src/git-auth-helper.ts src/git-auth-helper.ts
index 126e8e5ee..0c82dddab 100644
--- src/git-auth-helper.ts
+++ src/git-auth-helper.ts
@@ -346,8 +346,58 @@ class GitAuthHelper {
}
private async removeToken(): Promise<void> {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
await this.removeGitConfig(this.tokenConfigKey)
+
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') {
+ core.debug(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+ return
+ }
+
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set<string>()
+
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = await this.removeIncludeIfCredentials()
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
+
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths =
+ await this.git.getSubmoduleConfigPaths(true)
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths =
+ await this.removeIncludeIfCredentials(configPath)
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
+ }
+ } catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`)
+ }
+
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP']
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ await io.rmRF(credentialsPath)
+ } catch (err) {
+ core.debug(
+ `Failed to remove credentials config '${credentialsPath}': ${err}`
+ )
+ }
+ }
+ }
+ } catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`)
+ }
}
private async removeGitConfig(
@@ -371,4 +421,59 @@ class GitAuthHelper {
true
)
}
+
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ private async removeIncludeIfCredentials(
+ configPath?: string
+ ): Promise<string[]> {
+ const credentialsPaths = new Set<string>()
+
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = await this.git.tryGetConfigKeys(
+ '^includeIf\\.gitdir:',
+ false, // globalConfig?
+ configPath
+ )
+
+ for (const key of keys) {
+ // Get all values for this key
+ const values = await this.git.tryGetConfigValues(
+ key,
+ false, // globalConfig?
+ configPath
+ )
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value)
+ await this.git.tryConfigUnsetValue(key, value, false, configPath)
+ }
+ }
+ }
+ }
+ } catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(
+ `Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
+ )
+ }
+
+ return Array.from(credentialsPaths)
+ }
+
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ private testCredentialsConfigPath(path: string): boolean {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
+ }
}
diff --git src/git-command-manager.ts src/git-command-manager.ts
index 8e42a387f..9c789ac80 100644
--- src/git-command-manager.ts
+++ src/git-command-manager.ts
@@ -41,6 +41,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
+ getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
+ tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
+ tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
+ tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -323,6 +340,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
+ async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = await this.submoduleForeach(
+ `git config --local --show-origin --name-only --get-regexp remote.origin.url`,
+ recursive
+ )
+
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths =
+ output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
+
+ return configPaths
+ }
+
getWorkingDirectory(): string {
return this.workingDirectory
}
@@ -455,6 +487,24 @@ class GitCommandManager {
return output.exitCode === 0
}
+ async tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--unset', configKey, configValue)
+
+ const output = await this.execGit(args, true)
+ return output.exitCode === 0
+ }
+
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
@@ -481,6 +531,56 @@ class GitCommandManager {
return stdout
}
+ async tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--get-all', configKey)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(value => value.trim())
+ }
+
+ async tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--name-only', '--get-regexp', pattern)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim())
+ }
+
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
return output.exitCode === 0
Description
This pull request adds functionality to handle the cleanup of v6-style Git credentials after the actions/checkout@v6 execution. This involves removing entries related to includeIf git configurations and the associated credential files. The main changes are in:
- Introducing new helper functions in the
GitAuthHelperclass to identify and remove these configuration entries and their associated files. - Expanding the
GitCommandManagerto support additional Git operations related to this cleanup. - Adding unit tests to ensure the new functionality works as intended.
Possible Issues
- Compatibility Concerns: Since v6 configuration cleanup involves specific file patterns and environment configurations, there might be edge cases where this logic does not perform as expected if the environment deviates from expected patterns.
- Mock Behavior in Tests: The added unit tests rely heavily on mocked behavior for file system operations and git commands. If the mock implementations don't align perfectly with actual behavior, some edge cases may not be adequately covered.
-
Environment Variable Control: The skipping mechanism is controlled by an environment variable (
ACTIONS_CHECKOUT_SKIP_V6_CLEANUP). If not set or incorrectly set, it might affect the intended cleanup behavior.
Security Hotspots
-
Unauthorized File Deletion: The cleanup process involves deleting credential files, which could lead to unintentional file deletions if paths are incorrectly identified. There is some mitigation since the deletion is restricted to
RUNNER_TEMPdirectory, but caution is still necessary. - Pattern Matching for Credentials: The cleanup relies on identifying credential files with a specific naming pattern. If an attacker could place files matching this pattern outside of allowed directories, it might lead to disruptive deletions.
Privacy Hotspots
- Handling of Auth Tokens: While the authenticated access tokens seem to be temporary and created specifically for the git operations, ensuring these tokens are not leaked in logs or retained longer than necessary is essential.
Changes
Changes
-
Unit Tests (
__test__/git-auth-helper.test.tsand__test__/git-directory-helper.test.ts):- Added tests to verify the cleanup of v6-style credentials.
- Mocks added to simulate git commands and file system interactions.
-
Git Auth Helper (
src/git-auth-helper.ts,dist/index.js):- Updated to include logic for removing v6-style credentials.
- Added
removeIncludeIfCredentialsandtestCredentialsConfigPathmethods. - Expanded the
removeTokenmethod to include new cleanup logic.
-
Git Command Manager (
src/git-command-manager.ts,dist/index.js):- Added methods to get configuration keys and values, unset specific configuration values.
- Introduced logic to obtain submodule configuration paths.
sequenceDiagram
participant User
participant GitAuthHelper
participant GitCommandManager
participant FS as FileSystem
participant Core
participant IO
User->>GitAuthHelper: Call removeAuth()
GitAuthHelper->>GitAuthHelper: Check ACTIONS_CHECKOUT_SKIP_V6_CLEANUP
alt Cleanup Skipped
GitAuthHelper->>Core: Log "Skipping v6 style cleanup"
else Normal Cleanup
GitAuthHelper->>GitAuthHelper: Call removeGitConfig()
GitAuthHelper->>GitAuthHelper: Call removeIncludeIfCredentials()
GitAuthHelper->>GitCommandManager: getSubmoduleConfigPaths()
GitCommandManager->>GitAuthHelper: Return submodule config paths
loop Remove Submodule Configs
GitAuthHelper->>GitAuthHelper: Call removeIncludeIfCredentials(config path)
end
loop Remove Credential Files
GitAuthHelper->>IO: Call rmRF(credentialsPath)
end
GitAuthHelper->>Core: Log Debug Info
end
return
bedrock debug - [puLL-Merge] - actions/[email protected]
Diff
diff --git __test__/git-auth-helper.test.ts __test__/git-auth-helper.test.ts
index 7633704cc..9acba5463 100644
--- __test__/git-auth-helper.test.ts
+++ __test__/git-auth-helper.test.ts
@@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => {
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
})
+ const removeAuth_removesV6StyleCredentials =
+ 'removeAuth removes v6 style credentials'
+ it(removeAuth_removesV6StyleCredentials, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentials)
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Manually create v6-style credentials that would be left by v6
+ const credentialsFileName =
+ 'git-credentials-12345678-1234-1234-1234-123456789abc.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to local git config (simulating v6 configuration)
+ const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n`
+ )
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Mock the git methods to handle v6 cleanup
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockResolvedValue([
+ `includeIf.gitdir:${hostGitDir}/.path`,
+ 'includeIf.gitdir:/github/workspace/.git/.path'
+ ])
+
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(async (key: string) => {
+ if (key === `includeIf.gitdir:${hostGitDir}/.path`) {
+ return [credentialsFilePath]
+ }
+ if (key === 'includeIf.gitdir:/github/workspace/.git/.path') {
+ return [`/github/runner_temp/${credentialsFileName}`]
+ }
+ return []
+ })
+
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ // Remove the includeIf section
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert includeIf entries removed from local git config
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials config file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_removesV6StyleCredentialsFromSubmodules =
+ 'removeAuth removes v6 style credentials from submodules'
+ it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentialsFromSubmodules)
+
+ // Create fake submodule config paths
+ const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
+ const submodule1ConfigPath = path.join(submodule1Dir, 'config')
+ await fs.promises.mkdir(submodule1Dir, {recursive: true})
+ await fs.promises.writeFile(submodule1ConfigPath, '')
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file
+ const credentialsFileName =
+ 'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to submodule config
+ const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ submodule1ConfigPath,
+ `[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+
+ // Verify submodule config has includeIf entry
+ let submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(
+ 0
+ )
+ expect(
+ submoduleConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Mock getSubmoduleConfigPaths
+ const mockGetSubmoduleConfigPaths =
+ git.getSubmoduleConfigPaths as jest.Mock<any, any>
+ mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
+
+ // Mock tryGetConfigKeys for submodule
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockImplementation(
+ async (pattern: string, globalConfig?: boolean, configPath?: string) => {
+ if (configPath === submodule1ConfigPath) {
+ return [`includeIf.gitdir:${submodule1GitDir}/.path`]
+ }
+ return []
+ }
+ )
+
+ // Mock tryGetConfigValues for submodule
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(
+ async (key: string, globalConfig?: boolean, configPath?: string) => {
+ if (
+ configPath === submodule1ConfigPath &&
+ key === `includeIf.gitdir:${submodule1GitDir}/.path`
+ ) {
+ return [credentialsFilePath]
+ }
+ return []
+ }
+ )
+
+ // Mock tryConfigUnsetValue for submodule
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert submodule includeIf entries removed
+ submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_skipsV6CleanupWhenEnvVarSet =
+ 'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set'
+ it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => {
+ // Arrange
+ await setup(removeAuth_skipsV6CleanupWhenEnvVarSet)
+
+ // Set the skip environment variable
+ process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1'
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file in RUNNER_TEMP
+ const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const credentialsContent =
+ '[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n'
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf section to local git config (separate from http.* config)
+ const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n`
+ await fs.promises.appendFile(localGitConfigPath, includeIfSection)
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert v5 cleanup still happened (http.* removed)
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(
+ gitConfigContent.indexOf('http.https://github.com/.extraheader')
+ ).toBeLessThan(0)
+
+ // Assert v6 cleanup was skipped - includeIf should still be present
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Assert credentials file still exists (wasn't deleted)
+ await fs.promises.stat(credentialsFilePath) // File should still exist
+
+ // Assert debug message was logged
+ expect(core.debug).toHaveBeenCalledWith(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+
+ // Cleanup
+ delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ })
+
const removeGlobalConfig_removesOverride =
'removeGlobalConfig removes override'
it(removeGlobalConfig_removesOverride, async () => {
@@ -796,6 +1073,18 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(),
version: jest.fn()
}
diff --git __test__/git-directory-helper.test.ts __test__/git-directory-helper.test.ts
index 22e9ae6d4..1627b842e 100644
--- __test__/git-directory-helper.test.ts
+++ __test__/git-directory-helper.test.ts
@@ -499,6 +499,18 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(async () => {
return true
}),
diff --git dist/index.js dist/index.js
index f3ae6f3ea..b0add8ac4 100644
--- dist/index.js
+++ dist/index.js
@@ -411,8 +411,50 @@ class GitAuthHelper {
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
yield this.removeGitConfig(this.tokenConfigKey);
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'];
+ if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') {
+ core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP');
+ return;
+ }
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set();
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = yield this.removeIncludeIfCredentials();
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ }
+ }
+ catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`);
+ }
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP'];
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ yield io.rmRF(credentialsPath);
+ }
+ catch (err) {
+ core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`);
+ }
+ }
+ }
+ }
+ catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`);
+ }
});
}
removeGitConfig(configKey_1) {
@@ -430,6 +472,49 @@ class GitAuthHelper {
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
});
}
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ removeIncludeIfCredentials(configPath) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentialsPaths = new Set();
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
+ configPath);
+ for (const key of keys) {
+ // Get all values for this key
+ const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
+ configPath);
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value);
+ yield this.git.tryConfigUnsetValue(key, value, false, configPath);
+ }
+ }
+ }
+ }
+ }
+ catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
+ }
+ return Array.from(credentialsPaths);
+ });
+ }
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ testCredentialsConfigPath(path) {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
+ }
}
@@ -706,6 +791,16 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch');
});
}
+ getSubmoduleConfigPaths(recursive) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive);
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
+ return configPaths;
+ });
+ }
getWorkingDirectory() {
return this.workingDirectory;
}
@@ -836,6 +931,20 @@ 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.execGit(args, true);
+ return output.exitCode === 0;
+ });
+ }
tryDisableAutomaticGarbageCollection() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true);
@@ -855,6 +964,46 @@ class GitCommandManager {
return stdout;
});
}
+ 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.execGit(args, 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.execGit(args, true);
+ if (output.exitCode !== 0) {
+ return [];
+ }
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim());
+ });
+ }
tryReset() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['reset', '--hard', 'HEAD'], true);
diff --git src/git-auth-helper.ts src/git-auth-helper.ts
index 126e8e5ee..0c82dddab 100644
--- src/git-auth-helper.ts
+++ src/git-auth-helper.ts
@@ -346,8 +346,58 @@ class GitAuthHelper {
}
private async removeToken(): Promise<void> {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
await this.removeGitConfig(this.tokenConfigKey)
+
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') {
+ core.debug(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+ return
+ }
+
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set<string>()
+
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = await this.removeIncludeIfCredentials()
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
+
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths =
+ await this.git.getSubmoduleConfigPaths(true)
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths =
+ await this.removeIncludeIfCredentials(configPath)
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
+ }
+ } catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`)
+ }
+
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP']
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ await io.rmRF(credentialsPath)
+ } catch (err) {
+ core.debug(
+ `Failed to remove credentials config '${credentialsPath}': ${err}`
+ )
+ }
+ }
+ }
+ } catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`)
+ }
}
private async removeGitConfig(
@@ -371,4 +421,59 @@ class GitAuthHelper {
true
)
}
+
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ private async removeIncludeIfCredentials(
+ configPath?: string
+ ): Promise<string[]> {
+ const credentialsPaths = new Set<string>()
+
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = await this.git.tryGetConfigKeys(
+ '^includeIf\\.gitdir:',
+ false, // globalConfig?
+ configPath
+ )
+
+ for (const key of keys) {
+ // Get all values for this key
+ const values = await this.git.tryGetConfigValues(
+ key,
+ false, // globalConfig?
+ configPath
+ )
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value)
+ await this.git.tryConfigUnsetValue(key, value, false, configPath)
+ }
+ }
+ }
+ }
+ } catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(
+ `Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
+ )
+ }
+
+ return Array.from(credentialsPaths)
+ }
+
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ private testCredentialsConfigPath(path: string): boolean {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
+ }
}
diff --git src/git-command-manager.ts src/git-command-manager.ts
index 8e42a387f..9c789ac80 100644
--- src/git-command-manager.ts
+++ src/git-command-manager.ts
@@ -41,6 +41,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
+ getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
+ tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
+ tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
+ tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -323,6 +340,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
+ async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = await this.submoduleForeach(
+ `git config --local --show-origin --name-only --get-regexp remote.origin.url`,
+ recursive
+ )
+
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths =
+ output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
+
+ return configPaths
+ }
+
getWorkingDirectory(): string {
return this.workingDirectory
}
@@ -455,6 +487,24 @@ class GitCommandManager {
return output.exitCode === 0
}
+ async tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--unset', configKey, configValue)
+
+ const output = await this.execGit(args, true)
+ return output.exitCode === 0
+ }
+
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
@@ -481,6 +531,56 @@ class GitCommandManager {
return stdout
}
+ async tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--get-all', configKey)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(value => value.trim())
+ }
+
+ async tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--name-only', '--get-regexp', pattern)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim())
+ }
+
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
return output.exitCode === 0
Description
This PR adds functionality to properly clean up credentials when using the checkout action. Specifically, it adds support for cleaning up credentials configured by newer versions (v6+) of the action which use a different credential storage mechanism. The changes ensure that authentication tokens are properly removed after checkout operations, avoiding potential security issues.
Security Hotspots
- Credential File Cleanup: The PR addresses a security issue where v6-style credential files might be left behind after checkout operations complete. Proper cleanup of these credential files is essential as they contain authentication tokens.
-
Token Handling: There is some token handling in
removeAuth()that now deletes credential files more thoroughly. Proper token cleanup is essential for security.
Changes
Changes
-
src/git-auth-helper.ts
- Enhanced the
removeToken()method to clean up v6-style credentials - Added the
removeIncludeIfCredentials()method to handle cleanup of includeIf entries in git config - Added
testCredentialsConfigPath()to identify credential config files
- Enhanced the
-
src/git-command-manager.ts
- Added several new git command utilities:
-
getSubmoduleConfigPaths()- retrieves paths to submodule config files -
tryConfigUnsetValue()- removes specific values from git config -
tryGetConfigValues()- retrieves values for a specific config key -
tryGetConfigKeys()- retrieves config keys matching a pattern
-
- Added several new git command utilities:
-
test files
- Added extensive tests for the new functionality in
__test__/git-auth-helper.test.ts - Added test mocks in
__test__/git-directory-helper.test.ts - Test cases cover:
- Removal of v6-style credentials
- Handling of submodule credentials
- Skip functionality via environment variable
- Added extensive tests for the new functionality in
-
dist/index.js
- Compiled changes from the TypeScript source files
sequenceDiagram
participant App as Checkout Action
participant Auth as GitAuthHelper
participant Git as GitCommandManager
participant Config as Git Config
participant Filesystem
App->>Auth: removeAuth()
Auth->>Git: removeGitConfig(tokenConfigKey)
Git-->>Config: Remove HTTP extra header (v5 style)
Note over Auth: Begin v6 style cleanup
Auth->>Auth: Check ACTIONS_CHECKOUT_SKIP_V6_CLEANUP
opt When v6 cleanup is not skipped
Auth->>Auth: removeIncludeIfCredentials()
Auth->>Git: tryGetConfigKeys('^includeIf\\.gitdir:')
Git-->>Auth: includeIf keys
loop For each includeIf key
Auth->>Git: tryGetConfigValues(key)
Git-->>Auth: config values
loop For each value matching credentials pattern
Auth->>Git: tryConfigUnsetValue(key, value)
Git-->>Config: Remove includeIf entry
Auth->>Filesystem: Track credential path
end
end
Auth->>Git: getSubmoduleConfigPaths()
Git-->>Auth: submodule config paths
loop For each submodule config
Auth->>Auth: removeIncludeIfCredentials(configPath)
Note over Auth,Config: Same process as above but for submodule configs
end
loop For each credential file path
Auth->>Filesystem: io.rmRF(credentialsPath)
Filesystem-->>Auth: File deleted
end
end
anthropic debug - [puLL-Merge] - actions/[email protected]
Diff
diff --git __test__/git-auth-helper.test.ts __test__/git-auth-helper.test.ts
index 7633704cc..9acba5463 100644
--- __test__/git-auth-helper.test.ts
+++ __test__/git-auth-helper.test.ts
@@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => {
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
})
+ const removeAuth_removesV6StyleCredentials =
+ 'removeAuth removes v6 style credentials'
+ it(removeAuth_removesV6StyleCredentials, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentials)
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Manually create v6-style credentials that would be left by v6
+ const credentialsFileName =
+ 'git-credentials-12345678-1234-1234-1234-123456789abc.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to local git config (simulating v6 configuration)
+ const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+ await fs.promises.appendFile(
+ localGitConfigPath,
+ `[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n`
+ )
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Mock the git methods to handle v6 cleanup
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockResolvedValue([
+ `includeIf.gitdir:${hostGitDir}/.path`,
+ 'includeIf.gitdir:/github/workspace/.git/.path'
+ ])
+
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(async (key: string) => {
+ if (key === `includeIf.gitdir:${hostGitDir}/.path`) {
+ return [credentialsFilePath]
+ }
+ if (key === 'includeIf.gitdir:/github/workspace/.git/.path') {
+ return [`/github/runner_temp/${credentialsFileName}`]
+ }
+ return []
+ })
+
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ // Remove the includeIf section
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert includeIf entries removed from local git config
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials config file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_removesV6StyleCredentialsFromSubmodules =
+ 'removeAuth removes v6 style credentials from submodules'
+ it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => {
+ // Arrange
+ await setup(removeAuth_removesV6StyleCredentialsFromSubmodules)
+
+ // Create fake submodule config paths
+ const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
+ const submodule1ConfigPath = path.join(submodule1Dir, 'config')
+ await fs.promises.mkdir(submodule1Dir, {recursive: true})
+ await fs.promises.writeFile(submodule1ConfigPath, '')
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file
+ const credentialsFileName =
+ 'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const basicCredential = Buffer.from(
+ `x-access-token:${settings.authToken}`,
+ 'utf8'
+ ).toString('base64')
+ const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf entries to submodule config
+ const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
+ await fs.promises.appendFile(
+ submodule1ConfigPath,
+ `[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n`
+ )
+
+ // Verify submodule config has includeIf entry
+ let submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(
+ 0
+ )
+ expect(
+ submoduleConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Mock getSubmoduleConfigPaths
+ const mockGetSubmoduleConfigPaths =
+ git.getSubmoduleConfigPaths as jest.Mock<any, any>
+ mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
+
+ // Mock tryGetConfigKeys for submodule
+ const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+ mockTryGetConfigKeys.mockImplementation(
+ async (pattern: string, globalConfig?: boolean, configPath?: string) => {
+ if (configPath === submodule1ConfigPath) {
+ return [`includeIf.gitdir:${submodule1GitDir}/.path`]
+ }
+ return []
+ }
+ )
+
+ // Mock tryGetConfigValues for submodule
+ const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+ mockTryGetConfigValues.mockImplementation(
+ async (key: string, globalConfig?: boolean, configPath?: string) => {
+ if (
+ configPath === submodule1ConfigPath &&
+ key === `includeIf.gitdir:${submodule1GitDir}/.path`
+ ) {
+ return [credentialsFilePath]
+ }
+ return []
+ }
+ )
+
+ // Mock tryConfigUnsetValue for submodule
+ const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
+ any,
+ any
+ >
+ mockTryConfigUnsetValue.mockImplementation(
+ async (
+ key: string,
+ value: string,
+ globalConfig?: boolean,
+ configPath?: string
+ ) => {
+ const targetPath = configPath || localGitConfigPath
+ let content = await fs.promises.readFile(targetPath, 'utf8')
+ const lines = content
+ .split('\n')
+ .filter(line => !line.includes('includeIf') && !line.includes(value))
+ await fs.promises.writeFile(targetPath, lines.join('\n'))
+ return true
+ }
+ )
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert submodule includeIf entries removed
+ submoduleConfigContent = (
+ await fs.promises.readFile(submodule1ConfigPath)
+ ).toString()
+ expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0)
+ expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+
+ // Assert credentials file deleted
+ try {
+ await fs.promises.stat(credentialsFilePath)
+ throw new Error('Credentials file should have been deleted')
+ } catch (err) {
+ if ((err as any)?.code !== 'ENOENT') {
+ throw err
+ }
+ }
+ })
+
+ const removeAuth_skipsV6CleanupWhenEnvVarSet =
+ 'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set'
+ it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => {
+ // Arrange
+ await setup(removeAuth_skipsV6CleanupWhenEnvVarSet)
+
+ // Set the skip environment variable
+ process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1'
+
+ const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+ await authHelper.configureAuth()
+
+ // Create v6-style credentials file in RUNNER_TEMP
+ const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config'
+ const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
+ const credentialsContent =
+ '[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n'
+ await fs.promises.writeFile(credentialsFilePath, credentialsContent)
+
+ // Add includeIf section to local git config (separate from http.* config)
+ const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n`
+ await fs.promises.appendFile(localGitConfigPath, includeIfSection)
+
+ // Verify v6 style config exists
+ let gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ await fs.promises.stat(credentialsFilePath) // Verify file exists
+
+ // Act
+ await authHelper.removeAuth()
+
+ // Assert v5 cleanup still happened (http.* removed)
+ gitConfigContent = (
+ await fs.promises.readFile(localGitConfigPath)
+ ).toString()
+ expect(
+ gitConfigContent.indexOf('http.https://github.com/.extraheader')
+ ).toBeLessThan(0)
+
+ // Assert v6 cleanup was skipped - includeIf should still be present
+ expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
+ expect(
+ gitConfigContent.indexOf(credentialsFilePath)
+ ).toBeGreaterThanOrEqual(0)
+
+ // Assert credentials file still exists (wasn't deleted)
+ await fs.promises.stat(credentialsFilePath) // File should still exist
+
+ // Assert debug message was logged
+ expect(core.debug).toHaveBeenCalledWith(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+
+ // Cleanup
+ delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ })
+
const removeGlobalConfig_removesOverride =
'removeGlobalConfig removes override'
it(removeGlobalConfig_removesOverride, async () => {
@@ -796,6 +1073,18 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(),
version: jest.fn()
}
diff --git __test__/git-directory-helper.test.ts __test__/git-directory-helper.test.ts
index 22e9ae6d4..1627b842e 100644
--- __test__/git-directory-helper.test.ts
+++ __test__/git-directory-helper.test.ts
@@ -499,6 +499,18 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
+ getSubmoduleConfigPaths: jest.fn(async () => {
+ return []
+ }),
+ tryConfigUnsetValue: jest.fn(async () => {
+ return true
+ }),
+ tryGetConfigValues: jest.fn(async () => {
+ return []
+ }),
+ tryGetConfigKeys: jest.fn(async () => {
+ return []
+ }),
tryReset: jest.fn(async () => {
return true
}),
diff --git dist/index.js dist/index.js
index f3ae6f3ea..b0add8ac4 100644
--- dist/index.js
+++ dist/index.js
@@ -411,8 +411,50 @@ class GitAuthHelper {
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
yield this.removeGitConfig(this.tokenConfigKey);
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'];
+ if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') {
+ core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP');
+ return;
+ }
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set();
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = yield this.removeIncludeIfCredentials();
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
+ }
+ }
+ catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`);
+ }
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP'];
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ yield io.rmRF(credentialsPath);
+ }
+ catch (err) {
+ core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`);
+ }
+ }
+ }
+ }
+ catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`);
+ }
});
}
removeGitConfig(configKey_1) {
@@ -430,6 +472,49 @@ class GitAuthHelper {
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
});
}
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ removeIncludeIfCredentials(configPath) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentialsPaths = new Set();
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
+ configPath);
+ for (const key of keys) {
+ // Get all values for this key
+ const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
+ configPath);
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value);
+ yield this.git.tryConfigUnsetValue(key, value, false, configPath);
+ }
+ }
+ }
+ }
+ }
+ catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
+ }
+ return Array.from(credentialsPaths);
+ });
+ }
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ testCredentialsConfigPath(path) {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
+ }
}
@@ -706,6 +791,16 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch');
});
}
+ getSubmoduleConfigPaths(recursive) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive);
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
+ return configPaths;
+ });
+ }
getWorkingDirectory() {
return this.workingDirectory;
}
@@ -836,6 +931,20 @@ 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.execGit(args, true);
+ return output.exitCode === 0;
+ });
+ }
tryDisableAutomaticGarbageCollection() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true);
@@ -855,6 +964,46 @@ class GitCommandManager {
return stdout;
});
}
+ 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.execGit(args, 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.execGit(args, true);
+ if (output.exitCode !== 0) {
+ return [];
+ }
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim());
+ });
+ }
tryReset() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['reset', '--hard', 'HEAD'], true);
diff --git src/git-auth-helper.ts src/git-auth-helper.ts
index 126e8e5ee..0c82dddab 100644
--- src/git-auth-helper.ts
+++ src/git-auth-helper.ts
@@ -346,8 +346,58 @@ class GitAuthHelper {
}
private async removeToken(): Promise<void> {
- // HTTP extra header
+ // Remove HTTP extra header from local git config and submodule configs
await this.removeGitConfig(this.tokenConfigKey)
+
+ //
+ // Cleanup actions/checkout@v6 style credentials
+ //
+ const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
+ if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') {
+ core.debug(
+ 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
+ )
+ return
+ }
+
+ try {
+ // Collect credentials config paths that need to be removed
+ const credentialsPaths = new Set<string>()
+
+ // Remove includeIf entries that point to git-credentials-*.config files
+ const mainCredentialsPaths = await this.removeIncludeIfCredentials()
+ mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
+
+ // Remove submodule includeIf entries that point to git-credentials-*.config files
+ try {
+ const submoduleConfigPaths =
+ await this.git.getSubmoduleConfigPaths(true)
+ for (const configPath of submoduleConfigPaths) {
+ const submoduleCredentialsPaths =
+ await this.removeIncludeIfCredentials(configPath)
+ submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
+ }
+ } catch (err) {
+ core.debug(`Unable to get submodule config paths: ${err}`)
+ }
+
+ // Remove credentials config files
+ for (const credentialsPath of credentialsPaths) {
+ // Only remove credentials config files if they are under RUNNER_TEMP
+ const runnerTemp = process.env['RUNNER_TEMP']
+ if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
+ try {
+ await io.rmRF(credentialsPath)
+ } catch (err) {
+ core.debug(
+ `Failed to remove credentials config '${credentialsPath}': ${err}`
+ )
+ }
+ }
+ }
+ } catch (err) {
+ core.debug(`Failed to cleanup v6 style credentials: ${err}`)
+ }
}
private async removeGitConfig(
@@ -371,4 +421,59 @@ class GitAuthHelper {
true
)
}
+
+ /**
+ * Removes includeIf entries that point to git-credentials-*.config files.
+ * This handles cleanup of credentials configured by newer versions of the action.
+ * @param configPath Optional path to a specific git config file to operate on
+ * @returns Array of unique credentials config file paths that were found and removed
+ */
+ private async removeIncludeIfCredentials(
+ configPath?: string
+ ): Promise<string[]> {
+ const credentialsPaths = new Set<string>()
+
+ try {
+ // Get all includeIf.gitdir keys
+ const keys = await this.git.tryGetConfigKeys(
+ '^includeIf\\.gitdir:',
+ false, // globalConfig?
+ configPath
+ )
+
+ for (const key of keys) {
+ // Get all values for this key
+ const values = await this.git.tryGetConfigValues(
+ key,
+ false, // globalConfig?
+ configPath
+ )
+ if (values.length > 0) {
+ // Remove only values that match git-credentials-<uuid>.config pattern
+ for (const value of values) {
+ if (this.testCredentialsConfigPath(value)) {
+ credentialsPaths.add(value)
+ await this.git.tryConfigUnsetValue(key, value, false, configPath)
+ }
+ }
+ }
+ }
+ } catch (err) {
+ // Ignore errors - this is cleanup code
+ core.debug(
+ `Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
+ )
+ }
+
+ return Array.from(credentialsPaths)
+ }
+
+ /**
+ * Tests if a path matches the git-credentials-*.config pattern used by newer versions.
+ * @param path The path to test
+ * @returns True if the path matches the credentials config pattern
+ */
+ private testCredentialsConfigPath(path: string): boolean {
+ return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
+ }
}
diff --git src/git-command-manager.ts src/git-command-manager.ts
index 8e42a387f..9c789ac80 100644
--- src/git-command-manager.ts
+++ src/git-command-manager.ts
@@ -41,6 +41,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
+ getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
+ tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
+ tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
+ tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -323,6 +340,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
+ async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
+ // Get submodule config file paths.
+ // Use `--show-origin` to get the config file path for each submodule.
+ const output = await this.submoduleForeach(
+ `git config --local --show-origin --name-only --get-regexp remote.origin.url`,
+ recursive
+ )
+
+ // Extract config file paths from the output (lines starting with "file:").
+ const configPaths =
+ output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
+
+ return configPaths
+ }
+
getWorkingDirectory(): string {
return this.workingDirectory
}
@@ -455,6 +487,24 @@ class GitCommandManager {
return output.exitCode === 0
}
+ async tryConfigUnsetValue(
+ configKey: string,
+ configValue: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<boolean> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--unset', configKey, configValue)
+
+ const output = await this.execGit(args, true)
+ return output.exitCode === 0
+ }
+
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
@@ -481,6 +531,56 @@ class GitCommandManager {
return stdout
}
+ async tryGetConfigValues(
+ configKey: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--get-all', configKey)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(value => value.trim())
+ }
+
+ async tryGetConfigKeys(
+ pattern: string,
+ globalConfig?: boolean,
+ configFile?: string
+ ): Promise<string[]> {
+ const args = ['config']
+ if (configFile) {
+ args.push('--file', configFile)
+ } else {
+ args.push(globalConfig ? '--global' : '--local')
+ }
+ args.push('--name-only', '--get-regexp', pattern)
+
+ const output = await this.execGit(args, true)
+
+ if (output.exitCode !== 0) {
+ return []
+ }
+
+ return output.stdout
+ .trim()
+ .split('\n')
+ .filter(key => key.trim())
+ }
+
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
return output.exitCode === 0
Description
This PR adds cleanup logic for v6-style credentials left behind by the actions/checkout@v6 action. The v6 action used a different authentication mechanism that stored credentials in separate config files (with pattern git-credentials-<uuid>.config) and referenced them via includeIf directives in git config. This PR ensures these credentials are properly cleaned up when removeAuth() is called, including credentials in submodules. An escape hatch is provided via the ACTIONS_CHECKOUT_SKIP_V6_CLEANUP environment variable.
Possible Issues
-
Pattern matching may be too broad: The regex
/git-credentials-[0-9a-f-]+\.config$/icould potentially match legitimate user files if they follow a similar naming pattern (though unlikely given the specific format). -
Silent failure on permission errors: The code uses
core.debug()for errors during cleanup, which means permission errors or other issues won't be visible unless debug logging is enabled. This could leave credentials behind without clear indication to users. -
Race condition potential: If multiple cleanup operations run concurrently (e.g., in parallel jobs), they could interfere with each other when removing shared credential files.
-
No verification of cleanup success: The code doesn't verify that all credentials were actually removed before completing, which could leave partial cleanup state.
Security Hotspots
-
Path traversal risk in credential file deletion (Medium Risk)
- Location:
src/git-auth-helper.ts:385-390 - The code checks if
credentialsPath.startsWith(runnerTemp)to prevent deleting files outside RUNNER_TEMP, butstartsWith()is vulnerable to path traversal. An attacker could potentially craft a path like/tmp/runner/../../../etc/passwdthat might pass the check depending on howrunnerTempis set. - Recommendation: Use
path.resolve()andpath.relative()to ensure the resolved path is actually within RUNNER_TEMP:
const resolvedPath = path.resolve(credentialsPath) const relativePath = path.relative(runnerTemp, resolvedPath) if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) - Location:
-
Credential file contents not validated before deletion (Low Risk)
- Location:
src/git-auth-helper.ts:385-396 - The code deletes files matching the pattern without verifying their contents are actually credential files. While protected by the RUNNER_TEMP check, a malicious actor with access to create files in RUNNER_TEMP could potentially cause unintended file deletion.
- Location:
-
Environment variable trust (Low Risk)
- Location:
src/git-auth-helper.ts:355-361 - The
ACTIONS_CHECKOUT_SKIP_V6_CLEANUPenvironment variable can be set to bypass cleanup entirely, potentially leaving credentials on the runner. While this is an intentional escape hatch, it could be abused if an attacker can control environment variables.
- Location:
Privacy Hotspots
-
Debug logging of config paths (Low Risk)
- Location:
src/git-auth-helper.ts:388-390,src/git-auth-helper.ts:335 - Error messages log full file paths which may contain sensitive information about the runner's directory structure or repository paths. While only visible with debug logging enabled, this could leak information in CI/CD logs that are publicly visible.
- Location:
Changes
Changes
src/git-auth-helper.ts:
- Added
removeIncludeIfCredentials()method to find and removeincludeIfdirectives pointing to credential config files - Added
testCredentialsConfigPath()helper to validate credential file path patterns - Modified
removeToken()to perform v6-style cleanup after removing HTTP headers - Added logic to process both main repository and submodule configs
- Added environment variable check
ACTIONS_CHECKOUT_SKIP_V6_CLEANUPto allow skipping v6 cleanup - Added path validation to ensure only files under RUNNER_TEMP are deleted
src/git-command-manager.ts:
- Added
getSubmoduleConfigPaths()to discover submodule git config files usinggit config --show-origin - Added
tryGetConfigKeys()to retrieve config keys matching a regex pattern - Added
tryGetConfigValues()to retrieve all values for a given config key - Added
tryConfigUnsetValue()to remove specific key-value pairs from git config
__test__/git-auth-helper.test.ts:
- Added test
removeAuth removes v6 style credentialscovering main repository cleanup - Added test
removeAuth removes v6 style credentials from submodulescovering submodule cleanup - Added test
removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is setcovering the skip flag - Updated mock setup to include new git command manager methods
__test__/git-directory-helper.test.ts:
- Updated mock setup to include new git command manager methods
dist/index.js:
- Compiled/bundled output reflecting all source changes
sequenceDiagram
participant Client
participant GitAuthHelper
participant GitCommandManager
participant FileSystem
Client->>GitAuthHelper: removeAuth()
GitAuthHelper->>GitAuthHelper: removeGitConfig(tokenConfigKey)
alt SKIP_V6_CLEANUP not set
GitAuthHelper->>GitAuthHelper: removeIncludeIfCredentials()
GitAuthHelper->>GitCommandManager: tryGetConfigKeys('^includeIf\\.gitdir:')
GitCommandManager-->>GitAuthHelper: [keys]
loop for each key
GitAuthHelper->>GitCommandManager: tryGetConfigValues(key)
GitCommandManager-->>GitAuthHelper: [paths]
loop for each path matching pattern
GitAuthHelper->>GitCommandManager: tryConfigUnsetValue(key, path)
GitCommandManager->>FileSystem: git config --unset
end
end
GitAuthHelper->>GitCommandManager: getSubmoduleConfigPaths(true)
GitCommandManager->>FileSystem: git submodule foreach --recursive
GitCommandManager-->>GitAuthHelper: [submodule config paths]
loop for each submodule config
GitAuthHelper->>GitAuthHelper: removeIncludeIfCredentials(configPath)
Note over GitAuthHelper: Same process as main repo
end
loop for each credential file
alt path under RUNNER_TEMP
GitAuthHelper->>FileSystem: rmRF(credentialsPath)
end
end
else SKIP_V6_CLEANUP set
GitAuthHelper->>Client: Skip v6 cleanup (debug log)
end
GitAuthHelper-->>Client: Complete