diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index be8a99a..b553b7a 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,640 +1,673 @@ -// /** -// * Unit tests for the action's main functionality, src/main.ts -// * -// * These should be run as if the action was called from a workflow. -// * Specifically, the inputs listed in `action.yml` should be set as environment -// * variables following the pattern `INPUT_`. -// */ +/** + * Unit tests for the action's main functionality, src/main.ts + * + * These should be run as if the action was called from a workflow. + * Specifically, the inputs listed in `action.yml` should be set as environment + * variables following the pattern `INPUT_`. + */ -// import * as core from '@actions/core' -// import * as attest from '@actions/attest' -// import * as main from '../src/main' -// import * as cfg from '../src/config' -// import * as fsHelper from '../src/fs-helper' -// import * as ghcr from '../src/ghcr-client' -// import * as ociContainer from '../src/oci-container' -// import * as oci from '@sigstore/oci' +import * as core from '@actions/core' +import * as attest from '@actions/attest' +import * as main from '../src/main' +import * as cfg from '../src/config' +import * as fsHelper from '../src/fs-helper' +import * as ghcr from '../src/ghcr-client' +import * as ociContainer from '../src/oci-container' + +const ghcrUrl = new URL('https://ghcr.io') + +// Mock the GitHub Actions core library +let setFailedMock: jest.SpyInstance +let setOutputMock: jest.SpyInstance + +// Mock the FS Helper +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 uploadOCIImageManifestMock: jest.SpyInstance +let uploadOCIIndexManifestMock: jest.SpyInstance + +// Mock the config resolution +let resolvePublishActionOptionsMock: jest.SpyInstance + +// Mock generating attestation +let generateAttestationMock: 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 + beforeEach(() => { + jest.clearAllMocks() + + // Core mocks + setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() + setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() + + // FS mocks + createTempDirMock = jest + .spyOn(fsHelper, 'createTempDir') + .mockImplementation() + createArchivesMock = jest + .spyOn(fsHelper, 'createArchives') + .mockImplementation() + stageActionFilesMock = jest + .spyOn(fsHelper, 'stageActionFiles') + .mockImplementation() + ensureCorrectShaCheckedOutMock = jest + .spyOn(fsHelper, 'ensureTagAndRefCheckedOut') + .mockImplementation() + readFileContentsMock = jest + .spyOn(fsHelper, 'readFileContents') + .mockImplementation() + + // OCI Container mocks + calculateManifestDigestMock = jest + .spyOn(ociContainer, 'sha256Digest') + .mockImplementation() + + // GHCR Client mocks + uploadOCIImageManifestMock = jest + .spyOn(ghcr, 'uploadOCIImageManifest') + .mockImplementation() + uploadOCIIndexManifestMock = jest + .spyOn(ghcr, 'uploadOCIIndexManifest') + .mockImplementation() + + // Config mocks + resolvePublishActionOptionsMock = jest + .spyOn(cfg, 'resolvePublishActionOptions') + .mockImplementation() + + // Attestation mocks + generateAttestationMock = jest + .spyOn(attest, 'attestProvenance') + .mockImplementation() + }) + + it('fails if the action ref is not a tag', async () => { + const options = baseOptions() + options.ref = 'refs/heads/main' // This is a branch, not a tag + resolvePublishActionOptionsMock.mockReturnValueOnce(options) + + await main.run() + + expect(setFailedMock).toHaveBeenCalledWith( + 'The ref refs/heads/main is not a valid tag reference.' + ) + }) + + it('fails if the value of the tag ref is not a valid semver', async () => { + const tags = ['test', 'v1.0', 'chicken', '111111'] + + for (const tag of tags) { + const options = baseOptions() + options.ref = `refs/tags/${tag}` + resolvePublishActionOptionsMock.mockReturnValueOnce(options) + + await main.run() + expect(setFailedMock).toHaveBeenCalledWith( + `${tag} is not a valid semantic version tag, and so cannot be uploaded to the action package.` + ) + } + }) + + it('fails if ensuring the correct SHA is checked out errors', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if creating staging temp directory fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + createTempDirMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if staging files fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'tmpDir/staging' + }) + + stageActionFilesMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if creating archives temp directory fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation((_, path: string) => { + if (path === 'staging') { + return 'staging' + } + throw new Error('Something went wrong') + }) + + stageActionFilesMock.mockImplementation(() => {}) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if creating archives fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + createArchivesMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if creating attestation fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'test', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'test2', + size: 52, + sha256: '1234' + } + } + }) + + uploadOCIImageManifestMock.mockImplementation(() => { + return { + packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', + publishedDigest: 'sha256:my-test-digest' + } + }) + + generateAttestationMock.mockImplementation(async () => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if uploading attestation to GHCR 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' + } + } + }) + + 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: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + uploadOCIImageManifestMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if uploading referrer index manifest to GHCR 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' + } + } + }) + + 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: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + 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('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: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + uploadOCIImageManifestMock.mockImplementation( + (token, registry, 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 () => { + const options = baseOptions() + options.isEnterprise = true + resolvePublishActionOptionsMock.mockReturnValue(options) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + 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( + (token, registry, repository, manifest, blobs, tag) => { + expect(token).toBe(options.token) + expect(registry).toBe(options.containerRegistryUrl) + 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(3) + 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: { + 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 opts => { + expect(opts).toHaveProperty('skipWrite', true) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + uploadOCIIndexManifestMock.mockImplementation( + async (token, registry, repository, manifest, tag) => { + expect(token).toBe(options.token) + expect(registry).toBe(options.containerRegistryUrl) + expect(repository).toBe(options.nameWithOwner) + expect(tag).toBe('sha256-my-test-digest') + expect(manifest.mediaType).toBe(ociContainer.imageIndexMediaType) + return 'sha256:referrer-index-digest' + } + ) + + uploadOCIImageManifestMock.mockImplementation( + (token, registry, repository, manifest, blobs, tag) => { + let expectedBlobKeys: string[] = [] + let expectedLayers = 0 + let expectedAnnotationValue = '' + let expectedTagValue: string | undefined = undefined + let returnValue = '' + + if (tag === undefined) { + expectedAnnotationValue = + ociContainer.actionPackageAttestationAnnotationValue + const sigStoreLayer = manifest.layers.find( + (layer: ociContainer.Descriptor) => + layer.mediaType === ociContainer.sigstoreBundleMediaType + ) + + 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' + } + + expect(token).toBe(options.token) + expect(registry).toBe(options.containerRegistryUrl) + expect(repository).toBe(options.nameWithOwner) + expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType) + expect(manifest.annotations['com.github.package.type']).toBe( + expectedAnnotationValue + ) + expect(tag).toBe(expectedTagValue) + expect(manifest.layers.length).toBe(expectedLayers) + 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(uploadOCIImageManifestMock).toHaveBeenCalledTimes(2) + expect(uploadOCIIndexManifestMock).toHaveBeenCalledTimes(1) + + // Check outputs + expect(setOutputMock).toHaveBeenCalledTimes(3) + + expect(setOutputMock).toHaveBeenCalledWith( + 'attestation-manifest-sha', + 'sha256:attestation-digest' + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'referrer-index-manifest-sha', + 'sha256:referrer-index-digest' + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'package-manifest-sha', + 'sha256:my-test-digest' + ) }) }) -// const ghcrUrl = new URL('https://ghcr.io') - -// // Mock the GitHub Actions core library -// let setFailedMock: jest.SpyInstance -// let setOutputMock: jest.SpyInstance - -// // Mock the FS Helper -// let createTempDirMock: jest.SpyInstance -// let createArchivesMock: jest.SpyInstance -// let stageActionFilesMock: jest.SpyInstance -// let ensureCorrectShaCheckedOutMock: jest.SpyInstance - -// // Mock OCI container lib -// let calculateManifestDigestMock: jest.SpyInstance - -// // Mock GHCR client -// let publishOCIArtifactMock: jest.SpyInstance - -// // Mock the config resolution -// let resolvePublishActionOptionsMock: jest.SpyInstance - -// // Mock generating attestation -// let generateAttestationMock: jest.SpyInstance - -// // Mock uploading attestation with oci lib -// let attachArtifactToImageMock: jest.SpyInstance - -// describe('run', () => { -// beforeEach(() => { -// jest.clearAllMocks() - -// // Core mocks -// setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() -// setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() - -// // FS mocks -// createTempDirMock = jest -// .spyOn(fsHelper, 'createTempDir') -// .mockImplementation() -// createArchivesMock = jest -// .spyOn(fsHelper, 'createArchives') -// .mockImplementation() -// stageActionFilesMock = jest -// .spyOn(fsHelper, 'stageActionFiles') -// .mockImplementation() -// ensureCorrectShaCheckedOutMock = jest -// .spyOn(fsHelper, 'ensureTagAndRefCheckedOut') -// .mockImplementation() - -// // OCI Container mocks -// calculateManifestDigestMock = jest -// .spyOn(ociContainer, 'sha256Digest') -// .mockImplementation() - -// // GHCR Client mocks -// publishOCIArtifactMock = jest -// .spyOn(ghcr, 'pub') -// .mockImplementation() - -// // Config mocks -// resolvePublishActionOptionsMock = jest -// .spyOn(cfg, 'resolvePublishActionOptions') -// .mockImplementation() - -// // Attestation mocks -// generateAttestationMock = jest -// .spyOn(attest, 'attestProvenance') -// .mockImplementation() -// attachArtifactToImageMock = jest -// .spyOn(oci, 'attachArtifactToImage') -// .mockImplementation() -// }) - -// it('fails if the action ref is not a tag', async () => { -// const options = baseOptions() -// options.ref = 'refs/heads/main' // This is a branch, not a tag -// resolvePublishActionOptionsMock.mockReturnValueOnce(options) - -// await main.run() - -// expect(setFailedMock).toHaveBeenCalledWith( -// 'The ref refs/heads/main is not a valid tag reference.' -// ) -// }) - -// it('fails if the value of the tag ref is not a valid semver', async () => { -// const tags = ['test', 'v1.0', 'chicken', '111111'] - -// for (const tag of tags) { -// const options = baseOptions() -// options.ref = `refs/tags/${tag}` -// resolvePublishActionOptionsMock.mockReturnValueOnce(options) - -// await main.run() -// expect(setFailedMock).toHaveBeenCalledWith( -// `${tag} is not a valid semantic version tag, and so cannot be uploaded to the action package.` -// ) -// } -// }) - -// it('fails if ensuring the correct SHA is checked out errors', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if creating staging temp directory fails', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) -// createTempDirMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if staging files fails', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - -// createTempDirMock.mockImplementation(() => { -// return 'tmpDir/staging' -// }) - -// stageActionFilesMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if creating archives temp directory fails', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - -// createTempDirMock.mockImplementation((_, path: string) => { -// if (path === 'staging') { -// return 'staging' -// } -// throw new Error('Something went wrong') -// }) - -// stageActionFilesMock.mockImplementation(() => {}) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if creating archives fails', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - -// createTempDirMock.mockImplementation(() => { -// return 'stagingOrArchivesDir' -// }) - -// stageActionFilesMock.mockImplementation(() => {}) - -// createArchivesMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if creating attestation fails', async () => { -// resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - -// ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - -// createTempDirMock.mockImplementation(() => { -// return 'stagingOrArchivesDir' -// }) - -// stageActionFilesMock.mockImplementation(() => {}) - -// calculateManifestDigestMock.mockImplementation(() => { -// return 'sha256:my-test-digest' -// }) - -// createArchivesMock.mockImplementation(() => { -// return { -// zipFile: { -// path: 'test', -// size: 5, -// sha256: '123' -// }, -// tarFile: { -// path: 'test2', -// size: 52, -// sha256: '1234' -// } -// } -// }) - -// publishOCIArtifactMock.mockImplementation(() => { -// return { -// packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', -// publishedDigest: 'sha256:my-test-digest' -// } -// }) - -// generateAttestationMock.mockImplementation(async () => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if uploading attestation to GHCR 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' -// } -// } -// }) - -// 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: 'application/vnd.cncf.notary.v2+jwt', -// verificationMaterial: { -// publicKey: { -// hint: 'test-hint' -// } -// } -// } -// } -// }) - -// attachArtifactToImageMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if publishing OCI artifact 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' -// } -// } -// }) - -// 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: 'application/vnd.cncf.notary.v2+jwt', -// verificationMaterial: { -// publicKey: { -// hint: 'test-hint' -// } -// } -// } -// } -// }) - -// attachArtifactToImageMock.mockImplementation(() => { -// return { -// digest: 'sha256:my-test-attestation-digest', -// urls: [ -// 'ghcr.io/v2/test-org/test-package/manifests/sha256:my-test-attestation-digest' -// ] -// } -// }) - -// publishOCIArtifactMock.mockImplementation(() => { -// throw new Error('Something went wrong') -// }) - -// // Run the action -// await main.run() - -// // Check the results -// expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') -// }) - -// it('fails if unexpected digest returned from GHCR', 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' -// } -// } -// }) - -// 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: 'application/vnd.cncf.notary.v2+jwt', -// verificationMaterial: { -// publicKey: { -// hint: 'test-hint' -// } -// } -// } -// } -// }) - -// attachArtifactToImageMock.mockImplementation(() => { -// return { -// digest: 'sha256:some-other-digest', -// urls: [ -// 'ghcr.io/v2/test-org/test-package/manifests/sha256:some-other-digest' -// ] -// } -// }) - -// publishOCIArtifactMock.mockImplementation(() => { -// return { -// packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', -// publishedDigest: 'sha256:some-other-digest' -// } -// }) - -// // 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' -// ) -// }) - -// it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => { -// const options = baseOptions() -// options.isEnterprise = true -// resolvePublishActionOptionsMock.mockReturnValue(options) - -// 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' -// } -// } -// }) - -// 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' -// } -// } -// }) - -// 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: 'application/vnd.cncf.notary.v2+jwt', -// verificationMaterial: { -// publicKey: { -// hint: 'test-hint' -// } -// } -// } -// } -// }) - -// attachArtifactToImageMock.mockImplementation(async () => { -// return { -// digest: 'sha256:my-test-attestation-digest', -// urls: [ -// 'ghcr.io/v2/test-org/test-package/manifests/sha256:my-test-attestation-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(5) - -// expect(setOutputMock).toHaveBeenCalledWith( -// 'attestation-manifest-sha', -// 'sha256:my-test-attestation-digest' -// ) - -// expect(setOutputMock).toHaveBeenCalledWith( -// 'attestation-url', -// 'ghcr.io/v2/test-org/test-package/manifests/sha256:my-test-attestation-digest' -// ) - -// 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' -// ) -// }) -// }) - -// function baseOptions(): cfg.PublishActionOptions { -// return { -// nameWithOwner: 'nameWithOwner', -// workspaceDir: 'workspaceDir', -// event: 'release', -// apiBaseUrl: 'apiBaseUrl', -// runnerTempDir: 'runnerTempDir', -// sha: 'sha', -// repositoryId: 'repositoryId', -// repositoryOwnerId: 'repositoryOwnerId', -// isEnterprise: false, -// containerRegistryUrl: ghcrUrl, -// token: 'token', -// ref: 'refs/tags/v1.2.3', -// repositoryVisibility: 'public' -// } -// } +function baseOptions(): cfg.PublishActionOptions { + return { + nameWithOwner: 'nameWithOwner', + workspaceDir: 'workspaceDir', + event: 'release', + apiBaseUrl: 'apiBaseUrl', + runnerTempDir: 'runnerTempDir', + sha: 'sha', + repositoryId: 'repositoryId', + repositoryOwnerId: 'repositoryOwnerId', + isEnterprise: false, + containerRegistryUrl: ghcrUrl, + token: 'token', + ref: 'refs/tags/v1.2.3', + repositoryVisibility: 'public' + } +} diff --git a/badges/coverage.svg b/badges/coverage.svg index 8f44d6a..7ff9529 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 58.55%Coverage58.55% \ No newline at end of file +Coverage: 77.28%Coverage77.28% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 87c04d9..5c7a68f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -106690,13 +106690,21 @@ async function uploadOCIImageManifest(token, registry, repository, manifest, blo return uploadLayer(layer, blob, registry, repository, b64Token); }); await Promise.all(layerUploads); - return await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag || manifestSHA, b64Token); + const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag || manifestSHA, b64Token); + if (publishedDigest !== manifestSHA) { + throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`); + } + return manifestSHA; } async function uploadOCIIndexManifest(token, registry, repository, manifest, tag) { const b64Token = Buffer.from(token).toString('base64'); const manifestSHA = ociContainer.sha256Digest(manifest); core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`); - return await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag, b64Token); + const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag, b64Token); + if (publishedDigest !== manifestSHA) { + throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`); + } + return manifestSHA; } async function uploadLayer(layer, data, registryURL, repository, b64Token) { const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint(registryURL, repository, layer.digest), { @@ -107008,22 +107016,22 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.emptyConfigSha = exports.emptyConfigSize = exports.ociEmptyMediaType = void 0; +exports.emptyConfigSha = exports.emptyConfigSize = exports.ociEmptyMediaType = exports.actionPackageReferrerTagAnnotationValue = exports.actionPackageAttestationAnnotationValue = exports.actionPackageAnnotationValue = exports.sigstoreBundleMediaType = 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; const crypto = __importStar(__nccwpck_require__(6113)); -const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json'; -const imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json'; -const actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json'; -const actionsPackageTarLayerMediaType = 'application/vnd.github.actions.package.layer.v1.tar+gzip'; -const actionsPackageZipLayerMediaType = 'application/vnd.github.actions.package.layer.v1.zip'; -const sigstoreBundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json'; -const actionPackageAnnotationValue = 'actions_oci_pkg'; -const actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation'; -const actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_tag'; +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.sigstoreBundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json'; +exports.actionPackageAnnotationValue = 'actions_oci_pkg'; +exports.actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation'; +exports.actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_tag'; exports.ociEmptyMediaType = 'application/vnd.oci.empty.v1+json'; exports.emptyConfigSize = 2; exports.emptyConfigSha = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; @@ -107035,15 +107043,15 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner const zipLayer = createZipLayer(zipFile, sanitizedRepo, version); const manifest = { schemaVersion: 2, - mediaType: imageManifestMediaType, - artifactType: actionsPackageMediaType, + mediaType: exports.imageManifestMediaType, + artifactType: exports.actionsPackageMediaType, config: configLayer, layers: [configLayer, tarLayer, zipLayer], annotations: { 'org.opencontainers.image.created': created.toISOString(), 'action.tar.gz.digest': tarFile.sha256, 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': actionPackageAnnotationValue, + 'com.github.package.type': exports.actionPackageAnnotationValue, 'com.github.package.version': version, 'com.github.source.repo.id': repoId, 'com.github.source.repo.owner.id': ownerId, @@ -107055,26 +107063,26 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner function createSigstoreAttestationManifest(bundleSize, bundleDigest, subjectSize, subjectDigest, created = new Date()) { const configLayer = createConfigLayer(); const sigstoreAttestationLayer = { - mediaType: sigstoreBundleMediaType, + mediaType: exports.sigstoreBundleMediaType, size: bundleSize, digest: bundleDigest }; const subject = { - mediaType: imageManifestMediaType, + mediaType: exports.imageManifestMediaType, size: subjectSize, digest: subjectDigest }; const manifest = { schemaVersion: 2, - mediaType: imageManifestMediaType, - artifactType: sigstoreBundleMediaType, + mediaType: exports.imageManifestMediaType, + artifactType: exports.sigstoreBundleMediaType, config: configLayer, layers: [sigstoreAttestationLayer], subject, annotations: { 'dev.sigstore.bundle.content': 'dsse-envelope', 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1', - 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'com.github.package.type': exports.actionPackageAttestationAnnotationValue, 'org.opencontainers.image.created': created.toISOString() } }; @@ -107083,15 +107091,15 @@ function createSigstoreAttestationManifest(bundleSize, bundleDigest, subjectSize function createReferrerTagManifest(attestationDigest, attestationSize, attestationCreated, created = new Date()) { const manifest = { schemaVersion: 2, - mediaType: imageIndexMediaType, + mediaType: exports.imageIndexMediaType, manifests: [ { - mediaType: imageManifestMediaType, - artifactType: sigstoreBundleMediaType, + mediaType: exports.imageManifestMediaType, + artifactType: exports.sigstoreBundleMediaType, size: attestationSize, digest: attestationDigest, annotations: { - 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'com.github.package.type': exports.actionPackageAttestationAnnotationValue, 'org.opencontainers.image.created': attestationCreated.toISOString(), 'dev.sigstore.bundle.content': 'dsse-envelope', 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1' @@ -107099,7 +107107,7 @@ function createReferrerTagManifest(attestationDigest, attestationSize, attestati } ], annotations: { - 'com.github.package.type': actionPackageReferrerTagAnnotationValue, + 'com.github.package.type': exports.actionPackageReferrerTagAnnotationValue, 'org.opencontainers.image.created': created.toISOString() } }; @@ -107129,7 +107137,7 @@ function createConfigLayer() { } function createZipLayer(zipFile, repository, version) { const zipLayer = { - mediaType: actionsPackageZipLayerMediaType, + mediaType: exports.actionsPackageZipLayerMediaType, size: zipFile.size, digest: zipFile.sha256, annotations: { @@ -107140,7 +107148,7 @@ function createZipLayer(zipFile, repository, version) { } function createTarLayer(tarFile, repository, version) { const tarLayer = { - mediaType: actionsPackageTarLayerMediaType, + mediaType: exports.actionsPackageTarLayerMediaType, size: tarFile.size, digest: tarFile.sha256, annotations: { diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 5fcb330..89d1a30 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -30,7 +30,7 @@ export async function uploadOCIImageManifest( await Promise.all(layerUploads) - return await uploadManifest( + const publishedDigest = await uploadManifest( JSON.stringify(manifest), manifest.mediaType, registry, @@ -38,6 +38,14 @@ export async function uploadOCIImageManifest( tag || manifestSHA, b64Token ) + + if (publishedDigest !== manifestSHA) { + throw new Error( + `Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.` + ) + } + + return manifestSHA } export async function uploadOCIIndexManifest( @@ -54,7 +62,7 @@ export async function uploadOCIIndexManifest( `Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.` ) - return await uploadManifest( + const publishedDigest = await uploadManifest( JSON.stringify(manifest), manifest.mediaType, registry, @@ -62,6 +70,14 @@ export async function uploadOCIIndexManifest( tag, b64Token ) + + if (publishedDigest !== manifestSHA) { + throw new Error( + `Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.` + ) + } + + return manifestSHA } async function uploadLayer( diff --git a/src/oci-container.ts b/src/oci-container.ts index 568d3cb..5cccebc 100644 --- a/src/oci-container.ts +++ b/src/oci-container.ts @@ -1,18 +1,23 @@ import { FileMetadata } from './fs-helper' import * as crypto from 'crypto' -const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json' -const imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json' -const actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json' -const actionsPackageTarLayerMediaType = +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' -const actionsPackageZipLayerMediaType = +export const actionsPackageZipLayerMediaType = 'application/vnd.github.actions.package.layer.v1.zip' -const sigstoreBundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json' +export const sigstoreBundleMediaType = + 'application/vnd.dev.sigstore.bundle.v0.3+json' -const actionPackageAnnotationValue = 'actions_oci_pkg' -const actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation' -const actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_tag' +export const actionPackageAnnotationValue = 'actions_oci_pkg' +export const actionPackageAttestationAnnotationValue = + 'actions_oci_pkg_attestation' +export const actionPackageReferrerTagAnnotationValue = + 'actions_oci_pkg_referrer_tag' export const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json' export const emptyConfigSize = 2