Clean up submodule URL rewrites

This commit is contained in:
Camilo Celis Guzman 2026-09-23 21:05:17 +09:00
parent f548e57e54
commit ce29f5822a
No known key found for this signature in database
5 changed files with 150 additions and 75 deletions

View File

@ -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

View File

@ -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)

71
dist/index.js vendored
View File

@ -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();

View File

@ -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<void> {
// 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<void> {
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<string>()
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')

View File

@ -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.
*/