support multi-subject attestations (#164)

Signed-off-by: Brian DeHamer <[email protected]>
This commit is contained in:
Brian DeHamer
2024-11-05 09:16:07 -08:00
committed by GitHub
parent b485edd412
commit 85e94cb741
10 changed files with 140 additions and 244 deletions
+3 -8
View File
@@ -117,18 +117,14 @@ See [action.yml](action.yml)
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, each attestation will If multiple subjects are being attested at the same time, a single attestation
be written to the output file on a separate line (using the [JSON Lines][7] will be created with references to each of the supplied subjects.
format).
## Attestation Limits ## Attestation Limits
### Subject Limits ### Subject Limits
No more than 2500 subjects can be attested at the same time. Subjects will be No more than 1024 subjects can be attested at the same time.
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
@@ -269,7 +265,6 @@ 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
+15 -46
View File
@@ -46,8 +46,7 @@ const defaultInputs: main.RunInputs = {
pushToRegistry: false, pushToRegistry: false,
showSummary: true, showSummary: true,
githubToken: '', githubToken: '',
privateSigning: false, privateSigning: false
batchSize: 50
} }
describe('action', () => { describe('action', () => {
@@ -290,15 +289,11 @@ describe('action', () => {
}) })
}) })
describe('when the subject count exceeds the batch size', () => { describe('when the subject count is greater than 1', () => {
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'
@@ -309,38 +304,22 @@ 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 () => {
@@ -354,8 +333,7 @@ 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)
@@ -363,17 +341,8 @@ describe('action', () => {
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
1, 1,
expect.stringMatching('Processing subject batch 1/3') expect.stringMatching('Attestation created for 5 subjects')
) )
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)
}) })
}) })
@@ -382,7 +351,7 @@ describe('action', () => {
const filename = 'subject' const filename = 'subject'
beforeEach(async () => { beforeEach(async () => {
const subjectCount = 2501 const subjectCount = 1025
const content = 'file content' const content = 'file content'
// Set-up temp directory // Set-up temp directory
@@ -419,7 +388,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 2500.' 'Too many subjects specified. The maximum number of subjects is 1024.'
) )
) )
}) })
+17 -1
View File
@@ -2,7 +2,11 @@ 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 { subjectFromInputs, SubjectInputs } from '../src/subject' import {
formatSubjectDigest,
subjectFromInputs,
SubjectInputs
} from '../src/subject'
describe('subjectFromInputs', () => { describe('subjectFromInputs', () => {
const blankInputs: SubjectInputs = { const blankInputs: SubjectInputs = {
@@ -360,3 +364,15 @@ describe('subjectFromInputs', () => {
}) })
}) })
}) })
describe('subjectDigest', () => {
it('returns the digest', () => {
const subject = {
name: 'foo',
digest: { sha1: 'deadbeef' }
}
const digest = formatSubjectDigest(subject)
expect(digest).toEqual('sha1:deadbeef')
})
})
Generated Vendored
+47 -82
View File
@@ -70761,30 +70761,26 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.createAttestation = void 0; exports.createAttestation = void 0;
const attest_1 = __nccwpck_require__(11485); const attest_1 = __nccwpck_require__(11485);
const oci_1 = __nccwpck_require__(81057); const oci_1 = __nccwpck_require__(81057);
const subject_1 = __nccwpck_require__(36303);
const OCI_TIMEOUT = 30000; const OCI_TIMEOUT = 30000;
const OCI_RETRY = 3; const OCI_RETRY = 3;
const createAttestation = async (subject, predicate, opts) => { const createAttestation = async (subjects, predicate, opts) => {
// Sign provenance w/ Sigstore // Sign provenance w/ Sigstore
const attestation = await (0, attest_1.attest)({ const attestation = await (0, attest_1.attest)({
subjectName: subject.name, subjects,
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 subDigest = subjectDigest(subject); const result = attestation;
const result = { if (subjects.length === 1 && opts.pushToRegistry) {
...attestation, const subject = subjects[0];
subjectName: subject.name,
subjectDigest: subDigest
};
if (opts.pushToRegistry) {
const credentials = (0, oci_1.getRegistryCredentials)(subject.name); const credentials = (0, oci_1.getRegistryCredentials)(subject.name);
const artifact = await (0, oci_1.attachArtifactToImage)({ const artifact = await (0, oci_1.attachArtifactToImage)({
credentials, credentials,
imageName: subject.name, imageName: subject.name,
imageDigest: subDigest, imageDigest: (0, subject_1.formatSubjectDigest)(subject),
artifact: Buffer.from(JSON.stringify(attestation.bundle)), artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: attestation.bundle.mediaType, mediaType: attestation.bundle.mediaType,
annotations: { annotations: {
@@ -70799,12 +70795,6 @@ const createAttestation = async (subject, predicate, opts) => {
return result; return result;
}; };
exports.createAttestation = createAttestation; exports.createAttestation = createAttestation;
// Returns the subject's digest as a formatted string of the form
// "<algorithm>:<digest>".
const subjectDigest = (subject) => {
const alg = Object.keys(subject.digest).sort()[0];
return `${alg}:${subject.digest[alg]}`;
};
/***/ }), /***/ }),
@@ -70855,7 +70845,6 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
*/ */
const core = __importStar(__nccwpck_require__(37484)); const core = __importStar(__nccwpck_require__(37484));
const main_1 = __nccwpck_require__(41730); const main_1 = __nccwpck_require__(41730);
const DEFAULT_BATCH_SIZE = 50;
const inputs = { const inputs = {
subjectPath: core.getInput('subject-path'), subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'), subjectName: core.getInput('subject-name'),
@@ -70867,9 +70856,7 @@ const inputs = {
showSummary: core.getBooleanInput('show-summary'), 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(core.getInput('private-signing')), 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
(0, main_1.run)(inputs); (0, main_1.run)(inputs);
@@ -70921,8 +70908,6 @@ const predicate_1 = __nccwpck_require__(84982);
const style = __importStar(__nccwpck_require__(64542)); const style = __importStar(__nccwpck_require__(64542));
const subject_1 = __nccwpck_require__(36303); const subject_1 = __nccwpck_require__(36303);
const ATTESTATION_FILE_NAME = 'attestation.jsonl'; const ATTESTATION_FILE_NAME = 'attestation.jsonl';
const DELAY_INTERVAL_MS = 75;
const DELAY_MAX_MS = 1200;
/* istanbul ignore next */ /* istanbul ignore next */
const logHandler = (level, ...args) => { const logHandler = (level, ...args) => {
// Send any HTTP-related log events to the GitHub Actions debug log // Send any HTTP-related log events to the GitHub Actions debug log
@@ -70944,7 +70929,6 @@ async function run(inputs) {
? 'public-good' ? 'public-good'
: 'github'; : 'github';
try { try {
const atts = [];
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'); throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
} }
@@ -70955,35 +70939,19 @@ async function run(inputs) {
const predicate = (0, predicate_1.predicateFromInputs)(inputs); const predicate = (0, predicate_1.predicateFromInputs)(inputs);
const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME); const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME);
core.setOutput('bundle-path', outputPath); core.setOutput('bundle-path', outputPath);
const subjectChunks = chunkArray(subjects, inputs.batchSize); const att = await (0, attest_1.createAttestation)(subjects, predicate, {
// Generate attestations for each subject serially, working in batches sigstoreInstance,
for (let i = 0; i < subjectChunks.length; i++) { pushToRegistry: inputs.pushToRegistry,
if (subjectChunks.length > 1) { githubToken: inputs.githubToken
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`); });
} logAttestation(subjects, att, sigstoreInstance);
// Calculate the delay time for this batch // Write attestation bundle to output file
const delayTime = delay(i); fs_1.default.writeFileSync(outputPath, JSON.stringify(att.bundle) + os_1.default.EOL, {
for (const subject of subjectChunks[i]) { encoding: 'utf-8',
// Delay between attestations (only when chunk size > 1) flag: 'a'
if (i > 0) { });
await new Promise(resolve => setTimeout(resolve, delayTime));
}
const att = await (0, attest_1.createAttestation)(subject, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
});
atts.push(att);
logAttestation(att, sigstoreInstance);
// Write attestation bundle to output file
fs_1.default.writeFileSync(outputPath, JSON.stringify(att.bundle) + os_1.default.EOL, {
encoding: 'utf-8',
flag: 'a'
});
}
}
if (inputs.showSummary) { if (inputs.showSummary) {
logSummary(atts); logSummary(att);
} }
} }
catch (err) { catch (err) {
@@ -71001,8 +70969,13 @@ async function run(inputs) {
} }
} }
// Log details about the attestation to the GitHub Actions run // Log details about the attestation to the GitHub Actions run
const logAttestation = (attestation, sigstoreInstance) => { const logAttestation = (subjects, attestation, sigstoreInstance) => {
core.info(`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`); if (subjects.length === 1) {
core.info(`Attestation created for ${subjects[0].name}@${(0, subject_1.formatSubjectDigest)(subjects[0])}`);
}
else {
core.info(`Attestation created for ${subjects.length} subjects`);
}
const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'; const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub';
core.startGroup(style.highlight(`Attestation signed using certificate from ${instanceName} Sigstore instance`)); core.startGroup(style.highlight(`Attestation signed using certificate from ${instanceName} Sigstore instance`));
core.info(attestation.certificate); core.info(attestation.certificate);
@@ -71017,22 +70990,16 @@ const logAttestation = (attestation, sigstoreInstance) => {
} }
if (attestation.attestationDigest) { if (attestation.attestationDigest) {
core.info(style.highlight('Attestation uploaded to registry')); core.info(style.highlight('Attestation uploaded to registry'));
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`); core.info(`${subjects[0].name}@${attestation.attestationDigest}`);
} }
}; };
// Attach summary information to the GitHub Actions run // Attach summary information to the GitHub Actions run
const logSummary = (attestations) => { const logSummary = (attestation) => {
if (attestations.length > 0) { const { attestationID } = attestation;
core.summary.addHeading( if (attestationID) {
/* istanbul ignore next */ const url = attestationURL(attestationID);
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created', 3); core.summary.addHeading('Attestation Created', 3);
const listItems = []; core.summary.addList([`<a href="${url}">${url}</a>`]);
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(); core.summary.write();
} }
}; };
@@ -71044,13 +71011,6 @@ const tempDir = () => {
} }
return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep)); return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep));
}; };
// Transforms an array into an array of arrays, each containing at most
// `chunkSize` elements.
const chunkArray = (array, chunkSize) => {
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) => Math.min(DELAY_INTERVAL_MS * 2 ** iteration, DELAY_MAX_MS);
const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`; const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`;
@@ -71157,13 +71117,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.subjectFromInputs = void 0; exports.formatSubjectDigest = exports.subjectFromInputs = void 0;
const glob = __importStar(__nccwpck_require__(47206)); const glob = __importStar(__nccwpck_require__(47206));
const crypto_1 = __importDefault(__nccwpck_require__(76982)); const crypto_1 = __importDefault(__nccwpck_require__(76982));
const sync_1 = __nccwpck_require__(61110); const sync_1 = __nccwpck_require__(61110);
const fs_1 = __importDefault(__nccwpck_require__(79896)); const fs_1 = __importDefault(__nccwpck_require__(79896));
const path_1 = __importDefault(__nccwpck_require__(16928)); const path_1 = __importDefault(__nccwpck_require__(16928));
const MAX_SUBJECT_COUNT = 2500; const MAX_SUBJECT_COUNT = 1024;
const DIGEST_ALGORITHM = 'sha256'; const DIGEST_ALGORITHM = 'sha256';
// Returns the subject specified by the action's inputs. The subject may be // Returns the subject specified by the action's inputs. The subject may be
// specified as a path to a file or as a digest. If a path is provided, the // specified as a path to a file or as a digest. If a path is provided, the
@@ -71191,23 +71151,28 @@ const subjectFromInputs = async (inputs) => {
} }
}; };
exports.subjectFromInputs = subjectFromInputs; exports.subjectFromInputs = subjectFromInputs;
// Returns the subject's digest as a formatted string of the form
// "<algorithm>:<digest>".
const formatSubjectDigest = (subject) => {
const alg = Object.keys(subject.digest).sort()[0];
return `${alg}:${subject.digest[alg]}`;
};
exports.formatSubjectDigest = formatSubjectDigest;
// 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 (subjectPath, subjectName) => { const getSubjectFromPath = async (subjectPath, subjectName) => {
const digestedSubjects = []; const digestedSubjects = [];
// Parse the list of subject paths // Parse the list of subject paths
const subjectPaths = parseList(subjectPath).join('\n'); const subjectPaths = parseList(subjectPath).join('\n');
// Expand the globbed paths to a list of files // Expand the globbed paths to a list of actual paths
/* eslint-disable-next-line github/no-then */ /* eslint-disable-next-line github/no-then */
const files = await glob.create(subjectPaths).then(async (g) => g.glob()); 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_1.default.statSync(p).isFile());
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`); throw new Error(`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`);
} }
for (const file of files) { for (const file of files) {
// Skip anything that is NOT a file
if (!fs_1.default.statSync(file).isFile()) {
continue;
}
const name = subjectName || path_1.default.parse(file).base; const name = subjectName || path_1.default.parse(file).base;
const digest = await digestFile(DIGEST_ALGORITHM, file); const digest = await digestFile(DIGEST_ALGORITHM, file);
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } }); digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "actions/attest", "name": "actions/attest",
"version": "1.4.1", "version": "2.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "actions/attest", "name": "actions/attest",
"version": "1.4.1", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/attest": "^1.5.0", "@actions/attest": "^1.5.0",
+1 -1
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": "1.4.1", "version": "2.0.0",
"author": "", "author": "",
"private": true, "private": true,
"homepage": "https://github.com/actions/attest", "homepage": "https://github.com/actions/attest",
+7 -20
View File
@@ -1,18 +1,17 @@
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 = 30000
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 (
subject: Subject, subjects: Subject[],
predicate: Predicate, predicate: Predicate,
opts: { opts: {
sigstoreInstance: SigstoreInstance sigstoreInstance: SigstoreInstance
@@ -22,27 +21,22 @@ export const createAttestation = async (
): Promise<AttestResult> => { ): Promise<AttestResult> => {
// Sign provenance w/ Sigstore // Sign provenance w/ Sigstore
const attestation = await attest({ const attestation = await attest({
subjectName: subject.name, subjects,
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 subDigest = subjectDigest(subject) const result: AttestResult = attestation
const result: AttestResult = {
...attestation,
subjectName: subject.name,
subjectDigest: subDigest
}
if (opts.pushToRegistry) { if (subjects.length === 1 && 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: subDigest, imageDigest: formatSubjectDigest(subject),
artifact: Buffer.from(JSON.stringify(attestation.bundle)), artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: attestation.bundle.mediaType, mediaType: attestation.bundle.mediaType,
annotations: { annotations: {
@@ -58,10 +52,3 @@ 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]}`
}
+1 -5
View File
@@ -4,8 +4,6 @@
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'),
@@ -19,9 +17,7 @@ const inputs: RunInputs = {
// 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
+34 -71
View File
@@ -7,11 +7,15 @@ 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 { SubjectInputs, subjectFromInputs } from './subject' import {
SubjectInputs,
formatSubjectDigest,
subjectFromInputs
} from './subject'
import type { Subject } from '@actions/attest'
const ATTESTATION_FILE_NAME = 'attestation.jsonl' const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const DELAY_INTERVAL_MS = 75
const DELAY_MAX_MS = 1200
export type RunInputs = SubjectInputs & export type RunInputs = SubjectInputs &
PredicateInputs & { PredicateInputs & {
@@ -19,7 +23,6 @@ export type RunInputs = SubjectInputs &
githubToken: string githubToken: string
showSummary: boolean showSummary: boolean
privateSigning: boolean privateSigning: boolean
batchSize: number
} }
/* istanbul ignore next */ /* istanbul ignore next */
@@ -47,7 +50,6 @@ 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.'
@@ -63,42 +65,22 @@ 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 subjectChunks = chunkArray(subjects, inputs.batchSize) const att = await createAttestation(subjects, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
// Generate attestations for each subject serially, working in batches logAttestation(subjects, att, sigstoreInstance)
for (let i = 0; i < subjectChunks.length; i++) {
if (subjectChunks.length > 1) {
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`)
}
// Calculate the delay time for this batch // Write attestation bundle to output file
const delayTime = delay(i) fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
for (const subject of subjectChunks[i]) { flag: 'a'
// 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) { if (inputs.showSummary) {
logSummary(atts) logSummary(att)
} }
} catch (err) { } catch (err) {
// Fail the workflow run if an error occurs // Fail the workflow run if an error occurs
@@ -123,12 +105,17 @@ 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 => {
core.info( if (subjects.length === 1) {
`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}` core.info(
) `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'
@@ -156,29 +143,18 @@ 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(`${attestation.subjectName}@${attestation.attestationDigest}`) core.info(`${subjects[0].name}@${attestation.attestationDigest}`)
} }
} }
// Attach summary information to the GitHub Actions run // Attach summary information to the GitHub Actions run
const logSummary = (attestations: AttestResult[]): void => { const logSummary = (attestation: AttestResult): void => {
if (attestations.length > 0) { const { attestationID } = attestation
core.summary.addHeading(
/* istanbul ignore next */
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
3
)
const listItems = [] if (attestationID) {
for (const { subjectName, subjectDigest, attestationID } of attestations) { const url = attestationURL(attestationID)
if (attestationID) { core.summary.addHeading('Attestation Created', 3)
listItems.push( core.summary.addList([`<a href="${url}">${url}</a>`])
`<a href="${attestationURL(attestationID)}">${subjectName}@${subjectDigest}</a>`
)
}
}
core.summary.addList(listItems)
core.summary.write() core.summary.write()
} }
} }
@@ -194,18 +170,5 @@ 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}`
+13 -8
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 = 2500 const MAX_SUBJECT_COUNT = 1024
const DIGEST_ALGORITHM = 'sha256' const DIGEST_ALGORITHM = 'sha256'
export type SubjectInputs = { export type SubjectInputs = {
@@ -49,6 +49,13 @@ 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 (
@@ -60,9 +67,12 @@ const getSubjectFromPath = async (
// Parse the list of subject paths // Parse the list of subject paths
const subjectPaths = parseList(subjectPath).join('\n') const subjectPaths = parseList(subjectPath).join('\n')
// Expand the globbed paths to a list of files // Expand the globbed paths to a list of actual paths
/* eslint-disable-next-line github/no-then */ /* eslint-disable-next-line github/no-then */
const files = await glob.create(subjectPaths).then(async g => g.glob()) 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())
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error( throw new Error(
@@ -71,11 +81,6 @@ 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)