diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index bb69193..c90d585 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -12,6 +12,7 @@ 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') @@ -19,11 +20,16 @@ const ghcrUrl = new URL('https://ghcr.io') let setFailedMock: jest.SpyInstance let setOutputMock: jest.SpyInstance -// Mock the IA Toolkit +// 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 @@ -54,6 +60,11 @@ describe('run', () => { .spyOn(fsHelper, 'ensureTagAndRefCheckedOut') .mockImplementation() + // OCI Container mocks + calculateManifestDigestMock = jest + .spyOn(ociContainer, 'sha256Digest') + .mockImplementation() + // GHCR Client mocks publishOCIArtifactMock = jest .spyOn(ghcr, 'publishOCIArtifact') @@ -189,43 +200,6 @@ describe('run', () => { 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' - } - } - }) - - 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 creating attestation fails', async () => { resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) @@ -252,11 +226,8 @@ describe('run', () => { } }) - publishOCIArtifactMock.mockImplementation(() => { - return { - packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - manifestDigest: 'sha256:my-test-digest' - } + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' }) generateAttestationMock.mockImplementation(async () => { @@ -270,6 +241,127 @@ describe('run', () => { 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', false) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + 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', false) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + 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 @@ -298,10 +390,14 @@ describe('run', () => { } }) + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + publishOCIArtifactMock.mockImplementation(() => { return { packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - manifestDigest: 'sha256:my-test-digest' + publishedDigest: 'sha256:my-test-digest' } }) @@ -356,10 +452,14 @@ describe('run', () => { } }) + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + publishOCIArtifactMock.mockImplementation(() => { return { packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - manifestDigest: 'sha256:my-test-digest' + publishedDigest: 'sha256:my-test-digest' } }) @@ -439,10 +539,14 @@ describe('run', () => { } }) + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + publishOCIArtifactMock.mockImplementation(() => { return { packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', - manifestDigest: 'sha256:my-test-digest' + publishedDigest: 'sha256:my-test-digest' } }) diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index ed0b33f..10f1514 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -1,6 +1,44 @@ -import { createActionPackageManifest } from '../src/oci-container' +import { createActionPackageManifest, sha256Digest } from '../src/oci-container' import { FileMetadata } from '../src/fs-helper' +describe('sha256Digest', () => { + it('calculates the SHA256 digest of the provided manifest', () => { + const date = new Date('2021-01-01T00:00:00Z') + const repo = 'test-org/test-repo' + const version = '1.2.3' + const repoId = '123' + const ownerId = '456' + const sourceCommit = 'abc' + const tarFile: FileMetadata = { + path: '/test/test/test.tar.gz', + sha256: 'tarSha', + size: 123 + } + const zipFile: FileMetadata = { + path: '/test/test/test.zip', + sha256: 'zipSha', + size: 456 + } + + const manifest = createActionPackageManifest( + tarFile, + zipFile, + repo, + repoId, + ownerId, + sourceCommit, + version, + date + ) + + const digest = sha256Digest(manifest) + const expectedDigest = + 'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5' + + expect(digest).toEqual(expectedDigest) + }) +}) + describe('createActionPackageManifest', () => { it('creates a manifest containing the provided information', () => { const date = new Date() diff --git a/badges/coverage.svg b/badges/coverage.svg index 1b46f21..e9318f9 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 96.81%Coverage96.81% \ No newline at end of file +Coverage: 96.95%Coverage96.95% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 8f60271..3cc7b96 100644 --- a/dist/index.js +++ b/dist/index.js @@ -104732,7 +104732,7 @@ async function publishOCIArtifact(token, registry, repository, semver, zipFile, const digest = await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token); return { packageURL: new URL(`${repository}:${semver}`, registry), - manifestDigest: digest + publishedDigest: digest }; } exports.publishOCIArtifact = publishOCIArtifact; @@ -104884,10 +104884,7 @@ async function run() { const archiveDir = fsHelper.createTempDir(options.runnerTempDir, 'archives'); const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir); const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date()); - const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest); - core.setOutput('package-url', packageURL.toString()); - core.setOutput('package-manifest', JSON.stringify(manifest)); - core.setOutput('package-manifest-sha', manifestDigest); + const manifestDigest = ociContainer.sha256Digest(manifest); // Attestations are not currently supported in GHES. if (!options.isEnterprise) { const attestation = await generateAttestation(manifestDigest, semverTag.raw, options); @@ -104895,6 +104892,13 @@ async function run() { core.setOutput('attestation-id', attestation.attestationID); } } + const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest); + if (manifestDigest !== publishedDigest) { + throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`); + } + core.setOutput('package-url', packageURL.toString()); + core.setOutput('package-manifest', JSON.stringify(manifest)); + core.setOutput('package-manifest-sha', publishedDigest); } catch (error) { // Fail the workflow run if an error occurs @@ -104923,6 +104927,7 @@ function parseSemverTagFromRef(opts) { async function generateAttestation(manifestDigest, semverTag, options) { const subjectName = `${options.nameWithOwner}@${semverTag}`; const subjectDigest = removePrefix(manifestDigest, 'sha256:'); + core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`); return await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, @@ -104946,12 +104951,36 @@ function removePrefix(str, prefix) { /***/ }), /***/ 33207: -/***/ ((__unused_webpack_module, exports) => { +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.createActionPackageManifest = void 0; +exports.sha256Digest = exports.createActionPackageManifest = void 0; +const crypto = __importStar(__nccwpck_require__(6113)); // Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package. function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created) { const configLayer = createConfigLayer(); @@ -104978,6 +105007,17 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner return manifest; } exports.createActionPackageManifest = createActionPackageManifest; +// Calculate the SHA256 digest of a given manifest. +// This should match the digest which the GitHub container registry calculates for this manifest. +function sha256Digest(manifest) { + const data = JSON.stringify(manifest); + const buffer = Buffer.from(data, 'utf8'); + const hash = crypto.createHash('sha256'); + hash.update(buffer); + const hexHash = hash.digest('hex'); + return `sha256:${hexHash}`; +} +exports.sha256Digest = sha256Digest; function createConfigLayer() { const configLayer = { mediaType: 'application/vnd.oci.empty.v1+json', diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 91b1b9b..6aa4c0e 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -12,7 +12,7 @@ export async function publishOCIArtifact( zipFile: FileMetadata, tarFile: FileMetadata, manifest: ociContainer.Manifest -): Promise<{ packageURL: URL; manifestDigest: string }> { +): Promise<{ packageURL: URL; publishedDigest: string }> { const b64Token = Buffer.from(token).toString('base64') const checkBlobEndpoint = new URL( @@ -76,7 +76,7 @@ export async function publishOCIArtifact( return { packageURL: new URL(`${repository}:${semver}`, registry), - manifestDigest: digest + publishedDigest: digest } } diff --git a/src/main.ts b/src/main.ts index 97073ec..161a66c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,19 +50,7 @@ export async function run(): Promise { new Date() ) - const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact( - options.token, - options.containerRegistryUrl, - options.nameWithOwner, - semverTag.raw, - archives.zipFile, - archives.tarFile, - manifest - ) - - core.setOutput('package-url', packageURL.toString()) - core.setOutput('package-manifest', JSON.stringify(manifest)) - core.setOutput('package-manifest-sha', manifestDigest) + const manifestDigest = ociContainer.sha256Digest(manifest) // Attestations are not currently supported in GHES. if (!options.isEnterprise) { @@ -75,6 +63,26 @@ export async function run(): Promise { core.setOutput('attestation-id', attestation.attestationID) } } + + const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact( + options.token, + options.containerRegistryUrl, + options.nameWithOwner, + semverTag.raw, + archives.zipFile, + archives.tarFile, + manifest + ) + + if (manifestDigest !== publishedDigest) { + throw new Error( + `Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}` + ) + } + + core.setOutput('package-url', packageURL.toString()) + core.setOutput('package-manifest', JSON.stringify(manifest)) + core.setOutput('package-manifest-sha', publishedDigest) } catch (error) { // Fail the workflow run if an error occurs if (error instanceof Error) core.setFailed(error.message) @@ -112,6 +120,8 @@ async function generateAttestation( const subjectName = `${options.nameWithOwner}@${semverTag}` const subjectDigest = removePrefix(manifestDigest, 'sha256:') + core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`) + return await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, diff --git a/src/oci-container.ts b/src/oci-container.ts index 90088c0..66268dc 100644 --- a/src/oci-container.ts +++ b/src/oci-container.ts @@ -1,4 +1,5 @@ import { FileMetadata } from './fs-helper' +import * as crypto from 'crypto' export interface Manifest { schemaVersion: number @@ -53,6 +54,17 @@ export function createActionPackageManifest( return manifest } +// Calculate the SHA256 digest of a given manifest. +// This should match the digest which the GitHub container registry calculates for this manifest. +export function sha256Digest(manifest: Manifest): string { + const data = JSON.stringify(manifest) + const buffer = Buffer.from(data, 'utf8') + const hash = crypto.createHash('sha256') + hash.update(buffer) + const hexHash = hash.digest('hex') + return `sha256:${hexHash}` +} + function createConfigLayer(): Layer { const configLayer: Layer = { mediaType: 'application/vnd.oci.empty.v1+json',