mirror of https://github.com/actions/checkout.git
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.
This commit is contained in:
parent
56a41f36e5
commit
dbd3a6024b
|
|
@ -35,7 +35,11 @@ jest.unstable_mockModule('../src/git-command-manager.js', () => ({
|
||||||
MinimumGitSparseCheckoutVersion: '2.28'
|
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', () => ({
|
jest.unstable_mockModule('../src/github-api-helper.js', () => ({
|
||||||
downloadRepository: mockDownloadRepository,
|
downloadRepository: mockDownloadRepository,
|
||||||
getDefaultBranch: jest.fn(async () => 'refs/heads/main'),
|
getDefaultBranch: jest.fn(async () => 'refs/heads/main'),
|
||||||
|
|
@ -160,7 +164,7 @@ describe('git-source-provider tests', () => {
|
||||||
expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha)
|
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
|
// Arrange
|
||||||
mockCreateCommandManager.mockImplementation(async () => {
|
mockCreateCommandManager.mockImplementation(async () => {
|
||||||
throw new Error('Git is not installed')
|
throw new Error('Git is not installed')
|
||||||
|
|
@ -173,10 +177,10 @@ describe('git-source-provider tests', () => {
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockDownloadRepository).toHaveBeenCalled()
|
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
|
// Arrange
|
||||||
mockCreateCommandManager.mockImplementation(async () => {
|
mockCreateCommandManager.mockImplementation(async () => {
|
||||||
throw new Error('Git is not installed')
|
throw new Error('Git is not installed')
|
||||||
|
|
@ -190,7 +194,7 @@ describe('git-source-provider tests', () => {
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockDownloadRepository).toHaveBeenCalled()
|
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 () => {
|
it('sets the commit output when downloading a SHA-256 object format repository', async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'
|
||||||
|
|
||||||
// Mock @actions/core
|
// Mock @actions/core
|
||||||
const mockDebug = jest.fn()
|
const mockDebug = jest.fn()
|
||||||
|
const mockWarning = jest.fn()
|
||||||
jest.unstable_mockModule('@actions/core', () => ({
|
jest.unstable_mockModule('@actions/core', () => ({
|
||||||
debug: mockDebug,
|
debug: mockDebug,
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
warning: jest.fn(),
|
warning: mockWarning,
|
||||||
error: jest.fn()
|
error: jest.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -15,6 +16,11 @@ jest.unstable_mockModule('@actions/github', () => ({
|
||||||
getOctokit: mockGetOctokit
|
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<unknown>) => await action()
|
||||||
|
}))
|
||||||
|
|
||||||
// Dynamic imports after mocking
|
// Dynamic imports after mocking
|
||||||
const githubApiHelper = await import('../src/github-api-helper.js')
|
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<any>
|
||||||
|
|
||||||
|
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('<html>not a sha</html>')
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -41324,6 +41324,11 @@ async function downloadRepository(authToken, owner, repo, ref, commit, repositor
|
||||||
info('Determining the default branch');
|
info('Determining the default branch');
|
||||||
ref = await getDefaultBranch(authToken, owner, repo, baseUrl);
|
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
|
// Download the archive
|
||||||
let archiveData = await execute(async () => {
|
let archiveData = await execute(async () => {
|
||||||
info('Downloading the archive');
|
info('Downloading the archive');
|
||||||
|
|
@ -41367,6 +41372,30 @@ async function downloadRepository(authToken, owner, repo, ref, commit, repositor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await rmRF(extractPath);
|
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
|
* Looks up the default branch name
|
||||||
|
|
@ -41754,10 +41783,11 @@ async function getSource(settings) {
|
||||||
else if (settings.sshKey) {
|
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.`);
|
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
|
// 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.
|
// Git repository, so the SHA is the commit that was downloaded: the one
|
||||||
setOutput('commit', settings.commit);
|
// requested, or the one the ref resolved to.
|
||||||
|
setOutput('commit', downloadedCommit);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Save state for POST action
|
// Save state for POST action
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await githubApiHelper.downloadRepository(
|
const downloadedCommit = await githubApiHelper.downloadRepository(
|
||||||
settings.authToken,
|
settings.authToken,
|
||||||
settings.repositoryOwner,
|
settings.repositoryOwner,
|
||||||
settings.repositoryName,
|
settings.repositoryName,
|
||||||
|
|
@ -101,8 +101,9 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the commit output. The REST API fallback does not create a local
|
// 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.
|
// Git repository, so the SHA is the commit that was downloaded: the one
|
||||||
core.setOutput('commit', settings.commit)
|
// requested, or the one the ref resolved to.
|
||||||
|
core.setOutput('commit', downloadedCommit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,19 @@ export async function downloadRepository(
|
||||||
commit: string,
|
commit: string,
|
||||||
repositoryPath: string,
|
repositoryPath: string,
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
): Promise<void> {
|
): Promise<string> {
|
||||||
// Determine the default branch
|
// Determine the default branch
|
||||||
if (!ref && !commit) {
|
if (!ref && !commit) {
|
||||||
core.info('Determining the default branch')
|
core.info('Determining the default branch')
|
||||||
ref = await getDefaultBranch(authToken, owner, repo, baseUrl)
|
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
|
// Download the archive
|
||||||
let archiveData = await retryHelper.execute(async () => {
|
let archiveData = await retryHelper.execute(async () => {
|
||||||
core.info('Downloading the archive')
|
core.info('Downloading the archive')
|
||||||
|
|
@ -79,6 +85,45 @@ export async function downloadRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await io.rmRF(extractPath)
|
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<string> {
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue