From 7667f588f2f73a90cea6c7ac70e78266c4f76616 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 18 Dec 2025 11:30:45 -0800 Subject: [PATCH] Create Artifact Metadata Storage Record on registry push (#313) * first pass at creating storage record Signed-off-by: Meredith Lancaster * include storage record param in action config Signed-off-by: Meredith Lancaster * use latest actions/attest version Signed-off-by: Meredith Lancaster * update storage record params Signed-off-by: Meredith Lancaster * include storage record id in result Signed-off-by: Meredith Lancaster * regenerate dist Signed-off-by: Meredith Lancaster * add documentation on storage records Signed-off-by: Meredith Lancaster * log storage record creation Signed-off-by: Meredith Lancaster * add storage record output Signed-off-by: Meredith Lancaster * add new param Signed-off-by: Meredith Lancaster * add storage record id output Signed-off-by: Meredith Lancaster * fix linter errors Signed-off-by: Meredith Lancaster * return all storage record ids Signed-off-by: Meredith Lancaster * bump minor version Signed-off-by: Meredith Lancaster * use expect string match function Signed-off-by: Meredith Lancaster * add try catch block for storage record creation Signed-off-by: Meredith Lancaster * fix table column spacing Signed-off-by: Meredith Lancaster * check for protocol Signed-off-by: Meredith Lancaster * check for artifact url protocol Signed-off-by: Meredith Lancaster * only fill registry_url for now Signed-off-by: Meredith Lancaster * cleanup protocol handling Signed-off-by: Meredith Lancaster * regenerate dist Signed-off-by: Meredith Lancaster * handle subject name correctly Signed-off-by: Meredith Lancaster * move test Signed-off-by: Meredith Lancaster * add back assert statements Signed-off-by: Meredith Lancaster * add back output assert statements Signed-off-by: Meredith Lancaster * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * use url for subject name parsing Signed-off-by: Meredith Lancaster * add missing test setpu Signed-off-by: Meredith Lancaster * fix storage record fail test Signed-off-by: Meredith Lancaster * regenerate dist Signed-off-by: Meredith Lancaster --------- Signed-off-by: Meredith Lancaster Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 26 ++++-- __tests__/main.test.ts | 48 ++++++++++- action.yml | 8 ++ dist/index.js | 189 ++++++++++++++++++++++++++++++++++++++++- package-lock.json | 19 +++-- package.json | 4 +- src/attest.ts | 64 +++++++++++++- src/index.ts | 1 + src/main.ts | 10 +++ 9 files changed, 344 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 45cd0eb..fae03c5 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,13 @@ attest: permissions: id-token: write attestations: write + artifact-metadata: write ``` The `id-token` permission gives the action the ability to mint the OIDC token necessary to request a Sigstore signing certificate. The `attestations` - permission is necessary to persist the attestation. + permission is necessary to persist the attestation. The `artifact-metadata` + permission is necessary to create the artifact storage record. 1. Add the following to your workflow after your artifact has been built: @@ -118,6 +120,12 @@ See [action.yml](action.yml) # the "subject-digest" parameter be specified. Defaults to false. push-to-registry: + # Whether to create a storage record for the artifact. + # Requires that push-to-registry is set to true. + # Requires that the "subject-name" parameter specify the fully-qualified + # image name. Defaults to true. + create-storage-record: + # Whether to attach a list of generated attestations to the workflow run # summary page. Defaults to true. show-summary: @@ -131,11 +139,12 @@ See [action.yml](action.yml) -| Name | Description | Example | -| ----------------- | -------------------------------------------------------------- | ------------------------------------------------ | -| `attestation-id` | GitHub ID for the attestation | `123456` | -| `attestation-url` | URL for the attestation summary | `https://github.com/foo/bar/attestations/123456` | -| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` | +| Name | Description | Example | +| ------------------- | -------------------------------------------------------------- | ------------------------------------------------ | +| `attestation-id` | GitHub ID for the attestation | `123456` | +| `attestation-url` | URL for the attestation summary | `https://github.com/foo/bar/attestations/123456` | +| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` | +| `storage-record-ids` | GitHub IDs for the storage records | `987654` | @@ -269,6 +278,10 @@ fully-qualified image name (e.g. "ghcr.io/user/app" or "acme.azurecr.io/user/app"). Do NOT include a tag as part of the image name -- the specific image being attested is identified by the supplied digest. +If the `push-to-registry` option is set to true, the Action will also +emit an Artifact Metadata Storage Record. If you do not want to emit a +storage record, set `create-storage-record` to `false`. + > **NOTE**: When pushing to Docker Hub, please use "docker.io" as the registry > portion of the image name. @@ -287,6 +300,7 @@ jobs: packages: write contents: read attestations: write + artifact-metadata: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 7e114d4..4591dbe 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -9,6 +9,7 @@ import * as core from '@actions/core' import * as github from '@actions/github' import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock' import * as oci from '@sigstore/oci' +import * as attest from '@actions/attest' import fs from 'fs/promises' import nock from 'nock' import os from 'os' @@ -19,6 +20,7 @@ import * as main from '../src/main' // Mock the GitHub Actions core library const infoMock = jest.spyOn(core, 'info') +const warningMock = jest.spyOn(core, 'warning') const startGroupMock = jest.spyOn(core, 'startGroup') const setOutputMock = jest.spyOn(core, 'setOutput') const setFailedMock = jest.spyOn(core, 'setFailed') @@ -45,6 +47,7 @@ const defaultInputs: main.RunInputs = { subjectPath: '', subjectChecksums: '', pushToRegistry: false, + createStorageRecord: true, showSummary: true, githubToken: '', privateSigning: false @@ -66,13 +69,14 @@ describe('action', () => { 'base64' )}.}` - const subjectName = 'registry/foo/bar' + const subjectName = 'ghcr.io/registry/foo/bar' const subjectDigest = 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32' const predicate = '{}' const predicateType = 'https://in-toto.io/attestation/release/v0.1' const attestationID = '1234567890' + const storageRecordID = 987654321 beforeEach(() => { jest.clearAllMocks() @@ -82,14 +86,21 @@ describe('action', () => { .query({ audience: 'sigstore' }) .reply(200, { value: oidcToken }) - mockAgent - .get('https://api.github.com') + const pool = mockAgent.get('https://api.github.com') + pool .intercept({ path: /^\/repos\/.*\/.*\/attestations$/, method: 'post' }) .reply(201, { id: attestationID }) + pool + .intercept({ + path: /^\/orgs\/.*\/artifacts\/metadata\/storage-record$/, + method: 'post' + }) + .reply(200, { storage_records: [{ id: storageRecordID }] }) + process.env = { ...originalEnv, ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL, @@ -263,6 +274,7 @@ describe('action', () => { expect(setFailedMock).not.toHaveBeenCalled() expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName) expect(attachArtifactSpy).toHaveBeenCalled() + expect(warningMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenNthCalledWith( 1, expect.stringMatching( @@ -293,6 +305,14 @@ describe('action', () => { 6, expect.stringMatching(attestationID) ) + expect(infoMock).toHaveBeenNthCalledWith( + 9, + expect.stringMatching('Storage record created') + ) + expect(infoMock).toHaveBeenNthCalledWith( + 10, + expect.stringMatching('Storage record IDs: 987654321') + ) expect(setOutputMock).toHaveBeenNthCalledWith( 1, 'bundle-path', @@ -308,8 +328,30 @@ describe('action', () => { 'attestation-url', expect.stringContaining(`foo/bar/attestations/${attestationID}`) ) + expect(setOutputMock).toHaveBeenNthCalledWith( + 4, + 'storage-record-ids', + expect.stringMatching(storageRecordID.toString()) + ) expect(setFailedMock).not.toHaveBeenCalled() }) + + it('catches error when storage record creation fails and continues', async () => { + // Mock the createStorageRecord function and throw an error + const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord') + createStorageRecordSpy.mockRejectedValueOnce( + new Error('Failed to persist storage record: Not Found') + ) + + await main.run(inputs) + + expect(runMock).toHaveReturned() + expect(setFailedMock).not.toHaveBeenCalled() + expect(warningMock).toHaveBeenNthCalledWith( + 1, + expect.stringMatching('Failed to create storage record') + ) + }) }) describe('when the subject count is greater than 1', () => { diff --git a/action.yml b/action.yml index a5b01ca..66fe071 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,12 @@ inputs: the "subject-digest" parameter be specified. Defaults to false. default: false required: false + create-storage-record: + description: > + Whether to create a storage record for the artifact. + Requires that push-to-registry is set to true. Defaults to true. + default: true + required: false show-summary: description: > Whether to attach a list of generated attestations to the workflow run @@ -71,6 +77,8 @@ outputs: description: 'The ID of the attestation.' attestation-url: description: 'The URL for the attestation summary.' + storage-record-ids: + description: 'The IDs of the storage records created for the artifact.' runs: using: node24 diff --git a/dist/index.js b/dist/index.js index 8bc44a6..2e1c8ab 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,106 @@ /******/ (() => { // webpackBootstrap /******/ var __webpack_modules__ = ({ +/***/ 33354: +/***/ (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 () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __rest = (this && this.__rest) || function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.createStorageRecord = createStorageRecord; +const github = __importStar(__nccwpck_require__(93228)); +const plugin_retry_1 = __nccwpck_require__(33450); +const CREATE_STORAGE_RECORD_REQUEST = 'POST /orgs/{owner}/artifacts/metadata/storage-record'; +const DEFAULT_RETRY_COUNT = 5; +/** + * Writes a storage record on behalf of an artifact that has been attested + * @param artifactOptions - parameters for the storage record API request. + * @param packageRegistryOptions - parameters for the package registry API request. + * @param token - GitHub token used to authenticate the request. + * @param retryAttempts - The number of retries to attempt if the request fails. + * @param headers - Additional headers to include in the request. + * + * @returns The ID of the storage record. + * @throws Error if the storage record fails to persist. + */ +function createStorageRecord(artifactOptions, packageRegistryOptions, token, retryAttempts, headers) { + return __awaiter(this, void 0, void 0, function* () { + const retries = retryAttempts !== null && retryAttempts !== void 0 ? retryAttempts : DEFAULT_RETRY_COUNT; + const octokit = github.getOctokit(token, { retry: { retries } }, plugin_retry_1.retry); + try { + const response = yield octokit.request(CREATE_STORAGE_RECORD_REQUEST, Object.assign({ owner: github.context.repo.owner, headers }, buildRequestParams(artifactOptions, packageRegistryOptions))); + const data = typeof response.data == 'string' + ? JSON.parse(response.data) + : response.data; + return data === null || data === void 0 ? void 0 : data.storage_records.map((r) => r.id); + } + catch (err) { + const message = err instanceof Error ? err.message : err; + throw new Error(`Failed to persist storage record: ${message}`); + } + }); +} +function buildRequestParams(artifactOptions, packageRegistryOptions) { + const { registryUrl, artifactUrl } = packageRegistryOptions, rest = __rest(packageRegistryOptions, ["registryUrl", "artifactUrl"]); + return Object.assign(Object.assign(Object.assign({}, artifactOptions), { registry_url: registryUrl, artifact_url: artifactUrl }), rest); +} +//# sourceMappingURL=artifactMetadata.js.map + +/***/ }), + /***/ 17492: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { @@ -184,7 +284,9 @@ function buildGitHubEndpoints() { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.buildSLSAProvenancePredicate = exports.attestProvenance = exports.attest = void 0; +exports.buildSLSAProvenancePredicate = exports.attestProvenance = exports.attest = exports.createStorageRecord = void 0; +var artifactMetadata_1 = __nccwpck_require__(33354); +Object.defineProperty(exports, "createStorageRecord", ({ enumerable: true, get: function () { return artifactMetadata_1.createStorageRecord; } })); var attest_1 = __nccwpck_require__(17492); Object.defineProperty(exports, "attest", ({ enumerable: true, get: function () { return attest_1.attest; } })); var provenance_1 = __nccwpck_require__(13042); @@ -79579,15 +79681,49 @@ function wrappy (fn, cb) { /***/ }), /***/ 93738: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { +/***/ (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 () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.createAttestation = void 0; const attest_1 = __nccwpck_require__(11485); const oci_1 = __nccwpck_require__(81057); const subject_1 = __nccwpck_require__(36303); +const core = __importStar(__nccwpck_require__(37484)); const OCI_TIMEOUT = 30000; const OCI_RETRY = 3; const createAttestation = async (subjects, predicate, opts) => { @@ -79603,10 +79739,11 @@ const createAttestation = async (subjects, predicate, opts) => { if (subjects.length === 1 && opts.pushToRegistry) { const subject = subjects[0]; const credentials = (0, oci_1.getRegistryCredentials)(subject.name); + const subjectDigest = (0, subject_1.formatSubjectDigest)(subject); const artifact = await (0, oci_1.attachArtifactToImage)({ credentials, imageName: subject.name, - imageDigest: (0, subject_1.formatSubjectDigest)(subject), + imageDigest: subjectDigest, artifact: Buffer.from(JSON.stringify(attestation.bundle)), mediaType: attestation.bundle.mediaType, annotations: { @@ -79617,10 +79754,47 @@ const createAttestation = async (subjects, predicate, opts) => { }); // Add the attestation's digest to the result result.attestationDigest = artifact.digest; + // Because creating a storage record requires the 'artifact-metadata:write' + // permission, we wrap this in a try/catch to avoid failing the entire + // attestation process if the token does not have the correct permissions. + if (opts.createStorageRecord) { + try { + const registryUrl = getRegistryURL(subject.name); + const artifactOpts = { + name: subject.name, + digest: subjectDigest + }; + const packageRegistryOpts = { + registryUrl + }; + const records = await (0, attest_1.createStorageRecord)(artifactOpts, packageRegistryOpts, opts.githubToken); + if (!records || records.length === 0) { + core.warning('No storage records were created.'); + } + result.storageRecordIds = records; + } + catch (error) { + core.warning(`Failed to create storage record: ${error}`); + core.warning('Please check that the "artifact-metadata:write" permission has been included'); + } + } } return result; }; exports.createAttestation = createAttestation; +function getRegistryURL(subjectName) { + let url; + try { + url = new URL(subjectName); + } + catch { + url = new URL(`https://${subjectName}`); + } + if (url.protocol !== 'https:') { + throw new Error(`Unsupported protocol ${url.protocol} in subject name ${subjectName}`); + } + return url.origin; +} /***/ }), @@ -79690,6 +79864,7 @@ const inputs = { predicate: core.getInput('predicate'), predicatePath: core.getInput('predicate-path'), pushToRegistry: core.getBooleanInput('push-to-registry'), + createStorageRecord: core.getBooleanInput('create-storage-record'), showSummary: core.getBooleanInput('show-summary'), githubToken: core.getInput('github-token'), // undocumented -- not part of public interface @@ -79790,6 +79965,7 @@ async function run(inputs) { const att = await (0, attest_1.createAttestation)(subjects, predicate, { sigstoreInstance, pushToRegistry: inputs.pushToRegistry, + createStorageRecord: inputs.createStorageRecord, githubToken: inputs.githubToken }); logAttestation(subjects, att, sigstoreInstance); @@ -79816,6 +79992,9 @@ async function run(inputs) { core.setOutput('attestation-id', att.attestationID); core.setOutput('attestation-url', attestationURL(att.attestationID)); } + if (att.storageRecordIds) { + core.setOutput('storage-record-ids', att.storageRecordIds.join(',')); + } /* istanbul ignore else */ if (inputs.showSummary) { await logSummary(att); @@ -79860,6 +80039,10 @@ const logAttestation = (subjects, attestation, sigstoreInstance) => { core.info(style.highlight('Attestation uploaded to registry')); core.info(`${subjects[0].name}@${attestation.attestationDigest}`); } + if (attestation.storageRecordIds && attestation.storageRecordIds.length > 0) { + core.info(style.highlight('Storage record created')); + core.info(`Storage record IDs: ${attestation.storageRecordIds.join(',')}`); + } }; // Attach summary information to the GitHub Actions run const logSummary = async (attestation) => { diff --git a/package-lock.json b/package-lock.json index 04b9e98..baec552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "actions/attest", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "actions/attest", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { - "@actions/attest": "^2.0.0", + "@actions/attest": "^2.1.0", "@actions/core": "^2.0.1", "@actions/github": "^6.0.1", "@actions/glob": "^0.5.0", @@ -49,9 +49,10 @@ } }, "node_modules/@actions/attest": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@actions/attest/-/attest-2.0.0.tgz", - "integrity": "sha512-9dy7ad6B98JnXPEcXlg372fWWRKPWct1d3FSzu1/h7QtrgqE9uRlgrrx9DIHuwojJ63aaDSciXkX53Tf1Swmpw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@actions/attest/-/attest-2.1.0.tgz", + "integrity": "sha512-MQOaqBL1uYMZXNYUKf2aBr7Exgs2OkGIhWY3BB4hz5eIvV1x/JulQ58sz4haHYjJxcvNd5/GzuCaycfiHLLL+g==", + "license": "MIT", "dependencies": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.0", @@ -9590,9 +9591,9 @@ "dev": true }, "@actions/attest": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@actions/attest/-/attest-2.0.0.tgz", - "integrity": "sha512-9dy7ad6B98JnXPEcXlg372fWWRKPWct1d3FSzu1/h7QtrgqE9uRlgrrx9DIHuwojJ63aaDSciXkX53Tf1Swmpw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@actions/attest/-/attest-2.1.0.tgz", + "integrity": "sha512-MQOaqBL1uYMZXNYUKf2aBr7Exgs2OkGIhWY3BB4hz5eIvV1x/JulQ58sz4haHYjJxcvNd5/GzuCaycfiHLLL+g==", "requires": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.0", diff --git a/package.json b/package.json index 8a9bbc4..b97f44f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "actions/attest", "description": "Generate signed attestations for workflow artifacts", - "version": "3.0.0", + "version": "3.1.0", "author": "", "private": true, "homepage": "https://github.com/actions/attest", @@ -69,7 +69,7 @@ ] }, "dependencies": { - "@actions/attest": "^2.0.0", + "@actions/attest": "^2.1.0", "@actions/core": "^2.0.1", "@actions/github": "^6.0.1", "@actions/glob": "^0.5.0", diff --git a/src/attest.ts b/src/attest.ts index 3b59cdb..2ae3573 100644 --- a/src/attest.ts +++ b/src/attest.ts @@ -1,6 +1,13 @@ -import { Attestation, Predicate, Subject, attest } from '@actions/attest' +import { + Attestation, + Predicate, + Subject, + attest, + createStorageRecord +} from '@actions/attest' import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci' import { formatSubjectDigest } from './subject' +import * as core from '@actions/core' const OCI_TIMEOUT = 30000 const OCI_RETRY = 3 @@ -8,6 +15,7 @@ const OCI_RETRY = 3 export type SigstoreInstance = 'public-good' | 'github' export type AttestResult = Attestation & { attestationDigest?: string + storageRecordIds?: number[] } export const createAttestation = async ( @@ -16,6 +24,7 @@ export const createAttestation = async ( opts: { sigstoreInstance: SigstoreInstance pushToRegistry: boolean + createStorageRecord: boolean githubToken: string } ): Promise => { @@ -33,10 +42,11 @@ export const createAttestation = async ( if (subjects.length === 1 && opts.pushToRegistry) { const subject = subjects[0] const credentials = getRegistryCredentials(subject.name) + const subjectDigest = formatSubjectDigest(subject) const artifact = await attachArtifactToImage({ credentials, imageName: subject.name, - imageDigest: formatSubjectDigest(subject), + imageDigest: subjectDigest, artifact: Buffer.from(JSON.stringify(attestation.bundle)), mediaType: attestation.bundle.mediaType, annotations: { @@ -48,7 +58,57 @@ export const createAttestation = async ( // Add the attestation's digest to the result result.attestationDigest = artifact.digest + + // Because creating a storage record requires the 'artifact-metadata:write' + // permission, we wrap this in a try/catch to avoid failing the entire + // attestation process if the token does not have the correct permissions. + if (opts.createStorageRecord) { + try { + const registryUrl = getRegistryURL(subject.name) + const artifactOpts = { + name: subject.name, + digest: subjectDigest + } + const packageRegistryOpts = { + registryUrl + } + const records = await createStorageRecord( + artifactOpts, + packageRegistryOpts, + opts.githubToken + ) + + if (!records || records.length === 0) { + core.warning('No storage records were created.') + } + + result.storageRecordIds = records + } catch (error) { + core.warning(`Failed to create storage record: ${error}`) + core.warning( + 'Please check that the "artifact-metadata:write" permission has been included' + ) + } + } } return result } + +function getRegistryURL(subjectName: string): string { + let url: URL + + try { + url = new URL(subjectName) + } catch { + url = new URL(`https://${subjectName}`) + } + + if (url.protocol !== 'https:') { + throw new Error( + `Unsupported protocol ${url.protocol} in subject name ${subjectName}` + ) + } + + return url.origin +} diff --git a/src/index.ts b/src/index.ts index a4de2b8..890f747 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ const inputs: RunInputs = { predicate: core.getInput('predicate'), predicatePath: core.getInput('predicate-path'), pushToRegistry: core.getBooleanInput('push-to-registry'), + createStorageRecord: core.getBooleanInput('create-storage-record'), showSummary: core.getBooleanInput('show-summary'), githubToken: core.getInput('github-token'), // undocumented -- not part of public interface diff --git a/src/main.ts b/src/main.ts index 1a0186e..fc4db78 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt' export type RunInputs = SubjectInputs & PredicateInputs & { pushToRegistry: boolean + createStorageRecord: boolean githubToken: string showSummary: boolean privateSigning: boolean @@ -69,6 +70,7 @@ export async function run(inputs: RunInputs): Promise { const att = await createAttestation(subjects, predicate, { sigstoreInstance, pushToRegistry: inputs.pushToRegistry, + createStorageRecord: inputs.createStorageRecord, githubToken: inputs.githubToken }) @@ -100,6 +102,9 @@ export async function run(inputs: RunInputs): Promise { core.setOutput('attestation-id', att.attestationID) core.setOutput('attestation-url', attestationURL(att.attestationID)) } + if (att.storageRecordIds) { + core.setOutput('storage-record-ids', att.storageRecordIds.join(',')) + } /* istanbul ignore else */ if (inputs.showSummary) { @@ -169,6 +174,11 @@ const logAttestation = ( core.info(style.highlight('Attestation uploaded to registry')) core.info(`${subjects[0].name}@${attestation.attestationDigest}`) } + + if (attestation.storageRecordIds && attestation.storageRecordIds.length > 0) { + core.info(style.highlight('Storage record created')) + core.info(`Storage record IDs: ${attestation.storageRecordIds.join(',')}`) + } } // Attach summary information to the GitHub Actions run