Compare commits

...

7 Commits

Author SHA1 Message Date
Drew Ballance cc1fbca669
Merge fbeb4a8af3 into df4cb1c069 2026-06-03 15:36:31 +02:00
Yashwanth Anantharaju df4cb1c069
Update changelog for v6.0.3 (#2446) 2026-06-02 10:31:30 -04:00
Yashwanth Anantharaju 1cce3390c2
Fix checkout init for SHA-256 repositories (#2439)
* Fix checkout init for SHA-256 repositories

* Remove unused object format result field
2026-06-01 11:35:58 -04:00
Drew Ballance fbeb4a8af3
fix: formatting 2026-04-28 13:01:14 -05:00
Drew Ballance 70f5fe34bd
fix: address comments 2026-04-28 12:58:52 -05:00
Drew Ballance 8461cead75
fix: review nits 2026-03-25 12:54:48 -05:00
Drew Ballance 70525c3952
fix: ensure symlinked git directories work 2026-03-25 12:40:16 -05:00
9 changed files with 397 additions and 13 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
## v6.0.3
* Fix checkout init for SHA-256 repositories by @yaananth in https://github.com/actions/checkout/pull/2439
* fix: expand merge commit SHA regex and add SHA-256 test cases by @yaananth in https://github.com/actions/checkout/pull/2414
## v6.0.2 ## v6.0.2
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356 * Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356

View File

@ -22,6 +22,11 @@ let settings: IGitSourceSettings
let sshPath: string let sshPath: string
let githubServerUrl: string let githubServerUrl: string
// Helper function to normalize path separators to forward slashes
function convertBackslashes(file: string): string {
return file.replace(/\\/g, '/')
}
describe('git-auth-helper tests', () => { describe('git-auth-helper tests', () => {
beforeAll(async () => { beforeAll(async () => {
// SSH // SSH
@ -238,6 +243,87 @@ describe('git-auth-helper tests', () => {
expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret) expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret)
}) })
const configureAuth_resolvesSymlinksInIncludeIfGitdir =
'configureAuth resolves symlinks in includeIf gitdir'
it(configureAuth_resolvesSymlinksInIncludeIfGitdir, async () => {
if (isWindows) {
process.stdout.write(
`Skipped test "${configureAuth_resolvesSymlinksInIncludeIfGitdir}". Symlink creation requires admin privileges on Windows.\n`
)
return
}
// Arrange
await setup(configureAuth_resolvesSymlinksInIncludeIfGitdir)
const symlinkPath = path.join(path.dirname(workspace), 'workspace-symlink')
try {
// Ensure no pre-existing symlink or file remains at this path
await fs.promises.rm(symlinkPath, {force: true})
// Create a symlink pointing to the real workspace directory
await fs.promises.symlink(workspace, symlinkPath)
// Make git appear to be operating from the symlink path
const mockGetWorkingDirectory = git.getWorkingDirectory as jest.Mock
mockGetWorkingDirectory.mockReturnValue(symlinkPath)
process.env['GITHUB_WORKSPACE'] = symlinkPath
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act
await authHelper.configureAuth()
// Assert the host includeIf uses the real resolved path, not the symlink path
const localConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
const realGitDir = convertBackslashes(
await fs.promises.realpath(path.join(symlinkPath, '.git'))
)
const symlinkGitDir = convertBackslashes(path.join(symlinkPath, '.git'))
expect(realGitDir).not.toBe(symlinkGitDir) // sanity check: paths differ
expect(
localConfigContent.indexOf(`includeIf.gitdir:${realGitDir}.path`)
).toBeGreaterThanOrEqual(0)
expect(localConfigContent.indexOf(symlinkGitDir)).toBeLessThan(0)
} finally {
// Clean up symlink (or any file) at the symlink path
await fs.promises.rm(symlinkPath, {force: true})
}
})
const configureAuth_fallsBackWhenRealpathSyncFails =
'configureAuth falls back to constructed path when realpathSync fails'
it(configureAuth_fallsBackWhenRealpathSyncFails, async () => {
// Arrange
await setup(configureAuth_fallsBackWhenRealpathSyncFails)
// Use a nonexistent path so realpathSync throws ENOENT naturally,
// exercising the catch fallback in configureToken()
const nonexistentPath = path.join(runnerTemp, 'does-not-exist')
const mockGetWorkingDirectory = git.getWorkingDirectory as jest.Mock
mockGetWorkingDirectory.mockReturnValue(nonexistentPath)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
// Act - should not throw despite realpathSync failure
await authHelper.configureAuth()
// Assert the fallback constructed path is used in the includeIf entry
const localConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
const fallbackGitDir = convertBackslashes(
path.join(nonexistentPath, '.git')
)
expect(
localConfigContent.indexOf(`includeIf.gitdir:${fallbackGitDir}.path`)
).toBeGreaterThanOrEqual(0)
})
const setsSshCommandEnvVarWhenPersistCredentialsFalse = const setsSshCommandEnvVarWhenPersistCredentialsFalse =
'sets SSH command env var when persist-credentials false' 'sets SSH command env var when persist-credentials false'
it(setsSshCommandEnvVarWhenPersistCredentialsFalse, async () => { it(setsSshCommandEnvVarWhenPersistCredentialsFalse, async () => {

View File

@ -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', () => { describe('git user-agent with orchestration ID', () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())

View File

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

71
dist/index.js vendored
View File

@ -406,9 +406,19 @@ class GitAuthHelper {
); );
} }
else { else {
// Host git directory // Host git directory - resolve symlinks so includeIf gitdir matching works
let gitDir = path.join(this.git.getWorkingDirectory(), '.git'); // on self-hosted runners where _work is a symlink to an external volume.
gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows let gitDir;
try {
const constructed = path.join(this.git.getWorkingDirectory(), '.git');
const resolved = yield fs.promises.realpath(constructed);
gitDir = resolved.replace(/\\/g, '/');
}
catch (_a) {
// Fall back to constructed path if realpath fails
gitDir = path.join(this.git.getWorkingDirectory(), '.git');
gitDir = gitDir.replace(/\\/g, '/');
}
// Configure host includeIf // Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`; const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
yield this.git.config(hostIncludeKey, credentialsConfigPath); yield this.git.config(hostIncludeKey, credentialsConfigPath);
@ -896,9 +906,14 @@ class GitCommandManager {
getWorkingDirectory() { getWorkingDirectory() {
return this.workingDirectory; return this.workingDirectory;
} }
init() { init(objectFormat) {
return __awaiter(this, void 0, void 0, function* () { 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() { isDetached() {
@ -1486,8 +1501,17 @@ function getSource(settings) {
stateHelper.setRepositoryPath(settings.repositoryPath); stateHelper.setRepositoryPath(settings.repositoryPath);
// Initialize the repository // Initialize the repository
if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { 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'); core.startGroup('Initializing the repository');
yield git.init(); yield git.init(objectFormat);
yield git.remoteAdd('origin', repositoryUrl); yield git.remoteAdd('origin', repositoryUrl);
core.endGroup(); core.endGroup();
} }
@ -1810,6 +1834,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.downloadRepository = downloadRepository; exports.downloadRepository = downloadRepository;
exports.getDefaultBranch = getDefaultBranch; exports.getDefaultBranch = getDefaultBranch;
exports.tryGetRepositoryObjectFormat = tryGetRepositoryObjectFormat;
const assert = __importStar(__nccwpck_require__(9491)); const assert = __importStar(__nccwpck_require__(9491));
const core = __importStar(__nccwpck_require__(2186)); const core = __importStar(__nccwpck_require__(2186));
const fs = __importStar(__nccwpck_require__(7147)); const fs = __importStar(__nccwpck_require__(7147));
@ -1911,6 +1936,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) { function downloadArchive(authToken, owner, repo, ref, commit, baseUrl) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const octokit = github.getOctokit(authToken, { const octokit = github.getOctokit(authToken, {

View File

@ -366,9 +366,18 @@ class GitAuthHelper {
true // globalConfig? true // globalConfig?
) )
} else { } else {
// Host git directory // Host git directory - resolve symlinks so includeIf gitdir matching works
let gitDir = path.join(this.git.getWorkingDirectory(), '.git') // on self-hosted runners where _work is a symlink to an external volume.
gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows let gitDir: string
try {
const constructed = path.join(this.git.getWorkingDirectory(), '.git')
const resolved = await fs.promises.realpath(constructed)
gitDir = resolved.replace(/\\/g, '/')
} catch {
// Fall back to constructed path if realpath fails
gitDir = path.join(this.git.getWorkingDirectory(), '.git')
gitDir = gitDir.replace(/\\/g, '/')
}
// Configure host includeIf // Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`

View File

@ -43,7 +43,7 @@ export interface IGitCommandManager {
getDefaultBranch(repositoryUrl: string): Promise<string> getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string getWorkingDirectory(): string
init(): Promise<void> init(objectFormat?: string): Promise<void>
isDetached(): Promise<boolean> isDetached(): Promise<boolean>
lfsFetch(ref: string): Promise<void> lfsFetch(ref: string): Promise<void>
lfsInstall(): Promise<void> lfsInstall(): Promise<void>
@ -364,8 +364,14 @@ class GitCommandManager {
return this.workingDirectory return this.workingDirectory
} }
async init(): Promise<void> { async init(objectFormat?: string): Promise<void> {
await this.execGit(['init', this.workingDirectory]) const args = ['init']
if (objectFormat === 'sha256') {
args.push('--object-format=sha256')
}
args.push(this.workingDirectory)
await this.execGit(args)
} }
async isDetached(): Promise<boolean> { async isDetached(): Promise<boolean> {

View File

@ -109,8 +109,25 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
if ( if (
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) !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') core.startGroup('Initializing the repository')
await git.init() await git.init(objectFormat)
await git.remoteAdd('origin', repositoryUrl) await git.remoteAdd('origin', repositoryUrl)
core.endGroup() core.endGroup()
} }

View File

@ -11,6 +11,11 @@ import {getServerApiUrl} from './url-helper'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
export interface RepositoryObjectFormatResult {
format: string
succeeded: boolean
}
export async function downloadRepository( export async function downloadRepository(
authToken: string, authToken: string,
owner: 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<RepositoryObjectFormatResult> {
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( async function downloadArchive(
authToken: string, authToken: string,
owner: string, owner: string,