Compare commits

..

1 Commits

Author SHA1 Message Date
Brian DeHamer 8361fa40db testing
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2024-08-19 14:33:29 -07:00
16 changed files with 9739 additions and 8617 deletions
+2 -1
View File
@@ -41,7 +41,8 @@ rules:
'eslint-comments/no-unused-disable': 'off',
'i18n-text/no-en': 'off',
'import/no-namespace': 'off',
'import/no-unresolved': ['error', { 'ignore': ['csv-parse/sync'] }],
'import/no-unresolved':
['error', { 'ignore': ['csv-parse/sync']}],
'no-console': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- name: Lint Codebase
id: super-linter
uses: super-linter/super-linter/slim@v7
uses: super-linter/super-linter/slim@v6
env:
DEFAULT_BRANCH: main
FILTER_REGEX_EXCLUDE: dist/**/*
@@ -1,22 +0,0 @@
name: 'Publish Immutable Action Version'
on:
release:
types: [published]
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
steps:
- name: Checking out
uses: actions/checkout@v4
- name: Publish
id: publish
uses: actions/publish-immutable-action@v0.0.4
+21 -28
View File
@@ -24,16 +24,6 @@ CLI][5].
See [Using artifact attestations to establish provenance for builds][9] for more
information on artifact attestations.
<!-- prettier-ignore-start -->
> [!NOTE]
> Artifact attestations are available in public repositories for all
> current GitHub plans. They are not available on legacy plans, such as Bronze,
> Silver, or Gold. If you are on a GitHub Free, GitHub Pro, or GitHub Team plan,
> artifact attestations are only available for public repositories. To use
> artifact attestations in private or internal repositories, you must be on a
> GitHub Enterprise Cloud plan.
<!-- prettier-ignore-end -->
## Usage
Within the GitHub Actions workflow which builds some artifact you would like to
@@ -54,7 +44,7 @@ attest:
1. Add the following to your workflow after your artifact has been built:
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: '<PATH TO ARTIFACT>'
predicate-type: '<PREDICATE URI>'
@@ -71,11 +61,11 @@ attest:
See [action.yml](action.yml)
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
# Path to the artifact serving as the subject of the attestation. Must
# specify exactly one of "subject-path" or "subject-digest". May contain
# a glob pattern or list of paths (total subject count cannot exceed 1024).
# a glob pattern or list of paths (total subject count cannot exceed 2500).
subject-path:
# SHA256 digest of the subject for the attestation. Must be in the form
@@ -119,24 +109,26 @@ See [action.yml](action.yml)
<!-- markdownlint-disable MD013 -->
| Name | Description | Example |
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------ |
| `attestation-id` | GitHub ID for the attestation | `123456` |
| `attestation-url` | Absolute path to the file containing the generated attestation | `https://github.com/foo/bar/attestations/123456` |
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` |
| Name | Description | Example |
| ------------- | -------------------------------------------------------------- | ------------------------ |
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` |
<!-- markdownlint-enable MD013 -->
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
If multiple subjects are being attested at the same time, a single attestation
will be created with references to each of the supplied subjects.
If multiple subjects are being attested at the same time, each attestation will
be written to the output file on a separate line (using the [JSON Lines][7]
format).
## Attestation Limits
### Subject Limits
No more than 1024 subjects can be attested at the same time.
No more than 2500 subjects can be attested at the same time. Subjects will be
processed in batches 50. After the initial group of 50, each subsequent batch
will incur an exponentially increasing amount of delay (capped at 1 minute of
delay per batch) to avoid overwhelming the attestation API.
### Predicate Limits
@@ -169,7 +161,7 @@ jobs:
- name: Build artifact
run: make my-app
- name: Attest
uses: actions/attest@v2
uses: actions/attest@v1
with:
subject-path: '${{ github.workspace }}/my-app'
predicate-type: 'https://example.com/predicate/v1'
@@ -178,11 +170,11 @@ jobs:
### Identify Multiple Subjects
If you are generating multiple artifacts, you can attest all of them at the same
time by using a wildcard in the `subject-path` input.
If you are generating multiple artifacts, you can generate an attestation for
each by using a wildcard in the `subject-path` input.
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: 'dist/**/my-bin-*'
predicate-type: 'https://example.com/predicate/v1'
@@ -196,13 +188,13 @@ Alternatively, you can explicitly list multiple subjects with either a comma or
newline delimited list:
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: 'dist/foo, dist/bar'
```
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: |
dist/foo
@@ -259,7 +251,7 @@ jobs:
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Attest
uses: actions/attest@v2
uses: actions/attest@v1
id: attest
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -277,6 +269,7 @@ jobs:
[5]: https://cli.github.com/manual/gh_attestation_verify
[6]:
https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto
[7]: https://jsonlines.org/
[8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
[9]:
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
+48 -37
View File
@@ -46,7 +46,8 @@ const defaultInputs: main.RunInputs = {
pushToRegistry: false,
showSummary: true,
githubToken: '',
privateSigning: false
privateSigning: false,
batchSize: 50
}
describe('action', () => {
@@ -197,17 +198,7 @@ describe('action', () => {
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
expect.stringMatching('attestation.jsonl')
)
expect(setFailedMock).not.toHaveBeenCalled()
})
@@ -293,27 +284,21 @@ describe('action', () => {
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
expect.stringMatching('attestation.jsonl')
)
expect(setFailedMock).not.toHaveBeenCalled()
})
})
describe('when the subject count is greater than 1', () => {
describe('when the subject count exceeds the batch size', () => {
let dir = ''
const filename = 'subject'
let scope: nock.Scope
beforeEach(async () => {
// Start from scratch
nock.cleanAll()
const subjectCount = 5
const content = 'file content'
@@ -324,22 +309,38 @@ describe('action', () => {
// Add files for glob testing
for (let i = 0; i < subjectCount; i++) {
await fs.writeFile(path.join(dir, `${filename}-${i}`), content)
// Set-up a Fulcio mock for each subject
await mockFulcio({
baseURL: 'https://fulcio.githubapp.com',
strict: false
})
// Set-up a TSA mock for each subject
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
// Set-up a GH API mock for each subject
mockAgent
.get('https://api.github.com')
.intercept({
path: /^\/repos\/.*\/.*\/attestations$/,
method: 'post'
})
.reply(201, { id: attestationID })
}
// Set-up a OIDC token mock for each subject
scope = nock(tokenURL)
.get('/')
.query({ audience: 'sigstore' })
.times(subjectCount)
.reply(200, { value: oidcToken })
// Set the GH context with private repository visibility and a repo owner.
setGHContext({
payload: { repository: { visibility: 'private' } },
repo: { owner: 'foo', repo: 'bar' }
})
// Set-up a Fulcio mock for each subject
await mockFulcio({
baseURL: 'https://fulcio.githubapp.com',
strict: false
})
// Set-up a TSA mock for each subject
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
})
afterEach(async () => {
@@ -353,7 +354,8 @@ describe('action', () => {
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
githubToken: 'gh-token',
batchSize: 2
}
await main.run(inputs)
@@ -361,8 +363,17 @@ describe('action', () => {
expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Attestation created for 5 subjects')
expect.stringMatching('Processing subject batch 1/3')
)
expect(infoMock).toHaveBeenNthCalledWith(
10,
expect.stringMatching('Processing subject batch 2/3')
)
expect(infoMock).toHaveBeenNthCalledWith(
19,
expect.stringMatching('Processing subject batch 3/3')
)
expect(scope.isDone()).toBe(true)
})
})
@@ -371,7 +382,7 @@ describe('action', () => {
const filename = 'subject'
beforeEach(async () => {
const subjectCount = 1025
const subjectCount = 2501
const content = 'file content'
// Set-up temp directory
@@ -408,7 +419,7 @@ describe('action', () => {
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'Too many subjects specified. The maximum number of subjects is 1024.'
'Too many subjects specified. The maximum number of subjects is 2500.'
)
)
})
+1 -42
View File
@@ -2,11 +2,7 @@ import crypto from 'crypto'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import {
formatSubjectDigest,
subjectFromInputs,
SubjectInputs
} from '../src/subject'
import { subjectFromInputs, SubjectInputs } from '../src/subject'
describe('subjectFromInputs', () => {
const blankInputs: SubjectInputs = {
@@ -362,42 +358,5 @@ describe('subjectFromInputs', () => {
})
})
})
describe('when duplicate subjects are supplied', () => {
let otherDir = ''
// Add duplicate subject in alternate directory
beforeEach(async () => {
// Set-up temp directory
const tmpDir = await fs.realpath(os.tmpdir())
otherDir = await fs.mkdtemp(tmpDir + path.sep)
// Write file to temp directory
await fs.writeFile(path.join(otherDir, filename), content)
})
it('returns de-duplicated subjects', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject')}, ${path.join(otherDir, 'subject')} `
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(1)
})
})
})
})
describe('subjectDigest', () => {
it('returns the digest', () => {
const subject = {
name: 'foo',
digest: { sha1: 'deadbeef' }
}
const digest = formatSubjectDigest(subject)
expect(digest).toEqual('sha1:deadbeef')
})
})
+2 -6
View File
@@ -10,7 +10,7 @@ inputs:
description: >
Path to the artifact serving as the subject of the attestation. Must
specify exactly one of "subject-path" or "subject-digest". May contain a
glob pattern or list of paths (total subject count cannot exceed 1024).
glob pattern or list of paths (total subject count cannot exceed 2500).
required: false
subject-digest:
description: >
@@ -60,11 +60,7 @@ inputs:
required: false
outputs:
bundle-path:
description: 'The path to the file containing the attestation bundle.'
attestation-id:
description: 'The ID of the attestation.'
attestation-url:
description: 'The URL for the attestation summary.'
description: 'The path to the file containing the attestation bundle(s).'
runs:
using: node20
Generated Vendored
-287
View File
@@ -1,287 +0,0 @@
"use strict";
exports.id = 606;
exports.ids = [606];
exports.modules = {
/***/ 606:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (/* binding */ pMap)
/* harmony export */ });
/* unused harmony exports pMapIterable, pMapSkip */
async function pMap(
iterable,
mapper,
{
concurrency = Number.POSITIVE_INFINITY,
stopOnError = true,
signal,
} = {},
) {
return new Promise((resolve, reject_) => {
if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) {
throw new TypeError(`Expected \`input\` to be either an \`Iterable\` or \`AsyncIterable\`, got (${typeof iterable})`);
}
if (typeof mapper !== 'function') {
throw new TypeError('Mapper function is required');
}
if (!((Number.isSafeInteger(concurrency) && concurrency >= 1) || concurrency === Number.POSITIVE_INFINITY)) {
throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`);
}
const result = [];
const errors = [];
const skippedIndexesMap = new Map();
let isRejected = false;
let isResolved = false;
let isIterableDone = false;
let resolvingCount = 0;
let currentIndex = 0;
const iterator = iterable[Symbol.iterator] === undefined ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator]();
const reject = reason => {
isRejected = true;
isResolved = true;
reject_(reason);
};
if (signal) {
if (signal.aborted) {
reject(signal.reason);
}
signal.addEventListener('abort', () => {
reject(signal.reason);
});
}
const next = async () => {
if (isResolved) {
return;
}
const nextItem = await iterator.next();
const index = currentIndex;
currentIndex++;
// Note: `iterator.next()` can be called many times in parallel.
// This can cause multiple calls to this `next()` function to
// receive a `nextItem` with `done === true`.
// The shutdown logic that rejects/resolves must be protected
// so it runs only one time as the `skippedIndex` logic is
// non-idempotent.
if (nextItem.done) {
isIterableDone = true;
if (resolvingCount === 0 && !isResolved) {
if (!stopOnError && errors.length > 0) {
reject(new AggregateError(errors)); // eslint-disable-line unicorn/error-message
return;
}
isResolved = true;
if (skippedIndexesMap.size === 0) {
resolve(result);
return;
}
const pureResult = [];
// Support multiple `pMapSkip`'s.
for (const [index, value] of result.entries()) {
if (skippedIndexesMap.get(index) === pMapSkip) {
continue;
}
pureResult.push(value);
}
resolve(pureResult);
}
return;
}
resolvingCount++;
// Intentionally detached
(async () => {
try {
const element = await nextItem.value;
if (isResolved) {
return;
}
const value = await mapper(element, index);
// Use Map to stage the index of the element.
if (value === pMapSkip) {
skippedIndexesMap.set(index, value);
}
result[index] = value;
resolvingCount--;
await next();
} catch (error) {
if (stopOnError) {
reject(error);
} else {
errors.push(error);
resolvingCount--;
// In that case we can't really continue regardless of `stopOnError` state
// since an iterable is likely to continue throwing after it throws once.
// If we continue calling `next()` indefinitely we will likely end up
// in an infinite loop of failed iteration.
try {
await next();
} catch (error) {
reject(error);
}
}
}
})();
};
// Create the concurrent runners in a detached (non-awaited)
// promise. We need this so we can await the `next()` calls
// to stop creating runners before hitting the concurrency limit
// if the iterable has already been marked as done.
// NOTE: We *must* do this for async iterators otherwise we'll spin up
// infinite `next()` calls by default and never start the event loop.
(async () => {
for (let index = 0; index < concurrency; index++) {
try {
// eslint-disable-next-line no-await-in-loop
await next();
} catch (error) {
reject(error);
break;
}
if (isIterableDone || isRejected) {
break;
}
}
})();
});
}
function pMapIterable(
iterable,
mapper,
{
concurrency = Number.POSITIVE_INFINITY,
backpressure = concurrency,
} = {},
) {
if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) {
throw new TypeError(`Expected \`input\` to be either an \`Iterable\` or \`AsyncIterable\`, got (${typeof iterable})`);
}
if (typeof mapper !== 'function') {
throw new TypeError('Mapper function is required');
}
if (!((Number.isSafeInteger(concurrency) && concurrency >= 1) || concurrency === Number.POSITIVE_INFINITY)) {
throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`);
}
if (!((Number.isSafeInteger(backpressure) && backpressure >= concurrency) || backpressure === Number.POSITIVE_INFINITY)) {
throw new TypeError(`Expected \`backpressure\` to be an integer from \`concurrency\` (${concurrency}) and up or \`Infinity\`, got \`${backpressure}\` (${typeof backpressure})`);
}
return {
async * [Symbol.asyncIterator]() {
const iterator = iterable[Symbol.asyncIterator] === undefined ? iterable[Symbol.iterator]() : iterable[Symbol.asyncIterator]();
const promises = [];
let runningMappersCount = 0;
let isDone = false;
let index = 0;
function trySpawn() {
if (isDone || !(runningMappersCount < concurrency && promises.length < backpressure)) {
return;
}
const promise = (async () => {
const {done, value} = await iterator.next();
if (done) {
return {done: true};
}
runningMappersCount++;
// Spawn if still below concurrency and backpressure limit
trySpawn();
try {
const returnValue = await mapper(await value, index++);
runningMappersCount--;
if (returnValue === pMapSkip) {
const index = promises.indexOf(promise);
if (index > 0) {
promises.splice(index, 1);
}
}
// Spawn if still below backpressure limit and just dropped below concurrency limit
trySpawn();
return {done: false, value: returnValue};
} catch (error) {
isDone = true;
return {error};
}
})();
promises.push(promise);
}
trySpawn();
while (promises.length > 0) {
const {error, done, value} = await promises[0]; // eslint-disable-line no-await-in-loop
promises.shift();
if (error) {
throw error;
}
if (done) {
return;
}
// Spawn if just dropped below backpressure limit and below the concurrency limit
trySpawn();
if (value === pMapSkip) {
continue;
}
yield value;
}
},
};
}
const pMapSkip = Symbol('skip');
/***/ })
};
;
Generated Vendored
+6080 -6414
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+2545
View File
File diff suppressed because it is too large Load Diff
+917 -1694
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -1,7 +1,7 @@
{
"name": "actions/attest",
"description": "Generate signed attestations for workflow artifacts",
"version": "2.1.0",
"version": "1.4.0",
"author": "",
"private": true,
"homepage": "https://github.com/actions/attest",
@@ -69,33 +69,33 @@
]
},
"dependencies": {
"@actions/attest": "^1.5.0",
"@actions/core": "^1.11.1",
"@actions/glob": "^0.5.0",
"@sigstore/oci": "^0.4.0",
"csv-parse": "^5.6.0"
"@actions/attest": "^1.3.1",
"@actions/core": "^1.10.1",
"@actions/glob": "^0.4.0",
"@sigstore/oci": "^0.3.7",
"csv-parse": "^5.5.6"
},
"devDependencies": {
"@sigstore/mock": "^0.8.0",
"@types/jest": "^29.5.14",
"@sigstore/mock": "^0.7.5",
"@types/jest": "^29.5.12",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^22.9.4",
"@types/node": "^22.2.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.57.1",
"eslint-plugin-github": "^5.1.2",
"eslint-plugin-jest": "^28.9.0",
"eslint-plugin-jsonc": "^2.18.2",
"@vercel/ncc": "^0.38.1",
"eslint": "^8.57.0",
"eslint-plugin-github": "^5.0.1",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"js-yaml": "^4.1.0",
"markdownlint-cli": "^0.43.0",
"nock": "^13.5.6",
"markdownlint-cli": "^0.41.0",
"nock": "^13.5.4",
"prettier": "^3.3.3",
"prettier-eslint": "^16.3.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2",
"ts-jest": "^29.2.4",
"typescript": "^5.5.4",
"undici": "^5.28.4"
}
}
+20 -7
View File
@@ -1,17 +1,18 @@
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject'
const OCI_TIMEOUT = 30000
const OCI_RETRY = 3
export type SigstoreInstance = 'public-good' | 'github'
export type AttestResult = Attestation & {
subjectName: string
subjectDigest: string
attestationDigest?: string
}
export const createAttestation = async (
subjects: Subject[],
subject: Subject,
predicate: Predicate,
opts: {
sigstoreInstance: SigstoreInstance
@@ -21,22 +22,27 @@ export const createAttestation = async (
): Promise<AttestResult> => {
// Sign provenance w/ Sigstore
const attestation = await attest({
subjects,
subjectName: subject.name,
subjectDigest: subject.digest,
predicateType: predicate.type,
predicate: predicate.params,
sigstore: opts.sigstoreInstance,
token: opts.githubToken
})
const result: AttestResult = attestation
const subDigest = subjectDigest(subject)
const result: AttestResult = {
...attestation,
subjectName: subject.name,
subjectDigest: subDigest
}
if (subjects.length === 1 && opts.pushToRegistry) {
const subject = subjects[0]
if (opts.pushToRegistry) {
const credentials = getRegistryCredentials(subject.name)
const artifact = await attachArtifactToImage({
credentials,
imageName: subject.name,
imageDigest: formatSubjectDigest(subject),
imageDigest: subDigest,
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: attestation.bundle.mediaType,
annotations: {
@@ -52,3 +58,10 @@ export const createAttestation = async (
return result
}
// Returns the subject's digest as a formatted string of the form
// "<algorithm>:<digest>".
const subjectDigest = (subject: Subject): string => {
const alg = Object.keys(subject.digest).sort()[0]
return `${alg}:${subject.digest[alg]}`
}
+5 -1
View File
@@ -4,6 +4,8 @@
import * as core from '@actions/core'
import { run, RunInputs } from './main'
const DEFAULT_BATCH_SIZE = 50
const inputs: RunInputs = {
subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'),
@@ -17,7 +19,9 @@ const inputs: RunInputs = {
// undocumented -- not part of public interface
privateSigning: ['true', 'True', 'TRUE', '1'].includes(
core.getInput('private-signing')
)
),
// internal only
batchSize: DEFAULT_BATCH_SIZE
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+70 -38
View File
@@ -7,15 +7,11 @@ import { AttestResult, SigstoreInstance, createAttestation } from './attest'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate'
import * as style from './style'
import {
SubjectInputs,
formatSubjectDigest,
subjectFromInputs
} from './subject'
import { SubjectInputs, subjectFromInputs } from './subject'
import type { Subject } from '@actions/attest'
const ATTESTATION_FILE_NAME = 'attestation.json'
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const DELAY_INTERVAL_MS = 75
const DELAY_MAX_MS = 1200
export type RunInputs = SubjectInputs &
PredicateInputs & {
@@ -23,6 +19,7 @@ export type RunInputs = SubjectInputs &
githubToken: string
showSummary: boolean
privateSigning: boolean
batchSize: number
}
/* istanbul ignore next */
@@ -50,6 +47,7 @@ export async function run(inputs: RunInputs): Promise<void> {
: 'github'
try {
const atts: AttestResult[] = []
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -65,27 +63,42 @@ export async function run(inputs: RunInputs): Promise<void> {
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath)
const att = await createAttestation(subjects, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
const subjectChunks = chunkArray(subjects, inputs.batchSize)
logAttestation(subjects, att, sigstoreInstance)
// Generate attestations for each subject serially, working in batches
for (let i = 0; i < subjectChunks.length; i++) {
if (subjectChunks.length > 1) {
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`)
}
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
// Calculate the delay time for this batch
const delayTime = delay(i)
if (att.attestationID) {
core.setOutput('attestation-id', att.attestationID)
core.setOutput('attestation-url', attestationURL(att.attestationID))
for (const subject of subjectChunks[i]) {
// Delay between attestations (only when chunk size > 1)
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, delayTime))
}
const att = await createAttestation(subject, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
atts.push(att)
logAttestation(att, sigstoreInstance)
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
}
}
if (inputs.showSummary) {
logSummary(att)
logSummary(atts)
}
} catch (err) {
// Fail the workflow run if an error occurs
@@ -110,17 +123,12 @@ export async function run(inputs: RunInputs): Promise<void> {
// Log details about the attestation to the GitHub Actions run
const logAttestation = (
subjects: Subject[],
attestation: AttestResult,
sigstoreInstance: SigstoreInstance
): void => {
if (subjects.length === 1) {
core.info(
`Attestation created for ${subjects[0].name}@${formatSubjectDigest(subjects[0])}`
)
} else {
core.info(`Attestation created for ${subjects.length} subjects`)
}
core.info(
`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`
)
const instanceName =
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
@@ -148,18 +156,29 @@ const logAttestation = (
if (attestation.attestationDigest) {
core.info(style.highlight('Attestation uploaded to registry'))
core.info(`${subjects[0].name}@${attestation.attestationDigest}`)
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`)
}
}
// Attach summary information to the GitHub Actions run
const logSummary = (attestation: AttestResult): void => {
const { attestationID } = attestation
const logSummary = (attestations: AttestResult[]): void => {
if (attestations.length > 0) {
core.summary.addHeading(
/* istanbul ignore next */
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
3
)
if (attestationID) {
const url = attestationURL(attestationID)
core.summary.addHeading('Attestation Created', 3)
core.summary.addList([`<a href="${url}">${url}</a>`])
const listItems = []
for (const { subjectName, subjectDigest, attestationID } of attestations) {
if (attestationID) {
listItems.push(
`<a href="${attestationURL(attestationID)}">${subjectName}@${subjectDigest}</a>`
)
}
}
core.summary.addList(listItems)
core.summary.write()
}
}
@@ -175,5 +194,18 @@ const tempDir = (): string => {
return fs.mkdtempSync(path.join(basePath, path.sep))
}
// Transforms an array into an array of arrays, each containing at most
// `chunkSize` elements.
const chunkArray = <T>(array: T[], chunkSize: number): T[][] => {
return Array.from(
{ length: Math.ceil(array.length / chunkSize) },
(_, index) => array.slice(index * chunkSize, (index + 1) * chunkSize)
)
}
// Calculate the delay time for a given iteration
const delay = (iteration: number): number =>
Math.min(DELAY_INTERVAL_MS * 2 ** iteration, DELAY_MAX_MS)
const attestationURL = (id: string): string =>
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
+9 -21
View File
@@ -6,7 +6,7 @@ import path from 'path'
import type { Subject } from '@actions/attest'
const MAX_SUBJECT_COUNT = 1024
const MAX_SUBJECT_COUNT = 2500
const DIGEST_ALGORITHM = 'sha256'
export type SubjectInputs = {
@@ -49,13 +49,6 @@ export const subjectFromInputs = async (
}
}
// Returns the subject's digest as a formatted string of the form
// "<algorithm>:<digest>".
export const formatSubjectDigest = (subject: Subject): string => {
const alg = Object.keys(subject.digest).sort()[0]
return `${alg}:${subject.digest[alg]}`
}
// Returns the subject specified by the path to a file. The file's digest is
// calculated and returned along with the subject's name.
const getSubjectFromPath = async (
@@ -67,12 +60,9 @@ const getSubjectFromPath = async (
// Parse the list of subject paths
const subjectPaths = parseList(subjectPath).join('\n')
// Expand the globbed paths to a list of actual paths
// Expand the globbed paths to a list of files
/* eslint-disable-next-line github/no-then */
const paths = await glob.create(subjectPaths).then(async g => g.glob())
// Filter path list to just the files (not directories)
const files = paths.filter(p => fs.statSync(p).isFile())
const files = await glob.create(subjectPaths).then(async g => g.glob())
if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(
@@ -81,17 +71,15 @@ const getSubjectFromPath = async (
}
for (const file of files) {
// Skip anything that is NOT a file
if (!fs.statSync(file).isFile()) {
continue
}
const name = subjectName || path.parse(file).base
const digest = await digestFile(DIGEST_ALGORITHM, file)
// Only add the subject if it is not already in the list
if (
!digestedSubjects.some(
s => s.name === name && s.digest[DIGEST_ALGORITHM] === digest
)
) {
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
if (digestedSubjects.length === 0) {