Compare commits

..
1 Commits
Author SHA1 Message Date
Brian DeHamer 6c3e7c389e testing
Signed-off-by: Brian DeHamer <[email protected]>
2024-06-13 10:50:45 -07:00
16 changed files with 23239 additions and 12926 deletions
+2 -1
View File
@@ -41,7 +41,8 @@ rules:
'eslint-comments/no-unused-disable': 'off', 'eslint-comments/no-unused-disable': 'off',
'i18n-text/no-en': 'off', 'i18n-text/no-en': 'off',
'import/no-namespace': '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-console': 'off',
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'prettier/prettier': 'error', 'prettier/prettier': 'error',
+1 -2
View File
@@ -38,7 +38,7 @@ jobs:
- name: Lint Codebase - name: Lint Codebase
id: super-linter id: super-linter
uses: super-linter/super-linter/slim@v7 uses: super-linter/super-linter/slim@v6
env: env:
DEFAULT_BRANCH: main DEFAULT_BRANCH: main
FILTER_REGEX_EXCLUDE: dist/**/* FILTER_REGEX_EXCLUDE: dist/**/*
@@ -46,5 +46,4 @@ jobs:
TYPESCRIPT_DEFAULT_STYLE: prettier TYPESCRIPT_DEFAULT_STYLE: prettier
VALIDATE_ALL_CODEBASE: true VALIDATE_ALL_CODEBASE: true
VALIDATE_JAVASCRIPT_STANDARD: false VALIDATE_JAVASCRIPT_STANDARD: false
VALIDATE_TYPESCRIPT_STANDARD: false
VALIDATE_JSCPD: false VALIDATE_JSCPD: false
@@ -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/[email protected]
+15 -31
View File
@@ -65,7 +65,7 @@ See [action.yml](action.yml)
with: with:
# Path to the artifact serving as the subject of the attestation. Must # Path to the artifact serving as the subject of the attestation. Must
# specify exactly one of "subject-path" or "subject-digest". May contain # 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: subject-path:
# SHA256 digest of the subject for the attestation. Must be in the form # SHA256 digest of the subject for the attestation. Must be in the form
@@ -96,10 +96,6 @@ See [action.yml](action.yml)
# the "subject-digest" parameter be specified. Defaults to false. # the "subject-digest" parameter be specified. Defaults to false.
push-to-registry: push-to-registry:
# Whether to attach a list of generated attestations to the workflow run
# summary page. Defaults to true.
show-summary:
# The GitHub token used to make authenticated API requests. Default is # The GitHub token used to make authenticated API requests. Default is
# ${{ github.token }} # ${{ github.token }}
github-token: github-token:
@@ -109,22 +105,26 @@ See [action.yml](action.yml)
<!-- markdownlint-disable MD013 --> <!-- markdownlint-disable MD013 -->
| Name | Description | Example | | Name | Description | Example |
| ------------- | -------------------------------------------------------------- | ----------------------- | | ------------- | -------------------------------------------------------------- | ------------------------ |
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` | | `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` |
<!-- markdownlint-enable MD013 --> <!-- markdownlint-enable MD013 -->
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format. Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
If multiple subjects are being attested at the same time, a single attestation If multiple subjects are being attested at the same time, each attestation will
will be created with references to each of the supplied subjects. be written to the output file on a separate line (using the [JSON Lines][7]
format).
## Attestation Limits ## Attestation Limits
### Subject 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 ### Predicate Limits
@@ -164,10 +164,10 @@ jobs:
predicate: '{}' predicate: '{}'
``` ```
### Identify Multiple Subjects ### Identify Subjects by Wildcard
If you are generating multiple artifacts, you can attest all of them at the same If you are generating multiple artifacts, you can generate an attestation for
time by using a wildcard in the `subject-path` input. each by using a wildcard in the `subject-path` input.
```yaml ```yaml
- uses: actions/attest@v1 - uses: actions/attest@v1
@@ -180,23 +180,6 @@ time by using a wildcard in the `subject-path` input.
For supported wildcards along with behavior and documentation, see For supported wildcards along with behavior and documentation, see
[@actions/glob][8] which is used internally to search for files. [@actions/glob][8] which is used internally to search for files.
Alternatively, you can explicitly list multiple subjects with either a comma or
newline delimited list:
```yaml
- uses: actions/attest@v1
with:
subject-path: 'dist/foo, dist/bar'
```
```yaml
- uses: actions/attest@v1
with:
subject-path: |
dist/foo
dist/bar
```
### Container Image ### Container Image
When working with container images you can invoke the action with the When working with container images you can invoke the action with the
@@ -265,6 +248,7 @@ jobs:
[5]: https://cli.github.com/manual/gh_attestation_verify [5]: https://cli.github.com/manual/gh_attestation_verify
[6]: [6]:
https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto 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 [8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
[9]: [9]:
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
+48 -18
View File
@@ -44,9 +44,9 @@ const defaultInputs: main.RunInputs = {
subjectDigest: '', subjectDigest: '',
subjectPath: '', subjectPath: '',
pushToRegistry: false, pushToRegistry: false,
showSummary: true,
githubToken: '', githubToken: '',
privateSigning: false privateSigning: false,
batchSize: 50
} }
describe('action', () => { describe('action', () => {
@@ -197,7 +197,7 @@ describe('action', () => {
expect(setOutputMock).toHaveBeenNthCalledWith( expect(setOutputMock).toHaveBeenNthCalledWith(
1, 1,
'bundle-path', 'bundle-path',
expect.stringMatching('attestation.json') expect.stringMatching('attestation.jsonl')
) )
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
}) })
@@ -283,17 +283,21 @@ describe('action', () => {
expect(setOutputMock).toHaveBeenNthCalledWith( expect(setOutputMock).toHaveBeenNthCalledWith(
1, 1,
'bundle-path', 'bundle-path',
expect.stringMatching('attestation.json') expect.stringMatching('attestation.jsonl')
) )
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
}) })
}) })
describe('when the subject count is greater than 1', () => { describe('when the subject count exceeds the batch size', () => {
let dir = '' let dir = ''
const filename = 'subject' const filename = 'subject'
let scope: nock.Scope
beforeEach(async () => { beforeEach(async () => {
// Start from scratch
nock.cleanAll()
const subjectCount = 5 const subjectCount = 5
const content = 'file content' const content = 'file content'
@@ -304,22 +308,38 @@ describe('action', () => {
// Add files for glob testing // Add files for glob testing
for (let i = 0; i < subjectCount; i++) { for (let i = 0; i < subjectCount; i++) {
await fs.writeFile(path.join(dir, `${filename}-${i}`), content) 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. // Set the GH context with private repository visibility and a repo owner.
setGHContext({ setGHContext({
payload: { repository: { visibility: 'private' } }, payload: { repository: { visibility: 'private' } },
repo: { owner: 'foo', repo: 'bar' } 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 () => { afterEach(async () => {
@@ -333,7 +353,8 @@ describe('action', () => {
subjectPath: path.join(dir, `${filename}-*`), subjectPath: path.join(dir, `${filename}-*`),
predicateType, predicateType,
predicate, predicate,
githubToken: 'gh-token' githubToken: 'gh-token',
batchSize: 2
} }
await main.run(inputs) await main.run(inputs)
@@ -341,8 +362,17 @@ describe('action', () => {
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
1, 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)
}) })
}) })
@@ -351,7 +381,7 @@ describe('action', () => {
const filename = 'subject' const filename = 'subject'
beforeEach(async () => { beforeEach(async () => {
const subjectCount = 1025 const subjectCount = 2501
const content = 'file content' const content = 'file content'
// Set-up temp directory // Set-up temp directory
@@ -388,7 +418,7 @@ describe('action', () => {
expect(runMock).toHaveReturned() expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( 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 -65
View File
@@ -2,11 +2,7 @@ import crypto from 'crypto'
import fs from 'fs/promises' import fs from 'fs/promises'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { import { subjectFromInputs, SubjectInputs } from '../src/subject'
formatSubjectDigest,
subjectFromInputs,
SubjectInputs
} from '../src/subject'
describe('subjectFromInputs', () => { describe('subjectFromInputs', () => {
const blankInputs: SubjectInputs = { const blankInputs: SubjectInputs = {
@@ -300,29 +296,6 @@ describe('subjectFromInputs', () => {
}) })
}) })
describe('when an excluding glob is supplied', () => {
it('returns the multiple subjects', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-*')},!${path.join(dir, 'subject-1')}`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
expect(subjects).toContainEqual({
name: 'subject-0',
digest: { sha256: expectedDigest }
})
expect(subjects).toContainEqual({
name: 'subject-2',
digest: { sha256: expectedDigest }
})
})
})
describe('when a multi-line glob list is supplied', () => { describe('when a multi-line glob list is supplied', () => {
it('returns the multiple subjects', async () => { it('returns the multiple subjects', async () => {
const inputs: SubjectInputs = { const inputs: SubjectInputs = {
@@ -362,42 +335,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 -8
View File
@@ -10,7 +10,7 @@ inputs:
description: > description: >
Path to the artifact serving as the subject of the attestation. Must Path to the artifact serving as the subject of the attestation. Must
specify exactly one of "subject-path" or "subject-digest". May contain a 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 required: false
subject-digest: subject-digest:
description: > description: >
@@ -47,12 +47,6 @@ inputs:
the "subject-digest" parameter be specified. Defaults to false. the "subject-digest" parameter be specified. Defaults to false.
default: false default: false
required: false required: false
show-summary:
description: >
Whether to attach a list of generated attestations to the workflow run
summary page. Defaults to true.
default: true
required: false
github-token: github-token:
description: > description: >
The GitHub token used to make authenticated API requests. The GitHub token used to make authenticated API requests.
@@ -60,7 +54,7 @@ inputs:
required: false required: false
outputs: outputs:
bundle-path: bundle-path:
description: 'The path to the file containing the attestation bundle.' description: 'The path to the file containing the attestation bundle(s).'
runs: runs:
using: node20 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
+18210 -10428
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+3283
View File
File diff suppressed because it is too large Load Diff
+1546 -1972
View File
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -1,7 +1,7 @@
{ {
"name": "actions/attest", "name": "actions/attest",
"description": "Generate signed attestations for workflow artifacts", "description": "Generate signed attestations for workflow artifacts",
"version": "2.0.1", "version": "1.3.0",
"author": "", "author": "",
"private": true, "private": true,
"homepage": "https://github.com/actions/attest", "homepage": "https://github.com/actions/attest",
@@ -69,33 +69,33 @@
] ]
}, },
"dependencies": { "dependencies": {
"@actions/attest": "^1.5.0", "@actions/attest": "^1.3.0",
"@actions/core": "^1.11.1", "@actions/core": "^1.10.1",
"@actions/glob": "^0.5.0", "@actions/glob": "^0.4.0",
"@sigstore/oci": "^0.4.0", "@sigstore/oci": "^0.3.6",
"csv-parse": "^5.6.0" "csv-parse": "^5.5.6"
}, },
"devDependencies": { "devDependencies": {
"@sigstore/mock": "^0.8.0", "@sigstore/mock": "^0.7.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.12",
"@types/make-fetch-happen": "^10.0.4", "@types/make-fetch-happen": "^10.0.4",
"@types/node": "^22.9.4", "@types/node": "^20.14.2",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.13.0",
"@vercel/ncc": "^0.38.3", "@vercel/ncc": "^0.38.1",
"eslint": "^8.57.1", "eslint": "^8.57.0",
"eslint-plugin-github": "^5.1.2", "eslint-plugin-github": "^5.0.1",
"eslint-plugin-jest": "^28.9.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-jsonc": "^2.18.2", "eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"markdownlint-cli": "^0.43.0", "markdownlint-cli": "^0.41.0",
"nock": "^13.5.6", "nock": "^13.5.4",
"prettier": "^3.3.3", "prettier": "^3.3.1",
"prettier-eslint": "^16.3.0", "prettier-eslint": "^16.3.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.1.4",
"typescript": "^5.7.2", "typescript": "^5.4.5",
"undici": "^5.28.4" "undici": "^5.28.4"
} }
} }
+21 -8
View File
@@ -1,17 +1,18 @@
import { Attestation, Predicate, Subject, attest } from '@actions/attest' import { Attestation, Predicate, Subject, attest } from '@actions/attest'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci' import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject'
const OCI_TIMEOUT = 30000 const OCI_TIMEOUT = 2000
const OCI_RETRY = 3 const OCI_RETRY = 3
export type SigstoreInstance = 'public-good' | 'github' export type SigstoreInstance = 'public-good' | 'github'
export type AttestResult = Attestation & { export type AttestResult = Attestation & {
subjectName: string
subjectDigest: string
attestationDigest?: string attestationDigest?: string
} }
export const createAttestation = async ( export const createAttestation = async (
subjects: Subject[], subject: Subject,
predicate: Predicate, predicate: Predicate,
opts: { opts: {
sigstoreInstance: SigstoreInstance sigstoreInstance: SigstoreInstance
@@ -21,22 +22,27 @@ export const createAttestation = async (
): Promise<AttestResult> => { ): Promise<AttestResult> => {
// Sign provenance w/ Sigstore // Sign provenance w/ Sigstore
const attestation = await attest({ const attestation = await attest({
subjects, subjectName: subject.name,
subjectDigest: subject.digest,
predicateType: predicate.type, predicateType: predicate.type,
predicate: predicate.params, predicate: predicate.params,
sigstore: opts.sigstoreInstance, sigstore: opts.sigstoreInstance,
token: opts.githubToken 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) { if (opts.pushToRegistry) {
const subject = subjects[0]
const credentials = getRegistryCredentials(subject.name) const credentials = getRegistryCredentials(subject.name)
const artifact = await attachArtifactToImage({ const artifact = await attachArtifactToImage({
credentials, credentials,
imageName: subject.name, imageName: subject.name,
imageDigest: formatSubjectDigest(subject), imageDigest: subDigest,
artifact: Buffer.from(JSON.stringify(attestation.bundle)), artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: attestation.bundle.mediaType, mediaType: attestation.bundle.mediaType,
annotations: { annotations: {
@@ -52,3 +58,10 @@ export const createAttestation = async (
return result 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 -2
View File
@@ -4,6 +4,8 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import { run, RunInputs } from './main' import { run, RunInputs } from './main'
const DEFAULT_BATCH_SIZE = 50
const inputs: RunInputs = { const inputs: RunInputs = {
subjectPath: core.getInput('subject-path'), subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'), subjectName: core.getInput('subject-name'),
@@ -12,12 +14,13 @@ const inputs: RunInputs = {
predicate: core.getInput('predicate'), predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'), predicatePath: core.getInput('predicate-path'),
pushToRegistry: core.getBooleanInput('push-to-registry'), pushToRegistry: core.getBooleanInput('push-to-registry'),
showSummary: core.getBooleanInput('show-summary'),
githubToken: core.getInput('github-token'), githubToken: core.getInput('github-token'),
// undocumented -- not part of public interface // undocumented -- not part of public interface
privateSigning: ['true', 'True', 'TRUE', '1'].includes( privateSigning: ['true', 'True', 'TRUE', '1'].includes(
core.getInput('private-signing') core.getInput('private-signing')
) ),
// internal only
batchSize: DEFAULT_BATCH_SIZE
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
+69 -37
View File
@@ -7,22 +7,18 @@ import { AttestResult, SigstoreInstance, createAttestation } from './attest'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints' import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate' import { PredicateInputs, predicateFromInputs } from './predicate'
import * as style from './style' import * as style from './style'
import { import { SubjectInputs, subjectFromInputs } from './subject'
SubjectInputs,
formatSubjectDigest,
subjectFromInputs
} from './subject'
import type { Subject } from '@actions/attest' const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const DELAY_INTERVAL_MS = 75
const ATTESTATION_FILE_NAME = 'attestation.json' const DELAY_MAX_MS = 1200
export type RunInputs = SubjectInputs & export type RunInputs = SubjectInputs &
PredicateInputs & { PredicateInputs & {
pushToRegistry: boolean pushToRegistry: boolean
githubToken: string githubToken: string
showSummary: boolean
privateSigning: boolean privateSigning: boolean
batchSize: number
} }
/* istanbul ignore next */ /* istanbul ignore next */
@@ -50,6 +46,7 @@ export async function run(inputs: RunInputs): Promise<void> {
: 'github' : 'github'
try { try {
const atts: AttestResult[] = []
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error( throw new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.' 'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -65,23 +62,41 @@ export async function run(inputs: RunInputs): Promise<void> {
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME) const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath) core.setOutput('bundle-path', outputPath)
const att = await createAttestation(subjects, predicate, { const subjectChunks = chunkArray(subjects, inputs.batchSize)
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
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 // Calculate the delay time for this batch
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, { const delayTime = delay(i)
encoding: 'utf-8',
flag: 'a'
})
if (inputs.showSummary) { for (const subject of subjectChunks[i]) {
logSummary(att) // 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'
})
}
} }
logSummary(atts)
} catch (err) { } catch (err) {
// Fail the workflow run if an error occurs // Fail the workflow run if an error occurs
core.setFailed( core.setFailed(
@@ -105,17 +120,12 @@ export async function run(inputs: RunInputs): Promise<void> {
// Log details about the attestation to the GitHub Actions run // Log details about the attestation to the GitHub Actions run
const logAttestation = ( const logAttestation = (
subjects: Subject[],
attestation: AttestResult, attestation: AttestResult,
sigstoreInstance: SigstoreInstance sigstoreInstance: SigstoreInstance
): void => { ): void => {
if (subjects.length === 1) { core.info(
core.info( `Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`
`Attestation created for ${subjects[0].name}@${formatSubjectDigest(subjects[0])}` )
)
} else {
core.info(`Attestation created for ${subjects.length} subjects`)
}
const instanceName = const instanceName =
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub' sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
@@ -143,18 +153,27 @@ const logAttestation = (
if (attestation.attestationDigest) { if (attestation.attestationDigest) {
core.info(style.highlight('Attestation uploaded to registry')) 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 // Attach summary information to the GitHub Actions run
const logSummary = (attestation: AttestResult): void => { const logSummary = (attestations: AttestResult[]): void => {
const { attestationID } = attestation if (attestations.length > 0) {
core.summary.addHeading(
/* istanbul ignore next */
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
3
)
if (attestationID) { for (const { subjectName, subjectDigest, attestationID } of attestations) {
const url = attestationURL(attestationID) if (attestationID) {
core.summary.addHeading('Attestation Created', 3) core.summary.addLink(
core.summary.addList([`<a href="${url}">${url}</a>`]) `${subjectName}@${subjectDigest}`,
attestationURL(attestationID)
)
}
}
core.summary.write() core.summary.write()
} }
} }
@@ -170,5 +189,18 @@ const tempDir = (): string => {
return fs.mkdtempSync(path.join(basePath, path.sep)) 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 => const attestationURL = (id: string): string =>
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}` `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
+14 -23
View File
@@ -6,7 +6,7 @@ import path from 'path'
import type { Subject } from '@actions/attest' import type { Subject } from '@actions/attest'
const MAX_SUBJECT_COUNT = 1024 const MAX_SUBJECT_COUNT = 2500
const DIGEST_ALGORITHM = 'sha256' const DIGEST_ALGORITHM = 'sha256'
export type SubjectInputs = { 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 // Returns the subject specified by the path to a file. The file's digest is
// calculated and returned along with the subject's name. // calculated and returned along with the subject's name.
const getSubjectFromPath = async ( const getSubjectFromPath = async (
@@ -63,16 +56,16 @@ const getSubjectFromPath = async (
subjectName?: string subjectName?: string
): Promise<Subject[]> => { ): Promise<Subject[]> => {
const digestedSubjects: Subject[] = [] const digestedSubjects: Subject[] = []
const files: string[] = []
// Parse the list of subject paths // Parse the list of subject paths
const subjectPaths = parseList(subjectPath).join('\n') const subjectPaths = parseList(subjectPath)
// 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 */ for (const subPath of subjectPaths) {
const paths = await glob.create(subjectPaths).then(async g => g.glob()) /* eslint-disable-next-line github/no-then */
files.push(...(await glob.create(subPath).then(async g => g.glob())))
// Filter path list to just the files (not directories) }
const files = paths.filter(p => fs.statSync(p).isFile())
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error( throw new Error(
@@ -81,17 +74,15 @@ const getSubjectFromPath = async (
} }
for (const file of files) { 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 name = subjectName || path.parse(file).base
const digest = await digestFile(DIGEST_ALGORITHM, file) const digest = await digestFile(DIGEST_ALGORITHM, file)
// Only add the subject if it is not already in the list digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
if (
!digestedSubjects.some(
s => s.name === name && s.digest[DIGEST_ALGORITHM] === digest
)
) {
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
} }
if (digestedSubjects.length === 0) { if (digestedSubjects.length === 0) {