From 507635d01b63071d935ab87ddb7633aba6f2409a Mon Sep 17 00:00:00 2001 From: Conor Sloan Date: Mon, 15 Apr 2024 12:26:26 +0100 Subject: [PATCH] only write attestation for non-private repos --- __tests__/config.test.ts | 37 ++++++++++++++++- __tests__/main.test.ts | 89 ++++++++++++++++++++++++++++++++++++++-- badges/coverage.svg | 2 +- dist/index.js | 27 ++++++------ src/api-client.ts | 20 --------- src/config.ts | 8 ++++ src/main.ts | 7 +++- 7 files changed, 149 insertions(+), 41 deletions(-) diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index a55944b..e60a74c 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -162,6 +162,34 @@ describe('config.resolvePublishActionOptions', () => { ) }) + it('throws an error when returned repository id does not match env var', async () => { + getInputMock.mockReturnValueOnce('token') + getContainerRegistryURLMock.mockResolvedValue(ghcrUrl) + getRepositoryMetadataMock.mockResolvedValue({ + visibility: 'public', + ownerId: '12345', + repoId: '54321' + }) + + await expect(cfg.resolvePublishActionOptions()).rejects.toThrow( + 'Repository ID mismatch.' + ) + }) + + it('throws an error when returned repository owner id does not match env var', async () => { + getInputMock.mockReturnValueOnce('token') + getContainerRegistryURLMock.mockResolvedValue(ghcrUrl) + getRepositoryMetadataMock.mockResolvedValue({ + visibility: 'public', + ownerId: '123124', + repoId: 'repositoryId' + }) + + await expect(cfg.resolvePublishActionOptions()).rejects.toThrow( + 'Repository Owner ID mismatch.' + ) + }) + it('returns options when all values are present', async () => { getInputMock.mockImplementation((name: string) => { expect(name).toBe('github-token') @@ -170,7 +198,9 @@ describe('config.resolvePublishActionOptions', () => { getContainerRegistryURLMock.mockResolvedValue(ghcrUrl) getRepositoryMetadataMock.mockResolvedValue({ - visibility: 'public' + visibility: 'public', + repoId: 'repositoryId', + ownerId: 'repositoryOwnerId' }) const options = await cfg.resolvePublishActionOptions() @@ -198,8 +228,11 @@ describe('config.resolvePublishActionOptions', () => { return 'token' }) getContainerRegistryURLMock.mockResolvedValue(ghcrUrl) + getRepositoryMetadataMock.mockResolvedValue({ - visibility: 'public' + visibility: 'public', + repoId: 'repositoryId', + ownerId: 'repositoryOwnerId' }) process.env.GITHUB_SERVER_URL = 'https://github-enterprise.com' diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 6e68f36..703a39b 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -241,7 +241,7 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') }) - it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in enterprise', async () => { + it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => { const options = baseOptions() options.isEnterprise = true resolvePublishActionOptionsMock.mockReturnValue(options) @@ -299,7 +299,7 @@ describe('run', () => { ) }) - it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise', async () => { + it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise for public repo', async () => { resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) createTempDirMock.mockImplementation(() => { @@ -330,7 +330,90 @@ describe('run', () => { } }) - generateAttestationMock.mockImplementation(async () => { + generateAttestationMock.mockImplementation(async options => { + expect(options).toHaveProperty('skipWrite', false) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + // Run the action + await main.run() + + // Check the results + expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1) + + // Check outputs + expect(setOutputMock).toHaveBeenCalledTimes(4) + + expect(setOutputMock).toHaveBeenCalledWith( + 'package-url', + 'https://ghcr.io/v2/test-org/test-repo:1.2.3' + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'package-manifest', + expect.any(String) + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'package-manifest-sha', + 'sha256:my-test-digest' + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'attestation-id', + 'test-attestation-id' + ) + }) + + it('uploads the artifact, returns package metadata from GHCR, and creates an attestation but skips storing it in non-enterprise for private repo', async () => { + const opts = baseOptions() + opts.repositoryVisibility = 'private' + + resolvePublishActionOptionsMock.mockReturnValue(opts) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'test', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'test2', + size: 52, + sha256: '1234' + } + } + }) + + publishOCIArtifactMock.mockImplementation(() => { + return { + packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', + manifestDigest: 'sha256:my-test-digest' + } + }) + + generateAttestationMock.mockImplementation(async options => { + expect(options).toHaveProperty('skipWrite', true) + return { attestationID: 'test-attestation-id', certificate: 'test', diff --git a/badges/coverage.svg b/badges/coverage.svg index 0005240..6746ba8 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 93.8%Coverage93.8% \ No newline at end of file +Coverage: 96.63%Coverage96.63% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index d7dd128..b0cba62 100644 --- a/dist/index.js +++ b/dist/index.js @@ -99284,7 +99284,7 @@ ZipStream.prototype.finalize = function() { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getRepositoryVisibility = exports.getContainerRegistryURL = exports.getRepositoryMetadata = void 0; +exports.getContainerRegistryURL = exports.getRepositoryMetadata = void 0; async function getRepositoryMetadata(githubAPIURL, repository, token) { const response = await fetch(`${githubAPIURL}/repos/${repository}`, { method: 'GET', @@ -99321,18 +99321,6 @@ async function getContainerRegistryURL(githubAPIURL) { return registryURL; } exports.getContainerRegistryURL = getContainerRegistryURL; -async function getRepositoryVisibility(githubAPIURL) { - const response = await fetch(`${githubAPIURL}/`); - if (!response.ok) { - throw new Error(`Failed to fetch repository metadata due to bad status code: ${response.status}`); - } - const data = await response.json(); - if (!data.full_name) { - throw new Error(`Failed to fetch repository metadata: unexpected response format`); - } - return data.full_name; -} -exports.getRepositoryVisibility = getRepositoryVisibility; /***/ }), @@ -99426,6 +99414,12 @@ async function resolvePublishActionOptions() { if (repoMetadata.visibility === '') { throw new Error(`Could not find repository visibility.`); } + if (repoMetadata.repoId !== repositoryId) { + throw new Error(`Repository ID mismatch.`); + } + if (repoMetadata.ownerId !== repositoryOwnerId) { + throw new Error(`Repository Owner ID mismatch.`); + } const repositoryVisibility = repoMetadata.visibility; return { event, @@ -99816,6 +99810,7 @@ async function run() { core.setOutput('package-url', packageURL.toString()); core.setOutput('package-manifest', JSON.stringify(manifest)); core.setOutput('package-manifest-sha', manifestDigest); + // Attestations are not currently supported in GHES. if (!options.isEnterprise) { const attestation = await generateAttestation(manifestDigest, semverTag.raw, options); if (attestation.attestationID !== undefined) { @@ -99854,7 +99849,11 @@ async function generateAttestation(manifestDigest, semverTag, options) { subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - skipWrite: false // TODO: Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account + // Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account. + // See: https://github.com/actions/toolkit/tree/main/packages/attest#storage + // Since internal repos can only be owned by Enterprises, we'll use this visibility as a proxy for "owned by a GitHub Enterprise Cloud account." + // See: https://docs.github.com/en/enterprise-cloud@latest/repositories/creating-and-managing-repositories/about-repositories#about-internal-repositories + skipWrite: options.repositoryVisibility === 'private' }); } function removePrefix(str, prefix) { diff --git a/src/api-client.ts b/src/api-client.ts index 952f811..ac92e53 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -55,23 +55,3 @@ export async function getContainerRegistryURL( const registryURL: URL = new URL(data.url) return registryURL } - -export async function getRepositoryVisibility( - githubAPIURL: string -): Promise { - const response = await fetch(`${githubAPIURL}/`) - if (!response.ok) { - throw new Error( - `Failed to fetch repository metadata due to bad status code: ${response.status}` - ) - } - const data = await response.json() - - if (!data.full_name) { - throw new Error( - `Failed to fetch repository metadata: unexpected response format` - ) - } - - return data.full_name -} diff --git a/src/config.ts b/src/config.ts index ef307ab..7d356cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -109,6 +109,14 @@ export async function resolvePublishActionOptions(): Promise { core.setOutput('package-manifest', JSON.stringify(manifest)) core.setOutput('package-manifest-sha', manifestDigest) + // Attestations are not currently supported in GHES. if (!options.isEnterprise) { const attestation = await generateAttestation( manifestDigest, @@ -106,7 +107,11 @@ async function generateAttestation( subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - skipWrite: false // TODO: Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account + // Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account. + // See: https://github.com/actions/toolkit/tree/main/packages/attest#storage + // Since internal repos can only be owned by Enterprises, we'll use this visibility as a proxy for "owned by a GitHub Enterprise Cloud account." + // See: https://docs.github.com/en/enterprise-cloud@latest/repositories/creating-and-managing-repositories/about-repositories#about-internal-repositories + skipWrite: options.repositoryVisibility === 'private' }) }