From be385d8ffff400114cff2e11db545f0e5df9099d Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Wed, 6 May 2026 12:17:20 +0000 Subject: [PATCH 1/2] Fix includeIf case sensitivity on Windows self-hosted runners Switch to includeIf.gitdir/i: on Windows so path matching is case-insensitive, matching the filesystem behavior. This fixes auth failures on self-hosted Windows runners where the workspace folder casing doesn't match between the runner config and disk. Also update the cleanup regex to handle both gitdir: and gitdir/i: variants. Fixes #2345 --- __test__/git-auth-helper.test.ts | 40 ++++++++++++++++++++++++++++++++ dist/index.js | 20 ++++++++++------ src/git-auth-helper.ts | 20 ++++++++++------ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index ad3566a..debee46 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -974,6 +974,46 @@ describe('git-auth-helper tests', () => { ).toBe(false) expect((authHelper as any).testCredentialsConfigPath('')).toBe(false) }) + + const includeIfCleanupRegex_matchesBothVariants = + 'includeIf cleanup regex matches both gitdir: and gitdir/i: keys' + it(includeIfCleanupRegex_matchesBothVariants, async () => { + // The cleanup regex must match both variants so credential + // removal works regardless of which was written + const regex = /^includeIf\.gitdir(\/i)?:/ + expect(regex.test('includeIf.gitdir:D:/workspaces/repo/.git.path')).toBe( + true + ) + expect(regex.test('includeIf.gitdir/i:D:/Workspaces/repo/.git.path')).toBe( + true + ) + expect(regex.test('includeIf.gitdir/i:/github/workspace/.git.path')).toBe( + true + ) + expect(regex.test('includeIf.gitdir:~/projects/foo/.git.path')).toBe(true) + expect(regex.test('includeIf.onbranch:main.path')).toBe(false) + expect(regex.test('include.path')).toBe(false) + }) + + const includeIfDirective_usesCorrectVariantForPlatform = + 'includeIf directive uses gitdir/i on Windows and gitdir on other platforms' + it(includeIfDirective_usesCorrectVariantForPlatform, async () => { + await setup(includeIfDirective_usesCorrectVariantForPlatform) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + + const localConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + + if (isWindows) { + expect(localConfigContent).toContain('includeIf.gitdir/i:') + expect(localConfigContent).not.toContain('includeIf.gitdir:') + } else { + expect(localConfigContent).toContain('includeIf.gitdir:') + expect(localConfigContent).not.toContain('includeIf.gitdir/i:') + } + }) }) async function setup(testName: string): Promise { diff --git a/dist/index.js b/dist/index.js index 57729b2..4419fc5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -151,6 +151,12 @@ const stateHelper = __importStar(__nccwpck_require__(4866)); const urlHelper = __importStar(__nccwpck_require__(9437)); const uuid_1 = __nccwpck_require__(5840); const IS_WINDOWS = process.platform === 'win32'; +// Use case-insensitive gitdir matching on Windows to handle path casing mismatches +// between the runner's GITHUB_WORKSPACE and the actual filesystem casing. +// See: https://github.com/actions/checkout/issues/2345 +const INCLUDE_IF_GITDIR = IS_WINDOWS + ? 'includeIf.gitdir/i:' + : 'includeIf.gitdir:'; const SSH_COMMAND_KEY = 'core.sshCommand'; function createAuthHelper(git, settings) { return new GitAuthHelper(git, settings); @@ -270,7 +276,7 @@ class GitAuthHelper { 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? + yield this.git.config(`${INCLUDE_IF_GITDIR}${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig? false, // add? configPath); // Container submodule git directory @@ -280,7 +286,7 @@ class GitAuthHelper { 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? + yield this.git.config(`${INCLUDE_IF_GITDIR}${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig? false, // add? configPath); } @@ -410,10 +416,10 @@ class GitAuthHelper { 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`; + const hostIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}.path`; yield this.git.config(hostIncludeKey, credentialsConfigPath); // Configure host includeIf for worktrees - const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`; + const hostWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}/worktrees/*.path`; yield this.git.config(hostWorktreeIncludeKey, credentialsConfigPath); // Container git directory const workingDirectory = this.git.getWorkingDirectory(); @@ -425,10 +431,10 @@ class GitAuthHelper { // Container credentials config path const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); // Configure container includeIf - const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`; + const containerIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}.path`; yield this.git.config(containerIncludeKey, containerCredentialsPath); // Configure container includeIf for worktrees - const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`; + const containerWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}/worktrees/*.path`; yield this.git.config(containerWorktreeIncludeKey, containerCredentialsPath); } }); @@ -565,7 +571,7 @@ class GitAuthHelper { const credentialsPaths = new Set(); try { // Get all includeIf.gitdir keys - const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig? + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir(/i)?:', false, // globalConfig? configPath); for (const key of keys) { // Get all values for this key diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index e67db14..8d2eb91 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -13,6 +13,12 @@ import {IGitCommandManager} from './git-command-manager' import {IGitSourceSettings} from './git-source-settings' const IS_WINDOWS = process.platform === 'win32' +// Use case-insensitive gitdir matching on Windows to handle path casing mismatches +// between the runner's GITHUB_WORKSPACE and the actual filesystem casing. +// See: https://github.com/actions/checkout/issues/2345 +const INCLUDE_IF_GITDIR = IS_WINDOWS + ? 'includeIf.gitdir/i:' + : 'includeIf.gitdir:' const SSH_COMMAND_KEY = 'core.sshCommand' export interface IGitAuthHelper { @@ -182,7 +188,7 @@ class GitAuthHelper { // Configure host includeIf await this.git.config( - `includeIf.gitdir:${submoduleGitDir}.path`, + `${INCLUDE_IF_GITDIR}${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig? false, // add? @@ -204,7 +210,7 @@ class GitAuthHelper { // Configure container includeIf await this.git.config( - `includeIf.gitdir:${containerSubmoduleGitDir}.path`, + `${INCLUDE_IF_GITDIR}${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig? false, // add? @@ -371,11 +377,11 @@ class GitAuthHelper { gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows // Configure host includeIf - const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` + const hostIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}.path` await this.git.config(hostIncludeKey, credentialsConfigPath) // Configure host includeIf for worktrees - const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path` + const hostWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}/worktrees/*.path` await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath) // Container git directory @@ -397,11 +403,11 @@ class GitAuthHelper { ) // Configure container includeIf - const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path` + const containerIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}.path` await this.git.config(containerIncludeKey, containerCredentialsPath) // Configure container includeIf for worktrees - const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path` + const containerWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}/worktrees/*.path` await this.git.config( containerWorktreeIncludeKey, containerCredentialsPath @@ -554,7 +560,7 @@ class GitAuthHelper { try { // Get all includeIf.gitdir keys const keys = await this.git.tryGetConfigKeys( - '^includeIf\\.gitdir:', + '^includeIf\\.gitdir(/i)?:', false, // globalConfig? configPath ) From 1cce3390c2bfda521930d01229c073c7ff920824 Mon Sep 17 00:00:00 2001 From: Yashwanth Anantharaju Date: Mon, 1 Jun 2026 11:35:58 -0400 Subject: [PATCH 2/2] Fix checkout init for SHA-256 repositories (#2439) * Fix checkout init for SHA-256 repositories * Remove unused object format result field --- __test__/git-command-manager.test.ts | 53 +++++++++++++++ __test__/github-api-helper.test.ts | 98 ++++++++++++++++++++++++++++ dist/index.js | 55 +++++++++++++++- src/git-command-manager.ts | 12 +++- src/git-source-provider.ts | 19 +++++- src/github-api-helper.ts | 52 +++++++++++++++ 6 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 __test__/github-api-helper.test.ts diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts index 8a97d82..47a0f8f 100644 --- a/__test__/git-command-manager.test.ts +++ b/__test__/git-command-manager.test.ts @@ -378,6 +378,59 @@ describe('Test fetchDepth and fetchTags options', () => { }) }) +describe('repository initialization object format', () => { + beforeEach(async () => { + jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) + jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('initializes SHA-256 repositories with the matching object format', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await git.init('sha256') + + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + ['init', '--object-format=sha256', 'test'], + expect.any(Object) + ) + }) + + it('initializes SHA-1 repositories with existing default arguments', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await git.init('sha1') + + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + ['init', 'test'], + expect.any(Object) + ) + }) +}) + describe('git user-agent with orchestration ID', () => { beforeEach(async () => { jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) diff --git a/__test__/github-api-helper.test.ts b/__test__/github-api-helper.test.ts new file mode 100644 index 0000000..6319e20 --- /dev/null +++ b/__test__/github-api-helper.test.ts @@ -0,0 +1,98 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import * as githubApiHelper from '../lib/github-api-helper' + +describe('github-api-helper object format', () => { + let getOctokitSpy: jest.SpyInstance + let debugSpy: jest.SpyInstance + let request: jest.Mock + + function mockHashAlgorithmApi(hashAlgorithm: string): void { + request = jest.fn(async () => ({ + data: { + hash_algorithm: hashAlgorithm + } + })) + getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({ + request + } as any) + } + + beforeEach(() => { + debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('detects SHA-256 from the repository hash algorithm endpoint', async () => { + mockHashAlgorithmApi('sha256') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: 'sha256', succeeded: true}) + + expect(getOctokitSpy).toHaveBeenCalledWith( + 'token', + expect.objectContaining({baseUrl: 'https://api.github.com'}) + ) + expect(request).toHaveBeenCalledWith( + 'GET /repos/{owner}/{repo}/hash-algorithm', + {owner: 'owner', repo: 'repo'} + ) + }) + + it('detects SHA-1 from the repository hash algorithm endpoint', async () => { + mockHashAlgorithmApi('sha1') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: 'sha1', succeeded: true}) + }) + + it('detects object format from an existing commit without API calls', async () => { + const commitSha = + '9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92' + getOctokitSpy = jest.spyOn(github, 'getOctokit') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat( + 'token', + 'owner', + 'repo', + undefined, + commitSha + ) + ).resolves.toEqual({format: 'sha256', succeeded: true}) + + expect(getOctokitSpy).not.toHaveBeenCalled() + }) + + it('returns unsuccessful when the hash algorithm endpoint value is not recognized', async () => { + mockHashAlgorithmApi('unknown') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: '', succeeded: false}) + expect(debugSpy).toHaveBeenCalledWith( + 'Unable to determine repository object format from hash-algorithm endpoint' + ) + }) + + it('returns unsuccessful when the hash algorithm API lookup fails', async () => { + request = jest.fn(async () => { + throw new Error('not found') + }) + jest.spyOn(github, 'getOctokit').mockReturnValue({ + request + } as any) + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: '', succeeded: false}) + expect(debugSpy).toHaveBeenCalledWith( + 'Unable to determine repository object format from hash-algorithm endpoint: not found' + ) + }) +}) diff --git a/dist/index.js b/dist/index.js index 57729b2..906b59a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -896,9 +896,14 @@ class GitCommandManager { getWorkingDirectory() { return this.workingDirectory; } - init() { + init(objectFormat) { return __awaiter(this, void 0, void 0, function* () { - yield this.execGit(['init', this.workingDirectory]); + const args = ['init']; + if (objectFormat === 'sha256') { + args.push('--object-format=sha256'); + } + args.push(this.workingDirectory); + yield this.execGit(args); }); } isDetached() { @@ -1486,8 +1491,17 @@ function getSource(settings) { stateHelper.setRepositoryPath(settings.repositoryPath); // Initialize the repository if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { + core.startGroup('Determining repository object format'); + const objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl, settings.commit); + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : ''; + if (objectFormat === 'sha256') { + core.info('Detected SHA-256 repository object format'); + } + core.endGroup(); core.startGroup('Initializing the repository'); - yield git.init(); + yield git.init(objectFormat); yield git.remoteAdd('origin', repositoryUrl); core.endGroup(); } @@ -1810,6 +1824,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", ({ value: true })); exports.downloadRepository = downloadRepository; exports.getDefaultBranch = getDefaultBranch; +exports.tryGetRepositoryObjectFormat = tryGetRepositoryObjectFormat; const assert = __importStar(__nccwpck_require__(9491)); const core = __importStar(__nccwpck_require__(2186)); const fs = __importStar(__nccwpck_require__(7147)); @@ -1911,6 +1926,40 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) { })); }); } +function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, commit) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + const commitFormat = getObjectFormat(commit); + if (commitFormat) { + return { format: commitFormat, succeeded: true }; + } + try { + const octokit = github.getOctokit(authToken, { + baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl) + }); + const response = yield octokit.request('GET /repos/{owner}/{repo}/hash-algorithm', { owner, repo }); + const hashAlgorithm = response.data.hash_algorithm; + if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') { + return { format: hashAlgorithm, succeeded: true }; + } + core.debug('Unable to determine repository object format from hash-algorithm endpoint'); + return { format: '', succeeded: false }; + } + catch (err) { + core.debug(`Unable to determine repository object format from hash-algorithm endpoint: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); + return { format: '', succeeded: false }; + } + }); +} +function getObjectFormat(sha) { + if (/^[0-9a-fA-F]{64}$/.test(sha || '')) { + return 'sha256'; + } + if (/^[0-9a-fA-F]{40}$/.test(sha || '')) { + return 'sha1'; + } + return ''; +} function downloadArchive(authToken, owner, repo, ref, commit, baseUrl) { return __awaiter(this, void 0, void 0, function* () { const octokit = github.getOctokit(authToken, { diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index f5ba40e..f1349ce 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -43,7 +43,7 @@ export interface IGitCommandManager { getDefaultBranch(repositoryUrl: string): Promise getSubmoduleConfigPaths(recursive: boolean): Promise getWorkingDirectory(): string - init(): Promise + init(objectFormat?: string): Promise isDetached(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise @@ -364,8 +364,14 @@ class GitCommandManager { return this.workingDirectory } - async init(): Promise { - await this.execGit(['init', this.workingDirectory]) + async init(objectFormat?: string): Promise { + const args = ['init'] + if (objectFormat === 'sha256') { + args.push('--object-format=sha256') + } + args.push(this.workingDirectory) + + await this.execGit(args) } async isDetached(): Promise { diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index ec87178..452d44e 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -109,8 +109,25 @@ export async function getSource(settings: IGitSourceSettings): Promise { if ( !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) ) { + core.startGroup('Determining repository object format') + const objectFormatResult = + await githubApiHelper.tryGetRepositoryObjectFormat( + settings.authToken, + settings.repositoryOwner, + settings.repositoryName, + settings.githubServerUrl, + settings.commit + ) + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : '' + if (objectFormat === 'sha256') { + core.info('Detected SHA-256 repository object format') + } + core.endGroup() + core.startGroup('Initializing the repository') - await git.init() + await git.init(objectFormat) await git.remoteAdd('origin', repositoryUrl) core.endGroup() } diff --git a/src/github-api-helper.ts b/src/github-api-helper.ts index 1ff27c2..bef31e6 100644 --- a/src/github-api-helper.ts +++ b/src/github-api-helper.ts @@ -11,6 +11,11 @@ import {getServerApiUrl} from './url-helper' const IS_WINDOWS = process.platform === 'win32' +export interface RepositoryObjectFormatResult { + format: string + succeeded: boolean +} + export async function downloadRepository( authToken: string, owner: string, @@ -122,6 +127,53 @@ export async function getDefaultBranch( }) } +export async function tryGetRepositoryObjectFormat( + authToken: string, + owner: string, + repo: string, + baseUrl?: string, + commit?: string +): Promise { + const commitFormat = getObjectFormat(commit) + if (commitFormat) { + return {format: commitFormat, succeeded: true} + } + + try { + const octokit = github.getOctokit(authToken, { + baseUrl: getServerApiUrl(baseUrl) + }) + const response = await octokit.request( + 'GET /repos/{owner}/{repo}/hash-algorithm', + {owner, repo} + ) + const hashAlgorithm = response.data.hash_algorithm + if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') { + return {format: hashAlgorithm, succeeded: true} + } + + core.debug( + 'Unable to determine repository object format from hash-algorithm endpoint' + ) + return {format: '', succeeded: false} + } catch (err) { + core.debug( + `Unable to determine repository object format from hash-algorithm endpoint: ${(err as any)?.message ?? err}` + ) + return {format: '', succeeded: false} + } +} + +function getObjectFormat(sha?: string): string { + if (/^[0-9a-fA-F]{64}$/.test(sha || '')) { + return 'sha256' + } + if (/^[0-9a-fA-F]{40}$/.test(sha || '')) { + return 'sha1' + } + return '' +} + async function downloadArchive( authToken: string, owner: string,