Create Artifact Metadata Storage Record on registry push (#313)

* first pass at creating storage record

Signed-off-by: Meredith Lancaster <malancas@github.com>

* include storage record param in action config

Signed-off-by: Meredith Lancaster <malancas@github.com>

* use latest actions/attest version

Signed-off-by: Meredith Lancaster <malancas@github.com>

* update storage record params

Signed-off-by: Meredith Lancaster <malancas@github.com>

* include storage record id in result

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add documentation on storage records

Signed-off-by: Meredith Lancaster <malancas@github.com>

* log storage record creation

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add storage record output

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add new param

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add storage record id output

Signed-off-by: Meredith Lancaster <malancas@github.com>

* fix linter errors

Signed-off-by: Meredith Lancaster <malancas@github.com>

* return all storage record ids

Signed-off-by: Meredith Lancaster <malancas@github.com>

* bump minor version

Signed-off-by: Meredith Lancaster <malancas@github.com>

* use expect string match function

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add try catch block for storage record creation

Signed-off-by: Meredith Lancaster <malancas@github.com>

* fix table column spacing

Signed-off-by: Meredith Lancaster <malancas@github.com>

* check for protocol

Signed-off-by: Meredith Lancaster <malancas@github.com>

* check for artifact url protocol

Signed-off-by: Meredith Lancaster <malancas@github.com>

* only fill registry_url for now

Signed-off-by: Meredith Lancaster <malancas@github.com>

* cleanup protocol handling

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

* handle subject name correctly

Signed-off-by: Meredith Lancaster <malancas@github.com>

* move test

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add back assert statements

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add back output assert statements

Signed-off-by: Meredith Lancaster <malancas@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>

* 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 <malancas@github.com>

* add missing test setpu

Signed-off-by: Meredith Lancaster <malancas@github.com>

* fix storage record fail test

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

---------

Signed-off-by: Meredith Lancaster <malancas@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Meredith Lancaster
2025-12-18 11:30:45 -08:00
committed by GitHub
parent 0512723b04
commit 7667f588f2
9 changed files with 344 additions and 25 deletions
+20 -6
View File
@@ -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)
<!-- markdownlint-disable MD013 -->
| 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` |
<!-- markdownlint-enable MD013 -->
@@ -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 }}
+45 -3
View File
@@ -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', () => {
+8
View File
@@ -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
Generated Vendored
+186 -3
View File
@@ -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) => {
+10 -9
View File
@@ -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",
+2 -2
View File
@@ -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",
+62 -2
View File
@@ -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<AttestResult> => {
@@ -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
}
+1
View File
@@ -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
+10
View File
@@ -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<void> {
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<void> {
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