PHANTOM
🇮🇳 IN
Skip to content

chore(deps): update actions/checkout action to v6#132

Merged
mschfh merged 1 commit intomasterfrom
renovate/actions-checkout-6-x
Dec 2, 2025
Merged

chore(deps): update actions/checkout action to v6#132
mschfh merged 1 commit intomasterfrom
renovate/actions-checkout-6-x

Conversation

@renovate
Copy link
Contributor

@renovate renovate bot commented Nov 26, 2025

This PR contains the following updates:

Package Type Update Change
actions/checkout action major v5.0.0 -> v6.0.0

Release Notes

actions/checkout (actions/checkout)

v6.0.0

Compare Source

v5.0.1

Compare Source

What's Changed

Full Changelog: actions/checkout@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 is behind base branch, 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.

@renovate renovate bot force-pushed the renovate/actions-checkout-6-x branch from e64f96f to 1308edf Compare November 27, 2025 18:41
@github-actions
Copy link

[puLL-Merge] - actions/checkout@v5.0.0..v6.0.0

Diff
diff --git .github/workflows/test.yml .github/workflows/test.yml
index e62ac3ba6..7c47d7b6a 100644
--- .github/workflows/test.yml
+++ .github/workflows/test.yml
@@ -302,12 +302,15 @@ jobs:
       # Clone this repo
       - name: Checkout
         uses: actions/checkout@v4.1.6
+        with:
+          path: actions-checkout
 
       # Basic checkout using git
       - name: Checkout basic
         id: checkout
-        uses: ./
+        uses: ./actions-checkout
         with:
+          path: cloned-using-local-action
           ref: test-data/v2/basic
 
       # Verify output
@@ -325,7 +328,3 @@ jobs:
             echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d"
             exit 1
           fi
-
-      # needed to make checkout post cleanup succeed
-      - name: Fix Checkout
-        uses: actions/checkout@v4.1.6
diff --git CHANGELOG.md CHANGELOG.md
index ff8b4e7b0..25befb782 100644
--- CHANGELOG.md
+++ CHANGELOG.md
@@ -1,8 +1,17 @@
 # Changelog
 
+## V6.0.0
+* Persist creds to a separate file by @ericsciple in https://github.com/actions/checkout/pull/2286
+* Update README to include Node.js 24 support details and requirements by @salmanmkc in https://github.com/actions/checkout/pull/2248
+
+## V5.0.1
+* Port v6 cleanup to v5 by @ericsciple in https://github.com/actions/checkout/pull/2301
+
 ## V5.0.0
 * Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
 
+## V4.3.1
+* Port v6 cleanup to v4 by @ericsciple in https://github.com/actions/checkout/pull/2305
 
 ## V4.3.0
 * docs: update README.md by @motss in https://github.com/actions/checkout/pull/1971
