mirror of
https://github.com/actions/toolkit.git
synced 2026-08-07 00:00:18 +02:00
Merge pull request #2192 from actions/malancas/create-artifact-metadata-storage-record
Add the `createStorageRecord` function to the `attest` package for creating artifact storage records
This commit is contained in:
@@ -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)
|
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.
|
for more information on artifact attestations.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [attest](#attest)
|
||||||
|
- [attestProvenance](#attestprovenance)
|
||||||
|
- [Attestation](#attestation)
|
||||||
|
- [Sigstore Instance](#sigstore-instance)
|
||||||
|
- [Storage](#storage)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### `attest`
|
### `attest`
|
||||||
@@ -165,6 +173,78 @@ export type Attestation = {
|
|||||||
For details about the Sigstore bundle format, see the [Bundle protobuf
|
For details about the Sigstore bundle format, see the [Bundle protobuf
|
||||||
specification](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto).
|
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
|
## Sigstore Instance
|
||||||
|
|
||||||
When generating the signed attestation there are two different Sigstore
|
When generating the signed attestation there are two different Sigstore
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Generated
+2
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/attest",
|
"name": "@actions/attest",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/attest",
|
"name": "@actions/attest",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.11.1",
|
"@actions/core": "^1.11.1",
|
||||||
@@ -192,7 +192,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz",
|
||||||
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
|
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-token": "^4.0.0",
|
"@octokit/auth-token": "^4.0.0",
|
||||||
"@octokit/graphql": "^7.1.0",
|
"@octokit/graphql": "^7.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/attest",
|
"name": "@actions/attest",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "Actions attestation lib",
|
"description": "Actions attestation lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"github",
|
"github",
|
||||||
@@ -55,4 +55,4 @@
|
|||||||
"@octokit/core": "^5.2.0"
|
"@octokit/core": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<number[]> {
|
||||||
|
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<string, unknown> {
|
||||||
|
const {registryUrl, artifactUrl, ...rest} = options.packageRegistryOptions
|
||||||
|
return {
|
||||||
|
...options.artifactOptions,
|
||||||
|
registry_url: registryUrl,
|
||||||
|
artifact_url: artifactUrl,
|
||||||
|
...rest
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export {createStorageRecord} from './artifactMetadata'
|
||||||
export {AttestOptions, attest} from './attest'
|
export {AttestOptions, attest} from './attest'
|
||||||
export {
|
export {
|
||||||
AttestProvenanceOptions,
|
AttestProvenanceOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user