diff --git a/README.md b/README.md index 5509e7d1..7cf56533 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Only a single commit is fetched by default, for the ref/SHA that triggered the w The auth token is persisted in the local git config. This enables your scripts to run authenticated git commands. The token is removed during post-job cleanup. Set `persist-credentials: false` to opt-out. +For token-authenticated submodules, checkout keeps its SSH-to-HTTPS URL rewrites in a temporary config alongside the submodule credentials. Both are removed during credential cleanup, without changing pre-existing URL rewrite rules. Unmarked local rewrites left by older checkout versions are not removed automatically because they cannot be distinguished from user configuration. + When Git 2.18 or higher is not in your PATH, falls back to the REST API to download the files. ### Note diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 2c963b90..51b6539c 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -30,6 +30,8 @@ jest.unstable_mockModule('@actions/core', () => ({ jest.unstable_mockModule('../src/state-helper.js', () => ({ setSshKeyPath: jest.fn(), setSshKnownHostsPath: jest.fn(), + setCredentialsConfigPaths: jest.fn(), + CredentialsConfigPaths: [], IsPost: false, RepositoryPath: '' })) @@ -557,10 +559,7 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - expect(mockSubmoduleForeach).toBeCalledTimes(1) - expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch( - /unset-all.*insteadOf/ - ) + expect(mockSubmoduleForeach).not.toHaveBeenCalled() } ) @@ -590,10 +589,7 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(1) - expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( - /unset-all.*insteadOf/ - ) + expect(mockSubmoduleForeach).not.toHaveBeenCalled() } ) @@ -616,16 +612,21 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - // Should configure insteadOf (2 calls for two values) - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) - expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( - /unset-all.*insteadOf/ + expect(mockSubmoduleForeach).not.toHaveBeenCalled() + const credentialsFiles = await fs.promises.readdir(runnerTemp) + const contents = await Promise.all( + credentialsFiles.map(file => + fs.promises.readFile(path.join(runnerTemp, file), 'utf8') + ) ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( - /url.*insteadOf.*git@github.com:/ + const submoduleConfig = contents.find(content => + content.includes('url.https://github.com/.insteadOf') ) - expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( - /url.*insteadOf.*org-123456@github.com:/ + expect(submoduleConfig).toContain( + 'url.https://github.com/.insteadOf git@github.com:' + ) + expect(submoduleConfig).toContain( + 'url.https://github.com/.insteadOf org-123456@github.com:' ) } ) @@ -656,11 +657,8 @@ describe('git-auth-helper tests', () => { // Assert // 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(/core\.sshCommand/) + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(1) + expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(/core\.sshCommand/) } ) @@ -816,6 +814,7 @@ describe('git-auth-helper tests', () => { it(removeAuth_removesTokenFromSubmodules, async () => { // Arrange await setup(removeAuth_removesTokenFromSubmodules) + settings.sshKey = '' // Create fake submodule config paths const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1') @@ -844,14 +843,19 @@ describe('git-auth-helper tests', () => { 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]) + expect(credentialsFiles.length).toBe(2) + const submodule1GitDir = submodule1Dir.replace(/\\/g, '/') + const [credentialsFilePath] = await git.tryGetConfigValues( + `includeIf.gitdir:${submodule1GitDir}.path`, + false, + submodule1ConfigPath + ) + expect(credentialsFilePath).toBeTruthy() // 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) diff --git a/dist/index.js b/dist/index.js index 06ae5d22..f07febd9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -34926,6 +34926,13 @@ const SshKeyPath = getState('sshKeyPath'); * The SSH known hosts path for the POST action. The value is empty during the MAIN action. */ const SshKnownHostsPath = getState('sshKnownHostsPath'); +/** + * Credentials config files owned by this action, including incomplete setup. + */ +const CredentialsConfigPaths = JSON.parse(getState('credentialsConfigPaths') || '[]'); +function setCredentialsConfigPaths(credentialsConfigPaths) { + saveState('credentialsConfigPaths', credentialsConfigPaths); +} /** * Save the repository path so the POST action can retrieve the value. */ @@ -35051,6 +35058,7 @@ class GitAuthHelper { sshKnownHostsPath = ''; temporaryHomePath = ''; credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP + submoduleCredentialsConfigPath = ''; constructor(gitCommandManager, gitSourceSettings) { this.git = gitCommandManager; this.settings = gitSourceSettings || {}; @@ -35135,11 +35143,15 @@ class GitAuthHelper { } } async configureSubmoduleAuth() { - // Remove possible previous HTTPS instead of SSH - await this.removeSubmoduleGitConfig(this.insteadOfKey); if (this.settings.persistCredentials) { - // Get the credentials config file path in RUNNER_TEMP - const credentialsConfigPath = this.getCredentialsConfigPath(); + const credentialsConfigPath = this.getCredentialsConfigPath(!this.settings.sshKey); + if (!this.settings.sshKey) { + await external_fs_namespaceObject.promises.copyFile(this.getCredentialsConfigPath(), credentialsConfigPath); + // Keep rewrites with their credentials, without changing user config or main-repository URLs. + for (const insteadOfValue of this.insteadOfValues) { + await this.git.config(this.insteadOfKey, insteadOfValue, false, true, credentialsConfigPath); + } + } // Container credentials config path const containerCredentialsPath = external_path_namespaceObject.posix.join('/github/runner_temp', external_path_namespaceObject.basename(credentialsConfigPath)); // Get submodule config file paths. @@ -35151,9 +35163,7 @@ class GitAuthHelper { let submoduleGitDir = external_path_namespaceObject.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); + await this.configureSubmoduleIncludeIf(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, configPath); // Container submodule git directory const githubWorkspace = process.env['GITHUB_WORKSPACE']; external_assert_.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); @@ -35161,20 +35171,12 @@ class GitAuthHelper { relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows const containerSubmoduleGitDir = external_path_namespaceObject.posix.join('/github/workspace', relativeSubmoduleGitDir); // Configure container includeIf - await this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig? - false, // add? - configPath); + await this.configureSubmoduleIncludeIf(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, configPath); } if (this.settings.sshKey) { // Configure core.sshCommand await this.git.submoduleForeach(`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, this.settings.nestedSubmodules); } - else { - // Configure HTTPS instead of SSH - for (const insteadOfValue of this.insteadOfValues) { - await this.git.submoduleForeach(`git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`, this.settings.nestedSubmodules); - } - } } } async removeAuth() { @@ -35188,6 +35190,12 @@ class GitAuthHelper { await rmRF(this.temporaryHomePath); } } + async configureSubmoduleIncludeIf(key, value, configPath) { + const values = await this.git.tryGetConfigValues(key, false, configPath); + if (!values.includes(value)) { + await this.git.config(key, value, false, true, configPath); + } + } /** * Configures SSH authentication by writing the SSH key and known hosts, * and setting up the GIT_SSH_COMMAND environment variable. @@ -35305,20 +35313,31 @@ class GitAuthHelper { } } /** - * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * Gets or creates the path to a credentials config file in RUNNER_TEMP. * @returns The absolute path to the credentials config file */ - getCredentialsConfigPath() { - if (this.credentialsConfigPath) { - return this.credentialsConfigPath; + getCredentialsConfigPath(forSubmodules = false) { + const existingPath = forSubmodules + ? this.submoduleCredentialsConfigPath + : this.credentialsConfigPath; + if (existingPath) { + return existingPath; } const runnerTemp = process.env['RUNNER_TEMP'] || ''; external_assert_.ok(runnerTemp, 'RUNNER_TEMP is not defined'); // Create a unique filename for this checkout instance const configFileName = `git-credentials-${(0,external_crypto_namespaceObject.randomUUID)()}.config`; - this.credentialsConfigPath = external_path_namespaceObject.join(runnerTemp, configFileName); - core_debug(`Credentials config path: ${this.credentialsConfigPath}`); - return this.credentialsConfigPath; + const configPath = external_path_namespaceObject.join(runnerTemp, configFileName); + if (forSubmodules) { + this.submoduleCredentialsConfigPath = configPath; + } + else { + this.credentialsConfigPath = configPath; + } + // Save before writing so post cleanup also handles incomplete setup or removed submodules. + setCredentialsConfigPaths([this.credentialsConfigPath, this.submoduleCredentialsConfigPath].filter(Boolean)); + core_debug(`Credentials config path: ${configPath}`); + return configPath; } /** * Removes SSH authentication configuration by cleaning up SSH keys, @@ -35364,7 +35383,11 @@ class GitAuthHelper { await this.removeGitConfig(this.tokenConfigKey); await this.removeSubmoduleGitConfig(this.tokenConfigKey); // Collect credentials config paths that need to be removed - const credentialsPaths = new Set(); + const credentialsPaths = new Set([ + this.credentialsConfigPath, + this.submoduleCredentialsConfigPath, + ...CredentialsConfigPaths + ].filter(Boolean)); // Remove includeIf entries that point to git-credentials-*.config files info('Removing includeIf entries pointing to credentials config files'); const mainCredentialsPaths = await this.removeIncludeIfCredentials(); diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index dd7e6fbd..b7b601eb 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -44,6 +44,7 @@ class GitAuthHelper { private sshKnownHostsPath = '' private temporaryHomePath = '' private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP + private submoduleCredentialsConfigPath = '' constructor( gitCommandManager: IGitCommandManager, @@ -155,12 +156,27 @@ class GitAuthHelper { } async configureSubmoduleAuth(): Promise { - // Remove possible previous HTTPS instead of SSH - await this.removeSubmoduleGitConfig(this.insteadOfKey) - if (this.settings.persistCredentials) { - // Get the credentials config file path in RUNNER_TEMP - const credentialsConfigPath = this.getCredentialsConfigPath() + const credentialsConfigPath = this.getCredentialsConfigPath( + !this.settings.sshKey + ) + if (!this.settings.sshKey) { + await fs.promises.copyFile( + this.getCredentialsConfigPath(), + credentialsConfigPath + ) + + // Keep rewrites with their credentials, without changing user config or main-repository URLs. + for (const insteadOfValue of this.insteadOfValues) { + await this.git.config( + this.insteadOfKey, + insteadOfValue, + false, + true, + credentialsConfigPath + ) + } + } // Container credentials config path const containerCredentialsPath = path.posix.join( @@ -181,11 +197,9 @@ class GitAuthHelper { submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows // Configure host includeIf - await this.git.config( + await this.configureSubmoduleIncludeIf( `includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, - false, // globalConfig? - false, // add? configPath ) @@ -203,11 +217,9 @@ class GitAuthHelper { ) // Configure container includeIf - await this.git.config( + await this.configureSubmoduleIncludeIf( `includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, - false, // globalConfig? - false, // add? configPath ) } @@ -218,14 +230,6 @@ class GitAuthHelper { `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, this.settings.nestedSubmodules ) - } else { - // Configure HTTPS instead of SSH - for (const insteadOfValue of this.insteadOfValues) { - await this.git.submoduleForeach( - `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`, - this.settings.nestedSubmodules - ) - } } } } @@ -243,6 +247,17 @@ class GitAuthHelper { } } + private async configureSubmoduleIncludeIf( + key: string, + value: string, + configPath: string + ): Promise { + const values = await this.git.tryGetConfigValues(key, false, configPath) + if (!values.includes(value)) { + await this.git.config(key, value, false, true, configPath) + } + } + /** * Configures SSH authentication by writing the SSH key and known hosts, * and setting up the GIT_SSH_COMMAND environment variable. @@ -410,12 +425,15 @@ class GitAuthHelper { } /** - * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * Gets or creates the path to a credentials config file in RUNNER_TEMP. * @returns The absolute path to the credentials config file */ - private getCredentialsConfigPath(): string { - if (this.credentialsConfigPath) { - return this.credentialsConfigPath + private getCredentialsConfigPath(forSubmodules = false): string { + const existingPath = forSubmodules + ? this.submoduleCredentialsConfigPath + : this.credentialsConfigPath + if (existingPath) { + return existingPath } const runnerTemp = process.env['RUNNER_TEMP'] || '' @@ -423,10 +441,21 @@ class GitAuthHelper { // Create a unique filename for this checkout instance const configFileName = `git-credentials-${randomUUID()}.config` - this.credentialsConfigPath = path.join(runnerTemp, configFileName) + const configPath = path.join(runnerTemp, configFileName) + if (forSubmodules) { + this.submoduleCredentialsConfigPath = configPath + } else { + this.credentialsConfigPath = configPath + } + // Save before writing so post cleanup also handles incomplete setup or removed submodules. + stateHelper.setCredentialsConfigPaths( + [this.credentialsConfigPath, this.submoduleCredentialsConfigPath].filter( + Boolean + ) + ) - core.debug(`Credentials config path: ${this.credentialsConfigPath}`) - return this.credentialsConfigPath + core.debug(`Credentials config path: ${configPath}`) + return configPath } /** @@ -476,7 +505,13 @@ class GitAuthHelper { await this.removeSubmoduleGitConfig(this.tokenConfigKey) // Collect credentials config paths that need to be removed - const credentialsPaths = new Set() + const credentialsPaths = new Set( + [ + this.credentialsConfigPath, + this.submoduleCredentialsConfigPath, + ...stateHelper.CredentialsConfigPaths + ].filter(Boolean) + ) // Remove includeIf entries that point to git-credentials-*.config files core.info('Removing includeIf entries pointing to credentials config files') diff --git a/src/state-helper.ts b/src/state-helper.ts index aa3eecc7..d59cef96 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -25,6 +25,17 @@ export const SshKeyPath = core.getState('sshKeyPath') */ export const SshKnownHostsPath = core.getState('sshKnownHostsPath') +/** + * Credentials config files owned by this action, including incomplete setup. + */ +export const CredentialsConfigPaths: string[] = JSON.parse( + core.getState('credentialsConfigPaths') || '[]' +) + +export function setCredentialsConfigPaths(credentialsConfigPaths: string[]) { + core.saveState('credentialsConfigPaths', credentialsConfigPaths) +} + /** * Save the repository path so the POST action can retrieve the value. */