From e308348d01efe2f49fafe23520eacdd4834d3c4e Mon Sep 17 00:00:00 2001 From: Conor Sloan Date: Fri, 23 Aug 2024 10:56:04 +0100 Subject: [PATCH] fix up ghcr client tests and remove config from action package layers --- __tests__/ghcr-client.test.ts | 985 +++++++++++++++----------------- __tests__/main.test.ts | 7 +- __tests__/oci-container.test.ts | 9 +- badges/coverage.svg | 2 +- dist/index.js | 13 +- src/ghcr-client.ts | 5 +- src/oci-container.ts | 8 +- 7 files changed, 482 insertions(+), 547 deletions(-) diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index b0e1390..2ae5adb 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -1,543 +1,480 @@ -// import { publishImmutableActionVersion } from '../src/ghcr-client' -// import * as fsHelper from '../src/fs-helper' -// import * as ociContainer from '../src/oci-container' +import { + uploadOCIImageManifest + // uploadOCIIndexManifest +} 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 +// Mocks +let fetchMock: jest.SpyInstance -describe('run', () => { - it('does not fail when running in a test', () => { - // This is a dummy test to ensure that the run function does not fail when running in a test +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 checkBlobNoExistingBlobs = (): object => { + // Simulate none of the blobs existing currently + return { + text() { + return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' + }, + status: 404, + statusText: 'Not Found' + } +} + +const checkBlobAllExistingBlobs = (): object => { + // Simulate all of the blobs existing currently + return { + status: 200, + statusText: 'OK' + } +} + +let count = 0 +const checkBlobSomeExistingBlobs = (): object => { + count++ + // report one as existing + if (count === 1) { + return { + status: 200, + statusText: 'OK' + } + } else { + // report all others are missing + return { + text() { + return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' + }, + status: 404, + statusText: 'Not Found' + } + } +} + +const checkBlobFailure = (): object => { + return { + text() { + // In this case we'll simulate a response which does not use the expected error format + return '503 Service Unavailable' + }, + status: 503, + statusText: 'Service Unavailable' + } +} + +const initiateBlobUploadSuccessForAllBlobs = (): object => { + // Simulate successful initiation of uploads for all blobs & return location + return { + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + } +} + +const initiateBlobUploadFailureForAllBlobs = (): object => { + // Simulate failed initiation of uploads + return { + text() { + // In this case we'll simulate a response which does not use the expected error format + return '503 Service Unavailable' + }, + status: 503, + statusText: 'Service Unavailable' + } +} + +const initiateBlobUploadNoLocationHeader = (): object => { + return { + status: 202, + headers: { + get: () => {} + } + } +} + +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 digestToReturn + } + } + } + } + } +} + +const putBlobSuccess = (): object => { + return { + status: 201 + } +} + +const putManifestFailure = (): object => { + // Simulate fails upload of all blobs & 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 { + text() { + return '{"errors": [{"code": "BAD_REQUEST", "message": "digest issue."}]}' + }, + status: 400, + statusText: 'Bad Request' + } +} + +type MethodHandlers = { + 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 +} + +function configureFetchMock( + fetchMockInstance: jest.SpyInstance, + methodHandlers: MethodHandlers +): void { + fetchMockInstance.mockImplementation( + async (url: string, options: { method: string }) => { + validateRequestConfig(url, options) + switch (options.method) { + case 'HEAD': + return methodHandlers.checkBlobMock?.(url, options) + case 'POST': + return methodHandlers.initiateBlobUploadMock?.(url, options) + case 'PUT': + if (url.includes('manifest')) { + return methodHandlers.putManifestMock?.(url, options) + } else { + return methodHandlers.putBlobMock?.(url, options) + } + } + } + ) +} + +describe('uploadOCIImageManifest', () => { + beforeEach(() => { + jest.clearAllMocks() + fetchMock = jest.spyOn(global, 'fetch').mockImplementation() + }) + + it('uploads blobs then untagged manifest to the provided registry', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await uploadOCIImageManifest(token, registry, repository, manifest, blobs) + + // TODO: See what calls there are + 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 blobs then tagged manifest to the provided registry', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, semver) + }) + + await uploadOCIImageManifest( + token, + registry, + repository, + manifest, + blobs, + semver + ) + + // TODO: See what calls there are + 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('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 uploadOCIImageManifest(token, registry, repository, manifest, blobs) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(0) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(1) + }) + + it('skips blob uploads if some blobs already exist', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobSomeExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await uploadOCIImageManifest(token, registry, repository, manifest, blobs) + + expect(fetchMock).toHaveBeenCalledTimes(8) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(2) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(3) + }) + + it('throws an error if checking for existing blobs fails', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobFailure, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await expect( + uploadOCIImageManifest(token, registry, repository, manifest, blobs) + ).rejects.toThrow( + /^Unexpected 503 Service Unavailable response from check blob/ + ) + }) + + 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( + uploadOCIImageManifest(token, registry, 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, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadNoLocationHeader, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await expect( + uploadOCIImageManifest(token, registry, repository, manifest, blobs) + ).rejects.toThrow(/^No location header in response from upload post/) + }) + + it('throws an error if a layer upload fails', async () => { + const { manifest, sha, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobNoExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobFailure, + putManifestMock: putManifestSuccessful(sha, sha) + }) + + await expect( + uploadOCIImageManifest(token, registry, repository, manifest, blobs) + ).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/) + }) + + 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( + uploadOCIImageManifest(token, registry, 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, blobs } = testImageManifest() + + configureFetchMock(fetchMock, { + checkBlobMock: checkBlobAllExistingBlobs, + initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs, + putBlobMock: putBlobSuccess, + putManifestMock: putManifestSuccessful('some-garbage-digest', sha) + }) + + await expect( + uploadOCIImageManifest(token, registry, repository, manifest, blobs) + ).rejects.toThrow( + `Digest mismatch. Expected ${sha}, got some-garbage-digest.` + ) }) }) -// 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 -// } +describe('uploadOCIIndexManifest', () => { + beforeEach(() => { + jest.clearAllMocks() + fetchMock = jest.spyOn(global, 'fetch').mockImplementation() + }) -// const headMockNoExistingBlobs = (): object => { -// // Simulate none of the blobs existing currently -// return { -// text() { -// return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' -// }, -// status: 404, -// statusText: 'Not Found' -// } -// } + it('uploads the tagged manifest with the appropriate tag', async () => {}) -// const headMockAllExistingBlobs = (): object => { -// // Simulate all of the blobs existing currently -// return { -// status: 200, -// statusText: 'OK' -// } -// } + it('throws an error if a manifest upload fails', async () => {}) -// let count = 0 -// const headMockSomeExistingBlobs = (): object => { -// count++ -// // report one as existing -// if (count === 1) { -// return { -// status: 200, -// statusText: 'OK' -// } -// } else { -// // report all others are missing -// return { -// text() { -// return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' -// }, -// status: 404, -// statusText: 'Not Found' -// } -// } -// } + it('throws an error if the returned digest does not match the precalculated one', async () => {}) +}) -// const headMockFailure = (): object => { -// return { -// text() { -// // In this case we'll simulate a response which does not use the expected error format -// return '503 Service Unavailable' -// }, -// status: 503, -// statusText: 'Service Unavailable' -// } -// } +function testImageManifest(): { + manifest: ociContainer.OCIImageManifest + sha: string + blobs: Map +} { + const blobs = new Map() + blobs.set(ociContainer.emptyConfigSha, Buffer.from('{}')) -// const postMockSuccessfulIniationForAllBlobs = (): object => { -// // Simulate successful initiation of uploads for all blobs & return location -// return { -// status: 202, -// headers: { -// get: (header: string) => { -// if (header === 'location') { -// return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` -// } -// } -// } -// } -// } + const firstFile = Buffer.from('test1') + const secondFile = Buffer.from('test2') -// const postMockFailure = (): object => { -// // Simulate failed initiation of uploads -// return { -// text() { -// // In this case we'll simulate a response which does not use the expected error format -// return '503 Service Unavailable' -// }, -// status: 503, -// statusText: 'Service Unavailable' -// } -// } + const firstFileDigest = `sha256:${crypto + .createHash('sha256') + .update(firstFile) + .digest('hex')}` -// const postMockNoLocationHeader = (): object => { -// return { -// status: 202, -// headers: { -// get: () => {} -// } -// } -// } + const secondFileDigest = `sha256:${crypto + .createHash('sha256') + .update(secondFile) + .digest('hex')}` -// const putMockSuccessfulBlobUpload = (url: string): object => { -// // Simulate successful upload of all blobs & then the manifest -// if (url.includes('manifest')) { -// return { -// status: 201, -// headers: { -// get: (header: string) => { -// if (header === 'docker-content-digest') { -// return '1234567678' -// } -// } -// } -// } -// } -// return { -// status: 201 -// } -// } + blobs.set(firstFileDigest, firstFile) + blobs.set(secondFileDigest, secondFile) -// const putMockFailure = (): object => { -// // Simulate fails upload of all blobs & manifest -// return { -// text() { -// return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}' -// }, -// status: 400, -// statusText: 'Bad Request' -// } -// } + 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 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' -// } -// } -// return { -// status: 201 -// } -// } + const sha = ociContainer.sha256Digest(manifest) -// 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 -// } + return { manifest, sha, blobs } +} -// function configureFetchMock( -// fetchMockInstance: jest.SpyInstance, -// methodHandlers: MethodHandlers -// ): void { -// fetchMockInstance.mockImplementation( -// async (url: string, options: { method: string }) => { -// validateRequestConfig(url, options) -// switch (options.method) { -// case 'GET': -// return methodHandlers.getMock?.(url, options) -// case 'HEAD': -// return methodHandlers.headMock?.(url, options) -// case 'POST': -// return methodHandlers.postMock?.(url, options) -// case 'PUT': -// return methodHandlers.putMock?.(url, options) -// } -// } -// ) -// } +// 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 +function validateRequestConfig(url: string, config: any): void { + // Basic URL checks + expect(url).toBeDefined() + if (!url.startsWith(registry.toString())) { + console.log(`${url} does not start with ${registry}`) + } + // if these expect fails, run the test again with `-- --silent=false` + // the console.log above should give a clue about which URL is failing + expect(url.startsWith(registry.toString())).toBeTruthy() -// const testManifest: ociContainer.OCIImageManifest = { -// 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' -// } -// } + // Config checks + expect(config).toBeDefined() -// describe('publishOCIArtifact', () => { -// beforeEach(() => { -// jest.clearAllMocks() - -// fsReadFileSyncMock = jest -// .spyOn(fsHelper, 'readFileContents') -// .mockImplementation() - -// fetchMock = jest.spyOn(global, 'fetch').mockImplementation() -// }) - -// it('publishes layer blobs & then a manifest to the provided registry', async () => { -// configureFetchMock(fetchMock, { -// headMock: headMockNoExistingBlobs, -// postMock: postMockSuccessfulIniationForAllBlobs, -// putMock: putMockSuccessfulBlobUpload -// }) - -// // Simulate successful reading of all the files -// fsReadFileSyncMock.mockImplementation(() => { -// return Buffer.from('test') -// }) - -// await publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) - -// 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('skips uploading all layer blobs when they all already exist', async () => { -// configureFetchMock(fetchMock, { -// headMock: headMockAllExistingBlobs, -// postMock: postMockSuccessfulIniationForAllBlobs, -// putMock: putMockSuccessfulBlobUpload -// }) - -// // Simulate successful reading of all the files -// fsReadFileSyncMock.mockImplementation(() => { -// return Buffer.from('test') -// }) - -// await publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) - -// // 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') -// ).toHaveLength(3) -// expect( -// fetchMock.mock.calls.filter(call => call[1].method === 'POST') -// ).toHaveLength(0) -// expect( -// fetchMock.mock.calls.filter(call => call[1].method === 'PUT') -// ).toHaveLength(1) -// }) - -// it('skips uploading layer blobs that already exist', async () => { -// configureFetchMock(fetchMock, { -// headMock: headMockSomeExistingBlobs, -// postMock: postMockSuccessfulIniationForAllBlobs, -// putMock: putMockSuccessfulBlobUpload -// }) -// count = 0 - -// // Simulate successful reading of all the files -// fsReadFileSyncMock.mockImplementation(() => { -// return Buffer.from('test') -// }) - -// await publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) - -// 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) -// expect( -// fetchMock.mock.calls.filter(call => call[1].method === 'POST') -// ).toHaveLength(2) -// expect( -// fetchMock.mock.calls.filter(call => call[1].method === 'PUT') -// ).toHaveLength(3) -// }) - -// it('throws an error if checking for existing blobs fails', async () => { -// configureFetchMock(fetchMock, { headMock: headMockFailure }) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) -// ).rejects.toThrow( -// /^Unexpected 503 Service Unavailable response from check blob/ -// ) -// }) - -// it('throws an error if initiating layer upload fails', async () => { -// configureFetchMock(fetchMock, { -// headMock: headMockNoExistingBlobs, -// postMock: postMockFailure -// }) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) -// ).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 () => { -// configureFetchMock(fetchMock, { -// headMock: headMockNoExistingBlobs, -// postMock: postMockNoLocationHeader -// }) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) -// ).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 -// }) - -// // Simulate successful reading of all the files -// fsReadFileSyncMock.mockImplementation(() => { -// return Buffer.from('test') -// }) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) -// ).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 -// }) - -// // Simulate successful reading of all the files -// fsReadFileSyncMock.mockImplementation(() => { -// return Buffer.from('test') -// }) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// testManifest -// ) -// ).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 () => { -// 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') -// }) - -// await expect( -// publishImmutableActionVersion( -// 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 -// ) - -// await expect( -// publishImmutableActionVersion( -// token, -// registry, -// repository, -// semver, -// zipFile, -// tarFile, -// modifiedTestManifest -// ) -// ).rejects.toThrow('Unknown media type application/json') -// }) -// }) - -// // 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 -// function validateRequestConfig(url: string, config: any): void { -// // Basic URL checks -// expect(url).toBeDefined() -// if (!url.startsWith(registry.toString())) { -// console.log(`${url} does not start with ${registry}`) -// } -// // if these expect fails, run the test again with `-- --silent=false` -// // the console.log above should give a clue about which URL is failing -// expect(url.startsWith(registry.toString())).toBeTruthy() - -// // Config checks -// expect(config).toBeDefined() - -// expect(config.headers).toBeDefined() -// if (config.headers) { -// // Check the auth header is set -// expect(config.headers.Authorization).toBeDefined() -// // Check the auth header is the base 64 encoded token -// expect(config.headers.Authorization).toBe( -// `Bearer ${Buffer.from(token).toString('base64')}` -// ) -// } -// } - -// function cloneLayers( -// layers: ociContainer.Descriptor[] -// ): ociContainer.Descriptor[] { -// const result: ociContainer.Descriptor[] = [] -// for (const layer of layers) { -// result.push({ ...layer }) // this is _NOT_ a deep clone -// } -// return result -// } + expect(config.headers).toBeDefined() + if (config.headers) { + // Check the auth header is set + expect(config.headers.Authorization).toBeDefined() + // Check the auth header is the base 64 encoded token + expect(config.headers.Authorization).toBe( + `Bearer ${Buffer.from(token).toString('base64')}` + ) + } +} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index b553b7a..d103bf3 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -495,7 +495,7 @@ describe('run', () => { expect(blobs.has('123')).toBeTruthy() expect(blobs.has('1234')).toBeTruthy() expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType) - expect(manifest.layers.length).toBe(3) + expect(manifest.layers.length).toBe(2) expect(manifest.annotations['com.github.package.type']).toBe( ociContainer.actionPackageAnnotationValue ) @@ -585,7 +585,6 @@ describe('run', () => { uploadOCIImageManifestMock.mockImplementation( (token, registry, repository, manifest, blobs, tag) => { let expectedBlobKeys: string[] = [] - let expectedLayers = 0 let expectedAnnotationValue = '' let expectedTagValue: string | undefined = undefined let returnValue = '' @@ -599,13 +598,11 @@ describe('run', () => { ) expectedBlobKeys = [sigStoreLayer.digest, ociContainer.emptyConfigSha] - expectedLayers = 1 returnValue = 'sha256:attestation-digest' } else { expectedAnnotationValue = ociContainer.actionPackageAnnotationValue expectedTagValue = '1.2.3' expectedBlobKeys = ['123', '1234', ociContainer.emptyConfigSha] - expectedLayers = 3 returnValue = 'sha256:my-test-digest' } @@ -617,7 +614,7 @@ describe('run', () => { expectedAnnotationValue ) expect(tag).toBe(expectedTagValue) - expect(manifest.layers.length).toBe(expectedLayers) + 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() diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index f610c4c..781c044 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -16,7 +16,7 @@ describe('sha256Digest', () => { const { manifest } = testActionPackageManifest() const digest = sha256Digest(manifest) const expectedDigest = - 'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5' + 'sha256:1af9bf993bf068a51fbb54822471ab7507b07c553bcac09a7c91328740d8ed69' expect(digest).toEqual(expectedDigest) }) @@ -26,7 +26,7 @@ describe('size', () => { it('returns the total size of the provided manifest', () => { const { manifest } = testActionPackageManifest() const size = sizeInBytes(manifest) - expect(size).toBe(1133) + expect(size).toBe(991) }) }) @@ -44,11 +44,6 @@ 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}, diff --git a/badges/coverage.svg b/badges/coverage.svg index 7ff9529..6b04204 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 77.28%Coverage77.28% \ No newline at end of file +Coverage: 95.01%Coverage95.01% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 5c7a68f..10e1874 100644 --- a/dist/index.js +++ b/dist/index.js @@ -106682,7 +106682,9 @@ async function uploadOCIImageManifest(token, registry, repository, manifest, blo else { core.info(`Uploading manifest ${manifestSHA} to ${repository}.`); } - const layerUploads = manifest.layers.map(async (layer) => { + // 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`); @@ -107022,6 +107024,7 @@ 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'; @@ -107037,7 +107040,7 @@ 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 = new Date()) { - const configLayer = createConfigLayer(); + const configLayer = createEmptyConfigLayer(); const sanitizedRepo = sanitizeRepository(repository); const tarLayer = createTarLayer(tarFile, sanitizedRepo, version); const zipLayer = createZipLayer(zipFile, sanitizedRepo, version); @@ -107046,7 +107049,7 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner 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, @@ -107061,7 +107064,7 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner return manifest; } function createSigstoreAttestationManifest(bundleSize, bundleDigest, subjectSize, subjectDigest, created = new Date()) { - const configLayer = createConfigLayer(); + const configLayer = createEmptyConfigLayer(); const sigstoreAttestationLayer = { mediaType: exports.sigstoreBundleMediaType, size: bundleSize, @@ -107127,7 +107130,7 @@ function sizeInBytes(manifest) { const data = JSON.stringify(manifest); return Buffer.byteLength(data, 'utf8'); } -function createConfigLayer() { +function createEmptyConfigLayer() { const configLayer = { mediaType: exports.ociEmptyMediaType, size: exports.emptyConfigSize, diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 89d1a30..0566f79 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -20,7 +20,10 @@ export async function uploadOCIImageManifest( core.info(`Uploading manifest ${manifestSHA} to ${repository}.`) } - const layerUploads: Promise[] = manifest.layers.map(async layer => { + // 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`) diff --git a/src/oci-container.ts b/src/oci-container.ts index 5cccebc..a5a7c08 100644 --- a/src/oci-container.ts +++ b/src/oci-container.ts @@ -60,7 +60,7 @@ export function createActionPackageManifest( version: string, created: Date = new Date() ): OCIImageManifest { - const configLayer = createConfigLayer() + const configLayer = createEmptyConfigLayer() const sanitizedRepo = sanitizeRepository(repository) const tarLayer = createTarLayer(tarFile, sanitizedRepo, version) const zipLayer = createZipLayer(zipFile, sanitizedRepo, version) @@ -70,7 +70,7 @@ export function createActionPackageManifest( 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, @@ -93,7 +93,7 @@ export function createSigstoreAttestationManifest( subjectDigest: string, created: Date = new Date() ): OCIImageManifest { - const configLayer = createConfigLayer() + const configLayer = createEmptyConfigLayer() const sigstoreAttestationLayer: Descriptor = { mediaType: sigstoreBundleMediaType, @@ -178,7 +178,7 @@ export function sizeInBytes( return Buffer.byteLength(data, 'utf8') } -function createConfigLayer(): Descriptor { +export function createEmptyConfigLayer(): Descriptor { const configLayer: Descriptor = { mediaType: ociEmptyMediaType, size: emptyConfigSize,