diff --git a/README.md b/README.md index 5849c6a0c..f64413c1c 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,11 @@ 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 the `single-subject-attestations` option has been set to true, +one attestation will be generated per provided subject. +All of these attestations will be written to the output file, +using the [JSON Lines][7] format (one attestation per line). + ## Attestation Limits ### Subject Limits @@ -320,6 +325,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 diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 7e114d499..faff523de 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -47,6 +47,7 @@ const defaultInputs: main.RunInputs = { pushToRegistry: false, showSummary: true, githubToken: '', + singleSubjectAttestations: false, privateSigning: false } diff --git a/action.yml b/action.yml index bc45f792d..e9ee1c20f 100644 --- a/action.yml +++ b/action.yml @@ -64,6 +64,11 @@ inputs: The GitHub token used to make authenticated API requests. default: ${{ github.token }} required: false + single-subject-attestations: + description: > + If true, generate one attestation per subject. Defaults to false. + default: false + required: false outputs: bundle-path: description: 'The path to the file containing the attestation bundle.' diff --git a/src/attest.ts b/src/attest.ts index 3b59cdbab..4db3cd1be 100644 --- a/src/attest.ts +++ b/src/attest.ts @@ -20,7 +20,7 @@ export const createAttestation = async ( } ): Promise => { // Sign provenance w/ Sigstore - const attestation = await attest({ + const attestation: Attestation = await attest({ subjects, predicateType: predicate.type, predicate: predicate.params, diff --git a/src/index.ts b/src/index.ts index a4de2b8ce..872e21668 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,9 @@ const inputs: RunInputs = { pushToRegistry: core.getBooleanInput('push-to-registry'), showSummary: core.getBooleanInput('show-summary'), githubToken: core.getInput('github-token'), + singleSubjectAttestations: core.getBooleanInput( + 'single-subject-attestations' + ), // undocumented -- not part of public interface privateSigning: ['true', 'True', 'TRUE', '1'].includes( core.getInput('private-signing') diff --git a/src/main.ts b/src/main.ts index fcc5c93d0..ed42872be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,8 @@ export type RunInputs = SubjectInputs & pushToRegistry: boolean githubToken: string showSummary: boolean + singleSubjectAttestations: boolean + // undocumented -- not part of public interface privateSigning: boolean } @@ -65,27 +67,43 @@ export async function run(inputs: RunInputs): Promise { const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME) core.setOutput('bundle-path', outputPath) - const att = await createAttestation(subjects, predicate, { + const opts = { sigstoreInstance, pushToRegistry: inputs.pushToRegistry, githubToken: inputs.githubToken - }) + } - logAttestation(subjects, att, sigstoreInstance) + const atts: AttestResult[] = [] + if (inputs.singleSubjectAttestations) { + // Generate one attestation for each subject + for (const subject of subjects) { + const att = await createAttestation([subject], predicate, opts) + atts.push(att) + } + } else { + const att = await createAttestation(subjects, predicate, opts) + atts.push(att) + } - // Write attestation bundle to output file - fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, { - encoding: 'utf-8', - flag: 'a' - }) + for (const att of atts) { + logAttestation(att, sigstoreInstance) + + // Write attestation bundle to output file + fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, { + encoding: 'utf-8', + flag: 'a' + }) + } + + logSubjects(subjects) - if (att.attestationID) { - core.setOutput('attestation-id', att.attestationID) - core.setOutput('attestation-url', attestationURL(att.attestationID)) + if (atts[0].attestationID) { + core.setOutput('attestation-id', atts[0].attestationID) + core.setOutput('attestation-url', attestationURL(atts[0].attestationID)) } if (inputs.showSummary) { - await logSummary(att) + await logSummary(atts) } } catch (err) { // Fail the workflow run if an error occurs @@ -110,18 +128,9 @@ export async function run(inputs: RunInputs): Promise { // 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`) - } - const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub' core.startGroup( @@ -152,16 +161,34 @@ const logAttestation = ( } } +// Log details about attestation subjects to the GitHub Actions run +const logSubjects = (subjects: Subject[]): void => { + core.info(`Attestation created for ${subjects.length} subjects`) + for (const subject of subjects) { + core.info( + `Attestation created for ${subject.name}@${formatSubjectDigest(subject)}` + ) + } +} + // Attach summary information to the GitHub Actions run -const logSummary = async (attestation: AttestResult): Promise => { - const { attestationID } = attestation - - if (attestationID) { - const url = attestationURL(attestationID) - core.summary.addHeading('Attestation Created', 3) - core.summary.addList([`${url}`]) - await core.summary.write() +const logSummary = async (attestations: AttestResult[]): Promise => { + if (attestations.length <= 0) return + + core.summary.addHeading( + /* istanbul ignore next */ + attestations.length !== 1 ? 'Attestations Created' : 'Attestation Created', + 3 + ) + const listItems: string[] = [] + for (const { attestationID } of attestations) { + if (attestationID) { + const url = attestationURL(attestationID) + listItems.push(`${url}`) + } } + core.summary.addList(listItems) + await core.summary.write() } const tempDir = (): string => { diff --git a/src/subject.ts b/src/subject.ts index b977f18fa..9a426b372 100644 --- a/src/subject.ts +++ b/src/subject.ts @@ -18,6 +18,7 @@ export type SubjectInputs = { subjectName: string subjectDigest: string subjectChecksums: string + singleSubjectAttestations: boolean downcaseName?: boolean } // Returns the subject specified by the action's inputs. The subject may be