From 7282dbb8541bb7ec1a008950b4c1a128dd6a7452 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:57:28 +0000 Subject: [PATCH 1/4] set the commit output when falling back to the REST API When Git is not available on the runner, getSource() downloads the repository using the REST API and returns early, before the commit output is set. The ref output is still set by main.ts after getSource() returns, so the step reports a ref but an empty commit, even though action.yml documents commit unconditionally. Set the output from the resolved settings on that path. The REST API fallback does not create a local Git repository, so git log -1 cannot be used there. --- __test__/git-source-provider.test.ts | 190 +++++++++++++++++++++++++++ dist/index.js | 3 + src/git-source-provider.ts | 4 + 3 files changed, 197 insertions(+) create mode 100644 __test__/git-source-provider.test.ts diff --git a/__test__/git-source-provider.test.ts b/__test__/git-source-provider.test.ts new file mode 100644 index 0000000..44cd1bb --- /dev/null +++ b/__test__/git-source-provider.test.ts @@ -0,0 +1,190 @@ +import {jest, describe, it, expect, beforeEach} from '@jest/globals' + +// Mock @actions/core before loading git-source-provider +const mockSetOutput = jest.fn() +jest.unstable_mockModule('@actions/core', () => ({ + setOutput: mockSetOutput, + setSecret: jest.fn(), + setFailed: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + startGroup: jest.fn(), + endGroup: jest.fn() +})) + +jest.unstable_mockModule('@actions/io', () => ({ + cp: jest.fn(), + mkdirP: jest.fn(), + mv: jest.fn(), + rmRF: jest.fn(), + which: jest.fn() +})) + +jest.unstable_mockModule('../src/fs-helper.js', () => ({ + directoryExistsSync: jest.fn(() => true), + existsSync: jest.fn(() => true), + fileExistsSync: jest.fn(() => false) +})) + +const mockCreateCommandManager = jest.fn() +jest.unstable_mockModule('../src/git-command-manager.js', () => ({ + createCommandManager: mockCreateCommandManager, + MinimumGitVersion: '2.18', + MinimumGitSparseCheckoutVersion: '2.28' +})) + +const mockDownloadRepository = jest.fn() +jest.unstable_mockModule('../src/github-api-helper.js', () => ({ + downloadRepository: mockDownloadRepository, + getDefaultBranch: jest.fn(async () => 'refs/heads/main'), + tryGetRepositoryObjectFormat: jest.fn(async () => ({ + format: 'sha1', + succeeded: true + })) +})) + +jest.unstable_mockModule('../src/git-auth-helper.js', () => ({ + createAuthHelper: jest.fn(() => ({ + configureAuth: jest.fn(), + configureGlobalAuth: jest.fn(), + configureSubmoduleAuth: jest.fn(), + configureTempGlobalConfig: jest.fn(), + removeAuth: jest.fn(), + removeGlobalAuth: jest.fn(), + removeGlobalConfig: jest.fn() + })) +})) + +jest.unstable_mockModule('../src/git-directory-helper.js', () => ({ + prepareExistingDirectory: jest.fn() +})) + +jest.unstable_mockModule('../src/ref-helper.js', () => ({ + checkCommitInfo: jest.fn(), + getCheckoutInfo: jest.fn(async () => ({ + ref: 'main', + startPoint: 'refs/remotes/origin/main' + })), + getRefSpec: jest.fn(() => ['+refs/heads/main:refs/remotes/origin/main']), + getRefSpecForAllHistory: jest.fn(() => [ + '+refs/heads/main*:refs/remotes/origin/main*' + ]), + testRef: jest.fn(async () => true) +})) + +jest.unstable_mockModule('../src/state-helper.js', () => ({ + setRepositoryPath: jest.fn(), + setSafeDirectory: jest.fn(), + IsPost: false, + PostSetSafeDirectory: false, + RepositoryPath: '' +})) + +// Dynamic imports after mocking +const gitSourceProvider = await import('../src/git-source-provider.js') +type IGitSourceSettings = + import('../src/git-source-settings.js').IGitSourceSettings + +const commitSha = '1234567890123456789012345678901234567890' + +function getSettings(): IGitSourceSettings { + return { + allowUnsafePrCheckout: false, + authToken: 'token', + clean: true, + commit: commitSha, + fetchDepth: 1, + fetchTags: false, + filter: undefined, + githubServerUrl: undefined, + lfs: false, + nestedSubmodules: false, + persistCredentials: true, + ref: 'refs/heads/main', + repositoryName: 'my-repo', + repositoryOwner: 'my-org', + repositoryPath: '/home/runner/work/my-repo/my-repo', + setSafeDirectory: false, + showProgress: false, + // Matches getInputs(), which leaves sparseCheckout undefined when the input is empty + sparseCheckout: undefined, + sparseCheckoutConeMode: true, + sshKey: '', + sshKnownHosts: '', + sshStrict: true, + sshUser: '', + submodules: false, + workflowOrganizationId: undefined + } as unknown as IGitSourceSettings +} + +// A minimal git command manager, for the cases that do not fall back to the REST API. +function getGitCommandManager(): any { + return { + checkout: jest.fn(), + config: jest.fn(), + disableSparseCheckout: jest.fn(), + init: jest.fn(), + log1: jest.fn(async (format?: string) => + format ? `${commitSha}\n` : `commit ${commitSha}\n` + ), + remoteAdd: jest.fn(), + fetch: jest.fn(), + tryDisableAutomaticGarbageCollection: jest.fn(async () => true), + version: jest.fn(async () => ({checkMinimum: () => false})) + } +} + +describe('git-source-provider tests', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('sets the commit output when downloading using the REST API', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + }) + + it('sets the commit output when downloading using the REST API without a commit', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + settings.commit = '' + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', '') + }) + + it('sets the commit output from git when git is available (control)', async () => { + // Arrange + const git = getGitCommandManager() + mockCreateCommandManager.mockImplementation(async () => git) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).not.toHaveBeenCalled() + expect(git.checkout).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + }) +}) diff --git a/dist/index.js b/dist/index.js index 06ae5d2..495e1f0 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41755,6 +41755,9 @@ async function getSource(settings) { 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); + // 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); return; } // Save state for POST action diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index b9c1d35..5649999 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -99,6 +99,10 @@ export async function getSource(settings: IGitSourceSettings): Promise { 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. + core.setOutput('commit', settings.commit) return } From f954d861d4105c0cc1fa87c62f90a830aad220d0 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:59:42 +0000 Subject: [PATCH 2/4] add a test for a non-SHA ref on the REST API fallback path --- __test__/git-source-provider.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/__test__/git-source-provider.test.ts b/__test__/git-source-provider.test.ts index 44cd1bb..114b6dc 100644 --- a/__test__/git-source-provider.test.ts +++ b/__test__/git-source-provider.test.ts @@ -173,6 +173,23 @@ describe('git-source-provider tests', () => { expect(mockSetOutput).toHaveBeenCalledWith('commit', '') }) + it('sets an empty commit output when downloading a ref that is not a SHA', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + // getInputs() leaves commit undefined when a non-SHA ref is given for another repository + settings.commit = undefined as unknown as string + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', undefined) + }) + it('sets the commit output from git when git is available (control)', async () => { // Arrange const git = getGitCommandManager() From 56a41f36e54c40b267e3da3300123e9f31b4d268 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:52:15 +0000 Subject: [PATCH 3/4] add tests for the sha256, ordering and error paths of the REST API fallback --- __test__/git-source-provider.test.ts | 127 +++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/__test__/git-source-provider.test.ts b/__test__/git-source-provider.test.ts index 114b6dc..aab048d 100644 --- a/__test__/git-source-provider.test.ts +++ b/__test__/git-source-provider.test.ts @@ -45,16 +45,17 @@ jest.unstable_mockModule('../src/github-api-helper.js', () => ({ })) })) +const mockCreateAuthHelper = jest.fn(() => ({ + configureAuth: jest.fn(), + configureGlobalAuth: jest.fn(), + configureSubmoduleAuth: jest.fn(), + configureTempGlobalConfig: jest.fn(), + removeAuth: jest.fn(), + removeGlobalAuth: jest.fn(), + removeGlobalConfig: jest.fn() +})) jest.unstable_mockModule('../src/git-auth-helper.js', () => ({ - createAuthHelper: jest.fn(() => ({ - configureAuth: jest.fn(), - configureGlobalAuth: jest.fn(), - configureSubmoduleAuth: jest.fn(), - configureTempGlobalConfig: jest.fn(), - removeAuth: jest.fn(), - removeGlobalAuth: jest.fn(), - removeGlobalConfig: jest.fn() - })) + createAuthHelper: mockCreateAuthHelper })) jest.unstable_mockModule('../src/git-directory-helper.js', () => ({ @@ -88,6 +89,8 @@ type IGitSourceSettings = import('../src/git-source-settings.js').IGitSourceSettings const commitSha = '1234567890123456789012345678901234567890' +const commitSha256 = + '1234567890123456789012345678901234567890123456789012345678901234' function getSettings(): IGitSourceSettings { return { @@ -190,6 +193,97 @@ describe('git-source-provider tests', () => { expect(mockSetOutput).toHaveBeenCalledWith('commit', undefined) }) + it('sets the commit output when downloading a SHA-256 object format repository', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + // getInputs() accepts a 64 hex character ref as a commit, for sha256 repositories + settings.commit = commitSha256 + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalledWith( + settings.authToken, + settings.repositoryOwner, + settings.repositoryName, + settings.ref, + commitSha256, + settings.repositoryPath, + settings.githubServerUrl + ) + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha256) + }) + + it('sets the commit output after the repository has been downloaded', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + expect(mockSetOutput.mock.invocationCallOrder[0]).toBeGreaterThan( + mockDownloadRepository.mock.invocationCallOrder[0] + ) + }) + + it('does not set the commit output when the REST API download fails (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + mockDownloadRepository.mockImplementation(async () => { + throw new Error('Download failed') + }) + const settings = getSettings() + + // Act + await expect(gitSourceProvider.getSource(settings)).rejects.toThrow( + 'Download failed' + ) + + // Assert + expect(mockSetOutput).not.toHaveBeenCalledWith( + 'commit', + expect.anything() as unknown as string + ) + mockDownloadRepository.mockReset() + }) + + it('does not download or set the commit output when an input is not supported by the REST API fallback (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const submoduleSettings = getSettings() + submoduleSettings.submodules = true + const sshKeySettings = getSettings() + sshKeySettings.sshKey = 'ssh-key' + + // Act + await expect( + gitSourceProvider.getSource(submoduleSettings) + ).rejects.toThrow(`Input 'submodules' not supported`) + await expect(gitSourceProvider.getSource(sshKeySettings)).rejects.toThrow( + `Input 'ssh-key' not supported` + ) + + // Assert + expect(mockDownloadRepository).not.toHaveBeenCalled() + expect(mockSetOutput).not.toHaveBeenCalledWith( + 'commit', + expect.anything() as unknown as string + ) + }) + it('sets the commit output from git when git is available (control)', async () => { // Arrange const git = getGitCommandManager() @@ -204,4 +298,19 @@ describe('git-source-provider tests', () => { expect(git.checkout).toHaveBeenCalled() expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) }) + + it('does not configure auth on the REST API fallback path (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert: the fallback returns with authHelper still null, so the finally + // block removes nothing. The added setOutput call does not change that. + expect(mockCreateAuthHelper).not.toHaveBeenCalled() + }) }) From dbd3a6024b146d3a66f498b0a3d4bd368d7e111f Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:38:49 -0400 Subject: [PATCH 4/4] report the resolved commit when a ref is downloaded without a SHA On the REST API fallback, a ref given without a SHA (a branch or tag of another repository, or the default branch) left the commit output empty: the archive was downloaded by ref and nothing recorded which commit that was. downloadRepository now resolves the ref to its commit first (GET /repos/{owner}/{repo}/commits/{ref} with the sha media type), downloads that commit, and returns its SHA, which getSource sets as the output. The downloaded content and the reported SHA are the same commit even if the ref moves meanwhile. If the ref cannot be resolved, the download falls back to the ref as before, a warning is logged, and the output stays empty. --- __test__/git-source-provider.test.ts | 14 +++-- __test__/github-api-helper.test.ts | 79 +++++++++++++++++++++++++++- dist/index.js | 36 +++++++++++-- src/git-source-provider.ts | 7 +-- src/github-api-helper.ts | 47 ++++++++++++++++- 5 files changed, 170 insertions(+), 13 deletions(-) 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 '' + } } /**