diff --git README.md README.md
index f9175e995..5ad476f49 100644
--- README.md
+++ README.md
@@ -1,10 +1,21 @@
 [![Build and Test](https://github.com/actions/checkout/actions/workflows/test.yml/badge.svg)](https://github.com/actions/checkout/actions/workflows/test.yml)
 
-# Checkout V5
+# Checkout v6
 
-Checkout v5 now supports Node.js 24
+## What's new
 
-# Checkout V4
+- Updated `persist-credentials` to store the credentials under `$RUNNER_TEMP` instead of directly in the local git config.
+  - This requires a minimum Actions Runner version of [v2.329.0](https://github.com/actions/runner/releases/tag/v2.329.0) to access the persisted credentials for [Docker container action](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-a-docker-container-action) scenarios.
+
+# Checkout v5
+
+## What's new
+
+- Updated to the node24 runtime
+  - This requires a minimum Actions Runner version of [v2.327.1](https://github.com/actions/runner/releases/tag/v2.327.1) to run.
+
+
+# Checkout v4
 
 This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it.
 
@@ -154,9 +165,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
 # Scenarios
 
 - [Checkout V5](#checkout-v5)
+  - [What's new](#whats-new)
 - [Checkout V4](#checkout-v4)
     - [Note](#note)
-- [What's new](#whats-new)
+- [What's new](#whats-new-1)
 - [Usage](#usage)
 - [Scenarios](#scenarios)
   - [Fetch only the root files](#fetch-only-the-root-files)
diff --git __test__/git-auth-helper.test.ts __test__/git-auth-helper.test.ts
index 7633704cc..ad3566ad6 100644
--- __test__/git-auth-helper.test.ts
+++ __test__/git-auth-helper.test.ts
@@ -86,16 +86,29 @@ describe('git-auth-helper tests', () => {
     // Act
     await authHelper.configureAuth()
 
-    // Assert config
-    const configContent = (
+    // Assert config - check that .git/config contains includeIf entries
+    const localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Assert credentials config file contains the actual credentials
+    const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+    const credentialsContent = (
+      await fs.promises.readFile(credentialsConfigPath)
+    ).toString()
     const basicCredential = Buffer.from(
       `x-access-token:${settings.authToken}`,
       'utf8'
     ).toString('base64')
     expect(
-      configContent.indexOf(
+      credentialsContent.indexOf(
         `http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}`
       )
     ).toBeGreaterThanOrEqual(0)
@@ -120,7 +133,7 @@ describe('git-auth-helper tests', () => {
     'inject https://github.com as github server url'
   it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => {
     await testAuthHeader(
-      configureAuth_AcceptsGitHubServerUrl,
+      configureAuth_AcceptsGitHubServerUrlSetToGHEC,
       'https://github.com'
     )
   })
@@ -141,12 +154,17 @@ describe('git-auth-helper tests', () => {
       // Act
       await authHelper.configureAuth()
 
-      // Assert config
-      const configContent = (
-        await fs.promises.readFile(localGitConfigPath)
+      // Assert config - check credentials config file (not local .git/config)
+      const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+        f => f.startsWith('git-credentials-') && f.endsWith('.config')
+      )
+      expect(credentialsFiles.length).toBe(1)
+      const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+      const credentialsContent = (
+        await fs.promises.readFile(credentialsConfigPath)
       ).toString()
       expect(
-        configContent.indexOf(
+        credentialsContent.indexOf(
           `http.https://github.com/.extraheader AUTHORIZATION`
         )
       ).toBeGreaterThanOrEqual(0)
@@ -251,13 +269,16 @@ describe('git-auth-helper tests', () => {
       expectedSshCommand
     )
 
-    // Asserty git config
+    // Assert git config
     const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
       .toString()
       .split('\n')
       .filter(x => x)
-    expect(gitConfigLines).toHaveLength(1)
-    expect(gitConfigLines[0]).toMatch(/^http\./)
+    // Should have includeIf entries pointing to credentials file
+    expect(gitConfigLines.length).toBeGreaterThan(0)
+    expect(
+      gitConfigLines.some(line => line.indexOf('includeIf.gitdir:') >= 0)
+    ).toBeTruthy()
   })
 
   const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
@@ -419,8 +440,20 @@ describe('git-auth-helper tests', () => {
     expect(
       configContent.indexOf('value-from-global-config')
     ).toBeGreaterThanOrEqual(0)
+    // Global config should have include.path pointing to credentials file
+    expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
+
+    // Check credentials in the separate config file
+    const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBeGreaterThan(0)
+    const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+    const credentialsContent = (
+      await fs.promises.readFile(credentialsConfigPath)
+    ).toString()
     expect(
-      configContent.indexOf(
+      credentialsContent.indexOf(
         `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
       )
     ).toBeGreaterThanOrEqual(0)
@@ -463,8 +496,20 @@ describe('git-auth-helper tests', () => {
       const configContent = (
         await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
       ).toString()
+      // Global config should have include.path pointing to credentials file
+      expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
+
+      // Check credentials in the separate config file
+      const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+        f => f.startsWith('git-credentials-') && f.endsWith('.config')
+      )
+      expect(credentialsFiles.length).toBeGreaterThan(0)
+      const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+      const credentialsContent = (
+        await fs.promises.readFile(credentialsConfigPath)
+      ).toString()
       expect(
-        configContent.indexOf(
+        credentialsContent.indexOf(
           `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
         )
       ).toBeGreaterThanOrEqual(0)
@@ -550,15 +595,15 @@ describe('git-auth-helper tests', () => {
       await authHelper.configureSubmoduleAuth()
 
       // Assert
-      expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4)
+      // Should configure insteadOf (2 calls for two values)
+      expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
       expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
         /unset-all.*insteadOf/
       )
-      expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
-      expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(
+      expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(
         /url.*insteadOf.*git@github.com:/
       )
-      expect(mockSubmoduleForeach.mock.calls[3][0]).toMatch(
+      expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(
         /url.*insteadOf.*org-123456@github.com:/
       )
     }
@@ -589,12 +634,12 @@ describe('git-auth-helper tests', () => {
       await authHelper.configureSubmoduleAuth()
 
       // Assert
-      expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
+      // Should configure sshCommand (1 call)
+      expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2)
       expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
         /unset-all.*insteadOf/
       )
-      expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
-      expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/)
+      expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/core\.sshCommand/)
     }
   )
 
@@ -660,19 +705,201 @@ describe('git-auth-helper tests', () => {
     await setup(removeAuth_removesToken)
     const authHelper = gitAuthHelper.createAuthHelper(git, settings)
     await authHelper.configureAuth()
-    let gitConfigContent = (
+
+    // Verify includeIf entries exist in local config
+    let localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
-    expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify both host and container includeIf entries are present
+    const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
+    expect(
+      localConfigContent.indexOf(`includeIf.gitdir:${hostGitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:/github/workspace/.git.path')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify credentials file exists
+    let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsFilePath = path.join(runnerTemp, credentialsFiles[0])
+
+    // Verify credentials file contains the auth token
+    let credentialsContent = (
+      await fs.promises.readFile(credentialsFilePath)
+    ).toString()
+    const basicCredential = Buffer.from(
+      `x-access-token:${settings.authToken}`,
+      'utf8'
+    ).toString('base64')
+    expect(
+      credentialsContent.indexOf(
+        `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
+      )
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify the includeIf entries point to the credentials file
+    const containerCredentialsPath = path.posix.join(
+      '/github/runner_temp',
+      path.basename(credentialsFilePath)
+    )
+    expect(
+      localConfigContent.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      localConfigContent.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
 
     // Act
     await authHelper.removeAuth()
 
-    // Assert git config
-    gitConfigContent = (
+    // Assert all includeIf entries removed from local git config
+    localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
-    expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
+    expect(localConfigContent.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(
+      localConfigContent.indexOf(`includeIf.gitdir:${hostGitDir}.path`)
+    ).toBeLessThan(0)
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:/github/workspace/.git.path')
+    ).toBeLessThan(0)
+    expect(localConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(localConfigContent.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert credentials config file deleted
+    credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(0)
+
+    // Verify credentials file no longer exists on disk
+    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_removesTokenFromSubmodules =
+    'removeAuth removes token from submodules'
+  it(removeAuth_removesTokenFromSubmodules, async () => {
+    // Arrange
+    await setup(removeAuth_removesTokenFromSubmodules)
+
+    // Create fake submodule config paths
+    const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
+    const submodule2Dir = path.join(workspace, '.git', 'modules', 'submodule-2')
+    const submodule1ConfigPath = path.join(submodule1Dir, 'config')
+    const submodule2ConfigPath = path.join(submodule2Dir, 'config')
+
+    await fs.promises.mkdir(submodule1Dir, {recursive: true})
+    await fs.promises.mkdir(submodule2Dir, {recursive: true})
+    await fs.promises.writeFile(submodule1ConfigPath, '')
+    await fs.promises.writeFile(submodule2ConfigPath, '')
+
+    // Mock getSubmoduleConfigPaths to return our fake submodules (for both configure and remove)
+    const mockGetSubmoduleConfigPaths =
+      git.getSubmoduleConfigPaths as jest.Mock<any, any>
+    mockGetSubmoduleConfigPaths.mockResolvedValue([
+      submodule1ConfigPath,
+      submodule2ConfigPath
+    ])
+
+    const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+    await authHelper.configureAuth()
+    await authHelper.configureSubmoduleAuth()
+
+    // Verify credentials file exists
+    let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsFilePath = path.join(runnerTemp, credentialsFiles[0])
+
+    // Verify submodule 1 config has includeIf entries
+    let submodule1Content = (
+      await fs.promises.readFile(submodule1ConfigPath)
+    ).toString()
+    const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
+    expect(
+      submodule1Content.indexOf(`includeIf.gitdir:${submodule1GitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule1Content.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify submodule 2 config has includeIf entries
+    let submodule2Content = (
+      await fs.promises.readFile(submodule2ConfigPath)
+    ).toString()
+    const submodule2GitDir = submodule2Dir.replace(/\\/g, '/')
+    expect(
+      submodule2Content.indexOf(`includeIf.gitdir:${submodule2GitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule2Content.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify both host and container paths are in each submodule config
+    const containerCredentialsPath = path.posix.join(
+      '/github/runner_temp',
+      path.basename(credentialsFilePath)
+    )
+    expect(
+      submodule1Content.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule2Content.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Act - ensure mock persists for removeAuth
+    mockGetSubmoduleConfigPaths.mockResolvedValue([
+      submodule1ConfigPath,
+      submodule2ConfigPath
+    ])
+    await authHelper.removeAuth()
+
+    // Assert submodule 1 includeIf entries removed
+    submodule1Content = (
+      await fs.promises.readFile(submodule1ConfigPath)
+    ).toString()
+    expect(submodule1Content.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(submodule1Content.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(submodule1Content.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert submodule 2 includeIf entries removed
+    submodule2Content = (
+      await fs.promises.readFile(submodule2ConfigPath)
+    ).toString()
+    expect(submodule2Content.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(submodule2Content.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(submodule2Content.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert credentials config file deleted
+    credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(0)
+
+    // Verify credentials file no longer exists on disk
+    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 removeGlobalConfig_removesOverride =
@@ -701,6 +928,52 @@ describe('git-auth-helper tests', () => {
       }
     }
   })
+
+  const testCredentialsConfigPath_matchesCredentialsConfigPaths =
+    'testCredentialsConfigPath matches credentials config paths'
+  it(testCredentialsConfigPath_matchesCredentialsConfigPaths, async () => {
+    // Arrange
+    await setup(testCredentialsConfigPath_matchesCredentialsConfigPaths)
+    const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+
+    // Get a real credentials config path
+    const credentialsConfigPath = await (
+      authHelper as any
+    ).getCredentialsConfigPath()
+
+    // Act & Assert
+    expect(
+      (authHelper as any).testCredentialsConfigPath(credentialsConfigPath)
+    ).toBe(true)
+    expect(
+      (authHelper as any).testCredentialsConfigPath(
+        '/some/path/git-credentials-12345678-abcd-1234-5678-123456789012.config'
+      )
+    ).toBe(true)
+    expect(
+      (authHelper as any).testCredentialsConfigPath(
+        '/some/path/git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
+      )
+    ).toBe(true)
+
+    // Test invalid paths
+    expect(
+      (authHelper as any).testCredentialsConfigPath(
+        '/some/path/other-config.config'
+      )
+    ).toBe(false)
+    expect(
+      (authHelper as any).testCredentialsConfigPath(
+        '/some/path/git-credentials-invalid.config'
+      )
+    ).toBe(false)
+    expect(
+      (authHelper as any).testCredentialsConfigPath(
+        '/some/path/git-credentials-.config'
+      )
+    ).toBe(false)
+    expect((authHelper as any).testCredentialsConfigPath('')).toBe(false)
+  })
 })
 
 async function setup(testName: string): Promise<void> {
@@ -715,6 +988,7 @@ async function setup(testName: string): Promise<void> {
   await fs.promises.mkdir(tempHomedir, {recursive: true})
   process.env['RUNNER_TEMP'] = runnerTemp
   process.env['HOME'] = tempHomedir
+  process.env['GITHUB_WORKSPACE'] = workspace
 
   // Create git config
   globalGitConfigPath = path.join(tempHomedir, '.gitconfig')
@@ -733,10 +1007,20 @@ async function setup(testName: string): Promise<void> {
     checkout: jest.fn(),
     checkoutDetach: jest.fn(),
     config: jest.fn(
-      async (key: string, value: string, globalConfig?: boolean) => {
-        const configPath = globalConfig
-          ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
-          : localGitConfigPath
+      async (
+        key: string,
+        value: string,
+        globalConfig?: boolean,
+        add?: boolean,
+        configFile?: string
+      ) => {
+        const configPath =
+          configFile ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        // Ensure directory exists
+        await fs.promises.mkdir(path.dirname(configPath), {recursive: true})
         await fs.promises.appendFile(configPath, `\n${key} ${value}`)
       }
     ),
@@ -756,6 +1040,7 @@ async function setup(testName: string): Promise<void> {
     env: {},
     fetch: jest.fn(),
     getDefaultBranch: jest.fn(),
+    getSubmoduleConfigPaths: jest.fn(async () => []),
     getWorkingDirectory: jest.fn(() => workspace),
     init: jest.fn(),
     isDetached: jest.fn(),
@@ -794,8 +1079,72 @@ async function setup(testName: string): Promise<void> {
         return true
       }
     ),
+    tryConfigUnsetValue: jest.fn(
+      async (
+        key: string,
+        value: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<boolean> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        let content = await fs.promises.readFile(targetConfigPath)
+        let lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x)
+          .filter(x => !(x.startsWith(key) && x.includes(value)))
+        await fs.promises.writeFile(targetConfigPath, lines.join('\n'))
+        return true
+      }
+    ),
     tryDisableAutomaticGarbageCollection: jest.fn(),
     tryGetFetchUrl: jest.fn(),
+    tryGetConfigValues: jest.fn(
+      async (
+        key: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<string[]> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        const content = await fs.promises.readFile(targetConfigPath)
+        const lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x && x.startsWith(key))
+          .map(x => x.substring(key.length).trim())
+        return lines
+      }
+    ),
+    tryGetConfigKeys: jest.fn(
+      async (
+        pattern: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<string[]> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        const content = await fs.promises.readFile(targetConfigPath)
+        const lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x)
+        const keys = lines
+          .filter(x => new RegExp(pattern).test(x.split(' ')[0]))
+          .map(x => x.split(' ')[0])
+        return [...new Set(keys)] // Remove duplicates
+      }
+    ),
     tryReset: jest.fn(),
     version: jest.fn()
   }
@@ -830,6 +1179,7 @@ async function setup(testName: string): Promise<void> {
 
 async function getActualSshKeyPath(): Promise<string> {
   let actualTempFiles = (await fs.promises.readdir(runnerTemp))
+    .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
     .sort()
     .map(x => path.join(runnerTemp, x))
   if (actualTempFiles.length === 0) {
@@ -843,6 +1193,7 @@ async function getActualSshKeyPath(): Promise<string> {
 
 async function getActualSshKnownHostsPath(): Promise<string> {
   let actualTempFiles = (await fs.promises.readdir(runnerTemp))
+    .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
     .sort()
     .map(x => path.join(runnerTemp, x))
   if (actualTempFiles.length === 0) {
diff --git __test__/git-directory-helper.test.ts __test__/git-directory-helper.test.ts
index 22e9ae6d4..de79dc890 100644
--- __test__/git-directory-helper.test.ts
+++ __test__/git-directory-helper.test.ts
@@ -471,6 +471,7 @@ async function setup(testName: string): Promise<void> {
     configExists: jest.fn(),
     fetch: jest.fn(),
     getDefaultBranch: jest.fn(),
+    getSubmoduleConfigPaths: jest.fn(async () => []),
     getWorkingDirectory: jest.fn(() => repositoryPath),
     init: jest.fn(),
     isDetached: jest.fn(),
@@ -493,12 +494,15 @@ async function setup(testName: string): Promise<void> {
       return true
     }),
     tryConfigUnset: jest.fn(),
+    tryConfigUnsetValue: jest.fn(),
     tryDisableAutomaticGarbageCollection: jest.fn(),
     tryGetFetchUrl: jest.fn(async () => {
       // Sanity check - this function shouldn't be called when the .git directory doesn't exist
       await fs.promises.stat(path.join(repositoryPath, '.git'))
       return repositoryUrl
     }),
+    tryGetConfigValues: jest.fn(),
+    tryGetConfigKeys: jest.fn(),
     tryReset: jest.fn(async () => {
       return true
     }),
diff --git __test__/verify-submodules-recursive.sh __test__/verify-submodules-recursive.sh
index 1b68f9b97..5ecbb42d0 100755
--- __test__/verify-submodules-recursive.sh
+++ __test__/verify-submodules-recursive.sh
@@ -17,7 +17,7 @@ fi
 
 echo "Testing persisted credential"
 pushd ./submodules-recursive/submodule-level-1/submodule-level-2
-git config --local --name-only --get-regexp http.+extraheader && git fetch
+git config --local --includes --name-only --get-regexp http.+extraheader && git fetch
 if [ "$?" != "0" ]; then
     echo "Failed to validate persisted credential"
     popd
diff --git __test__/verify-submodules-true.sh __test__/verify-submodules-true.sh
index 43769fe06..4c311f846 100755
--- __test__/verify-submodules-true.sh
+++ __test__/verify-submodules-true.sh
@@ -17,7 +17,7 @@ fi
 
 echo "Testing persisted credential"
 pushd ./submodules-true/submodule-level-1
-git config --local --name-only --get-regexp http.+extraheader && git fetch
+git config --local --includes --name-only --get-regexp http.+extraheader && git fetch
 if [ "$?" != "0" ]; then
     echo "Failed to validate persisted credential"
     popd
diff --git dist/index.js dist/index.js
index f3ae6f3ea..a251a1966 100644
--- dist/index.js
+++ dist/index.js
@@ -162,6 +162,7 @@ class GitAuthHelper {
         this.sshKeyPath = '';
         this.sshKnownHostsPath = '';
         this.temporaryHomePath = '';
+        this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP
         this.git = gitCommandManager;
         this.settings = gitSourceSettings || {};
         // Token auth header
@@ -229,15 +230,17 @@ class GitAuthHelper {
     configureGlobalAuth() {
         return __awaiter(this, void 0, void 0, function* () {
             // 'configureTempGlobalConfig' noops if already set, just returns the path
-            const newGitConfigPath = yield this.configureTempGlobalConfig();
+            yield this.configureTempGlobalConfig();
             try {
                 // Configure the token
-                yield this.configureToken(newGitConfigPath, true);
+                yield this.configureToken(true);
                 // Configure HTTPS instead of SSH
                 yield this.git.tryConfigUnset(this.insteadOfKey, true);
                 if (!this.settings.sshKey) {
                     for (const insteadOfValue of this.insteadOfValues) {
-                        yield this.git.config(this.insteadOfKey, insteadOfValue, true, true);
+                        yield this.git.config(this.insteadOfKey, insteadOfValue, true, // globalConfig?
+                        true // add?
+                        );
                     }
                 }
             }
@@ -252,19 +255,34 @@ class GitAuthHelper {
     configureSubmoduleAuth() {
         return __awaiter(this, void 0, void 0, function* () {
             // Remove possible previous HTTPS instead of SSH
-            yield this.removeGitConfig(this.insteadOfKey, true);
+            yield this.removeSubmoduleGitConfig(this.insteadOfKey);
             if (this.settings.persistCredentials) {
-                // 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
-                const output = yield this.git.submoduleForeach(
-                // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
-                `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules);
-                // Replace the placeholder
-                const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
+                // Get the credentials config file path in RUNNER_TEMP
+                const credentialsConfigPath = this.getCredentialsConfigPath();
+                // Container credentials config path
+                const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
+                // Get submodule config file paths.
+                const configPaths = yield this.git.getSubmoduleConfigPaths(this.settings.nestedSubmodules);
+                // For each submodule, configure includeIf entries pointing to the shared credentials file.
+                // Configure both host and container paths to support Docker container actions.
                 for (const configPath of configPaths) {
-                    core.debug(`Replacing token placeholder in '${configPath}'`);
-                    yield this.replaceTokenPlaceholder(configPath);
+                    // Submodule Git directory
+                    let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config
+                    submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                    // Configure host includeIf
+                    yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig?
+                    false, // add?
+                    configPath);
+                    // Container submodule git directory
+                    const githubWorkspace = process.env['GITHUB_WORKSPACE'];
+                    assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
+                    let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir);
+                    relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                    const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir);
+                    // Configure container includeIf
+                    yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig?
+                    false, // add?
+                    configPath);
                 }
                 if (this.settings.sshKey) {
                     // Configure core.sshCommand
@@ -295,6 +313,10 @@ class GitAuthHelper {
             }
         });
     }
+    /**
+     * Configures SSH authentication by writing the SSH key and known hosts,
+     * and setting up the GIT_SSH_COMMAND environment variable.
+     */
     configureSsh() {
         return __awaiter(this, void 0, void 0, function* () {
             if (!this.settings.sshKey) {
@@ -351,43 +373,88 @@ class GitAuthHelper {
             }
         });
     }
-    configureToken(configPath, globalConfig) {
-        return __awaiter(this, void 0, void 0, function* () {
-            // Validate args
-            assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations');
-            // Default config path
-            if (!configPath && !globalConfig) {
-                configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config');
-            }
-            // 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.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig);
-            // Replace the placeholder
-            yield this.replaceTokenPlaceholder(configPath || '');
-        });
-    }
-    replaceTokenPlaceholder(configPath) {
+    /**
+     * Configures token-based authentication by creating a credentials config file
+     * and setting up includeIf entries to reference it.
+     * @param globalConfig Whether to configure global config instead of local
+     */
+    configureToken(globalConfig) {
         return __awaiter(this, void 0, void 0, function* () {
-            assert.ok(configPath, 'configPath is not defined');
-            let content = (yield fs.promises.readFile(configPath)).toString();
+            // Get the credentials config file path in RUNNER_TEMP
+            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.tokenConfigKey, this.tokenPlaceholderConfigValue, 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.tokenPlaceholderConfigValue);
             if (placeholderIndex < 0 ||
                 placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) {
-                throw new Error(`Unable to replace auth placeholder in ${configPath}`);
+                throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`);
             }
             assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined');
             content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue);
-            yield fs.promises.writeFile(configPath, content);
+            yield fs.promises.writeFile(credentialsConfigPath, content);
+            // Add include or includeIf to reference the credentials config
+            if (globalConfig) {
+                // Global config file is temporary
+                yield this.git.config('include.path', credentialsConfigPath, true // globalConfig?
+                );
+            }
+            else {
+                // Host git directory
+                let gitDir = path.join(this.git.getWorkingDirectory(), '.git');
+                gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                // Configure host includeIf
+                const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
+                yield this.git.config(hostIncludeKey, credentialsConfigPath);
+                // Container git directory
+                const workingDirectory = this.git.getWorkingDirectory();
+                const githubWorkspace = process.env['GITHUB_WORKSPACE'];
+                assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
+                let relativePath = path.relative(githubWorkspace, 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);
+            }
         });
     }
+    /**
+     * 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'] || '';
+        assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
+        // Create a unique filename for this checkout 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;
+    }
+    /**
+     * Removes SSH authentication configuration by cleaning up SSH keys,
+     * known hosts files, and SSH command configurations.
+     */
     removeSsh() {
         return __awaiter(this, void 0, void 0, function* () {
-            var _a;
+            var _a, _b;
             // SSH key
             const keyPath = this.sshKeyPath || stateHelper.SshKeyPath;
             if (keyPath) {
                 try {
+                    core.info(`Removing SSH key '${keyPath}'`);
                     yield io.rmRF(keyPath);
                 }
                 catch (err) {
@@ -399,37 +466,136 @@ class GitAuthHelper {
             const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath;
             if (knownHostsPath) {
                 try {
+                    core.info(`Removing SSH known hosts '${knownHostsPath}'`);
                     yield io.rmRF(knownHostsPath);
                 }
-                catch (_b) {
-                    // Intentionally empty
+                catch (err) {
+                    core.debug(`${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
+                    core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`);
                 }
             }
             // SSH command
+            core.info('Removing SSH command configuration');
             yield this.removeGitConfig(SSH_COMMAND_KEY);
+            yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY);
         });
     }
+    /**
+     * Removes token-based authentication by cleaning up HTTP headers,
+     * includeIf entries, and credentials config files.
+     */
     removeToken() {
         return __awaiter(this, void 0, void 0, function* () {
-            // HTTP extra header
+            var _a;
+            // Remove HTTP extra header
+            core.info('Removing HTTP extra header');
             yield this.removeGitConfig(this.tokenConfigKey);
+            yield this.removeSubmoduleGitConfig(this.tokenConfigKey);
+            // Collect credentials config paths that need to be removed
+            const credentialsPaths = new Set();
+            // Remove includeIf entries that point to git-credentials-*.config files
+            core.info('Removing includeIf entries pointing to 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
+            const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
+            for (const configPath of submoduleConfigPaths) {
+                const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
+                submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
+            }
+            // 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'];
+                assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
+                if (credentialsPath.startsWith(runnerTemp)) {
+                    try {
+                        core.info(`Removing credentials config '${credentialsPath}'`);
+                        yield io.rmRF(credentialsPath);
+                    }
+                    catch (err) {
+                        core.debug(`${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
+                        core.warning(`Failed to remove credentials config '${credentialsPath}'`);
+                    }
+                }
+                else {
+                    core.debug(`Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`);
+                }
+            }
         });
     }
-    removeGitConfig(configKey_1) {
-        return __awaiter(this, arguments, void 0, function* (configKey, submoduleOnly = false) {
-            if (!submoduleOnly) {
-                if ((yield this.git.configExists(configKey)) &&
-                    !(yield this.git.tryConfigUnset(configKey))) {
-                    // Load the config contents
-                    core.warning(`Failed to remove '${configKey}' from the git config`);
-                }
+    /**
+     * Removes a git config key from the local repository config.
+     * @param configKey The git config key to remove
+     */
+    removeGitConfig(configKey) {
+        return __awaiter(this, void 0, void 0, function* () {
+            if ((yield this.git.configExists(configKey)) &&
+                !(yield this.git.tryConfigUnset(configKey))) {
+                // Load the config contents
+                core.warning(`Failed to remove '${configKey}' from the git config`);
             }
+        });
+    }
+    /**
+     * Removes a git config key from all submodule configs.
+     * @param configKey The git config key to remove
+     */
+    removeSubmoduleGitConfig(configKey) {
+        return __awaiter(this, void 0, void 0, function* () {
             const pattern = regexpHelper.escape(configKey);
             yield this.git.submoduleForeach(
-            // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
+            // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
             `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.
+     * @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
+                if (configPath) {
+                    core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`);
+                }
+                else {
+                    core.debug(`Error during includeIf cleanup: ${err}`);
+                }
+            }
+            return Array.from(credentialsPaths);
+        });
+    }
+    /**
+     * Tests if a path matches the git-credentials-*.config pattern.
+     * @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);
+    }
 }
 
 
@@ -627,9 +793,15 @@ class GitCommandManager {
             yield this.execGit(args);
         });
     }
-    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');
             }
@@ -706,6 +878,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 +1018,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 +1051,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..a1950a60c 100644
--- src/git-auth-helper.ts
+++ src/git-auth-helper.ts
@@ -43,6 +43,7 @@ class GitAuthHelper {
   private sshKeyPath = ''
   private sshKnownHostsPath = ''
   private temporaryHomePath = ''
+  private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
 
   constructor(
     gitCommandManager: IGitCommandManager,
@@ -126,16 +127,21 @@ class GitAuthHelper {
 
   async configureGlobalAuth(): Promise<void> {
     // 'configureTempGlobalConfig' noops if already set, just returns the path
-    const newGitConfigPath = await this.configureTempGlobalConfig()
+    await this.configureTempGlobalConfig()
     try {
       // Configure the token
-      await this.configureToken(newGitConfigPath, true)
+      await this.configureToken(true)
 
       // Configure HTTPS instead of SSH
       await this.git.tryConfigUnset(this.insteadOfKey, true)
       if (!this.settings.sshKey) {
         for (const insteadOfValue of this.insteadOfValues) {
-          await this.git.config(this.insteadOfKey, insteadOfValue, true, true)
+          await this.git.config(
+            this.insteadOfKey,
+            insteadOfValue,
+            true, // globalConfig?
+            true // add?
+          )
         }
       }
     } catch (err) {
@@ -150,24 +156,60 @@ class GitAuthHelper {
 
   async configureSubmoduleAuth(): Promise<void> {
     // Remove possible previous HTTPS instead of SSH
-    await this.removeGitConfig(this.insteadOfKey, true)
+    await this.removeSubmoduleGitConfig(this.insteadOfKey)
 
     if (this.settings.persistCredentials) {
-      // 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
-      const output = await this.git.submoduleForeach(
-        // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
-        `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`,
+      // Get the credentials config file path in RUNNER_TEMP
+      const credentialsConfigPath = this.getCredentialsConfigPath()
+
+      // Container credentials config path
+      const containerCredentialsPath = path.posix.join(
+        '/github/runner_temp',
+        path.basename(credentialsConfigPath)
+      )
+
+      // Get submodule config file paths.
+      const configPaths = await this.git.getSubmoduleConfigPaths(
         this.settings.nestedSubmodules
       )
 
-      // Replace the placeholder
-      const configPaths: string[] =
-        output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
+      // For each submodule, configure includeIf entries pointing to the shared credentials file.
+      // Configure both host and container paths to support Docker container actions.
       for (const configPath of configPaths) {
-        core.debug(`Replacing token placeholder in '${configPath}'`)
-        await this.replaceTokenPlaceholder(configPath)
+        // Submodule Git directory
+        let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config
+        submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+
+        // Configure host includeIf
+        await this.git.config(
+          `includeIf.gitdir:${submoduleGitDir}.path`,
+          credentialsConfigPath,
+          false, // globalConfig?
+          false, // add?
+          configPath
+        )
+
+        // Container submodule git directory
+        const githubWorkspace = process.env['GITHUB_WORKSPACE']
+        assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
+        let relativeSubmoduleGitDir = path.relative(
+          githubWorkspace,
+          submoduleGitDir
+        )
+        relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+        const containerSubmoduleGitDir = path.posix.join(
+          '/github/workspace',
+          relativeSubmoduleGitDir
+        )
+
+        // Configure container includeIf
+        await this.git.config(
+          `includeIf.gitdir:${containerSubmoduleGitDir}.path`,
+          containerCredentialsPath,
+          false, // globalConfig?
+          false, // add?
+          configPath
+        )
       }
 
       if (this.settings.sshKey) {
@@ -201,6 +243,10 @@ class GitAuthHelper {
     }
   }
 
+  /**
+   * Configures SSH authentication by writing the SSH key and known hosts,
+   * and setting up the GIT_SSH_COMMAND environment variable.
+   */
   private async configureSsh(): Promise<void> {
     if (!this.settings.sshKey) {
       return
@@ -272,57 +318,116 @@ class GitAuthHelper {
     }
   }
 
-  private async configureToken(
-    configPath?: string,
-    globalConfig?: boolean
-  ): Promise<void> {
-    // Validate args
-    assert.ok(
-      (configPath && globalConfig) || (!configPath && !globalConfig),
-      'Unexpected configureToken parameter combinations'
-    )
-
-    // Default config path
-    if (!configPath && !globalConfig) {
-      configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
-    }
-
-    // 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
+  /**
+   * Configures token-based authentication by creating a credentials config file
+   * and setting up includeIf entries to reference it.
+   * @param globalConfig Whether to configure global config instead of local
+   */
+  private async configureToken(globalConfig?: boolean): Promise<void> {
+    // Get the credentials config file path in RUNNER_TEMP
+    const credentialsConfigPath = this.getCredentialsConfigPath()
+
+    // Write placeholder to the separate credentials config file using git config.
+    // This approach avoids the credential being captured by process creation audit events,
+    // which are commonly logged. For more information, refer to
+    // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
     await this.git.config(
       this.tokenConfigKey,
       this.tokenPlaceholderConfigValue,
-      globalConfig
+      false, // globalConfig?
+      false, // add?
+      credentialsConfigPath
     )
 
-    // Replace the placeholder
-    await this.replaceTokenPlaceholder(configPath || '')
-  }
-
-  private async replaceTokenPlaceholder(configPath: string): Promise<void> {
-    assert.ok(configPath, 'configPath is not defined')
-    let content = (await fs.promises.readFile(configPath)).toString()
+    // Replace the placeholder in the credentials config file
+    let content = (await fs.promises.readFile(credentialsConfigPath)).toString()
     const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
     if (
       placeholderIndex < 0 ||
       placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
     ) {
-      throw new Error(`Unable to replace auth placeholder in ${configPath}`)
+      throw new Error(
+        `Unable to replace auth placeholder in ${credentialsConfigPath}`
+      )
     }
     assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
     content = content.replace(
       this.tokenPlaceholderConfigValue,
       this.tokenConfigValue
     )
-    await fs.promises.writeFile(configPath, content)
+    await fs.promises.writeFile(credentialsConfigPath, content)
+
+    // Add include or includeIf to reference the credentials config
+    if (globalConfig) {
+      // Global config file is temporary
+      await this.git.config(
+        'include.path',
+        credentialsConfigPath,
+        true // globalConfig?
+      )
+    } else {
+      // Host git directory
+      let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
+      gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+
+      // Configure host includeIf
+      const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
+      await this.git.config(hostIncludeKey, credentialsConfigPath)
+
+      // Container git directory
+      const workingDirectory = this.git.getWorkingDirectory()
+      const githubWorkspace = process.env['GITHUB_WORKSPACE']
+      assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
+      let relativePath = path.relative(githubWorkspace, 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)
+    }
+  }
+
+  /**
+   * 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'] || ''
+    assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
+
+    // Create a unique filename for this checkout instance
+    const configFileName = `git-credentials-${uuid()}.config`
+    this.credentialsConfigPath = path.join(runnerTemp, configFileName)
+
+    core.debug(`Credentials config path: ${this.credentialsConfigPath}`)
+    return this.credentialsConfigPath
   }
 
+  /**
+   * Removes SSH authentication configuration by cleaning up SSH keys,
+   * known hosts files, and SSH command configurations.
+   */
   private async removeSsh(): Promise<void> {
     // SSH key
     const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
     if (keyPath) {
       try {
+        core.info(`Removing SSH key '${keyPath}'`)
         await io.rmRF(keyPath)
       } catch (err) {
         core.debug(`${(err as any)?.message ?? err}`)
@@ -335,40 +440,149 @@ class GitAuthHelper {
       this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
     if (knownHostsPath) {
       try {
+        core.info(`Removing SSH known hosts '${knownHostsPath}'`)
         await io.rmRF(knownHostsPath)
-      } catch {
-        // Intentionally empty
+      } catch (err) {
+        core.debug(`${(err as any)?.message ?? err}`)
+        core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`)
       }
     }
 
     // SSH command
+    core.info('Removing SSH command configuration')
     await this.removeGitConfig(SSH_COMMAND_KEY)
+    await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY)
   }
 
+  /**
+   * Removes token-based authentication by cleaning up HTTP headers,
+   * includeIf entries, and credentials config files.
+   */
   private async removeToken(): Promise<void> {
-    // HTTP extra header
+    // Remove HTTP extra header
+    core.info('Removing HTTP extra header')
     await this.removeGitConfig(this.tokenConfigKey)
-  }
+    await this.removeSubmoduleGitConfig(this.tokenConfigKey)
+
+    // Collect credentials config paths that need to be removed
+    const credentialsPaths = new Set<string>()
+
+    // Remove includeIf entries that point to git-credentials-*.config files
+    core.info('Removing includeIf entries pointing to 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
+    const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
+    for (const configPath of submoduleConfigPaths) {
+      const submoduleCredentialsPaths =
+        await this.removeIncludeIfCredentials(configPath)
+      submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
+    }
 
-  private async removeGitConfig(
-    configKey: string,
-    submoduleOnly: boolean = false
-  ): Promise<void> {
-    if (!submoduleOnly) {
-      if (
-        (await this.git.configExists(configKey)) &&
-        !(await this.git.tryConfigUnset(configKey))
-      ) {
-        // Load the config contents
-        core.warning(`Failed to remove '${configKey}' from the git config`)
+    // 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']
+      assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
+      if (credentialsPath.startsWith(runnerTemp)) {
+        try {
+          core.info(`Removing credentials config '${credentialsPath}'`)
+          await io.rmRF(credentialsPath)
+        } catch (err) {
+          core.debug(`${(err as any)?.message ?? err}`)
+          core.warning(
+            `Failed to remove credentials config '${credentialsPath}'`
+          )
+        }
+      } else {
+        core.debug(
+          `Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`
+        )
       }
     }
+  }
+
+  /**
+   * Removes a git config key from the local repository config.
+   * @param configKey The git config key to remove
+   */
+  private async removeGitConfig(configKey: string): Promise<void> {
+    if (
+      (await this.git.configExists(configKey)) &&
+      !(await this.git.tryConfigUnset(configKey))
+    ) {
+      // Load the config contents
+      core.warning(`Failed to remove '${configKey}' from the git config`)
+    }
+  }
 
+  /**
+   * Removes a git config key from all submodule configs.
+   * @param configKey The git config key to remove
+   */
+  private async removeSubmoduleGitConfig(configKey: string): Promise<void> {
     const pattern = regexpHelper.escape(configKey)
     await this.git.submoduleForeach(
-      // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
+      // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
       `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`,
       true
     )
   }
+
+  /**
+   * Removes includeIf entries t,hat point to git-credentials-*.config files.
+   * @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
+      if (configPath) {
+        core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`)
+      } else {
+        core.debug(`Error during includeIf cleanup: ${err}`)
+      }
+    }
+
+    return Array.from(credentialsPaths)
+  }
+
+  /**
+   * Tests if a path matches the git-credentials-*.config pattern.
+   * @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..a45e15a86 100644
--- src/git-command-manager.ts
+++ src/git-command-manager.ts
@@ -28,7 +28,8 @@ export interface IGitCommandManager {
     configKey: string,
     configValue: string,
     globalConfig?: boolean,
-    add?: boolean
+    add?: boolean,
+    configFile?: string
   ): Promise<void>
   configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
   fetch(
@@ -41,6 +42,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 +61,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>
 }
@@ -223,9 +241,15 @@ class GitCommandManager {
     configKey: string,
     configValue: string,
     globalConfig?: boolean,
-    add?: boolean
+    add?: boolean,
+    configFile?: string
   ): Promise<void> {
-    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')
     }
@@ -323,6 +347,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 +494,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 +538,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 implements a major security improvement by changing how Git credentials are persisted. Previously, credentials were stored directly in the local .git/config file. Now they are stored in a separate config file under $RUNNER_TEMP with references via Git's includeIf directive. This is v6 of the checkout action with backports to v4 and v5.

Key changes:

  1. Creates a unique credentials config file (git-credentials-<uuid>.config) in RUNNER_TEMP
  2. Uses includeIf.gitdir directives to reference this file from both local and submodule configs
  3. Supports both host and Docker container paths for containerized actions
  4. Improves cleanup by removing includeIf entries and deleting credentials files
  5. Requires Actions Runner v2.329.0+ for Docker container scenarios

Possible Issues

  1. Path handling complexity: The code manages both Windows and POSIX paths, with conversions between host and container paths. This could be error-prone on Windows systems with complex path structures.

  2. Race conditions: Multiple concurrent checkouts in the same workspace could potentially create/delete credentials files simultaneously, though the UUID naming should mitigate this.

  3. Cleanup robustness: The cleanup code uses catch blocks that may silently ignore legitimate errors. While warnings are logged, failures during cleanup could leave credentials on disk.

  4. Backward compatibility: The change requires Runner v2.329.0+. Workflows using older runners will break when upgrading to v6, though this is documented.

  5. Git version dependency: The includeIf.gitdir feature requires Git 2.13+. Older Git versions may not be explicitly checked.

Security Hotspots

  1. Credential file permissions (Medium Risk): The credentials config file is created in RUNNER_TEMP but there's no explicit permission setting. On shared runners, default umask might allow other users to read the file before it's deleted.

    • Location: getCredentialsConfigPath() and configureToken()
    • Mitigation: Should set file permissions to 0600 immediately after creation
  2. Incomplete cleanup (Medium Risk): If the cleanup process fails (process crash, runner termination), credentials files may remain in RUNNER_TEMP. The code only removes files matching the UUID pattern but doesn't clean up orphaned credentials from previous failed runs.

    • Location: removeToken() method
    • Consider: Runner-level cleanup of orphaned credential files
  3. Path traversal in cleanup (Low Risk): The testCredentialsConfigPath() regex validation could potentially be bypassed with carefully crafted paths, though the actual risk is minimal since paths come from git config.

    • Location: testCredentialsConfigPath() and removeIncludeIfCredentials()
  4. Credential exposure in process arguments (Mitigated): The code uses placeholder values to avoid exposing credentials in process audit logs, which is good. However, the final credentials are written directly to the file system.

    • Location: configureToken() method

Privacy Hotspots

  1. Repository paths in logs (Low Risk): The code logs absolute paths to repositories and credentials files via core.debug() and core.info(), which could expose workspace structure.
    • Locations: Throughout, especially getCredentialsConfigPath(), configureToken(), removeToken()
Changes

Changes

.github/workflows/test.yml

  • Updated test workflow to use explicit path parameters for checkout actions
  • Removed workaround for cleanup issues (no longer needed with new credential storage approach)
  • Changed local action reference from ./ to ./actions-checkout

CHANGELOG.md

  • Added entries for v6.0.0, v5.0.1, and v4.3.1
  • Documents the credentials persistence change and runner version requirements

README.md

  • Updated to document v6 as the latest version
  • Added "What's new" sections explaining the credentials change
  • Notes minimum Runner version requirement (v2.329.0 for Docker containers)
  • Reorganized table of contents

__test__/git-auth-helper.test.ts

  • Completely rewrote tests to validate new credentials file approach
  • Added tests for includeIf configuration in both main repo and submodules
  • Added tests for both host and container path configurations
  • Enhanced cleanup tests to verify credentials file deletion
  • Added new test testCredentialsConfigPath_matchesCredentialsConfigPaths to validate path pattern matching
  • Added new test removeAuth_removesTokenFromSubmodules for submodule cleanup
  • Updated mocks to support new git operations (tryConfigUnsetValue, tryGetConfigValues, tryGetConfigKeys, getSubmoduleConfigPaths)

__test__/git-directory-helper.test.ts

  • Added mock implementations for new git command manager methods

__test__/verify-submodules-*.sh

  • Added --includes flag to git config commands to properly read included config files

dist/index.js (compiled output)

  • Reflects all source changes in the compiled distribution

src/git-auth-helper.ts

  • Major refactor of credential storage:
    • Added credentialsConfigPath property to track separate credentials file
    • Removed configureToken(configPath, globalConfig) parameters - now uses separate file
    • Added getCredentialsConfigPath() to generate unique credential file paths using UUID
    • Changed configureToken() to write credentials to separate file and set up includeIf references
    • Updated configureSubmoduleAuth() to use includeIf instead of direct credential writing
    • Added removeIncludeIfCredentials() to find and remove includeIf entries pointing to credentials
    • Added testCredentialsConfigPath() to validate credential file path patterns
    • Enhanced removeToken() to clean up includeIf entries and delete credentials files
    • Split removeGitConfig() into separate methods for main repo and submodules
    • Added comprehensive error handling and logging throughout
    • Support for both host and container paths (Docker container actions)

src/git-command-manager.ts

  • Added configFile parameter to config() method to write to arbitrary config files
  • Added getSubmoduleConfigPaths() to retrieve paths to all submodule config files
  • Added tryConfigUnsetValue() to remove specific key-value pairs (not just keys)
  • Added tryGetConfigValues() to retrieve all values for a config key
  • Added tryGetConfigKeys() to retrieve keys matching a pattern
  • Updated interface IGitCommandManager with all new methods
sequenceDiagram
    participant Action as Checkout Action
    participant GitAuth as GitAuthHelper
    participant GitCmd as GitCommandManager
    participant FS as File System
    participant GitConfig as Git Config Files

    Note over Action,GitConfig: Configure Authentication
    Action->>GitAuth: configureAuth()
    GitAuth->>GitAuth: getCredentialsConfigPath()
    GitAuth->>FS: Create unique file in RUNNER_TEMP<br/>git-credentials-{uuid}.config
    GitAuth->>GitCmd: config(tokenKey, placeholder, file)
    GitCmd->>FS: Write placeholder to credentials file
    GitAuth->>FS: Replace placeholder with actual token
    
    Note over GitAuth,GitConfig: Set up includeIf references
    GitAuth->>GitCmd: config(includeIf.gitdir:{host-path}, creds-file)
    GitCmd->>GitConfig: Write to .git/config
    GitAuth->>GitCmd: config(includeIf.gitdir:{container-path}, creds-file)
    GitCmd->>GitConfig: Write to .git/config
    
    Note over Action,GitConfig: Configure Submodules
    Action->>GitAuth: configureSubmoduleAuth()
    GitAuth->>GitCmd: getSubmoduleConfigPaths()
    GitCmd-->>GitAuth: [submodule config paths]
    
    loop For each submodule
        GitAuth->>GitCmd: config(includeIf.gitdir:{submodule}, creds-file, configFile)
        GitCmd->>GitConfig: Write to submodule config
    end
    
    Note over Action,GitConfig: Cleanup/Remove Authentication
    Action->>GitAuth: removeAuth()
    GitAuth->>GitAuth: removeToken()
    GitAuth->>GitCmd: tryGetConfigKeys(includeIf.gitdir:)
    GitCmd-->>GitAuth: [includeIf keys]
    
    loop For each includeIf entry
        GitAuth->>GitCmd: tryGetConfigValues(key)
        GitCmd-->>GitAuth: [credential file paths]
        GitAuth->>GitAuth: testCredentialsConfigPath(path)
        GitAuth->>GitCmd: tryConfigUnsetValue(key, value)
        GitCmd->>GitConfig: Remove includeIf entry
    end
    
    GitAuth->>GitCmd: getSubmoduleConfigPaths()
    GitCmd-->>GitAuth: [submodule config paths]
    
    loop For each submodule
        GitAuth->>GitAuth: removeIncludeIfCredentials(configPath)
        GitAuth->>GitConfig: Remove includeIf entries
    end
    
    loop For each credentials file found
        GitAuth->>FS: Delete credentials file
    end
Loading

@mschfh mschfh enabled auto-merge (squash) December 2, 2025 09:50
@mschfh mschfh merged commit 0629db3 into master Dec 2, 2025
6 checks passed
@mschfh mschfh deleted the renovate/actions-checkout-6-x branch December 2, 2025 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant