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 @@
-
\ No newline at end of file
+
\ 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',