diff --git a/packages/attest/README.md b/packages/attest/README.md index e6761ea6..49903437 100644 --- a/packages/attest/README.md +++ b/packages/attest/README.md @@ -15,6 +15,14 @@ initiated. See [Using artifact attestations to establish provenance for builds](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) for more information on artifact attestations. +## Table of Contents +- [Usage](#usage) + - [attest](#attest) + - [attestProvenance](#attestprovenance) + - [Attestation](#attestation) +- [Sigstore Instance](#sigstore-instance) +- [Storage](#storage) + ## Usage ### `attest` @@ -165,6 +173,78 @@ export type Attestation = { For details about the Sigstore bundle format, see the [Bundle protobuf specification](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto). +### createStorageRecord + +The `createStorageRecord` function creates an +[artifact metadata storage record](https://docs.github.com/en/rest/orgs/artifact-metadata?apiVersion=2022-11-28#create-artifact-metadata-storage-record) +on behalf of an attested artifact. It accepts parameters defining artifact +and package registry details. The storage record contains metadata about where the artifact is stored on a given package registry. + +```js +const { createStorageRecord } = require('@actions/attest'); +const core = require('@actions/core'); + +async function run() { + // In order to persist attestations to the repo, this should be a token with + // repository write permissions. + const ghToken = core.getInput('gh-token'); + + const record = await createStorageRecord({ + artifactOptions: { + name: 'my-artifact-name', + digest: { 'sha256': '36ab4667...'}, + version: "v1.0.0" + }, + packageRegistryOptions: { + registryUrl: "https://my-fave-pkg-registry.com" + }, + token: ghToken + }); + + console.log(record); +} + +run(); +``` + +The `createStorageRecord` function supports the following options: + +```typescript +export type StorageRecordOptions = { + // Includes details about the attested artifact + artifactOptions: { + // The name of the artifact + name: string + // The digest of the artifact + digest: string + // The version of the artifact + version?: string + // The status of the artifact + status?: string + }, + // Includes details about the package registry the artifact was published to + packageRegistryOptions: { + // The URL of the package registry + registryUrl: string + // The URL of the artifact in the package registry + artifactUrl?: string + // The package registry repository the artifact was published to. + repo?: string + // The path of the artifact in the package registry repository. + path?: string + }, + // GitHub token for writing attestations. + token: string + // Optional parameters for the write operation. + writeOptions: { + // The number of times to retry the request. + retry?: number + // HTTP headers to include in request to Artifact Metadata API. + headers?: RequestHeaders + } +} +``` + ## Sigstore Instance When generating the signed attestation there are two different Sigstore diff --git a/packages/attest/__tests__/artifactMetadata.test.ts b/packages/attest/__tests__/artifactMetadata.test.ts new file mode 100644 index 00000000..c447ba55 --- /dev/null +++ b/packages/attest/__tests__/artifactMetadata.test.ts @@ -0,0 +1,127 @@ +import {MockAgent, setGlobalDispatcher} from 'undici' +import {createStorageRecord} from '../src/artifactMetadata' + +describe('createStorageRecord', () => { + const originalEnv = process.env + const token = 'token' + const headers = {'X-GitHub-Foo': 'true'} + + const options = { + artifactOptions: { + name: 'my-lib', + version: '1.0.0', + digest: `sha256:${'a'.repeat(64)}` + }, + packageRegistryOptions: { + registryUrl: 'https://my-registry.org' + }, + token, + writeOptions: {headers} + } + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + + beforeEach(() => { + process.env = { + ...originalEnv, + GITHUB_REPOSITORY: 'foo/bar' + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe('when the api call is successful', () => { + beforeEach(() => { + mockAgent + .get('https://api.github.com') + .intercept({ + path: '/orgs/foo/artifacts/metadata/storage-record', + method: 'POST', + headers: {authorization: `token ${token}`, ...headers}, + body: JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + digest: `sha256:${'a'.repeat(64)}`, + registry_url: 'https://my-registry.org' + }) + }) + .reply(200, {storage_records: [{id: 123}, {id: 456}]}) + }) + + it('persists the storage record', async () => { + await expect(createStorageRecord(options)).resolves.toEqual([123, 456]) + }) + }) + + describe('when the api call fails', () => { + beforeEach(() => { + mockAgent + .get('https://api.github.com') + .intercept({ + path: '/orgs/foo/artifacts/metadata/storage-record', + method: 'POST', + headers: {authorization: `token ${token}`}, + body: JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + digest: `sha256:${'a'.repeat(64)}`, + registry_url: 'https://my-registry.org' + }) + }) + .reply(500, 'oops') + }) + + it('throws an error', async () => { + await expect( + createStorageRecord({ + ...options, + writeOptions: {retry: 0} + }) + ).rejects.toThrow(/oops/) + }) + }) + + describe('when the api call fails but succeeds on retry', () => { + beforeEach(() => { + const pool = mockAgent.get('https://api.github.com') + + pool + .intercept({ + path: '/orgs/foo/artifacts/metadata/storage-record', + method: 'POST', + headers: {authorization: `token ${token}`}, + body: JSON.stringify({ + ...options.artifactOptions, + registry_url: options.packageRegistryOptions.registryUrl + }) + }) + .reply(500, 'oops') + .times(1) + + pool + .intercept({ + path: '/orgs/foo/artifacts/metadata/storage-record', + method: 'POST', + headers: {authorization: `token ${token}`}, + body: JSON.stringify({ + ...options.artifactOptions, + registry_url: options.packageRegistryOptions.registryUrl + }) + }) + .reply(200, {storage_records: [{id: 123}, {id: 456}]}) + .times(1) + }) + + it('persists the storage record', async () => { + await expect( + createStorageRecord({ + ...options, + writeOptions: {} + }) + ).resolves.toEqual([123, 456]) + }) + }) +}) diff --git a/packages/attest/package-lock.json b/packages/attest/package-lock.json index 985d44c4..a3309fcd 100644 --- a/packages/attest/package-lock.json +++ b/packages/attest/package-lock.json @@ -1,12 +1,12 @@ { "name": "@actions/attest", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@actions/attest", - "version": "2.0.0", + "version": "2.1.0", "license": "MIT", "dependencies": { "@actions/core": "^1.11.1", @@ -192,7 +192,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", diff --git a/packages/attest/package.json b/packages/attest/package.json index d8b70ee8..371c553f 100644 --- a/packages/attest/package.json +++ b/packages/attest/package.json @@ -1,6 +1,6 @@ { "name": "@actions/attest", - "version": "2.0.0", + "version": "2.1.0", "description": "Actions attestation lib", "keywords": [ "github", @@ -55,4 +55,4 @@ "@octokit/core": "^5.2.0" } } -} \ No newline at end of file +} diff --git a/packages/attest/src/artifactMetadata.ts b/packages/attest/src/artifactMetadata.ts new file mode 100644 index 00000000..6a5c514f --- /dev/null +++ b/packages/attest/src/artifactMetadata.ts @@ -0,0 +1,87 @@ +import * as github from '@actions/github' +import {retry} from '@octokit/plugin-retry' +import {RequestHeaders} from '@octokit/types' + +const CREATE_STORAGE_RECORD_REQUEST = + 'POST /orgs/{owner}/artifacts/metadata/storage-record' +const DEFAULT_RETRY_COUNT = 5 + +/** + * Options for creating a storage record for an attested artifact. + */ +export type StorageRecordOptions = { + // Includes details about the attested artifact + artifactOptions: { + // The name of the artifact + name: string + // The digest of the artifact + digest: string + // The version of the artifact + version?: string + // The status of the artifact + status?: string + } + // Includes details about the package registry the artifact was published to + packageRegistryOptions: { + // The URL of the package registry + registryUrl: string + // The URL of the artifact in the package registry + artifactUrl?: string + // The package registry repository the artifact was published to. + repo?: string + // The path of the artifact in the package registry repository. + path?: string + } + // GitHub token for writing attestations. + token: string + // Optional parameters for the write operation. + writeOptions: { + // The number of times to retry the request. + retry?: number + // HTTP headers to include in request to Artifact Metadata API. + headers?: RequestHeaders + } +} + +/** + * Writes a storage record on behalf of an artifact that has been attested + * @param StorageRecordOptions - parameters for the storage record API request. + * @returns The ID of the storage record. + * @throws Error if the storage record fails to persist. + */ +export async function createStorageRecord( + options: StorageRecordOptions +): Promise { + const retries = options.writeOptions.retry ?? DEFAULT_RETRY_COUNT + const octokit = github.getOctokit(options.token, {retry: {retries}}, retry) + + try { + const response = await octokit.request(CREATE_STORAGE_RECORD_REQUEST, { + owner: github.context.repo.owner, + headers: options.writeOptions.headers, + ...buildRequestParams(options) + }) + + const data = + typeof response.data == 'string' + ? JSON.parse(response.data) + : response.data + + return data?.storage_records.map((r: {id: number}) => r.id) + } catch (err) { + const message = err instanceof Error ? err.message : err + throw new Error(`Failed to persist storage record: ${message}`) + } +} + +function buildRequestParams( + options: StorageRecordOptions +): Record { + const {registryUrl, artifactUrl, ...rest} = options.packageRegistryOptions + return { + ...options.artifactOptions, + registry_url: registryUrl, + artifact_url: artifactUrl, + ...rest + } +} diff --git a/packages/attest/src/index.ts b/packages/attest/src/index.ts index 43c0a472..78384d32 100644 --- a/packages/attest/src/index.ts +++ b/packages/attest/src/index.ts @@ -1,3 +1,4 @@ +export {createStorageRecord} from './artifactMetadata' export {AttestOptions, attest} from './attest' export { AttestProvenanceOptions,