diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index 571b8e7..7696008 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -1,28 +1,19 @@ -import { publishOCIArtifact } from '../src/ghcr-client' -import * as fsHelper from '../src/fs-helper' +import { Client } from '../src/ghcr-client' import * as ociContainer from '../src/oci-container' +import * as crypto from 'crypto' // Mocks -let fsReadFileSyncMock: jest.SpyInstance let fetchMock: jest.SpyInstance +let client: Client + const token = 'test-token' const registry = new URL('https://ghcr.io') const repository = 'test-org/test-repo' const semver = '1.2.3' const genericSha = '1234567890' // We should look at using different shas here to catch bug, but that make location validation harder -const zipFile: fsHelper.FileMetadata = { - path: `test-repo-${semver}.zip`, - size: 123, - sha256: genericSha -} -const tarFile: fsHelper.FileMetadata = { - path: `test-repo-${semver}.tar.gz`, - size: 456, - sha256: genericSha -} -const headMockNoExistingBlobs = (): object => { +const checkBlobNoExistingBlobs = (): object => { // Simulate none of the blobs existing currently return { text() { @@ -33,7 +24,7 @@ const headMockNoExistingBlobs = (): object => { } } -const headMockAllExistingBlobs = (): object => { +const checkBlobAllExistingBlobs = (): object => { // Simulate all of the blobs existing currently return { status: 200, @@ -42,7 +33,7 @@ const headMockAllExistingBlobs = (): object => { } let count = 0 -const headMockSomeExistingBlobs = (): object => { +const checkBlobSomeExistingBlobs = (): object => { count++ // report one as existing if (count === 1) { @@ -62,7 +53,7 @@ const headMockSomeExistingBlobs = (): object => { } } -const headMockFailure = (): object => { +const checkBlobFailure = (): object => { return { text() { // In this case we'll simulate a response which does not use the expected error format @@ -73,7 +64,7 @@ const headMockFailure = (): object => { } } -const postMockSuccessfulIniationForAllBlobs = (): object => { +const initiateBlobUploadSuccessForAllBlobs = (): object => { // Simulate successful initiation of uploads for all blobs & return location return { status: 202, @@ -87,7 +78,7 @@ const postMockSuccessfulIniationForAllBlobs = (): object => { } } -const postMockFailure = (): object => { +const initiateBlobUploadFailureForAllBlobs = (): object => { // Simulate failed initiation of uploads return { text() { @@ -99,7 +90,7 @@ const postMockFailure = (): object => { } } -const postMockNoLocationHeader = (): object => { +const initiateBlobUploadNoLocationHeader = (): object => { return { status: 202, headers: { @@ -108,26 +99,33 @@ const postMockNoLocationHeader = (): object => { } } -const putMockSuccessfulBlobUpload = (url: string): object => { - // Simulate successful upload of all blobs & then the manifest - if (url.includes('manifest')) { +const putManifestSuccessful = ( + digestToReturn: string, + expectedVersion: string +): ((url: string) => object) => { + return (url: string): object => { + expect(url.endsWith(`manifests/${expectedVersion}`)).toBeTruthy() + return { status: 201, headers: { get: (header: string) => { if (header === 'docker-content-digest') { - return '1234567678' + return digestToReturn } } } } } +} + +const putBlobSuccess = (): object => { return { status: 201 } } -const putMockFailure = (): object => { +const putManifestFailure = (): object => { // Simulate fails upload of all blobs & manifest return { text() { @@ -138,124 +136,181 @@ const putMockFailure = (): object => { } } -const putMockFailureManifestUpload = (url: string): object => { - // Simulate unsuccessful upload of all blobs & then the manifest - if (url.includes('manifest')) { - return { - text() { - return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}' - }, - status: 400, - statusText: 'Bad Request' - } - } +const putBlobFailure = (): object => { + // Simulate fails upload of all blobs & manifest return { - status: 201 + text() { + return '{"errors": [{"code": "BAD_REQUEST", "message": "digest issue."}]}' + }, + status: 400, + statusText: 'Bad Request' } } type MethodHandlers = { - getMock?: (url: string, options: { method: string }) => object - headMock?: (url: string, options: { method: string }) => object - postMock?: (url: string, options: { method: string }) => object - putMock?: (url: string, options: { method: string }) => object + checkBlobMock?: (url: string, options: { method: string }) => object + initiateBlobUploadMock?: (url: string, options: { method: string }) => object + putManifestMock?: (url: string, options: { method: string }) => object + putBlobMock?: (url: string, options: { method: string }) => object +} + +type ForcedRetries = { + checkBlob: number + initiateBlobUpload: number + putBlob: number + putManifest: number } function configureFetchMock( fetchMockInstance: jest.SpyInstance, - methodHandlers: MethodHandlers + methodHandlers: MethodHandlers, + forcedRetries: ForcedRetries = { + checkBlob: 0, + initiateBlobUpload: 0, + putBlob: 0, + putManifest: 0 + } ): void { + const retriableError = async (retries: number): Promise => { + if (retries % 2 === 0) { + throw new Error('Network Error') + } else { + return { + status: 429, + statusText: 'Too Many Requests', + headers: { + get: (header: string) => { + if (header === 'retry-after') { + return '0.1' + } + } + } + } + } + } + fetchMockInstance.mockImplementation( async (url: string, options: { method: string }) => { + // Simulate retries for every request until the number of forced retries is exhausted. + // We'll simulate both failing status codes and network errors for full coverage. validateRequestConfig(url, options) switch (options.method) { - case 'GET': - return methodHandlers.getMock?.(url, options) case 'HEAD': - return methodHandlers.headMock?.(url, options) + if (forcedRetries.checkBlob > 0) { + forcedRetries.checkBlob-- + return retriableError(forcedRetries.checkBlob) + } + + return methodHandlers.checkBlobMock?.(url, options) case 'POST': - return methodHandlers.postMock?.(url, options) + if (forcedRetries.initiateBlobUpload > 0) { + forcedRetries.initiateBlobUpload-- + return retriableError(forcedRetries.initiateBlobUpload) + } + + return methodHandlers.initiateBlobUploadMock?.(url, options) case 'PUT': - return methodHandlers.putMock?.(url, options) + if (url.includes('manifest')) { + if (forcedRetries.putManifest > 0) { + forcedRetries.putManifest-- + return retriableError(forcedRetries.putManifest) + } + + return methodHandlers.putManifestMock?.(url, options) + } else { + if (forcedRetries.putBlob > 0) { + forcedRetries.putBlob-- + return retriableError(forcedRetries.putBlob) + } + + return methodHandlers.putBlobMock?.(url, options) + } } } ) } -const testManifest: ociContainer.Manifest = { - schemaVersion: 2, - mediaType: 'application/vnd.oci.image.manifest.v1+json', - artifactType: 'application/vnd.oci.image.manifest.v1+json', - config: { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: - 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' - }, - layers: [ - { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: - 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' - }, - { - mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', - size: tarFile.size, - digest: `sha256:${tarFile.sha256}`, - annotations: { - 'org.opencontainers.image.title': tarFile.path - } - }, - { - mediaType: 'application/vnd.github.actions.package.layer.v1.zip', - size: zipFile.size, - digest: `sha256:${zipFile.sha256}`, - annotations: { - 'org.opencontainers.image.title': zipFile.path - } - } - ], - annotations: { - 'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z', - 'action.tar.gz.digest': tarFile.sha256, - 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': 'actions_oci_pkg' - } -} - -describe('publishOCIArtifact', () => { +describe('uploadOCIIndexManifest', () => { beforeEach(() => { jest.clearAllMocks() + fetchMock = jest.spyOn(global, 'fetch').mockImplementation() - fsReadFileSyncMock = jest - .spyOn(fsHelper, 'readFileContents') - .mockImplementation() + client = new Client(token, registry, { + retries: 5, + backoff: 1 + }) + }) + it('uploads the tagged manifest with the appropriate tag', async () => { + const { manifest, sha } = testIndexManifest() + const tag = 'sha-1234' + + configureFetchMock(fetchMock, { + putManifestMock: putManifestSuccessful(sha, tag) + }) + + await client.uploadOCIIndexManifest(repository, manifest, tag) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(1) + }) + + it('throws an error if a manifest upload fails', async () => { + const { manifest, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestFailure + }) + + await expect( + client.uploadOCIImageManifest(repository, manifest, blobs) + ).rejects.toThrow( + 'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.' + ) + }) + + it('throws an error if the returned digest does not match the precalculated one', async () => { + const { manifest, sha } = testIndexManifest() + + const tag = 'sha-1234' + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful('some-garbage-digest', tag) + }) + + await expect( + client.uploadOCIIndexManifest(repository, manifest, tag) + ).rejects.toThrow( + `Digest mismatch. Expected ${sha}, got some-garbage-digest.` + ) + }) +}) + +describe('uploadOCIImageManifest', () => { + beforeEach(() => { + jest.clearAllMocks() fetchMock = jest.spyOn(global, 'fetch').mockImplementation() }) - it('publishes layer blobs & then a manifest to the provided registry', async () => { + it('uploads blobs then untagged manifest to the provided registry', async () => { + const { manifest, sha, blobs } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockSuccessfulBlobUpload + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) }) - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - return Buffer.from('test') - }) - - await publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + await client.uploadOCIImageManifest(repository, manifest, blobs) expect(fetchMock).toHaveBeenCalledTimes(10) expect( @@ -269,29 +324,76 @@ describe('publishOCIArtifact', () => { ).toHaveLength(4) }) - it('skips uploading all layer blobs when they all already exist', async () => { + it('uploads blobs then tagged manifest to the provided registry', async () => { + const { manifest, sha, blobs } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockAllExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockSuccessfulBlobUpload + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, semver) }) - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - return Buffer.from('test') + await client.uploadOCIImageManifest(repository, manifest, blobs, semver) + + expect(fetchMock).toHaveBeenCalledTimes(10) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(4) + }) + + it('uploads everything to the provided registry by retrying requests', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock( + fetchMock, + { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }, + { + checkBlob: 2, + initiateBlobUpload: 2, + putBlob: 2, + putManifest: 2 + } + ) // Fail each request twice before succeeding + + await client.uploadOCIImageManifest(repository, manifest, blobs) + + // 8 Additional requests - 2 for each of the 4 failed request types + expect(fetchMock).toHaveBeenCalledTimes(18) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(5) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(5) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(8) + }) + + it('skips blob uploads if all blobs already exist', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) }) - await publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + await client.uploadOCIImageManifest(repository, manifest, blobs) - // We should only head all the blobs and then upload the manifest expect(fetchMock).toHaveBeenCalledTimes(4) expect( fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') @@ -304,31 +406,19 @@ describe('publishOCIArtifact', () => { ).toHaveLength(1) }) - it('skips uploading layer blobs that already exist', async () => { + it('skips blob uploads if some blobs already exist', async () => { + const { manifest, sha, blobs } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockSomeExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockSuccessfulBlobUpload - }) - count = 0 - - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - return Buffer.from('test') + checkBlobMock: checkBlobSomeExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) }) - await publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + await client.uploadOCIImageManifest(repository, manifest, blobs) expect(fetchMock).toHaveBeenCalledTimes(8) - // We should only head all the blobs and then upload the missing blobs and manifest expect( fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') ).toHaveLength(3) @@ -341,164 +431,190 @@ describe('publishOCIArtifact', () => { }) it('throws an error if checking for existing blobs fails', async () => { - configureFetchMock(fetchMock, { headMock: headMockFailure }) + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobFailure, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + client.uploadOCIImageManifest(repository, manifest, blobs) ).rejects.toThrow( /^Unexpected 503 Service Unavailable response from check blob/ ) }) - it('throws an error if initiating layer upload fails', async () => { + it('throws an error if a blob file is not provided', async () => { + const { manifest, sha } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockFailure + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) }) await expect( - publishOCIArtifact( - token, - registry, + client.uploadOCIImageManifest( repository, - semver, - zipFile, - tarFile, - testManifest + manifest, + new Map() ) + ).rejects.toThrow(/^Blob for layer sha256:[a-zA-Z0-9]+ not found/) + }) + + it('throws an error if initiating layer upload fails', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadFailureForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await expect( + client.uploadOCIImageManifest(repository, manifest, blobs) ).rejects.toThrow( 'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.' ) }) it('throws an error if the upload endpoint does not return a location', async () => { + const { manifest, sha, blobs } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockNoLocationHeader + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadNoLocationHeader, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) }) await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + client.uploadOCIImageManifest(repository, manifest, blobs) ).rejects.toThrow(/^No location header in response from upload post/) }) it('throws an error if a layer upload fails', async () => { - configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockFailure - }) + const { manifest, sha, blobs } = testImageManifest() - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - return Buffer.from('test') + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobFailure, + putManifestMock: putManifestSuccessful(sha, sha) }) await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + client.uploadOCIImageManifest(repository, manifest, blobs) ).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/) }) it('throws an error if a manifest upload fails', async () => { - configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockFailureManifestUpload - }) + const { manifest, blobs } = testImageManifest() - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - return Buffer.from('test') + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestFailure }) await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) + client.uploadOCIImageManifest(repository, manifest, blobs) ).rejects.toThrow( 'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.' ) }) - it('throws an error if reading one of the files fails', async () => { + it('throws an error if the returned digest does not match the precalculated one', async () => { + const { manifest, sha, blobs } = testImageManifest() + configureFetchMock(fetchMock, { - headMock: headMockNoExistingBlobs, - postMock: postMockSuccessfulIniationForAllBlobs, - putMock: putMockSuccessfulBlobUpload - }) - - // Simulate successful reading of all the files - fsReadFileSyncMock.mockImplementation(() => { - throw new Error('failed to read a file: test') + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful('some-garbage-digest', sha) }) await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - testManifest - ) - ).rejects.toThrow('failed to read a file: test') - }) - - it('throws an error if one of the layers has the wrong media type', async () => { - const modifiedTestManifest = { ...testManifest } // This is _NOT_ a deep clone - modifiedTestManifest.layers = cloneLayers(modifiedTestManifest.layers) - modifiedTestManifest.layers[0].mediaType = 'application/json' - - // just checking to make sure we are not changing the shared object - expect(modifiedTestManifest.layers[0].mediaType).not.toEqual( - testManifest.layers[0].mediaType + client.uploadOCIImageManifest(repository, manifest, blobs) + ).rejects.toThrow( + `Digest mismatch. Expected ${sha}, got some-garbage-digest.` ) - - await expect( - publishOCIArtifact( - token, - registry, - repository, - semver, - zipFile, - tarFile, - modifiedTestManifest - ) - ).rejects.toThrow('Unknown media type application/json') }) }) +function testImageManifest(): { + manifest: ociContainer.OCIImageManifest + sha: string + blobs: Map +} { + const blobs = new Map() + blobs.set(ociContainer.emptyConfigSha, Buffer.from('{}')) + + const firstFile = Buffer.from('test1') + const secondFile = Buffer.from('test2') + + const firstFileDigest = `sha256:${crypto + .createHash('sha256') + .update(firstFile) + .digest('hex')}` + + const secondFileDigest = `sha256:${crypto + .createHash('sha256') + .update(secondFile) + .digest('hex')}` + + blobs.set(firstFileDigest, firstFile) + blobs.set(secondFileDigest, secondFile) + + const manifest: ociContainer.OCIImageManifest = { + schemaVersion: 2, + mediaType: ociContainer.imageManifestMediaType, + artifactType: ociContainer.imageManifestMediaType, + config: ociContainer.createEmptyConfigLayer(), + layers: [ + { + mediaType: 'application/octet-stream', + size: firstFile.length, + digest: firstFileDigest + }, + { + mediaType: 'application/octet-stream', + size: secondFile.length, + digest: secondFileDigest + } + ], + annotations: { + 'org.opencontainers.image.created': new Date().toISOString() + } + } + + const sha = ociContainer.sha256Digest(manifest) + + return { manifest, sha, blobs } +} + +function testIndexManifest(): { + manifest: ociContainer.OCIIndexManifest + sha: string +} { + const manifest = ociContainer.createReferrerTagManifest( + 'attestation-digest', + 1234, + 'bundle-media-type', + 'bundle-predicate-type', + new Date(), + new Date() + ) + const sha = ociContainer.sha256Digest(manifest) + return { manifest, sha } +} + // We expect all fetch calls to have auth headers set // This function verifies that given an request config. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -525,11 +641,3 @@ function validateRequestConfig(url: string, config: any): void { ) } } - -function cloneLayers(layers: ociContainer.Layer[]): ociContainer.Layer[] { - const result: ociContainer.Layer[] = [] - for (const layer of layers) { - result.push({ ...layer }) // this is _NOT_ a deep clone - } - return result -} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index fbe21d3..fde06bc 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -15,6 +15,8 @@ import * as ghcr from '../src/ghcr-client' import * as ociContainer from '../src/oci-container' const ghcrUrl = new URL('https://ghcr.io') +const predicateType = 'https://slsa.dev/provenance/v1' +const bundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json' // Mock the GitHub Actions core library let setFailedMock: jest.SpyInstance @@ -25,12 +27,17 @@ let createTempDirMock: jest.SpyInstance let createArchivesMock: jest.SpyInstance let stageActionFilesMock: jest.SpyInstance let ensureCorrectShaCheckedOutMock: jest.SpyInstance +let readFileContentsMock: jest.SpyInstance // Mock OCI container lib let calculateManifestDigestMock: jest.SpyInstance // Mock GHCR client -let publishOCIArtifactMock: jest.SpyInstance +let client: ghcr.Client +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let createGHCRClient: jest.SpyInstance +let uploadOCIImageManifestMock: jest.SpyInstance +let uploadOCIIndexManifestMock: jest.SpyInstance // Mock the config resolution let resolvePublishActionOptionsMock: jest.SpyInstance @@ -42,6 +49,8 @@ describe('run', () => { beforeEach(() => { jest.clearAllMocks() + client = new ghcr.Client('token', ghcrUrl) + // Core mocks setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() @@ -59,6 +68,9 @@ describe('run', () => { ensureCorrectShaCheckedOutMock = jest .spyOn(fsHelper, 'ensureTagAndRefCheckedOut') .mockImplementation() + readFileContentsMock = jest + .spyOn(fsHelper, 'readFileContents') + .mockImplementation() // OCI Container mocks calculateManifestDigestMock = jest @@ -66,8 +78,15 @@ describe('run', () => { .mockImplementation() // GHCR Client mocks - publishOCIArtifactMock = jest - .spyOn(ghcr, 'publishOCIArtifact') + createGHCRClient = jest + .spyOn(ghcr, 'Client') + .mockImplementation(() => client) + + uploadOCIImageManifestMock = jest + .spyOn(client, 'uploadOCIImageManifest') + .mockImplementation() + uploadOCIIndexManifestMock = jest + .spyOn(client, 'uploadOCIIndexManifest') .mockImplementation() // Config mocks @@ -211,6 +230,10 @@ describe('run', () => { stageActionFilesMock.mockImplementation(() => {}) + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + createArchivesMock.mockImplementation(() => { return { zipFile: { @@ -226,8 +249,11 @@ describe('run', () => { } }) - calculateManifestDigestMock.mockImplementation(() => { - return 'sha256:my-test-digest' + uploadOCIImageManifestMock.mockImplementation(() => { + return { + packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', + publishedDigest: 'sha256:my-test-digest' + } }) generateAttestationMock.mockImplementation(async () => { @@ -241,7 +267,7 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') }) - it('fails if publishing OCI artifact fails', async () => { + it('fails if uploading attestation to GHCR fails', async () => { resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) @@ -272,23 +298,26 @@ describe('run', () => { }) generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + expect(options).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', certificate: 'test', bundle: { - mediaType: 'application/vnd.cncf.notary.v2+jwt', + mediaType: bundleMediaType, verificationMaterial: { publicKey: { hint: 'test-hint' } + }, + dsseEnvelope: { + payload: btoa(`{"predicateType": "${predicateType}"}`) } } } }) - publishOCIArtifactMock.mockImplementation(() => { + uploadOCIImageManifestMock.mockImplementation(() => { throw new Error('Something went wrong') }) @@ -299,7 +328,7 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') }) - it('fails if unexpected digest returned from GHCR', async () => { + it('fails if uploading referrer index manifest to GHCR fails', async () => { resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) @@ -330,36 +359,113 @@ describe('run', () => { }) generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + expect(options).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', certificate: 'test', bundle: { - mediaType: 'application/vnd.cncf.notary.v2+jwt', + mediaType: bundleMediaType, verificationMaterial: { publicKey: { hint: 'test-hint' } + }, + dsseEnvelope: { + payload: btoa(`{"predicateType": "${predicateType}"}`) } } } }) - publishOCIArtifactMock.mockImplementation(() => { - return { - packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - publishedDigest: 'sha256:some-other-digest' - } + uploadOCIImageManifestMock.mockImplementation(() => { + return 'attestation-digest' + }) + + uploadOCIIndexManifestMock.mockImplementation(() => { + throw new Error('Something went wrong') }) // Run the action await main.run() // Check the results - expect(setFailedMock).toHaveBeenCalledWith( - 'Unexpected digest returned for manifest. Expected sha256:my-test-digest, got sha256:some-other-digest' + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if publishing action package version fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'test', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'test2', + size: 52, + sha256: '1234' + } + } + }) + + readFileContentsMock.mockImplementation(() => { + return Buffer.from('test') + }) + + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + + generateAttestationMock.mockImplementation(async options => { + expect(options).toHaveProperty('skipWrite', true) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: bundleMediaType, + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + }, + dsseEnvelope: { + payload: btoa(`{"predicateType": "${predicateType}"}`) + } + } + } + }) + + uploadOCIImageManifestMock.mockImplementation( + (repo, manifest, blobs, tag) => { + if (tag === undefined) { + return 'attestation-digest' + } else { + throw new Error('Something went wrong') + } + } ) + + uploadOCIIndexManifestMock.mockImplementation(() => { + return 'referrer-index-digest' + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') }) it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => { @@ -375,6 +481,74 @@ describe('run', () => { stageActionFilesMock.mockImplementation(() => {}) + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'zip', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'tar', + size: 52, + sha256: '1234' + } + } + }) + + readFileContentsMock.mockImplementation(filepath => { + return Buffer.from(`${filepath}`) + }) + + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + + uploadOCIImageManifestMock.mockImplementation( + (repository, manifest, blobs, tag) => { + expect(repository).toBe(options.nameWithOwner) + expect(tag).toBe('1.2.3') + expect(blobs.size).toBe(3) + expect(blobs.has(ociContainer.emptyConfigSha)).toBeTruthy() + expect(blobs.has('123')).toBeTruthy() + expect(blobs.has('1234')).toBeTruthy() + expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType) + expect(manifest.layers.length).toBe(2) + expect(manifest.annotations['com.github.package.type']).toBe( + ociContainer.actionPackageAnnotationValue + ) + + return 'sha256:my-test-digest' + } + ) + + // Run the action + await main.run() + + // Check the results + expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(1) + + // Check outputs + expect(setOutputMock).toHaveBeenCalledTimes(1) + + expect(setOutputMock).toHaveBeenCalledWith( + 'package-manifest-sha', + 'sha256:my-test-digest' + ) + }) + + it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise', async () => { + const options = baseOptions() + resolvePublishActionOptionsMock.mockReturnValue(options) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + createArchivesMock.mockImplementation(() => { return { zipFile: { @@ -390,124 +564,134 @@ describe('run', () => { } }) - calculateManifestDigestMock.mockImplementation(() => { - return 'sha256:my-test-digest' - }) - - publishOCIArtifactMock.mockImplementation(() => { - return { - packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - publishedDigest: 'sha256:my-test-digest' - } - }) - - // Run the action - await main.run() - - // Check the results - expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1) - - // Check outputs - expect(setOutputMock).toHaveBeenCalledTimes(3) - - 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' - ) - }) - - it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise for public repo', async () => { - resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - - ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - - createTempDirMock.mockImplementation(() => { - return 'stagingOrArchivesDir' - }) - - stageActionFilesMock.mockImplementation(() => {}) - - createArchivesMock.mockImplementation(() => { - return { - zipFile: { - path: 'test', - size: 5, - sha256: '123' - }, - tarFile: { - path: 'test2', - size: 52, - sha256: '1234' - } - } + readFileContentsMock.mockImplementation(() => { + return Buffer.from('test') }) calculateManifestDigestMock.mockImplementation(() => { return 'sha256:my-test-digest' }) - publishOCIArtifactMock.mockImplementation(() => { - return { - packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - publishedDigest: 'sha256:my-test-digest' - } - }) - - generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + generateAttestationMock.mockImplementation(async opts => { + expect(opts).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', certificate: 'test', bundle: { - mediaType: 'application/vnd.cncf.notary.v2+jwt', + mediaType: bundleMediaType, verificationMaterial: { publicKey: { hint: 'test-hint' } + }, + dsseEnvelope: { + payload: btoa(`{"predicateType": "${predicateType}"}`) } } } }) + uploadOCIIndexManifestMock.mockImplementation( + async (repository, manifest, tag) => { + expect(repository).toBe(options.nameWithOwner) + expect(tag).toBe('sha256-my-test-digest') + expect(manifest.mediaType).toBe(ociContainer.imageIndexMediaType) + expect(manifest.annotations['com.github.package.type']).toBe( + ociContainer.actionPackageReferrerTagAnnotationValue + ) + expect(manifest.manifests.length).toBe(1) + expect(manifest.manifests[0].mediaType).toBe( + ociContainer.imageManifestMediaType + ) + expect(manifest.manifests[0].artifactType).toBe(bundleMediaType) + expect( + manifest.manifests[0].annotations['dev.sigstore.bundle.predicateType'] + ).toBe(predicateType) + expect( + manifest.manifests[0].annotations['com.github.package.type'] + ).toBe(ociContainer.actionPackageAttestationAnnotationValue) + + return 'sha256:referrer-index-digest' + } + ) + + uploadOCIImageManifestMock.mockImplementation( + (repository, manifest, blobs, tag) => { + let expectedBlobKeys: string[] = [] + let expectedAnnotationValue = '' + let expectedTagValue: string | undefined = undefined + let returnValue = '' + let expectedPredicateTypeValue: string | undefined = undefined + + let expectedSubjectMediaType: string | undefined = undefined + + if (tag === undefined) { + expectedAnnotationValue = + ociContainer.actionPackageAttestationAnnotationValue + const sigStoreLayer = manifest.layers.find( + (layer: ociContainer.Descriptor) => + layer.mediaType === bundleMediaType + ) + expectedPredicateTypeValue = predicateType + + expectedBlobKeys = [sigStoreLayer.digest, ociContainer.emptyConfigSha] + + expectedSubjectMediaType = ociContainer.imageManifestMediaType + + returnValue = 'sha256:attestation-digest' + } else { + expectedAnnotationValue = ociContainer.actionPackageAnnotationValue + expectedTagValue = '1.2.3' + expectedBlobKeys = ['123', '1234', ociContainer.emptyConfigSha] + returnValue = 'sha256:my-test-digest' + } + + expect(repository).toBe(options.nameWithOwner) + expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType) + expect(manifest.annotations['com.github.package.type']).toBe( + expectedAnnotationValue + ) + expect(manifest.annotations['dev.sigstore.bundle.predicateType']).toBe( + expectedPredicateTypeValue + ) + expect(tag).toBe(expectedTagValue) + expect(manifest.subject?.mediaType).toBe(expectedSubjectMediaType) + + expect(manifest.layers.length).toBe(expectedBlobKeys.length - 1) // Minus config layer + expect(blobs.size).toBe(expectedBlobKeys.length) + for (const expectedBlobKey of expectedBlobKeys) { + expect(blobs.has(expectedBlobKey)).toBeTruthy() + } + + return returnValue + } + ) + // Run the action await main.run() // Check the results - expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1) + expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(2) + expect(uploadOCIIndexManifestMock).toHaveBeenCalledTimes(1) // Check outputs - expect(setOutputMock).toHaveBeenCalledTimes(4) + expect(setOutputMock).toHaveBeenCalledTimes(3) expect(setOutputMock).toHaveBeenCalledWith( - 'package-url', - 'https://ghcr.io/v2/test-org/test-repo:1.2.3' + 'attestation-manifest-sha', + 'sha256:attestation-digest' ) expect(setOutputMock).toHaveBeenCalledWith( - 'package-manifest', - expect.any(String) + 'referrer-index-manifest-sha', + 'sha256:referrer-index-digest' ) expect(setOutputMock).toHaveBeenCalledWith( 'package-manifest-sha', 'sha256:my-test-digest' ) - - expect(setOutputMock).toHaveBeenCalledWith( - 'attestation-id', - 'test-attestation-id' - ) }) }) diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index 10f1514..80855b9 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -1,63 +1,38 @@ -import { createActionPackageManifest, sha256Digest } from '../src/oci-container' +import { + createActionPackageManifest, + sha256Digest, + sizeInBytes, + OCIImageManifest, + createSigstoreAttestationManifest, + OCIIndexManifest, + createReferrerTagManifest +} from '../src/oci-container' import { FileMetadata } from '../src/fs-helper' +const createdTimestamp = '2021-01-01T00:00:00.000Z' + describe('sha256Digest', () => { it('calculates the SHA256 digest of the provided manifest', () => { - const date = new Date('2021-01-01T00:00:00Z') - const repo = 'test-org/test-repo' - const version = '1.2.3' - const repoId = '123' - const ownerId = '456' - const sourceCommit = 'abc' - const tarFile: FileMetadata = { - path: '/test/test/test.tar.gz', - sha256: 'tarSha', - size: 123 - } - const zipFile: FileMetadata = { - path: '/test/test/test.zip', - sha256: 'zipSha', - size: 456 - } - - const manifest = createActionPackageManifest( - tarFile, - zipFile, - repo, - repoId, - ownerId, - sourceCommit, - version, - date - ) - + const { manifest } = testActionPackageManifest() const digest = sha256Digest(manifest) const expectedDigest = - 'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5' + 'sha256:1af9bf993bf068a51fbb54822471ab7507b07c553bcac09a7c91328740d8ed69' expect(digest).toEqual(expectedDigest) }) }) +describe('size', () => { + it('returns the total size of the provided manifest', () => { + const { manifest } = testActionPackageManifest() + const size = sizeInBytes(manifest) + expect(size).toBe(991) + }) +}) + describe('createActionPackageManifest', () => { it('creates a manifest containing the provided information', () => { - const date = new Date() - const repo = 'test-org/test-repo' - const sanitizedRepo = 'test-org-test-repo' - const version = '1.2.3' - const repoId = '123' - const ownerId = '456' - const sourceCommit = 'abc' - const tarFile: FileMetadata = { - path: '/test/test/test.tar.gz', - sha256: 'tarSha', - size: 123 - } - const zipFile: FileMetadata = { - path: '/test/test/test.zip', - sha256: 'zipSha', - size: 456 - } + const { manifest, zipFile, tarFile } = testActionPackageManifest() const expectedJSON = `{ "schemaVersion": 2, @@ -69,17 +44,12 @@ describe('createActionPackageManifest', () => { "digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" }, "layers":[ - { - "mediaType":"application/vnd.oci.empty.v1+json", - "size":2, - "digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" - }, { "mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip", "size":${tarFile.size}, "digest":"${tarFile.sha256}", "annotations":{ - "org.opencontainers.image.title":"${sanitizedRepo}_${version}.tar.gz" + "org.opencontainers.image.title":"test-org-test-repo_1.2.3.tar.gz" } }, { @@ -87,12 +57,12 @@ describe('createActionPackageManifest', () => { "size":${zipFile.size}, "digest":"${zipFile.sha256}", "annotations":{ - "org.opencontainers.image.title":"${sanitizedRepo}_${version}.zip" + "org.opencontainers.image.title":"test-org-test-repo_1.2.3.zip" } } ], "annotations":{ - "org.opencontainers.image.created":"${date.toISOString()}", + "org.opencontainers.image.created":"${createdTimestamp}", "action.tar.gz.digest":"${tarFile.sha256}", "action.zip.digest":"${zipFile.sha256}", "com.github.package.type":"actions_oci_pkg", @@ -103,26 +73,168 @@ describe('createActionPackageManifest', () => { } }` - const manifest = createActionPackageManifest( - { - path: 'test.tar.gz', - size: tarFile.size, - sha256: tarFile.sha256 - }, - { - path: 'test.zip', - size: zipFile.size, - sha256: zipFile.sha256 - }, - repo, - repoId, - ownerId, - sourceCommit, - version, - date - ) - const manifestJSON = JSON.stringify(manifest) expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) }) + + it('uses the current time if no created date is provided', () => { + const { manifest } = testActionPackageManifest(false) + expect( + manifest.annotations['org.opencontainers.image.created'] + ).toBeDefined() + }) }) + +describe('createSigstoreAttestationManifest', () => { + it('creates a manifest containing the provided information', () => { + const manifest = testAttestationManifest() + + const expectedJSON = `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "config": { + "mediaType": "application/vnd.oci.empty.v1+json", + "size": 2, + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "layers": [ + { + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "size": 10, + "digest": "bundleDigest" + } + ], + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 100, + "digest": "subjectDigest" + }, + "annotations": { + "dev.sigstore.bundle.content": "dsse-envelope", + "dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1", + "com.github.package.type": "actions_oci_pkg_attestation", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z" + } +} +` + + const manifestJSON = JSON.stringify(manifest) + + expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) + }) + + it('uses the current time if no created date is provided', () => { + const manifest = testAttestationManifest(false) + expect( + manifest.annotations['org.opencontainers.image.created'] + ).toBeDefined() + }) +}) + +describe('createReferrerIndexManifest', () => { + it('creates a manifest containing the provided information', () => { + const manifest = testReferrerIndexManifest() + + const expectedJSON = ` +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "size": 100, + "digest": "attDigest", + "annotations": { + "com.github.package.type": "actions_oci_pkg_attestation", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z", + "dev.sigstore.bundle.content": "dsse-envelope", + "dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1" + } + } + ], + "annotations": { + "com.github.package.type": "actions_oci_pkg_referrer_index", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z" + } +} + ` + + const manifestJSON = JSON.stringify(manifest) + + expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) + }) + + it('uses the current time if no created date is provided', () => { + const manifest = testReferrerIndexManifest(false) + expect( + manifest.annotations['org.opencontainers.image.created'] + ).toBeDefined() + }) +}) + +function testActionPackageManifest(setCreated = true): { + manifest: OCIImageManifest + tarFile: FileMetadata + zipFile: FileMetadata +} { + const date = new Date('2021-01-01T00:00:00Z') + const repo = 'test-org/test-repo' + const version = '1.2.3' + const repoId = '123' + const ownerId = '456' + const sourceCommit = 'abc' + const tarFile: FileMetadata = { + path: '/test/test/test.tar.gz', + sha256: 'tarSha', + size: 123 + } + const zipFile: FileMetadata = { + path: '/test/test/test.zip', + sha256: 'zipSha', + size: 456 + } + + const manifest = createActionPackageManifest( + tarFile, + zipFile, + repo, + repoId, + ownerId, + sourceCommit, + version, + setCreated ? date : undefined + ) + + return { + manifest, + tarFile, + zipFile + } +} + +function testAttestationManifest(setCreated = true): OCIImageManifest { + const date = new Date(createdTimestamp) + return createSigstoreAttestationManifest( + 10, + 'bundleDigest', + 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'https://slsa.dev/provenance/v1', + 100, + 'subjectDigest', + setCreated ? date : undefined + ) +} + +function testReferrerIndexManifest(setCreated = true): OCIIndexManifest { + const date = new Date(createdTimestamp) + return createReferrerTagManifest( + 'attDigest', + 100, + 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'https://slsa.dev/provenance/v1', + date, + setCreated ? date : undefined + ) +} diff --git a/action.yml b/action.yml index 782aa19..dc17c8f 100644 --- a/action.yml +++ b/action.yml @@ -11,14 +11,12 @@ inputs: description: 'The GitHub actions token used to authenticate with GitHub APIs' outputs: - package-url: - description: 'The name of package published to GHCR along with semver. For example, https://ghcr.io/actions/package-action:1.0.1' - package-manifest: - description: 'The package manifest of the published package in JSON format' package-manifest-sha: description: 'A sha256 hash of the package manifest' - attestation-id: - description: 'The attestation id of the generated provenance attestation. This is not present if the package is not attested, e.g. in enterprise environments.' + attestation-manifest-sha: + description: 'The sha256 of the provenance attestation uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.' + referrer-index-manifest-sha: + description: 'The sha256 of the referrer index uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.' runs: using: node20 diff --git a/badges/coverage.svg b/badges/coverage.svg index b75435b..d252813 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.06%Coverage97.06% \ No newline at end of file +Coverage: 97.56%Coverage97.56% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index de11e31..c3228d8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -106687,109 +106687,180 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.publishOCIArtifact = publishOCIArtifact; +exports.Client = void 0; const core = __importStar(__nccwpck_require__(42186)); -const fsHelper = __importStar(__nccwpck_require__(76642)); -// Publish the OCI artifact and return the URL where it can be downloaded -async function publishOCIArtifact(token, registry, repository, semver, zipFile, tarFile, manifest) { - const b64Token = Buffer.from(token).toString('base64'); - const checkBlobEndpoint = new URL(`v2/${repository}/blobs/`, registry).toString(); - const uploadBlobEndpoint = new URL(`v2/${repository}/blobs/uploads/`, registry).toString(); - const manifestEndpoint = new URL(`v2/${repository}/manifests/${semver}`, registry).toString(); - core.info(`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`); - const layerUploads = manifest.layers.map(async (layer) => { - switch (layer.mediaType) { - case 'application/vnd.github.actions.package.layer.v1.tar+gzip': - return uploadLayer(layer, tarFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); - case 'application/vnd.github.actions.package.layer.v1.zip': - return uploadLayer(layer, zipFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); - case 'application/vnd.oci.empty.v1+json': - return uploadLayer(layer, { path: '', size: 2, sha256: layer.digest }, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); - default: - throw new Error(`Unknown media type ${layer.mediaType}`); +const ociContainer = __importStar(__nccwpck_require__(33207)); +const defaultRetries = 5; +const defaultBackoff = 1000; +const retryableStatusCodes = [408, 429, 500, 502, 503, 504]; +class Client { + _b64Token; + _registry; + _retryOptions; + constructor(token, registry, retryOptions = { + retries: defaultRetries, + backoff: defaultBackoff + }) { + this._b64Token = Buffer.from(token).toString('base64'); + this._registry = registry; + this._retryOptions = retryOptions; + } + async uploadOCIImageManifest(repository, manifest, blobs, tag) { + const manifestSHA = ociContainer.sha256Digest(manifest); + if (tag) { + core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`); } - }); - await Promise.all(layerUploads); - const digest = await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token); - return { - packageURL: new URL(`${repository}:${semver}`, registry), - publishedDigest: digest - }; -} -async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBlobEndpoint, b64Token) { - const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint + layer.digest, { - method: 'HEAD', - headers: { - Authorization: `Bearer ${b64Token}` + else { + core.info(`Uploading manifest ${manifestSHA} to ${repository}.`); } - }); - if (checkExistsResponse.status === 200 || - checkExistsResponse.status === 202) { - core.info(`Layer ${layer.digest} already exists. Skipping upload.`); - return; + // We must also upload the config layer + const layersToUpload = manifest.layers.concat(manifest.config); + const layerUploads = layersToUpload.map(async (layer) => { + const blob = blobs.get(layer.digest); + if (!blob) { + throw new Error(`Blob for layer ${layer.digest} not found`); + } + return this.uploadLayer(layer, blob, repository); + }); + await Promise.all(layerUploads); + const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag || manifestSHA); + if (publishedDigest !== manifestSHA) { + throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`); + } + return manifestSHA; } - if (checkExistsResponse.status !== 404) { - throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse)); + async uploadOCIIndexManifest(repository, manifest, tag) { + const manifestSHA = ociContainer.sha256Digest(manifest); + core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`); + const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag); + if (publishedDigest !== manifestSHA) { + throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`); + } + return manifestSHA; } - core.info(`Uploading layer ${layer.digest}.`); - const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${b64Token}` - }, - body: JSON.stringify(layer) - }); - if (initiateUploadResponse.status !== 202) { - throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse)); + async uploadLayer(layer, data, repository) { + const checkExistsResponse = await this.fetchWithRetries(this.checkBlobEndpoint(repository, layer.digest), { + method: 'HEAD', + headers: { + Authorization: `Bearer ${this._b64Token}` + } + }); + if (checkExistsResponse.status === 200 || + checkExistsResponse.status === 202) { + core.info(`Layer ${layer.digest} already exists. Skipping upload.`); + return; + } + if (checkExistsResponse.status !== 404) { + throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse)); + } + core.info(`Uploading layer ${layer.digest}.`); + const initiateUploadBlobURL = this.uploadBlobEndpoint(repository); + const initiateUploadResponse = await this.fetchWithRetries(initiateUploadBlobURL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this._b64Token}` + }, + body: JSON.stringify(layer) + }); + if (initiateUploadResponse.status !== 202) { + throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse)); + } + const locationResponseHeader = initiateUploadResponse.headers.get('location'); + if (locationResponseHeader === undefined) { + throw new Error(`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`); + } + const pathname = `${locationResponseHeader}?digest=${layer.digest}`; + const uploadBlobUrl = new URL(pathname, this._registry).toString(); + const putResponse = await this.fetchWithRetries(uploadBlobUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${this._b64Token}`, + 'Content-Type': 'application/octet-stream', + 'Accept-Encoding': 'gzip', + 'Content-Length': layer.size.toString() + }, + body: data + }); + if (putResponse.status !== 201) { + throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse)); + } } - const locationResponseHeader = initiateUploadResponse.headers.get('location'); - if (locationResponseHeader === undefined) { - throw new Error(`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`); + // Uploads the manifest and returns the digest returned by GHCR + async uploadManifest(manifestJSON, manifestMediaType, repository, version) { + const manifestUrl = this.manifestEndpoint(repository, version); + core.info(`Uploading manifest to ${manifestUrl}.`); + const putResponse = await this.fetchWithRetries(manifestUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${this._b64Token}`, + 'Content-Type': manifestMediaType + }, + body: manifestJSON + }); + if (putResponse.status !== 201) { + throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse)); + } + const digestResponseHeader = putResponse.headers.get('docker-content-digest') || ''; + return digestResponseHeader; } - const pathname = `${locationResponseHeader}?digest=${layer.digest}`; - const uploadBlobUrl = new URL(pathname, registryURL).toString(); - // TODO: must we handle the empty config layer? Maybe we can just skip calling this at all - let data; - if (layer.mediaType === 'application/vnd.oci.empty.v1+json') { - data = Buffer.from('{}'); + checkBlobEndpoint(repository, digest) { + return new URL(`v2/${repository}/blobs/${digest}`, this._registry).toString(); } - else { - data = fsHelper.readFileContents(file.path); + uploadBlobEndpoint(repository) { + return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString(); } - const putResponse = await fetchWithDebug(uploadBlobUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/octet-stream', - 'Accept-Encoding': 'gzip', - 'Content-Length': layer.size.toString() - }, - body: data - }); - if (putResponse.status !== 201) { - throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse)); + manifestEndpoint(repository, version) { + return new URL(`v2/${repository}/manifests/${version}`, this._registry).toString(); + } + async fetchWithDebug(url, config = {}) { + core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`); + try { + const response = await fetch(url, config); + core.debug(`Response with ${JSON.stringify(response)}`); + return response; + } + catch (error) { + core.debug(`Error with ${error}`); + throw error; + } + } + async fetchWithRetries(url, config = {}) { + const allowedAttempts = this._retryOptions.retries + 1; // Initial attempt + retries + for (let attemptNumber = 1; attemptNumber <= allowedAttempts; attemptNumber++) { + let backoff = this._retryOptions.backoff; + try { + const response = await this.fetchWithDebug(url, config); + // If this is the last attempt, just return it + if (attemptNumber === allowedAttempts) { + return response; + } + // If the response is retryable, backoff and retry + if (retryableStatusCodes.includes(response.status)) { + const retryAfter = response.headers.get('retry-after'); + if (retryAfter) { + backoff = parseInt(retryAfter) * 1000; // convert to ms + } + core.info(`Received ${response.status} response. Retrying after ${backoff}ms...`); + await new Promise(resolve => setTimeout(resolve, backoff)); + continue; + } + // Otherwise, just return the response + return response; + } + catch (error) { + // If this is the last attempt, throw the error + if (attemptNumber === allowedAttempts) { + throw error; + } + core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`); + await new Promise(resolve => setTimeout(resolve, backoff)); + } + } + // Should be unreachable + throw new Error('Exhausted retries without a successful response'); } } -// Uploads the manifest and returns the digest returned by GHCR -async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { - core.info(`Uploading manifest to ${manifestEndpoint}.`); - const putResponse = await fetchWithDebug(manifestEndpoint, { - method: 'PUT', - headers: { - Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' - }, - body: manifestJSON - }); - if (putResponse.status !== 201) { - throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse)); - } - const digestResponseHeader = putResponse.headers.get('docker-content-digest'); - if (digestResponseHeader === undefined || digestResponseHeader === null) { - throw new Error(`No digest header in response from PUT manifest ${manifestEndpoint}`); - } - return digestResponseHeader; -} +exports.Client = Client; // Generate an error message for a failed HTTP request async function errorMessageForFailedRequest(requestDescription, response) { const bodyText = await response.text(); @@ -106824,18 +106895,6 @@ function isGHCRError(obj) { 'message' in obj && typeof obj.message === 'string'); } -const fetchWithDebug = async (url, config = {}) => { - core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`); - try { - const response = await fetch(url, config); - core.debug(`Response with ${JSON.stringify(response)}`); - return response; - } - catch (error) { - core.debug(`Error with ${error}`); - throw error; - } -}; /***/ }), @@ -106880,6 +106939,7 @@ const ociContainer = __importStar(__nccwpck_require__(33207)); const ghcr = __importStar(__nccwpck_require__(62894)); const attest = __importStar(__nccwpck_require__(74113)); const cfg = __importStar(__nccwpck_require__(96373)); +const crypto = __importStar(__nccwpck_require__(6113)); /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. @@ -106898,19 +106958,24 @@ async function run() { const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir); const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date()); const manifestDigest = ociContainer.sha256Digest(manifest); - // Attestations are not currently supported in GHES. + const ghcrClient = new ghcr.Client(options.token, options.containerRegistryUrl); + // Attestations are not supported in GHES. if (!options.isEnterprise) { - const attestation = await generateAttestation(manifestDigest, semverTag.raw, options); - if (attestation.attestationID !== undefined) { - core.setOutput('attestation-id', attestation.attestationID); + const { bundle, bundleDigest, bundleMediaType, bundlePredicateType } = await generateAttestation(manifestDigest, semverTag.raw, options); + const attestationCreated = new Date(); + const attestationManifest = ociContainer.createSigstoreAttestationManifest(bundle.length, bundleDigest, bundleMediaType, bundlePredicateType, ociContainer.sizeInBytes(manifest), manifestDigest, attestationCreated); + const referrerIndexManifest = ociContainer.createReferrerTagManifest(ociContainer.sha256Digest(attestationManifest), ociContainer.sizeInBytes(attestationManifest), bundleMediaType, bundlePredicateType, attestationCreated); + const { attestationSHA, referrerIndexSHA } = await publishAttestation(ghcrClient, options.nameWithOwner, bundle, bundleDigest, manifest, attestationManifest, referrerIndexManifest); + if (attestationSHA !== undefined) { + core.info(`Uploaded attestation ${attestationSHA}`); + core.setOutput('attestation-manifest-sha', attestationSHA); + } + if (referrerIndexSHA !== undefined) { + core.info(`Uploaded referrer index ${referrerIndexSHA}`); + core.setOutput('referrer-index-manifest-sha', referrerIndexSHA); } } - const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest); - if (manifestDigest !== publishedDigest) { - throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`); - } - core.setOutput('package-url', packageURL.toString()); - core.setOutput('package-manifest', JSON.stringify(manifest)); + const publishedDigest = await publishImmutableActionVersion(ghcrClient, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest); core.setOutput('package-manifest-sha', publishedDigest); } catch (error) { @@ -106934,22 +106999,61 @@ function parseSemverTagFromRef(opts) { } return semverTag; } -// Generate an attestation using the actions toolkit -// Subject name will contain the repo/package name and the tag name +async function publishImmutableActionVersion(client, nameWithOwner, semverTag, zipFile, tarFile, manifest) { + const manifestDigest = ociContainer.sha256Digest(manifest); + core.info(`Creating GHCR package ${manifestDigest} for release with semver: ${semver_1.default}.`); + const files = new Map(); + files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path)); + files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path)); + files.set(ociContainer.emptyConfigSha, Buffer.from('{}')); + return await client.uploadOCIImageManifest(nameWithOwner, manifest, files, semverTag); +} +async function publishAttestation(client, nameWithOwner, bundle, bundleDigest, subjectManifest, attestationManifest, referrerIndexManifest) { + const attestationManifestDigest = ociContainer.sha256Digest(attestationManifest); + const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest); + const referrerIndexManifestDigest = ociContainer.sha256Digest(referrerIndexManifest); + core.info(`Publishing attestation ${attestationManifestDigest} for subject ${subjectManifestDigest}.`); + const files = new Map(); + files.set(ociContainer.emptyConfigSha, Buffer.from('{}')); + files.set(bundleDigest, bundle); + const attestationSHA = await client.uploadOCIImageManifest(nameWithOwner, attestationManifest, files); + // The referrer index is tagged with the subject's digest in format sha256- + const referrerTag = subjectManifestDigest.replace(':', '-'); + core.info(`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`); + const referrerIndexSHA = await client.uploadOCIIndexManifest(nameWithOwner, referrerIndexManifest, referrerTag); + return { attestationSHA, referrerIndexSHA }; +} async function generateAttestation(manifestDigest, semverTag, options) { const subjectName = `${options.nameWithOwner}@${semverTag}`; const subjectDigest = removePrefix(manifestDigest, 'sha256:'); core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`); - return await attest.attestProvenance({ + const attestation = await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - // Always store the attestation using the GitHub Attestations API - skipWrite: false, - // Identify the attestation to our API as an Immutable Action - headers: { 'X-GitHub-Publish-Action': subjectName } + skipWrite: true // We will upload attestations to GHCR }); + const bundleArtifact = Buffer.from(JSON.stringify(attestation.bundle)); + const hash = crypto.createHash('sha256'); + hash.update(bundleArtifact); + const bundleSHA = hash.digest('hex'); + // We must base64 decode the dsse envelope to grab the predicate type + const dsseEnvelopeArtifact = attestation.bundle.dsseEnvelope; + if (dsseEnvelopeArtifact === undefined) { + throw new Error('Attestation bundle is missing dsseEnvelope artifact'); + } + const dsseEnvelope = JSON.parse(Buffer.from(dsseEnvelopeArtifact.payload, 'base64').toString('utf-8')); + const predicateType = dsseEnvelope.predicateType; + if (predicateType === undefined) { + throw new Error('Attestation bundle is missing predicateType'); + } + return { + bundle: bundleArtifact, + bundleDigest: `sha256:${bundleSHA}`, + bundleMediaType: attestation.bundle.mediaType, + bundlePredicateType: predicateType + }; } function removePrefix(str, prefix) { if (str.startsWith(prefix)) { @@ -106990,26 +107094,42 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.emptyConfigSha = exports.emptyConfigSize = exports.ociEmptyMediaType = exports.actionPackageReferrerTagAnnotationValue = exports.actionPackageAttestationAnnotationValue = exports.actionPackageAnnotationValue = exports.actionsPackageZipLayerMediaType = exports.actionsPackageTarLayerMediaType = exports.actionsPackageMediaType = exports.imageManifestMediaType = exports.imageIndexMediaType = void 0; exports.createActionPackageManifest = createActionPackageManifest; +exports.createSigstoreAttestationManifest = createSigstoreAttestationManifest; +exports.createReferrerTagManifest = createReferrerTagManifest; exports.sha256Digest = sha256Digest; +exports.sizeInBytes = sizeInBytes; +exports.createEmptyConfigLayer = createEmptyConfigLayer; const crypto = __importStar(__nccwpck_require__(6113)); +exports.imageIndexMediaType = 'application/vnd.oci.image.index.v1+json'; +exports.imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json'; +exports.actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json'; +exports.actionsPackageTarLayerMediaType = 'application/vnd.github.actions.package.layer.v1.tar+gzip'; +exports.actionsPackageZipLayerMediaType = 'application/vnd.github.actions.package.layer.v1.zip'; +exports.actionPackageAnnotationValue = 'actions_oci_pkg'; +exports.actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation'; +exports.actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_index'; +exports.ociEmptyMediaType = 'application/vnd.oci.empty.v1+json'; +exports.emptyConfigSize = 2; +exports.emptyConfigSha = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; // Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package. -function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created) { - const configLayer = createConfigLayer(); +function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created = new Date()) { + const configLayer = createEmptyConfigLayer(); const sanitizedRepo = sanitizeRepository(repository); const tarLayer = createTarLayer(tarFile, sanitizedRepo, version); const zipLayer = createZipLayer(zipFile, sanitizedRepo, version); const manifest = { schemaVersion: 2, - mediaType: 'application/vnd.oci.image.manifest.v1+json', - artifactType: 'application/vnd.github.actions.package.v1+json', + mediaType: exports.imageManifestMediaType, + artifactType: exports.actionsPackageMediaType, config: configLayer, - layers: [configLayer, tarLayer, zipLayer], + layers: [tarLayer, zipLayer], annotations: { 'org.opencontainers.image.created': created.toISOString(), 'action.tar.gz.digest': tarFile.sha256, 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': 'actions_oci_pkg', + 'com.github.package.type': exports.actionPackageAnnotationValue, 'com.github.package.version': version, 'com.github.source.repo.id': repoId, 'com.github.source.repo.owner.id': ownerId, @@ -107018,6 +107138,59 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner }; return manifest; } +function createSigstoreAttestationManifest(bundleSize, bundleDigest, bundleMediaType, bundlePredicateType, subjectSize, subjectDigest, created = new Date()) { + const configLayer = createEmptyConfigLayer(); + const sigstoreAttestationLayer = { + mediaType: bundleMediaType, + size: bundleSize, + digest: bundleDigest + }; + const subject = { + mediaType: exports.imageManifestMediaType, + size: subjectSize, + digest: subjectDigest + }; + const manifest = { + schemaVersion: 2, + mediaType: exports.imageManifestMediaType, + artifactType: bundleMediaType, + config: configLayer, + layers: [sigstoreAttestationLayer], + subject, + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': bundlePredicateType, + 'com.github.package.type': exports.actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + }; + return manifest; +} +function createReferrerTagManifest(attestationDigest, attestationSize, bundleMediaType, bundlePredicateType, attestationCreated, created = new Date()) { + const manifest = { + schemaVersion: 2, + mediaType: exports.imageIndexMediaType, + manifests: [ + { + mediaType: exports.imageManifestMediaType, + artifactType: bundleMediaType, + size: attestationSize, + digest: attestationDigest, + annotations: { + 'com.github.package.type': exports.actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': attestationCreated.toISOString(), + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': bundlePredicateType + } + } + ], + annotations: { + 'com.github.package.type': exports.actionPackageReferrerTagAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + }; + return manifest; +} // Calculate the SHA256 digest of a given manifest. // This should match the digest which the GitHub container registry calculates for this manifest. function sha256Digest(manifest) { @@ -107028,17 +107201,21 @@ function sha256Digest(manifest) { const hexHash = hash.digest('hex'); return `sha256:${hexHash}`; } -function createConfigLayer() { +function sizeInBytes(manifest) { + const data = JSON.stringify(manifest); + return Buffer.byteLength(data, 'utf8'); +} +function createEmptyConfigLayer() { const configLayer = { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + mediaType: exports.ociEmptyMediaType, + size: exports.emptyConfigSize, + digest: exports.emptyConfigSha }; return configLayer; } function createZipLayer(zipFile, repository, version) { const zipLayer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.zip', + mediaType: exports.actionsPackageZipLayerMediaType, size: zipFile.size, digest: zipFile.sha256, annotations: { @@ -107049,7 +107226,7 @@ function createZipLayer(zipFile, repository, version) { } function createTarLayer(tarFile, repository, version) { const tarLayer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', + mediaType: exports.actionsPackageTarLayerMediaType, size: tarFile.size, digest: tarFile.sha256, annotations: { diff --git a/package-lock.json b/package-lock.json index 12663b9..560b92a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "@sigstore/oci": "^0.3.7", "@types/fs-extra": "^11.0.4", "archiver": "^7.0.1", "fs-extra": "^11.2.0", @@ -1680,6 +1681,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sigstore/oci": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@sigstore/oci/-/oci-0.3.7.tgz", + "integrity": "sha512-1JmebwEXil+NVzugFURbC+D3Vzj6WyTI1B+7damUk94dWXamE9cJ057iSo72rupiSozM6N7lVMjtD1c/P5Rrrw==", + "dependencies": { + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/@sigstore/protobuf-specs": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", diff --git a/package.json b/package.json index 31af38a..77e25fc 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,12 @@ "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "@sigstore/oci": "^0.3.7", "@types/fs-extra": "^11.0.4", "archiver": "^7.0.1", "fs-extra": "^11.2.0", - "tar": "^7.4.3", - "simple-git": "^3.22.0" + "simple-git": "^3.22.0", + "tar": "^7.4.3" }, "devDependencies": { "@types/archiver": "^6.0.2", diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 7aba4ef..d1d6b1a 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -1,209 +1,309 @@ import * as core from '@actions/core' -import { FileMetadata } from './fs-helper' import * as ociContainer from './oci-container' -import * as fsHelper from './fs-helper' -// Publish the OCI artifact and return the URL where it can be downloaded -export async function publishOCIArtifact( - token: string, - registry: URL, - repository: string, - semver: string, - zipFile: FileMetadata, - tarFile: FileMetadata, - manifest: ociContainer.Manifest -): Promise<{ packageURL: URL; publishedDigest: string }> { - const b64Token = Buffer.from(token).toString('base64') +const defaultRetries = 5 +const defaultBackoff = 1000 +const retryableStatusCodes = [408, 429, 500, 502, 503, 504] - const checkBlobEndpoint = new URL( - `v2/${repository}/blobs/`, - registry - ).toString() - const uploadBlobEndpoint = new URL( - `v2/${repository}/blobs/uploads/`, - registry - ).toString() - const manifestEndpoint = new URL( - `v2/${repository}/manifests/${semver}`, - registry - ).toString() - - core.info( - `Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".` - ) - - const layerUploads: Promise[] = manifest.layers.map(async layer => { - switch (layer.mediaType) { - case 'application/vnd.github.actions.package.layer.v1.tar+gzip': - return uploadLayer( - layer, - tarFile, - registry, - checkBlobEndpoint, - uploadBlobEndpoint, - b64Token - ) - case 'application/vnd.github.actions.package.layer.v1.zip': - return uploadLayer( - layer, - zipFile, - registry, - checkBlobEndpoint, - uploadBlobEndpoint, - b64Token - ) - case 'application/vnd.oci.empty.v1+json': - return uploadLayer( - layer, - { path: '', size: 2, sha256: layer.digest }, - registry, - checkBlobEndpoint, - uploadBlobEndpoint, - b64Token - ) - default: - throw new Error(`Unknown media type ${layer.mediaType}`) - } - }) - - await Promise.all(layerUploads) - - const digest = await uploadManifest( - JSON.stringify(manifest), - manifestEndpoint, - b64Token - ) - - return { - packageURL: new URL(`${repository}:${semver}`, registry), - publishedDigest: digest - } +export interface RetryOptions { + retries: number + backoff: number } -async function uploadLayer( - layer: ociContainer.Layer, - file: FileMetadata, - registryURL: URL, - checkBlobEndpoint: string, - uploadBlobEndpoint: string, - b64Token: string -): Promise { - const checkExistsResponse = await fetchWithDebug( - checkBlobEndpoint + layer.digest, - { - method: 'HEAD', +export class Client { + private _b64Token: string + private _registry: URL + private _retryOptions: RetryOptions + + constructor( + token: string, + registry: URL, + retryOptions: RetryOptions = { + retries: defaultRetries, + backoff: defaultBackoff + } + ) { + this._b64Token = Buffer.from(token).toString('base64') + this._registry = registry + this._retryOptions = retryOptions + } + + async uploadOCIImageManifest( + repository: string, + manifest: ociContainer.OCIImageManifest, + blobs: Map, + tag?: string + ): Promise { + const manifestSHA = ociContainer.sha256Digest(manifest) + + if (tag) { + core.info( + `Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.` + ) + } else { + core.info(`Uploading manifest ${manifestSHA} to ${repository}.`) + } + + // We must also upload the config layer + const layersToUpload = manifest.layers.concat(manifest.config) + + const layerUploads: Promise[] = layersToUpload.map(async layer => { + const blob = blobs.get(layer.digest) + if (!blob) { + throw new Error(`Blob for layer ${layer.digest} not found`) + } + return this.uploadLayer(layer, blob, repository) + }) + + await Promise.all(layerUploads) + + const publishedDigest = await this.uploadManifest( + JSON.stringify(manifest), + manifest.mediaType, + repository, + tag || manifestSHA + ) + + if (publishedDigest !== manifestSHA) { + throw new Error( + `Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.` + ) + } + + return manifestSHA + } + + async uploadOCIIndexManifest( + repository: string, + manifest: ociContainer.OCIIndexManifest, + tag: string + ): Promise { + const manifestSHA = ociContainer.sha256Digest(manifest) + + core.info( + `Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.` + ) + + const publishedDigest = await this.uploadManifest( + JSON.stringify(manifest), + manifest.mediaType, + repository, + tag + ) + + if (publishedDigest !== manifestSHA) { + throw new Error( + `Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.` + ) + } + + return manifestSHA + } + + private async uploadLayer( + layer: ociContainer.Descriptor, + data: Buffer, + repository: string + ): Promise { + const checkExistsResponse = await this.fetchWithRetries( + this.checkBlobEndpoint(repository, layer.digest), + { + method: 'HEAD', + headers: { + Authorization: `Bearer ${this._b64Token}` + } + } + ) + + if ( + checkExistsResponse.status === 200 || + checkExistsResponse.status === 202 + ) { + core.info(`Layer ${layer.digest} already exists. Skipping upload.`) + return + } + + if (checkExistsResponse.status !== 404) { + throw new Error( + await errorMessageForFailedRequest( + `check blob (${layer.digest}) exists`, + checkExistsResponse + ) + ) + } + + core.info(`Uploading layer ${layer.digest}.`) + + const initiateUploadBlobURL = this.uploadBlobEndpoint(repository) + + const initiateUploadResponse = await this.fetchWithRetries( + initiateUploadBlobURL, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this._b64Token}` + }, + body: JSON.stringify(layer) + } + ) + + if (initiateUploadResponse.status !== 202) { + throw new Error( + await errorMessageForFailedRequest( + `initiate layer upload`, + initiateUploadResponse + ) + ) + } + + const locationResponseHeader = + initiateUploadResponse.headers.get('location') + if (locationResponseHeader === undefined) { + throw new Error( + `No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}` + ) + } + + const pathname = `${locationResponseHeader}?digest=${layer.digest}` + const uploadBlobUrl = new URL(pathname, this._registry).toString() + + const putResponse = await this.fetchWithRetries(uploadBlobUrl, { + method: 'PUT', headers: { - Authorization: `Bearer ${b64Token}` + Authorization: `Bearer ${this._b64Token}`, + 'Content-Type': 'application/octet-stream', + 'Accept-Encoding': 'gzip', + 'Content-Length': layer.size.toString() + }, + body: data + }) + + if (putResponse.status !== 201) { + throw new Error( + await errorMessageForFailedRequest( + `layer (${layer.digest}) upload`, + putResponse + ) + ) + } + } + + // Uploads the manifest and returns the digest returned by GHCR + private async uploadManifest( + manifestJSON: string, + manifestMediaType: string, + repository: string, + version: string + ): Promise { + const manifestUrl = this.manifestEndpoint(repository, version) + + core.info(`Uploading manifest to ${manifestUrl}.`) + + const putResponse = await this.fetchWithRetries(manifestUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${this._b64Token}`, + 'Content-Type': manifestMediaType + }, + body: manifestJSON + }) + + if (putResponse.status !== 201) { + throw new Error( + await errorMessageForFailedRequest(`manifest upload`, putResponse) + ) + } + + const digestResponseHeader = + putResponse.headers.get('docker-content-digest') || '' + + return digestResponseHeader + } + + private checkBlobEndpoint(repository: string, digest: string): string { + return new URL( + `v2/${repository}/blobs/${digest}`, + this._registry + ).toString() + } + + private uploadBlobEndpoint(repository: string): string { + return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString() + } + + private manifestEndpoint(repository: string, version: string): string { + return new URL( + `v2/${repository}/manifests/${version}`, + this._registry + ).toString() + } + + private async fetchWithDebug( + url: string, + config: RequestInit = {} + ): Promise { + core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`) + try { + const response = await fetch(url, config) + core.debug(`Response with ${JSON.stringify(response)}`) + return response + } catch (error) { + core.debug(`Error with ${error}`) + throw error + } + } + + private async fetchWithRetries( + url: string, + config: RequestInit = {} + ): Promise { + const allowedAttempts = this._retryOptions.retries + 1 // Initial attempt + retries + + for ( + let attemptNumber = 1; + attemptNumber <= allowedAttempts; + attemptNumber++ + ) { + let backoff = this._retryOptions.backoff + + try { + const response = await this.fetchWithDebug(url, config) + + // If this is the last attempt, just return it + if (attemptNumber === allowedAttempts) { + return response + } + + // If the response is retryable, backoff and retry + if (retryableStatusCodes.includes(response.status)) { + const retryAfter = response.headers.get('retry-after') + if (retryAfter) { + backoff = parseInt(retryAfter) * 1000 // convert to ms + } + + core.info( + `Received ${response.status} response. Retrying after ${backoff}ms...` + ) + await new Promise(resolve => setTimeout(resolve, backoff)) + continue + } + + // Otherwise, just return the response + return response + } catch (error) { + // If this is the last attempt, throw the error + if (attemptNumber === allowedAttempts) { + throw error + } + + core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`) + await new Promise(resolve => setTimeout(resolve, backoff)) } } - ) - if ( - checkExistsResponse.status === 200 || - checkExistsResponse.status === 202 - ) { - core.info(`Layer ${layer.digest} already exists. Skipping upload.`) - return + // Should be unreachable + throw new Error('Exhausted retries without a successful response') } - - if (checkExistsResponse.status !== 404) { - throw new Error( - await errorMessageForFailedRequest( - `check blob (${layer.digest}) exists`, - checkExistsResponse - ) - ) - } - - core.info(`Uploading layer ${layer.digest}.`) - - const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${b64Token}` - }, - body: JSON.stringify(layer) - }) - - if (initiateUploadResponse.status !== 202) { - throw new Error( - await errorMessageForFailedRequest( - `initiate layer upload`, - initiateUploadResponse - ) - ) - } - - const locationResponseHeader = initiateUploadResponse.headers.get('location') - if (locationResponseHeader === undefined) { - throw new Error( - `No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}` - ) - } - - const pathname = `${locationResponseHeader}?digest=${layer.digest}` - const uploadBlobUrl = new URL(pathname, registryURL).toString() - - // TODO: must we handle the empty config layer? Maybe we can just skip calling this at all - let data: Buffer - if (layer.mediaType === 'application/vnd.oci.empty.v1+json') { - data = Buffer.from('{}') - } else { - data = fsHelper.readFileContents(file.path) - } - - const putResponse = await fetchWithDebug(uploadBlobUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/octet-stream', - 'Accept-Encoding': 'gzip', - 'Content-Length': layer.size.toString() - }, - body: data - }) - - if (putResponse.status !== 201) { - throw new Error( - await errorMessageForFailedRequest( - `layer (${layer.digest}) upload`, - putResponse - ) - ) - } -} - -// Uploads the manifest and returns the digest returned by GHCR -async function uploadManifest( - manifestJSON: string, - manifestEndpoint: string, - b64Token: string -): Promise { - core.info(`Uploading manifest to ${manifestEndpoint}.`) - - const putResponse = await fetchWithDebug(manifestEndpoint, { - method: 'PUT', - headers: { - Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' - }, - body: manifestJSON - }) - - if (putResponse.status !== 201) { - throw new Error( - await errorMessageForFailedRequest(`manifest upload`, putResponse) - ) - } - - const digestResponseHeader = putResponse.headers.get('docker-content-digest') - if (digestResponseHeader === undefined || digestResponseHeader === null) { - throw new Error( - `No digest header in response from PUT manifest ${manifestEndpoint}` - ) - } - - return digestResponseHeader } interface ghcrError { @@ -256,18 +356,3 @@ function isGHCRError(obj: unknown): boolean { typeof (obj as { message: unknown }).message === 'string' ) } - -const fetchWithDebug = async ( - url: string, - config: RequestInit = {} -): Promise => { - core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`) - try { - const response = await fetch(url, config) - core.debug(`Response with ${JSON.stringify(response)}`) - return response - } catch (error) { - core.debug(`Error with ${error}`) - throw error - } -} diff --git a/src/main.ts b/src/main.ts index a9fe215..f31eb87 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import * as ociContainer from './oci-container' import * as ghcr from './ghcr-client' import * as attest from '@actions/attest' import * as cfg from './config' +import * as crypto from 'crypto' /** * The main function for the action. @@ -52,21 +53,58 @@ export async function run(): Promise { const manifestDigest = ociContainer.sha256Digest(manifest) - // Attestations are not currently supported in GHES. + const ghcrClient = new ghcr.Client( + options.token, + options.containerRegistryUrl + ) + + // Attestations are not supported in GHES. if (!options.isEnterprise) { - const attestation = await generateAttestation( - manifestDigest, - semverTag.raw, - options + const { bundle, bundleDigest, bundleMediaType, bundlePredicateType } = + await generateAttestation(manifestDigest, semverTag.raw, options) + + const attestationCreated = new Date() + const attestationManifest = + ociContainer.createSigstoreAttestationManifest( + bundle.length, + bundleDigest, + bundleMediaType, + bundlePredicateType, + ociContainer.sizeInBytes(manifest), + manifestDigest, + attestationCreated + ) + + const referrerIndexManifest = ociContainer.createReferrerTagManifest( + ociContainer.sha256Digest(attestationManifest), + ociContainer.sizeInBytes(attestationManifest), + bundleMediaType, + bundlePredicateType, + attestationCreated ) - if (attestation.attestationID !== undefined) { - core.setOutput('attestation-id', attestation.attestationID) + + const { attestationSHA, referrerIndexSHA } = await publishAttestation( + ghcrClient, + options.nameWithOwner, + bundle, + bundleDigest, + manifest, + attestationManifest, + referrerIndexManifest + ) + + if (attestationSHA !== undefined) { + core.info(`Uploaded attestation ${attestationSHA}`) + core.setOutput('attestation-manifest-sha', attestationSHA) + } + if (referrerIndexSHA !== undefined) { + core.info(`Uploaded referrer index ${referrerIndexSHA}`) + core.setOutput('referrer-index-manifest-sha', referrerIndexSHA) } } - const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact( - options.token, - options.containerRegistryUrl, + const publishedDigest = await publishImmutableActionVersion( + ghcrClient, options.nameWithOwner, semverTag.raw, archives.zipFile, @@ -74,14 +112,6 @@ export async function run(): Promise { manifest ) - if (manifestDigest !== publishedDigest) { - throw new Error( - `Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}` - ) - } - - core.setOutput('package-url', packageURL.toString()) - core.setOutput('package-manifest', JSON.stringify(manifest)) core.setOutput('package-manifest-sha', publishedDigest) } catch (error) { // Fail the workflow run if an error occurs @@ -110,28 +140,131 @@ function parseSemverTagFromRef(opts: cfg.PublishActionOptions): semver.SemVer { return semverTag } -// Generate an attestation using the actions toolkit -// Subject name will contain the repo/package name and the tag name +async function publishImmutableActionVersion( + client: ghcr.Client, + nameWithOwner: string, + semverTag: string, + zipFile: fsHelper.FileMetadata, + tarFile: fsHelper.FileMetadata, + manifest: ociContainer.OCIImageManifest +): Promise { + const manifestDigest = ociContainer.sha256Digest(manifest) + + core.info( + `Creating GHCR package ${manifestDigest} for release with semver: ${semver}.` + ) + + const files = new Map() + files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path)) + files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path)) + files.set(ociContainer.emptyConfigSha, Buffer.from('{}')) + + return await client.uploadOCIImageManifest( + nameWithOwner, + manifest, + files, + semverTag + ) +} + +async function publishAttestation( + client: ghcr.Client, + nameWithOwner: string, + bundle: Buffer, + bundleDigest: string, + subjectManifest: ociContainer.OCIImageManifest, + attestationManifest: ociContainer.OCIImageManifest, + referrerIndexManifest: ociContainer.OCIIndexManifest +): Promise<{ + attestationSHA: string + referrerIndexSHA: string +}> { + const attestationManifestDigest = + ociContainer.sha256Digest(attestationManifest) + const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest) + const referrerIndexManifestDigest = ociContainer.sha256Digest( + referrerIndexManifest + ) + + core.info( + `Publishing attestation ${attestationManifestDigest} for subject ${subjectManifestDigest}.` + ) + + const files = new Map() + files.set(ociContainer.emptyConfigSha, Buffer.from('{}')) + files.set(bundleDigest, bundle) + + const attestationSHA = await client.uploadOCIImageManifest( + nameWithOwner, + attestationManifest, + files + ) + + // The referrer index is tagged with the subject's digest in format sha256- + const referrerTag = subjectManifestDigest.replace(':', '-') + + core.info( + `Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.` + ) + + const referrerIndexSHA = await client.uploadOCIIndexManifest( + nameWithOwner, + referrerIndexManifest, + referrerTag + ) + + return { attestationSHA, referrerIndexSHA } +} + async function generateAttestation( manifestDigest: string, semverTag: string, options: cfg.PublishActionOptions -): Promise { +): Promise<{ + bundle: Buffer + bundleDigest: string + bundleMediaType: string + bundlePredicateType: string +}> { const subjectName = `${options.nameWithOwner}@${semverTag}` const subjectDigest = removePrefix(manifestDigest, 'sha256:') core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`) - return await attest.attestProvenance({ + const attestation = await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - // Always store the attestation using the GitHub Attestations API - skipWrite: false, - // Identify the attestation to our API as an Immutable Action - headers: { 'X-GitHub-Publish-Action': subjectName } + skipWrite: true // We will upload attestations to GHCR }) + + const bundleArtifact = Buffer.from(JSON.stringify(attestation.bundle)) + + const hash = crypto.createHash('sha256') + hash.update(bundleArtifact) + const bundleSHA = hash.digest('hex') + + // We must base64 decode the dsse envelope to grab the predicate type + const dsseEnvelopeArtifact = attestation.bundle.dsseEnvelope + if (dsseEnvelopeArtifact === undefined) { + throw new Error('Attestation bundle is missing dsseEnvelope artifact') + } + + const dsseEnvelope = JSON.parse( + Buffer.from(dsseEnvelopeArtifact.payload, 'base64').toString('utf-8') + ) + const predicateType = dsseEnvelope.predicateType + if (predicateType === undefined) { + throw new Error('Attestation bundle is missing predicateType') + } + + return { + bundle: bundleArtifact, + bundleDigest: `sha256:${bundleSHA}`, + bundleMediaType: attestation.bundle.mediaType, + bundlePredicateType: predicateType + } } function removePrefix(str: string, prefix: string): string { diff --git a/src/oci-container.ts b/src/oci-container.ts index 66268dc..343409d 100644 --- a/src/oci-container.ts +++ b/src/oci-container.ts @@ -1,19 +1,49 @@ import { FileMetadata } from './fs-helper' import * as crypto from 'crypto' -export interface Manifest { +export const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json' +export const imageManifestMediaType = + 'application/vnd.oci.image.manifest.v1+json' +export const actionsPackageMediaType = + 'application/vnd.github.actions.package.v1+json' +export const actionsPackageTarLayerMediaType = + 'application/vnd.github.actions.package.layer.v1.tar+gzip' +export const actionsPackageZipLayerMediaType = + 'application/vnd.github.actions.package.layer.v1.zip' + +export const actionPackageAnnotationValue = 'actions_oci_pkg' +export const actionPackageAttestationAnnotationValue = + 'actions_oci_pkg_attestation' +export const actionPackageReferrerTagAnnotationValue = + 'actions_oci_pkg_referrer_index' + +export const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json' +export const emptyConfigSize = 2 +export const emptyConfigSha = + 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + +export interface OCIImageManifest { schemaVersion: number mediaType: string artifactType: string - config: Layer - layers: Layer[] + config: Descriptor + layers: Descriptor[] + subject?: Descriptor annotations: { [key: string]: string } } -export interface Layer { +export interface OCIIndexManifest { + schemaVersion: number + mediaType: string + manifests: Descriptor[] + annotations: { [key: string]: string } +} + +export interface Descriptor { mediaType: string size: number digest: string + artifactType?: string annotations?: { [key: string]: string } } @@ -26,24 +56,24 @@ export function createActionPackageManifest( ownerId: string, sourceCommit: string, version: string, - created: Date -): Manifest { - const configLayer = createConfigLayer() + created: Date = new Date() +): OCIImageManifest { + const configLayer = createEmptyConfigLayer() const sanitizedRepo = sanitizeRepository(repository) const tarLayer = createTarLayer(tarFile, sanitizedRepo, version) const zipLayer = createZipLayer(zipFile, sanitizedRepo, version) - const manifest: Manifest = { + const manifest: OCIImageManifest = { schemaVersion: 2, - mediaType: 'application/vnd.oci.image.manifest.v1+json', - artifactType: 'application/vnd.github.actions.package.v1+json', + mediaType: imageManifestMediaType, + artifactType: actionsPackageMediaType, config: configLayer, - layers: [configLayer, tarLayer, zipLayer], + layers: [tarLayer, zipLayer], annotations: { 'org.opencontainers.image.created': created.toISOString(), 'action.tar.gz.digest': tarFile.sha256, 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': 'actions_oci_pkg', + 'com.github.package.type': actionPackageAnnotationValue, 'com.github.package.version': version, 'com.github.source.repo.id': repoId, 'com.github.source.repo.owner.id': ownerId, @@ -54,9 +84,87 @@ export function createActionPackageManifest( return manifest } +export function createSigstoreAttestationManifest( + bundleSize: number, + bundleDigest: string, + bundleMediaType: string, + bundlePredicateType: string, + subjectSize: number, + subjectDigest: string, + created: Date = new Date() +): OCIImageManifest { + const configLayer = createEmptyConfigLayer() + + const sigstoreAttestationLayer: Descriptor = { + mediaType: bundleMediaType, + size: bundleSize, + digest: bundleDigest + } + + const subject: Descriptor = { + mediaType: imageManifestMediaType, + size: subjectSize, + digest: subjectDigest + } + + const manifest: OCIImageManifest = { + schemaVersion: 2, + mediaType: imageManifestMediaType, + artifactType: bundleMediaType, + config: configLayer, + layers: [sigstoreAttestationLayer], + subject, + + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': bundlePredicateType, + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + } + + return manifest +} + +export function createReferrerTagManifest( + attestationDigest: string, + attestationSize: number, + bundleMediaType: string, + bundlePredicateType: string, + attestationCreated: Date, + created: Date = new Date() +): OCIIndexManifest { + const manifest: OCIIndexManifest = { + schemaVersion: 2, + mediaType: imageIndexMediaType, + manifests: [ + { + mediaType: imageManifestMediaType, + artifactType: bundleMediaType, + size: attestationSize, + digest: attestationDigest, + annotations: { + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': attestationCreated.toISOString(), + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': bundlePredicateType + } + } + ], + annotations: { + 'com.github.package.type': actionPackageReferrerTagAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + } + + return manifest +} + // Calculate the SHA256 digest of a given manifest. // This should match the digest which the GitHub container registry calculates for this manifest. -export function sha256Digest(manifest: Manifest): string { +export function sha256Digest( + manifest: OCIImageManifest | OCIIndexManifest +): string { const data = JSON.stringify(manifest) const buffer = Buffer.from(data, 'utf8') const hash = crypto.createHash('sha256') @@ -65,12 +173,18 @@ export function sha256Digest(manifest: Manifest): string { return `sha256:${hexHash}` } -function createConfigLayer(): Layer { - const configLayer: Layer = { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: - 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' +export function sizeInBytes( + manifest: OCIImageManifest | OCIIndexManifest +): number { + const data = JSON.stringify(manifest) + return Buffer.byteLength(data, 'utf8') +} + +export function createEmptyConfigLayer(): Descriptor { + const configLayer: Descriptor = { + mediaType: ociEmptyMediaType, + size: emptyConfigSize, + digest: emptyConfigSha } return configLayer @@ -80,9 +194,9 @@ function createZipLayer( zipFile: FileMetadata, repository: string, version: string -): Layer { - const zipLayer: Layer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.zip', +): Descriptor { + const zipLayer: Descriptor = { + mediaType: actionsPackageZipLayerMediaType, size: zipFile.size, digest: zipFile.sha256, annotations: { @@ -97,9 +211,9 @@ function createTarLayer( tarFile: FileMetadata, repository: string, version: string -): Layer { - const tarLayer: Layer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', +): Descriptor { + const tarLayer: Descriptor = { + mediaType: actionsPackageTarLayerMediaType, size: tarFile.size, digest: tarFile.sha256, annotations: {