/** * 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' 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 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 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 // Mock generating attestation let generateAttestationMock: jest.SpyInstance 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() // 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 createGHCRClient = jest .spyOn(ghcr, 'Client') .mockImplementation(() => client) uploadOCIImageManifestMock = jest .spyOn(client, 'uploadOCIImageManifest') .mockImplementation() uploadOCIIndexManifestMock = jest .spyOn(client, '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: bundleMediaType, verificationMaterial: { publicKey: { hint: 'test-hint' } }, dsseEnvelope: { payload: btoa(`{"predicateType": "${predicateType}"}`) } } } }) 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: bundleMediaType, verificationMaterial: { publicKey: { hint: 'test-hint' } }, dsseEnvelope: { payload: btoa(`{"predicateType": "${predicateType}"}`) } } } }) 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: 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 () => { 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( (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: { 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: 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(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' ) }) }) 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' } }