From 064fe7f3312418007dea2b49a19844a9ee378f49 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:07:38 -0500 Subject: [PATCH 1/6] Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID is set (#2355) * Initial plan * Add orchestration ID support to git user-agent Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improve tests to verify user-agent content and handle empty sanitized IDs Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com> * Simplify orchestration ID validation to accept any non-empty sanitized value Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com> * Remove test for orchestration ID with only invalid characters Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com> Co-authored-by: Tingluo Huang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- __test__/git-command-manager.test.ts | 117 +++++++++++++++++++++++++++ dist/index.js | 12 ++- src/git-command-manager.ts | 14 +++- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts index cea73d4..23b8863 100644 --- a/__test__/git-command-manager.test.ts +++ b/__test__/git-command-manager.test.ts @@ -376,3 +376,120 @@ describe('Test fetchDepth and fetchTags options', () => { ) }) }) + +describe('git user-agent with orchestration ID', () => { + beforeEach(async () => { + jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) + jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + // Clean up environment variable to prevent test pollution + delete process.env['ACTIONS_ORCHESTRATION_ID'] + }) + + it('should include orchestration ID in user-agent when ACTIONS_ORCHESTRATION_ID is set', async () => { + const orchId = 'test-orch-id-12345' + process.env['ACTIONS_ORCHESTRATION_ID'] = orchId + + let capturedEnv: any = null + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('2.18')) + } + // Capture env on any command + capturedEnv = options.env + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + const workingDirectory = 'test' + const lfs = false + const doSparseCheckout = false + git = await commandManager.createCommandManager( + workingDirectory, + lfs, + doSparseCheckout + ) + + // Call a git command to trigger env capture after user-agent is set + await git.init() + + // Verify the user agent includes the orchestration ID + expect(git).toBeDefined() + expect(capturedEnv).toBeDefined() + expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe( + `git/2.18 (github-actions-checkout) actions_orchestration_id/${orchId}` + ) + }) + + it('should sanitize invalid characters in orchestration ID', async () => { + const orchId = 'test (with) special/chars' + process.env['ACTIONS_ORCHESTRATION_ID'] = orchId + + let capturedEnv: any = null + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('2.18')) + } + // Capture env on any command + capturedEnv = options.env + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + const workingDirectory = 'test' + const lfs = false + const doSparseCheckout = false + git = await commandManager.createCommandManager( + workingDirectory, + lfs, + doSparseCheckout + ) + + // Call a git command to trigger env capture after user-agent is set + await git.init() + + // Verify the user agent has sanitized orchestration ID (spaces, parentheses, slash replaced) + expect(git).toBeDefined() + expect(capturedEnv).toBeDefined() + expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe( + 'git/2.18 (github-actions-checkout) actions_orchestration_id/test__with__special_chars' + ) + }) + + it('should not modify user-agent when ACTIONS_ORCHESTRATION_ID is not set', async () => { + delete process.env['ACTIONS_ORCHESTRATION_ID'] + + let capturedEnv: any = null + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('2.18')) + } + // Capture env on any command + capturedEnv = options.env + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + const workingDirectory = 'test' + const lfs = false + const doSparseCheckout = false + git = await commandManager.createCommandManager( + workingDirectory, + lfs, + doSparseCheckout + ) + + // Call a git command to trigger env capture after user-agent is set + await git.init() + + // Verify the user agent does NOT contain orchestration ID + expect(git).toBeDefined() + expect(capturedEnv).toBeDefined() + expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe( + 'git/2.18 (github-actions-checkout)' + ) + }) +}) diff --git a/dist/index.js b/dist/index.js index b9b34d3..4eab86e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1206,7 +1206,17 @@ class GitCommandManager { } } // Set the user agent - const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`; + let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`; + // Append orchestration ID if set + const orchId = process.env['ACTIONS_ORCHESTRATION_ID']; + if (orchId) { + // Sanitize the orchestration ID to ensure it contains only valid characters + // Valid characters: 0-9, a-z, _, -, . + const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_'); + if (sanitizedId) { + gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`; + } + } core.debug(`Set git useragent to: ${gitHttpUserAgent}`); this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent; }); diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index a45e15a..eba285a 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -730,7 +730,19 @@ class GitCommandManager { } } // Set the user agent - const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)` + let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)` + + // Append orchestration ID if set + const orchId = process.env['ACTIONS_ORCHESTRATION_ID'] + if (orchId) { + // Sanitize the orchestration ID to ensure it contains only valid characters + // Valid characters: 0-9, a-z, _, -, . + const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_') + if (sanitizedId) { + gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}` + } + } + core.debug(`Set git useragent to: ${gitHttpUserAgent}`) this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent } From de0fac2e4500dabe0009e67214ff5f5447ce83dd Mon Sep 17 00:00:00 2001 From: eric sciple Date: Fri, 9 Jan 2026 13:42:23 -0600 Subject: [PATCH 2/6] Fix tag handling: preserve annotations and explicit fetch-tags (#2356) This PR fixes several issues with tag handling in the checkout action: 1. fetch-tags: true now works (fixes #1471) - Tags refspec is now included in getRefSpec() when fetchTags=true - Previously tags were only fetched during a separate fetch that was overwritten by the main fetch 2. Tag checkout preserves annotations (fixes #290) - Tags are fetched via refspec (+refs/tags/*:refs/tags/*) instead of --tags flag - This fetches the actual tag objects, preserving annotations 3. Tag checkout with fetch-tags: true no longer fails (fixes #1467) - When checking out a tag with fetchTags=true, only the wildcard refspec is used (specific tag refspec is redundant) Changes: - src/ref-helper.ts: getRefSpec() now accepts fetchTags parameter and prepends tags refspec when true - src/git-command-manager.ts: fetch() simplified to always use --no-tags, tags are fetched explicitly via refspec - src/git-source-provider.ts: passes fetchTags to getRefSpec() - Added E2E test for fetch-tags option Related #1471, #1467, #290 --- .github/workflows/test.yml | 11 +++ __test__/git-command-manager.test.ts | 105 ++++++++++++++------------- __test__/ref-helper.test.ts | 42 ++++++++++- __test__/verify-fetch-tags.sh | 9 +++ dist/index.js | 68 ++++++++++++----- src/git-command-manager.ts | 8 +- src/git-source-provider.ts | 28 ++++++- src/ref-helper.ts | 50 +++++++++---- 8 files changed, 226 insertions(+), 95 deletions(-) create mode 100755 __test__/verify-fetch-tags.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe2539f..0383c88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,17 @@ jobs: - name: Verify fetch filter run: __test__/verify-fetch-filter.sh + # Fetch tags + - name: Checkout with fetch-tags + uses: ./ + with: + ref: test-data/v2/basic + path: fetch-tags-test + fetch-tags: true + - name: Verify fetch-tags + shell: bash + run: __test__/verify-fetch-tags.sh + # Sparse checkout - name: Sparse checkout uses: ./ diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts index 23b8863..8a97d82 100644 --- a/__test__/git-command-manager.test.ts +++ b/__test__/git-command-manager.test.ts @@ -108,7 +108,7 @@ describe('Test fetchDepth and fetchTags options', () => { jest.restoreAllMocks() }) - it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is true', async () => { + it('should call execGit with the correct arguments when fetchDepth is 0', async () => { jest.spyOn(exec, 'exec').mockImplementation(mockExec) const workingDirectory = 'test' const lfs = false @@ -122,45 +122,7 @@ describe('Test fetchDepth and fetchTags options', () => { const refSpec = ['refspec1', 'refspec2'] const options = { filter: 'filterValue', - fetchDepth: 0, - fetchTags: true - } - - await git.fetch(refSpec, options) - - expect(mockExec).toHaveBeenCalledWith( - expect.any(String), - [ - '-c', - 'protocol.version=2', - 'fetch', - '--prune', - '--no-recurse-submodules', - '--filter=filterValue', - 'origin', - 'refspec1', - 'refspec2' - ], - expect.any(Object) - ) - }) - - it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is false', async () => { - jest.spyOn(exec, 'exec').mockImplementation(mockExec) - - const workingDirectory = 'test' - const lfs = false - const doSparseCheckout = false - git = await commandManager.createCommandManager( - workingDirectory, - lfs, - doSparseCheckout - ) - const refSpec = ['refspec1', 'refspec2'] - const options = { - filter: 'filterValue', - fetchDepth: 0, - fetchTags: false + fetchDepth: 0 } await git.fetch(refSpec, options) @@ -183,7 +145,45 @@ describe('Test fetchDepth and fetchTags options', () => { ) }) - it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is false', async () => { + it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => { + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + const workingDirectory = 'test' + const lfs = false + const doSparseCheckout = false + git = await commandManager.createCommandManager( + workingDirectory, + lfs, + doSparseCheckout + ) + const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*'] + const options = { + filter: 'filterValue', + fetchDepth: 0 + } + + await git.fetch(refSpec, options) + + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + [ + '-c', + 'protocol.version=2', + 'fetch', + '--no-tags', + '--prune', + '--no-recurse-submodules', + '--filter=filterValue', + 'origin', + 'refspec1', + 'refspec2', + '+refs/tags/*:refs/tags/*' + ], + expect.any(Object) + ) + }) + + it('should call execGit with the correct arguments when fetchDepth is 1', async () => { jest.spyOn(exec, 'exec').mockImplementation(mockExec) const workingDirectory = 'test' @@ -197,8 +197,7 @@ describe('Test fetchDepth and fetchTags options', () => { const refSpec = ['refspec1', 'refspec2'] const options = { filter: 'filterValue', - fetchDepth: 1, - fetchTags: false + fetchDepth: 1 } await git.fetch(refSpec, options) @@ -222,7 +221,7 @@ describe('Test fetchDepth and fetchTags options', () => { ) }) - it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is true', async () => { + it('should call execGit with the correct arguments when fetchDepth is 1 and refSpec includes tags', async () => { jest.spyOn(exec, 'exec').mockImplementation(mockExec) const workingDirectory = 'test' @@ -233,11 +232,10 @@ describe('Test fetchDepth and fetchTags options', () => { lfs, doSparseCheckout ) - const refSpec = ['refspec1', 'refspec2'] + const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*'] const options = { filter: 'filterValue', - fetchDepth: 1, - fetchTags: true + fetchDepth: 1 } await git.fetch(refSpec, options) @@ -248,13 +246,15 @@ describe('Test fetchDepth and fetchTags options', () => { '-c', 'protocol.version=2', 'fetch', + '--no-tags', '--prune', '--no-recurse-submodules', '--filter=filterValue', '--depth=1', 'origin', 'refspec1', - 'refspec2' + 'refspec2', + '+refs/tags/*:refs/tags/*' ], expect.any(Object) ) @@ -338,7 +338,7 @@ describe('Test fetchDepth and fetchTags options', () => { ) }) - it('should call execGit with the correct arguments when fetchTags is true and showProgress is true', async () => { + it('should call execGit with the correct arguments when showProgress is true and refSpec includes tags', async () => { jest.spyOn(exec, 'exec').mockImplementation(mockExec) const workingDirectory = 'test' @@ -349,10 +349,9 @@ describe('Test fetchDepth and fetchTags options', () => { lfs, doSparseCheckout ) - const refSpec = ['refspec1', 'refspec2'] + const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*'] const options = { filter: 'filterValue', - fetchTags: true, showProgress: true } @@ -364,13 +363,15 @@ describe('Test fetchDepth and fetchTags options', () => { '-c', 'protocol.version=2', 'fetch', + '--no-tags', '--prune', '--no-recurse-submodules', '--progress', '--filter=filterValue', 'origin', 'refspec1', - 'refspec2' + 'refspec2', + '+refs/tags/*:refs/tags/*' ], expect.any(Object) ) diff --git a/__test__/ref-helper.test.ts b/__test__/ref-helper.test.ts index 5c8d76b..4943abd 100644 --- a/__test__/ref-helper.test.ts +++ b/__test__/ref-helper.test.ts @@ -152,7 +152,22 @@ describe('ref-helper tests', () => { it('getRefSpec sha + refs/tags/', async () => { const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit) expect(refSpec.length).toBe(1) - expect(refSpec[0]).toBe(`+${commit}:refs/tags/my-tag`) + expect(refSpec[0]).toBe(`+refs/tags/my-tag:refs/tags/my-tag`) + }) + + it('getRefSpec sha + refs/tags/ with fetchTags', async () => { + // When fetchTags is true, only include tags wildcard (specific tag is redundant) + const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit, true) + expect(refSpec.length).toBe(1) + expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') + }) + + it('getRefSpec sha + refs/heads/ with fetchTags', async () => { + // When fetchTags is true, include both the branch refspec and tags wildcard + const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit, true) + expect(refSpec.length).toBe(2) + expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') + expect(refSpec[1]).toBe(`+${commit}:refs/remotes/origin/my/branch`) }) it('getRefSpec sha only', async () => { @@ -168,6 +183,14 @@ describe('ref-helper tests', () => { expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*') }) + it('getRefSpec unqualified ref only with fetchTags', async () => { + // When fetchTags is true, skip specific tag pattern since wildcard covers all + const refSpec = refHelper.getRefSpec('my-ref', '', true) + expect(refSpec.length).toBe(2) + expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') + expect(refSpec[1]).toBe('+refs/heads/my-ref*:refs/remotes/origin/my-ref*') + }) + it('getRefSpec refs/heads/ only', async () => { const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '') expect(refSpec.length).toBe(1) @@ -187,4 +210,21 @@ describe('ref-helper tests', () => { expect(refSpec.length).toBe(1) expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag') }) + + it('getRefSpec refs/tags/ only with fetchTags', async () => { + // When fetchTags is true, only include tags wildcard (specific tag is redundant) + const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '', true) + expect(refSpec.length).toBe(1) + expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') + }) + + it('getRefSpec refs/heads/ only with fetchTags', async () => { + // When fetchTags is true, include both the branch refspec and tags wildcard + const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '', true) + expect(refSpec.length).toBe(2) + expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') + expect(refSpec[1]).toBe( + '+refs/heads/my/branch:refs/remotes/origin/my/branch' + ) + }) }) diff --git a/__test__/verify-fetch-tags.sh b/__test__/verify-fetch-tags.sh new file mode 100755 index 0000000..74cff1e --- /dev/null +++ b/__test__/verify-fetch-tags.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +# Verify tags were fetched +TAG_COUNT=$(git -C ./fetch-tags-test tag | wc -l) +if [ "$TAG_COUNT" -eq 0 ]; then + echo "Expected tags to be fetched, but found none" + exit 1 +fi +echo "Found $TAG_COUNT tags" diff --git a/dist/index.js b/dist/index.js index 4eab86e..fe3f317 100644 --- a/dist/index.js +++ b/dist/index.js @@ -653,7 +653,6 @@ const fs = __importStar(__nccwpck_require__(7147)); const fshelper = __importStar(__nccwpck_require__(7219)); const io = __importStar(__nccwpck_require__(7436)); const path = __importStar(__nccwpck_require__(1017)); -const refHelper = __importStar(__nccwpck_require__(8601)); const regexpHelper = __importStar(__nccwpck_require__(3120)); const retryHelper = __importStar(__nccwpck_require__(2155)); const git_version_1 = __nccwpck_require__(3142); @@ -831,9 +830,9 @@ class GitCommandManager { fetch(refSpec, options) { return __awaiter(this, void 0, void 0, function* () { const args = ['-c', 'protocol.version=2', 'fetch']; - if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) { - args.push('--no-tags'); - } + // Always use --no-tags for explicit control over tag fetching + // Tags are fetched explicitly via refspec when needed + args.push('--no-tags'); args.push('--prune', '--no-recurse-submodules'); if (options.showProgress) { args.push('--progress'); @@ -1539,13 +1538,26 @@ function getSource(settings) { if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) { refSpec = refHelper.getRefSpec(settings.ref, settings.commit); yield git.fetch(refSpec, fetchOptions); + // Verify the ref now matches. For branches, the targeted fetch above brings + // in the specific commit. For tags (fetched by ref), this will fail if + // the tag was moved after the workflow was triggered. + if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) { + throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` + + `The ref may have been updated after the workflow was triggered.`); + } } } else { fetchOptions.fetchDepth = settings.fetchDepth; - fetchOptions.fetchTags = settings.fetchTags; - const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); + const refSpec = refHelper.getRefSpec(settings.ref, settings.commit, settings.fetchTags); yield git.fetch(refSpec, fetchOptions); + // For tags, verify the ref still points to the expected commit. + // Tags are fetched by ref (not commit), so if a tag was moved after the + // workflow was triggered, we would silently check out the wrong commit. + if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) { + throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` + + `The ref may have been updated after the workflow was triggered.`); + } } core.endGroup(); // Checkout info @@ -2284,53 +2296,67 @@ function getRefSpecForAllHistory(ref, commit) { } return result; } -function getRefSpec(ref, commit) { +function getRefSpec(ref, commit, fetchTags) { if (!ref && !commit) { throw new Error('Args ref and commit cannot both be empty'); } const upperRef = (ref || '').toUpperCase(); + const result = []; + // When fetchTags is true, always include the tags refspec + if (fetchTags) { + result.push(exports.tagsRefSpec); + } // SHA if (commit) { // refs/heads if (upperRef.startsWith('REFS/HEADS/')) { const branch = ref.substring('refs/heads/'.length); - return [`+${commit}:refs/remotes/origin/${branch}`]; + result.push(`+${commit}:refs/remotes/origin/${branch}`); } // refs/pull/ else if (upperRef.startsWith('REFS/PULL/')) { const branch = ref.substring('refs/pull/'.length); - return [`+${commit}:refs/remotes/pull/${branch}`]; + result.push(`+${commit}:refs/remotes/pull/${branch}`); } // refs/tags/ else if (upperRef.startsWith('REFS/TAGS/')) { - return [`+${commit}:${ref}`]; + if (!fetchTags) { + result.push(`+${ref}:${ref}`); + } } // Otherwise no destination ref else { - return [commit]; + result.push(commit); } } // Unqualified ref, check for a matching branch or tag else if (!upperRef.startsWith('REFS/')) { - return [ - `+refs/heads/${ref}*:refs/remotes/origin/${ref}*`, - `+refs/tags/${ref}*:refs/tags/${ref}*` - ]; + result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`); + if (!fetchTags) { + result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`); + } } // refs/heads/ else if (upperRef.startsWith('REFS/HEADS/')) { const branch = ref.substring('refs/heads/'.length); - return [`+${ref}:refs/remotes/origin/${branch}`]; + result.push(`+${ref}:refs/remotes/origin/${branch}`); } // refs/pull/ else if (upperRef.startsWith('REFS/PULL/')) { const branch = ref.substring('refs/pull/'.length); - return [`+${ref}:refs/remotes/pull/${branch}`]; + result.push(`+${ref}:refs/remotes/pull/${branch}`); } // refs/tags/ - else { - return [`+${ref}:${ref}`]; + else if (upperRef.startsWith('REFS/TAGS/')) { + if (!fetchTags) { + result.push(`+${ref}:${ref}`); + } } + // Other refs + else { + result.push(`+${ref}:${ref}`); + } + return result; } /** * Tests whether the initial fetch created the ref at the expected commit @@ -2366,7 +2392,9 @@ function testRef(git, ref, commit) { // refs/tags/ else if (upperRef.startsWith('REFS/TAGS/')) { const tagName = ref.substring('refs/tags/'.length); - return ((yield git.tagExists(tagName)) && commit === (yield git.revParse(ref))); + // Use ^{commit} to dereference annotated tags to their underlying commit + return ((yield git.tagExists(tagName)) && + commit === (yield git.revParse(`${ref}^{commit}`))); } // Unexpected else { diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index eba285a..f5ba40e 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -37,7 +37,6 @@ export interface IGitCommandManager { options: { filter?: string fetchDepth?: number - fetchTags?: boolean showProgress?: boolean } ): Promise @@ -280,14 +279,13 @@ class GitCommandManager { options: { filter?: string fetchDepth?: number - fetchTags?: boolean showProgress?: boolean } ): Promise { const args = ['-c', 'protocol.version=2', 'fetch'] - if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) { - args.push('--no-tags') - } + // Always use --no-tags for explicit control over tag fetching + // Tags are fetched explicitly via refspec when needed + args.push('--no-tags') args.push('--prune', '--no-recurse-submodules') if (options.showProgress) { diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 2d35138..ec87178 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -159,7 +159,6 @@ export async function getSource(settings: IGitSourceSettings): Promise { const fetchOptions: { filter?: string fetchDepth?: number - fetchTags?: boolean showProgress?: boolean } = {} @@ -182,12 +181,35 @@ export async function getSource(settings: IGitSourceSettings): Promise { if (!(await refHelper.testRef(git, settings.ref, settings.commit))) { refSpec = refHelper.getRefSpec(settings.ref, settings.commit) await git.fetch(refSpec, fetchOptions) + + // Verify the ref now matches. For branches, the targeted fetch above brings + // in the specific commit. For tags (fetched by ref), this will fail if + // the tag was moved after the workflow was triggered. + if (!(await refHelper.testRef(git, settings.ref, settings.commit))) { + throw new Error( + `The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` + + `The ref may have been updated after the workflow was triggered.` + ) + } } } else { fetchOptions.fetchDepth = settings.fetchDepth - fetchOptions.fetchTags = settings.fetchTags - const refSpec = refHelper.getRefSpec(settings.ref, settings.commit) + const refSpec = refHelper.getRefSpec( + settings.ref, + settings.commit, + settings.fetchTags + ) await git.fetch(refSpec, fetchOptions) + + // For tags, verify the ref still points to the expected commit. + // Tags are fetched by ref (not commit), so if a tag was moved after the + // workflow was triggered, we would silently check out the wrong commit. + if (!(await refHelper.testRef(git, settings.ref, settings.commit))) { + throw new Error( + `The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` + + `The ref may have been updated after the workflow was triggered.` + ) + } } core.endGroup() diff --git a/src/ref-helper.ts b/src/ref-helper.ts index 58f9290..5130f53 100644 --- a/src/ref-helper.ts +++ b/src/ref-helper.ts @@ -76,55 +76,75 @@ export function getRefSpecForAllHistory(ref: string, commit: string): string[] { return result } -export function getRefSpec(ref: string, commit: string): string[] { +export function getRefSpec( + ref: string, + commit: string, + fetchTags?: boolean +): string[] { if (!ref && !commit) { throw new Error('Args ref and commit cannot both be empty') } const upperRef = (ref || '').toUpperCase() + const result: string[] = [] + + // When fetchTags is true, always include the tags refspec + if (fetchTags) { + result.push(tagsRefSpec) + } // SHA if (commit) { // refs/heads if (upperRef.startsWith('REFS/HEADS/')) { const branch = ref.substring('refs/heads/'.length) - return [`+${commit}:refs/remotes/origin/${branch}`] + result.push(`+${commit}:refs/remotes/origin/${branch}`) } // refs/pull/ else if (upperRef.startsWith('REFS/PULL/')) { const branch = ref.substring('refs/pull/'.length) - return [`+${commit}:refs/remotes/pull/${branch}`] + result.push(`+${commit}:refs/remotes/pull/${branch}`) } // refs/tags/ else if (upperRef.startsWith('REFS/TAGS/')) { - return [`+${commit}:${ref}`] + if (!fetchTags) { + result.push(`+${ref}:${ref}`) + } } // Otherwise no destination ref else { - return [commit] + result.push(commit) } } // Unqualified ref, check for a matching branch or tag else if (!upperRef.startsWith('REFS/')) { - return [ - `+refs/heads/${ref}*:refs/remotes/origin/${ref}*`, - `+refs/tags/${ref}*:refs/tags/${ref}*` - ] + result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`) + if (!fetchTags) { + result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`) + } } // refs/heads/ else if (upperRef.startsWith('REFS/HEADS/')) { const branch = ref.substring('refs/heads/'.length) - return [`+${ref}:refs/remotes/origin/${branch}`] + result.push(`+${ref}:refs/remotes/origin/${branch}`) } // refs/pull/ else if (upperRef.startsWith('REFS/PULL/')) { const branch = ref.substring('refs/pull/'.length) - return [`+${ref}:refs/remotes/pull/${branch}`] + result.push(`+${ref}:refs/remotes/pull/${branch}`) } // refs/tags/ - else { - return [`+${ref}:${ref}`] + else if (upperRef.startsWith('REFS/TAGS/')) { + if (!fetchTags) { + result.push(`+${ref}:${ref}`) + } } + // Other refs + else { + result.push(`+${ref}:${ref}`) + } + + return result } /** @@ -170,8 +190,10 @@ export async function testRef( // refs/tags/ else if (upperRef.startsWith('REFS/TAGS/')) { const tagName = ref.substring('refs/tags/'.length) + // Use ^{commit} to dereference annotated tags to their underlying commit return ( - (await git.tagExists(tagName)) && commit === (await git.revParse(ref)) + (await git.tagExists(tagName)) && + commit === (await git.revParse(`${ref}^{commit}`)) ) } // Unexpected From 0c366fd6a839edf440554fa01a7085ccba70ac98 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Fri, 9 Jan 2026 14:09:42 -0600 Subject: [PATCH 3/6] Update changelog (#2357) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5a6f3..bcd8126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v6.0.2 +* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356 + +## v6.0.1 +* Add worktree support for persist-credentials includeIf by @ericsciple in https://github.com/actions/checkout/pull/2327 + ## 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 From 900f2210b1d28bbbd0bd22d17926b9e224e8f231 Mon Sep 17 00:00:00 2001 From: Yashwanth Anantharaju Date: Mon, 4 May 2026 13:30:55 -0400 Subject: [PATCH 4/6] fix: expand merge commit SHA regex and add SHA-256 test cases (#2414) * fix: expand merge commit SHA regex and add SHA-256 test cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add checkCommitInfo SHA coverage Add checkCommitInfo tests for SHA-1 and SHA-256 merge messages and reject invalid 50-character hex merge heads.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: fix Prettier formatting in test and source files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __test__/input-helper.test.ts | 10 +++ __test__/ref-helper.test.ts | 148 ++++++++++++++++++++++++++++++++++ dist/index.js | 4 +- src/input-helper.ts | 2 +- src/ref-helper.ts | 4 +- 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 9514cb4..09331eb 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -133,6 +133,16 @@ describe('input-helper tests', () => { expect(settings.commit).toBe('1111111111222222222233333333334444444444') }) + it('sets ref to empty when explicit sha-256', async () => { + inputs.ref = + '1111111111222222222233333333334444444444555555555566666666667777' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.ref).toBeFalsy() + expect(settings.commit).toBe( + '1111111111222222222233333333334444444444555555555566666666667777' + ) + }) + it('sets sha to empty when explicit ref', async () => { inputs.ref = 'refs/heads/some-other-ref' const settings: IGitSourceSettings = await inputHelper.getInputs() diff --git a/__test__/ref-helper.test.ts b/__test__/ref-helper.test.ts index 4943abd..b1578f2 100644 --- a/__test__/ref-helper.test.ts +++ b/__test__/ref-helper.test.ts @@ -1,8 +1,12 @@ import * as assert from 'assert' +import * as core from '@actions/core' +import * as github from '@actions/github' import * as refHelper from '../lib/ref-helper' import {IGitCommandManager} from '../lib/git-command-manager' const commit = '1234567890123456789012345678901234567890' +const sha256Commit = + '1234567890123456789012345678901234567890123456789012345678901234' let git: IGitCommandManager describe('ref-helper tests', () => { @@ -37,6 +41,12 @@ describe('ref-helper tests', () => { expect(checkoutInfo.startPoint).toBeFalsy() }) + it('getCheckoutInfo sha-256 only', async () => { + const checkoutInfo = await refHelper.getCheckoutInfo(git, '', sha256Commit) + expect(checkoutInfo.ref).toBe(sha256Commit) + expect(checkoutInfo.startPoint).toBeFalsy() + }) + it('getCheckoutInfo refs/heads/', async () => { const checkoutInfo = await refHelper.getCheckoutInfo( git, @@ -227,4 +237,142 @@ describe('ref-helper tests', () => { '+refs/heads/my/branch:refs/remotes/origin/my/branch' ) }) + + describe('checkCommitInfo', () => { + const repositoryOwner = 'some-owner' + const repositoryName = 'some-repo' + const ref = 'refs/pull/123/merge' + const sha1Head = '1111111111222222222233333333334444444444' + const sha1Base = 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd' + const sha256Head = + '1111111111222222222233333333334444444444555555555566666666667777' + const sha256Base = + 'aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffff0000' + let debugSpy: jest.SpyInstance + let getOctokitSpy: jest.SpyInstance + let repoGetSpy: jest.Mock + let originalEventName: string + let originalPayload: unknown + let originalRef: string + let originalSha: string + + function setPullRequestContext( + expectedHeadSha: string, + expectedBaseSha: string, + mergeCommit: string + ): void { + ;(github.context as any).eventName = 'pull_request' + github.context.ref = ref + github.context.sha = mergeCommit + ;(github.context as any).payload = { + action: 'synchronize', + after: expectedHeadSha, + number: 123, + pull_request: { + base: { + sha: expectedBaseSha + } + }, + repository: { + private: false + } + } + } + + beforeEach(() => { + originalEventName = github.context.eventName + originalPayload = github.context.payload + originalRef = github.context.ref + originalSha = github.context.sha + + jest.spyOn(github.context, 'repo', 'get').mockReturnValue({ + owner: repositoryOwner, + repo: repositoryName + }) + debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn()) + repoGetSpy = jest.fn(async () => ({})) + getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({ + rest: { + repos: { + get: repoGetSpy + } + } + } as any) + }) + + afterEach(() => { + ;(github.context as any).eventName = originalEventName + ;(github.context as any).payload = originalPayload + github.context.ref = originalRef + github.context.sha = originalSha + jest.restoreAllMocks() + }) + + it('returns early for SHA-1 merge commit', async () => { + setPullRequestContext(sha1Head, sha1Base, commit) + + await refHelper.checkCommitInfo( + 'token', + `Merge ${sha1Head} into ${sha1Base}`, + repositoryOwner, + repositoryName, + ref, + commit + ) + + expect(getOctokitSpy).not.toHaveBeenCalled() + expect(repoGetSpy).not.toHaveBeenCalled() + }) + + it('matches SHA-256 merge commit info', async () => { + const actualHeadSha = + '9999999999888888888877777777776666666666555555555544444444443333' + setPullRequestContext(sha256Head, sha256Base, sha256Commit) + + await refHelper.checkCommitInfo( + 'token', + `Merge ${actualHeadSha} into ${sha256Base}`, + repositoryOwner, + repositoryName, + ref, + sha256Commit + ) + + expect(getOctokitSpy).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ + userAgent: expect.stringContaining( + `expected_head_sha=${sha256Head};actual_head_sha=${actualHeadSha}` + ) + }) + ) + expect(repoGetSpy).toHaveBeenCalledWith({ + owner: repositoryOwner, + repo: repositoryName + }) + expect(debugSpy).toHaveBeenCalledWith( + `Expected head sha ${sha256Head}; actual head sha ${actualHeadSha}` + ) + expect(debugSpy).not.toHaveBeenCalledWith('Unexpected message format') + }) + + it('does not match 50-char hex as a valid merge', async () => { + const invalidHeadSha = + '99999999998888888888777777777766666666665555555555' + setPullRequestContext(sha1Head, sha1Base, commit) + + await refHelper.checkCommitInfo( + 'token', + `Merge ${invalidHeadSha} into ${sha1Base}`, + repositoryOwner, + repositoryName, + ref, + commit + ) + + expect(getOctokitSpy).not.toHaveBeenCalled() + expect(repoGetSpy).not.toHaveBeenCalled() + expect(debugSpy).toHaveBeenCalledWith('Unexpected message format') + }) + }) }) diff --git a/dist/index.js b/dist/index.js index fe3f317..57729b2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2021,7 +2021,7 @@ function getInputs() { } } // SHA? - else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { + else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) { result.commit = result.ref; result.ref = ''; } @@ -2444,7 +2444,7 @@ function checkCommitInfo(token, commitInfo, repositoryOwner, repositoryName, ref return; } // Extract details from message - const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/); + const match = commitInfo.match(/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/); if (!match) { core.debug('Unexpected message format'); return; diff --git a/src/input-helper.ts b/src/input-helper.ts index 059232f..e0c61e2 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -71,7 +71,7 @@ export async function getInputs(): Promise { } } // SHA? - else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { + else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) { result.commit = result.ref result.ref = '' } diff --git a/src/ref-helper.ts b/src/ref-helper.ts index 5130f53..71e8b22 100644 --- a/src/ref-helper.ts +++ b/src/ref-helper.ts @@ -258,7 +258,9 @@ export async function checkCommitInfo( } // Extract details from message - const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/) + const match = commitInfo.match( + /Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/ + ) if (!match) { core.debug('Unexpected message format') return From 1cce3390c2bfda521930d01229c073c7ff920824 Mon Sep 17 00:00:00 2001 From: Yashwanth Anantharaju Date: Mon, 1 Jun 2026 11:35:58 -0400 Subject: [PATCH 5/6] 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, From df4cb1c069e1874edd31b4311f1884172cec0e10 Mon Sep 17 00:00:00 2001 From: Yashwanth Anantharaju Date: Tue, 2 Jun 2026 10:31:30 -0400 Subject: [PATCH 6/6] Update changelog for v6.0.3 (#2446) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8126..ac0716d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 * Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356