diff --git a/__test__/git-source-provider.test.ts b/__test__/git-source-provider.test.ts index aab048d..34efab0 100644 --- a/__test__/git-source-provider.test.ts +++ b/__test__/git-source-provider.test.ts @@ -35,7 +35,11 @@ jest.unstable_mockModule('../src/git-command-manager.js', () => ({ MinimumGitSparseCheckoutVersion: '2.28' })) -const mockDownloadRepository = jest.fn() +// downloadRepository returns the commit it downloaded: the one requested, or the one the ref resolved to. +const resolvedCommitSha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' +const mockDownloadRepository = jest.fn( + async (...args: any[]) => (args[4] as string) || resolvedCommitSha +) jest.unstable_mockModule('../src/github-api-helper.js', () => ({ downloadRepository: mockDownloadRepository, getDefaultBranch: jest.fn(async () => 'refs/heads/main'), @@ -160,7 +164,7 @@ describe('git-source-provider tests', () => { expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) }) - it('sets the commit output when downloading using the REST API without a commit', async () => { + it('sets the commit output to the resolved commit when downloading using the REST API without a commit', async () => { // Arrange mockCreateCommandManager.mockImplementation(async () => { throw new Error('Git is not installed') @@ -173,10 +177,10 @@ describe('git-source-provider tests', () => { // Assert expect(mockDownloadRepository).toHaveBeenCalled() - expect(mockSetOutput).toHaveBeenCalledWith('commit', '') + expect(mockSetOutput).toHaveBeenCalledWith('commit', resolvedCommitSha) }) - it('sets an empty commit output when downloading a ref that is not a SHA', async () => { + it('sets the commit output to the resolved commit when downloading a ref that is not a SHA', async () => { // Arrange mockCreateCommandManager.mockImplementation(async () => { throw new Error('Git is not installed') @@ -190,7 +194,7 @@ describe('git-source-provider tests', () => { // Assert expect(mockDownloadRepository).toHaveBeenCalled() - expect(mockSetOutput).toHaveBeenCalledWith('commit', undefined) + expect(mockSetOutput).toHaveBeenCalledWith('commit', resolvedCommitSha) }) it('sets the commit output when downloading a SHA-256 object format repository', async () => { diff --git a/__test__/github-api-helper.test.ts b/__test__/github-api-helper.test.ts index 3470f20..9341db9 100644 --- a/__test__/github-api-helper.test.ts +++ b/__test__/github-api-helper.test.ts @@ -2,10 +2,11 @@ import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals' // Mock @actions/core const mockDebug = jest.fn() +const mockWarning = jest.fn() jest.unstable_mockModule('@actions/core', () => ({ debug: mockDebug, info: jest.fn(), - warning: jest.fn(), + warning: mockWarning, error: jest.fn() })) @@ -15,6 +16,11 @@ jest.unstable_mockModule('@actions/github', () => ({ getOctokit: mockGetOctokit })) +// Run retried actions once, so a failing lookup does not wait out the backoff +jest.unstable_mockModule('../src/retry-helper.js', () => ({ + execute: async (action: () => Promise) => await action() +})) + // Dynamic imports after mocking const githubApiHelper = await import('../src/github-api-helper.js') @@ -110,3 +116,74 @@ describe('github-api-helper object format', () => { ) }) }) + +describe('github-api-helper commit resolution', () => { + const sha1 = '0123456789abcdef0123456789abcdef01234567' + const sha256 = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + let request: jest.Mock + + function mockCommitApi(data: unknown): void { + request = jest.fn(async () => ({data})) + mockGetOctokit.mockReturnValue({request} as any) + } + + afterEach(() => { + jest.clearAllMocks() + }) + + it('resolves a ref to the SHA of its commit', async () => { + mockCommitApi(`${sha1}\n`) + + await expect( + githubApiHelper.tryGetCommitSha( + 'token', + 'owner', + 'repo', + 'refs/heads/dev' + ) + ).resolves.toBe(sha1) + expect(request).toHaveBeenCalledWith( + 'GET /repos/{owner}/{repo}/commits/{ref}', + { + owner: 'owner', + repo: 'repo', + ref: 'refs/heads/dev', + headers: {accept: 'application/vnd.github.sha'} + } + ) + }) + + it('resolves a ref in a SHA-256 repository', async () => { + mockCommitApi(sha256) + + await expect( + githubApiHelper.tryGetCommitSha('token', 'owner', 'repo', 'v1.0.0') + ).resolves.toBe(sha256) + }) + + it('returns an empty string and warns when the response is not a SHA', async () => { + mockCommitApi('not a sha') + + await expect( + githubApiHelper.tryGetCommitSha('token', 'owner', 'repo', 'main') + ).resolves.toBe('') + expect(mockWarning).toHaveBeenCalledWith( + expect.stringContaining("Unable to resolve the commit for 'main'") + ) + }) + + it('returns an empty string and warns when the lookup fails', async () => { + request = jest.fn(async () => { + throw new Error('Not Found') + }) + mockGetOctokit.mockReturnValue({request} as any) + + await expect( + githubApiHelper.tryGetCommitSha('token', 'owner', 'repo', 'gone') + ).resolves.toBe('') + expect(mockWarning).toHaveBeenCalledWith( + "Unable to resolve the commit for 'gone': Not Found" + ) + }) +}) diff --git a/dist/index.js b/dist/index.js index 495e1f0..808f847 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41324,6 +41324,11 @@ async function downloadRepository(authToken, owner, repo, ref, commit, repositor info('Determining the default branch'); ref = await getDefaultBranch(authToken, owner, repo, baseUrl); } + // Without a commit, resolve the ref to one and download that commit, so the + // content and the returned SHA agree even if the ref moves in the meantime. + if (!commit) { + commit = await tryGetCommitSha(authToken, owner, repo, ref, baseUrl); + } // Download the archive let archiveData = await execute(async () => { info('Downloading the archive'); @@ -41367,6 +41372,30 @@ async function downloadRepository(authToken, owner, repo, ref, commit, repositor } } await rmRF(extractPath); + return commit; +} +/** + * Resolves a ref to the SHA of the commit it points at. Returns an empty string + * when the ref cannot be resolved, so the download falls back to the ref itself. + */ +async function tryGetCommitSha(authToken, owner, repo, ref, baseUrl) { + try { + return await execute(async () => { + info(`Resolving the commit for '${ref}'`); + const octokit = getOctokit(authToken, { + baseUrl: getServerApiUrl(baseUrl) + }); + const response = await octokit.request('GET /repos/{owner}/{repo}/commits/{ref}', { owner, repo, ref, headers: { accept: 'application/vnd.github.sha' } }); + const sha = String(response.data).trim(); + external_assert_.ok(/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(sha), `Unexpected commit SHA '${sha}'`); + info(`Resolved '${ref}' to ${sha}`); + return sha; + }); + } + catch (err) { + warning(`Unable to resolve the commit for '${ref}': ${err?.message ?? err}`); + return ''; + } } /** * Looks up the default branch name @@ -41754,10 +41783,11 @@ async function getSource(settings) { else if (settings.sshKey) { throw new Error(`Input 'ssh-key' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${MinimumGitVersion} or higher to the PATH.`); } - await downloadRepository(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit, settings.repositoryPath, settings.githubServerUrl); + const downloadedCommit = await downloadRepository(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit, settings.repositoryPath, settings.githubServerUrl); // Set the commit output. The REST API fallback does not create a local - // Git repository, so the SHA can only come from the resolved settings. - setOutput('commit', settings.commit); + // Git repository, so the SHA is the commit that was downloaded: the one + // requested, or the one the ref resolved to. + setOutput('commit', downloadedCommit); return; } // Save state for POST action diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 5649999..78008b5 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -90,7 +90,7 @@ export async function getSource(settings: IGitSourceSettings): Promise { ) } - await githubApiHelper.downloadRepository( + const downloadedCommit = await githubApiHelper.downloadRepository( settings.authToken, settings.repositoryOwner, settings.repositoryName, @@ -101,8 +101,9 @@ export async function getSource(settings: IGitSourceSettings): Promise { ) // Set the commit output. The REST API fallback does not create a local - // Git repository, so the SHA can only come from the resolved settings. - core.setOutput('commit', settings.commit) + // Git repository, so the SHA is the commit that was downloaded: the one + // requested, or the one the ref resolved to. + core.setOutput('commit', downloadedCommit) return } diff --git a/src/github-api-helper.ts b/src/github-api-helper.ts index 61d7459..e263207 100644 --- a/src/github-api-helper.ts +++ b/src/github-api-helper.ts @@ -24,13 +24,19 @@ export async function downloadRepository( commit: string, repositoryPath: string, baseUrl?: string -): Promise { +): Promise { // Determine the default branch if (!ref && !commit) { core.info('Determining the default branch') ref = await getDefaultBranch(authToken, owner, repo, baseUrl) } + // Without a commit, resolve the ref to one and download that commit, so the + // content and the returned SHA agree even if the ref moves in the meantime. + if (!commit) { + commit = await tryGetCommitSha(authToken, owner, repo, ref, baseUrl) + } + // Download the archive let archiveData = await retryHelper.execute(async () => { core.info('Downloading the archive') @@ -79,6 +85,45 @@ export async function downloadRepository( } } await io.rmRF(extractPath) + + return commit +} + +/** + * Resolves a ref to the SHA of the commit it points at. Returns an empty string + * when the ref cannot be resolved, so the download falls back to the ref itself. + */ +export async function tryGetCommitSha( + authToken: string, + owner: string, + repo: string, + ref: string, + baseUrl?: string +): Promise { + try { + return await retryHelper.execute(async () => { + core.info(`Resolving the commit for '${ref}'`) + const octokit = github.getOctokit(authToken, { + baseUrl: getServerApiUrl(baseUrl) + }) + const response = await octokit.request( + 'GET /repos/{owner}/{repo}/commits/{ref}', + {owner, repo, ref, headers: {accept: 'application/vnd.github.sha'}} + ) + const sha = String(response.data).trim() + assert.ok( + /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(sha), + `Unexpected commit SHA '${sha}'` + ) + core.info(`Resolved '${ref}' to ${sha}`) + return sha + }) + } catch (err) { + core.warning( + `Unable to resolve the commit for '${ref}': ${(err as any)?.message ?? err}` + ) + return '' + } } /**