From 4c98ef701da55d0c494511ff18c07ac98e078839 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Wed, 29 Jul 2026 21:07:07 -0700 Subject: [PATCH] feat: support single-subject attestations Add an opt-in mode that creates, paces, and reports one attestation per resolved subject while preserving the existing multi-subject default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c01927e-4bdb-4eef-9e04-ea9a3240ff67 --- README.md | 43 ++- __tests__/index.test.ts | 2 + __tests__/integration/main.test.ts | 338 +++++++++++++++++- __tests__/unit/artifacts.test.ts | 52 ++- .../unit/validate-registry-subjects.test.ts | 26 +- action.yml | 11 + dist/index.js | 192 +++++++--- src/artifacts.ts | 16 +- src/index.ts | 3 + src/main.ts | 246 ++++++++++--- src/subject.ts | 2 +- 11 files changed, 790 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 4f6c74d1..9220102c 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ See [action.yml](action.yml) # image name. Defaults to true. create-storage-record: + # Whether to create one attestation for each resolved subject instead of + # one attestation containing all subjects. Limited to 100 subjects. + # Defaults to false. + single-subject-attestations: + # Whether to attach a list of generated attestations to the workflow run # summary page. Defaults to true. show-summary: @@ -163,12 +168,13 @@ See [action.yml](action.yml) -| Name | Description | Example | -| -------------------- | -------------------------------------------------------------- | ------------------------------------------------ | -| `attestation-id` | GitHub ID for the attestation | `123456` | -| `attestation-url` | URL for the attestation summary | `https://github.com/foo/bar/attestations/123456` | -| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` | -| `storage-record-ids` | GitHub IDs for the storage records | `987654` | +| Name | Description | Example | +| -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `attestation-id` | GitHub ID for the attestation (set only when one logical attestation is created) | `123456` | +| `attestation-url` | URL for the attestation summary (set only when one logical attestation is created) | `https://github.com/foo/bar/attestations/123456` | +| `bundle-path` | Absolute path to the file containing the generated attestation(s) (JSONL) | `/tmp/attestation.json` | +| `storage-record-ids` | GitHub IDs for the storage records (set only when one logical attestation is created) | `987654` | +| `results-path` | Path to the JSON file containing the result for each attempted attestation | `/tmp/attestation-results.json` | @@ -177,6 +183,31 @@ 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. +### Single-Subject Attestations + +When `single-subject-attestations` is set to `true`, the action creates one +independently signed attestation per resolved subject instead of a single +attestation containing all subjects. This mode is limited to 100 subjects. + +- Subjects are processed serially with a one-second delay between operations. +- If a subject fails, the action continues processing the remaining subjects + and fails the step after all subjects have been attempted. +- The `results-path` output points to a JSON array that is updated after each + subject, so completed work is inspectable even after a later failure. +- The `bundle-path` output contains one JSONL record per successful attestation. +- `attestation-id`, `attestation-url`, and `storage-record-ids` are only set + when exactly one logical attestation was attempted. +- Multiple OCI subjects are allowed with `push-to-registry` only in + single-subject mode; each subject uses the existing registry publication + behavior. + +```yaml +- uses: actions/attest@v4 + with: + subject-path: 'dist/*' + single-subject-attestations: true +``` + ## Attestation Limits ### Subject Limits diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 38778768..2adef2cf 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -45,6 +45,7 @@ describe('index', () => { 'push-to-registry': false, 'create-storage-record': true, 'show-summary': true, + 'single-subject-attestations': true, 'private-signing': false } return inputs[name] || false @@ -67,6 +68,7 @@ describe('index', () => { pushToRegistry: false, createStorageRecord: true, showSummary: true, + singleSubjectAttestations: true, privateSigning: false }) }) diff --git a/__tests__/integration/main.test.ts b/__tests__/integration/main.test.ts index 727adc7a..37999ea3 100644 --- a/__tests__/integration/main.test.ts +++ b/__tests__/integration/main.test.ts @@ -103,6 +103,7 @@ const defaultInputs: RunInputs = { createStorageRecord: false, subjectVersion: '', showSummary: false, + singleSubjectAttestations: false, githubToken: 'test-token', privateSigning: false } @@ -405,7 +406,11 @@ describe('run', () => { it('should write summary when showSummary is true', async () => { await run(validInputs) - expect(summaryMock.addHeading).toHaveBeenCalled() + expect(summaryMock.addHeading).toHaveBeenCalledWith( + 'Attestation Created', + 3 + ) + expect(summaryMock.addTable).toHaveBeenCalled() expect(summaryMock.write).toHaveBeenCalled() }) @@ -414,6 +419,27 @@ describe('run', () => { expect(summaryMock.write).not.toHaveBeenCalled() }) + + it('should use plural heading for multiple attestations', async () => { + const checksums = [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b` + ].join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true, + showSummary: true + }) + + expect(summaryMock.addHeading).toHaveBeenCalledWith( + 'Attestations Created', + 3 + ) + }) }) describe('registry push', () => { @@ -534,7 +560,7 @@ describe('run', () => { expect(mockAttest).not.toHaveBeenCalled() }) - it('should fail when multiple discovered OCI subjects are used with push-to-registry', async () => { + it('should fail when multiple discovered OCI subjects are used with push-to-registry in default mode', async () => { const listPath = path.join(tempDir, 'artifacts.json') await fs.writeFile( listPath, @@ -566,13 +592,47 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching( - /push-to-registry requires exactly one subject/ + /push-to-registry requires exactly one subject but 2 subjects were resolved/ ) }) ) expect(mockAttest).not.toHaveBeenCalled() }) + it('should allow multiple discovered OCI subjects with push-to-registry in single-subject mode', async () => { + const listPath = path.join(tempDir, 'artifacts.json') + await fs.writeFile( + listPath, + JSON.stringify({ + version: 1, + subjects: [ + { + name: 'ghcr.io/owner/app1', + kind: 'oci', + digest: `sha256:${'a'.repeat(64)}` + }, + { + name: 'ghcr.io/owner/app2', + kind: 'oci', + digest: `sha256:${'b'.repeat(64)}` + } + ] + }) + ) + process.env.GITHUB_ARTIFACTS_LIST = listPath + + await run({ + ...defaultInputs, + pushToRegistry: true, + singleSubjectAttestations: true, + predicateType: 'https://example.com/predicate', + predicate: '{}' + }) + + expect(setFailedMock).not.toHaveBeenCalled() + expect(mockAttest).toHaveBeenCalledTimes(2) + }) + it('should allow SHA-512 subject-checksums when pushToRegistry is false', async () => { const sha512Digest = 'a'.repeat(128) @@ -611,7 +671,7 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching( - /push-to-registry requires a subject with a SHA-256 digest/ + /push-to-registry requires each subject to have only a SHA-256 digest/ ) }) ) @@ -672,7 +732,7 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching( - /push-to-registry requires a subject with a SHA-256 digest/ + /push-to-registry requires each subject to have only a SHA-256 digest/ ) }) ) @@ -702,4 +762,272 @@ describe('run', () => { ) }) }) + + describe('results-path output', () => { + it('should always set results-path output', async () => { + await run({ + ...defaultInputs, + subjectName: 'artifact', + subjectDigest: + 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32', + predicateType: 'https://example.com/predicate', + predicate: '{}' + }) + + expect(setOutputMock).toHaveBeenCalledWith( + 'results-path', + expect.stringContaining('attestation-results.json') + ) + }) + + it('should write valid JSON results for default single attestation', async () => { + await run({ + ...defaultInputs, + subjectName: 'artifact', + subjectDigest: + 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32', + predicateType: 'https://example.com/predicate', + predicate: '{}' + }) + + const resultsPath = setOutputMock.mock.calls.find( + (call: unknown[]) => call[0] === 'results-path' + )?.[1] as string + const results = JSON.parse(await fs.readFile(resultsPath, 'utf-8')) + + expect(results).toEqual([ + expect.objectContaining({ + status: 'success', + bundleLine: 1, + attestationId: 'att-123' + }) + ]) + }) + }) + + describe('single-subject attestations', () => { + it('should create one attestation per subject when enabled', async () => { + const checksums = [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b`, + `${'c'.repeat(64)} artifact-c` + ].join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(mockAttest).toHaveBeenCalledTimes(3) + expect(mockAttest.mock.calls.map((c: unknown[]) => (c as [{ subjects: unknown }])[0].subjects)).toEqual([ + [{ name: 'artifact-a', digest: { sha256: 'a'.repeat(64) } }], + [{ name: 'artifact-b', digest: { sha256: 'b'.repeat(64) } }], + [{ name: 'artifact-c', digest: { sha256: 'c'.repeat(64) } }] + ]) + }) + + it('should keep one multi-subject attestation by default', async () => { + const checksums = [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b` + ].join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}' + }) + + expect(mockAttest).toHaveBeenCalledTimes(1) + expect(mockAttest).toHaveBeenCalledWith( + expect.objectContaining({ + subjects: expect.arrayContaining([ + expect.objectContaining({ name: 'artifact-a' }), + expect.objectContaining({ name: 'artifact-b' }) + ]) + }) + ) + }) + + it('should wait one second between single-subject operations', async () => { + const callTimestamps: number[] = [] + mockAttest.mockImplementation(async () => { + callTimestamps.push(Date.now()) + return await Promise.resolve(createAttestationResult()) + }) + + await run({ + ...defaultInputs, + subjectChecksums: [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b` + ].join('\n'), + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(callTimestamps).toHaveLength(2) + const elapsed = callTimestamps[1] - callTimestamps[0] + expect(elapsed).toBeGreaterThanOrEqual(1000) + }) + + it('should reject more than 100 subjects in single-subject mode', async () => { + const checksums = Array.from( + { length: 101 }, + (_, index) => + `${index.toString(16).padStart(64, '0')} artifact-${index}` + ).join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(setFailedMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'single-subject-attestations supports at most 100 subjects' + ) + }) + ) + expect(mockAttest).not.toHaveBeenCalled() + }) + + it('should continue after a subject failure and fail after all attempts', async () => { + mockAttest + .mockResolvedValueOnce( + createAttestationResult({ attestationID: 'att-1' }) + ) + .mockRejectedValueOnce(new Error('service unavailable')) + .mockResolvedValueOnce( + createAttestationResult({ attestationID: 'att-3' }) + ) + + const checksums = [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b`, + `${'c'.repeat(64)} artifact-c` + ].join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(mockAttest).toHaveBeenCalledTimes(3) + expect(setFailedMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('1 of 3 attestations failed') + }) + ) + + // Verify the JSON results file + const resultsPath = setOutputMock.mock.calls.find( + (call: unknown[]) => call[0] === 'results-path' + )?.[1] as string + const results = JSON.parse(await fs.readFile(resultsPath, 'utf-8')) + + expect(results).toEqual([ + expect.objectContaining({ + subjects: [expect.objectContaining({ name: 'artifact-a' })], + status: 'success', + attestationId: 'att-1', + bundleLine: 1 + }), + expect.objectContaining({ + subjects: [expect.objectContaining({ name: 'artifact-b' })], + status: 'failure', + error: 'service unavailable' + }), + expect.objectContaining({ + subjects: [expect.objectContaining({ name: 'artifact-c' })], + status: 'success', + attestationId: 'att-3', + bundleLine: 2 + }) + ]) + + // Verify the shared bundle file has one JSONL record per success + const bundlePath = setOutputMock.mock.calls.find( + (call: unknown[]) => call[0] === 'bundle-path' + )?.[1] as string + const bundleLines = (await fs.readFile(bundlePath, 'utf-8')) + .trim() + .split(/\r?\n/) + expect(bundleLines).toHaveLength(2) + }) + + it('should omit singular outputs for multi-attempt runs', async () => { + const checksums = [ + `${'a'.repeat(64)} artifact-a`, + `${'b'.repeat(64)} artifact-b` + ].join('\n') + + await run({ + ...defaultInputs, + subjectChecksums: checksums, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(setOutputMock).not.toHaveBeenCalledWith( + 'attestation-id', + expect.anything() + ) + expect(setOutputMock).not.toHaveBeenCalledWith( + 'attestation-url', + expect.anything() + ) + }) + + it('should set singular outputs when single-subject mode has exactly one subject', async () => { + await run({ + ...defaultInputs, + subjectChecksums: `${'a'.repeat(64)} artifact-a`, + predicateType: 'https://example.com/predicate', + predicate: '{}', + singleSubjectAttestations: true + }) + + expect(setOutputMock).toHaveBeenCalledWith('attestation-id', 'att-123') + expect(setOutputMock).toHaveBeenCalledWith( + 'attestation-url', + expect.stringContaining('att-123') + ) + }) + + it('should fail when multiple explicit subject-checksums are used with pushToRegistry in single-subject mode', async () => { + const sha512Digest = 'a'.repeat(128) + + await run({ + ...defaultInputs, + subjectChecksums: `${sha512Digest} ghcr.io/owner/repo`, + predicateType: 'https://example.com/predicate', + predicate: '{}', + pushToRegistry: true, + singleSubjectAttestations: true + }) + + expect(setFailedMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /push-to-registry requires each subject to have only a SHA-256 digest/ + ) + }) + ) + expect(mockAttest).not.toHaveBeenCalled() + }) + }) }) diff --git a/__tests__/unit/artifacts.test.ts b/__tests__/unit/artifacts.test.ts index 059d973a..657a1d96 100644 --- a/__tests__/unit/artifacts.test.ts +++ b/__tests__/unit/artifacts.test.ts @@ -811,7 +811,7 @@ describe('parseArtifactsList', () => { }) }) - describe('requireSingleOCI option', () => { + describe('requireOCI option', () => { const wrap = (subjects: unknown[]): string => JSON.stringify({ version: 1, subjects }) @@ -824,7 +824,7 @@ describe('parseArtifactsList', () => { digest: `sha256:${'a'.repeat(64)}` } ]), - { requireSingleOCI: true } + { requireOCI: true } ) expect(result).toHaveLength(1) @@ -841,7 +841,7 @@ describe('parseArtifactsList', () => { digest: `sha256:${'a'.repeat(64)}` } ]), - { requireSingleOCI: true } + { requireOCI: true } ) ).toThrow( /push-to-registry requires an OCI subject but the discovered artifacts list contains file-kind subjects/ @@ -863,45 +863,43 @@ describe('parseArtifactsList', () => { digest: `sha256:${'b'.repeat(64)}` } ]), - { requireSingleOCI: true } + { requireOCI: true } ) ).toThrow( /push-to-registry requires an OCI subject but the discovered artifacts list contains file-kind subjects/ ) }) - it('should reject multiple OCI subjects', () => { - expect(() => - parseArtifactsList( - wrap([ - { - name: 'ghcr.io/owner/app1', - kind: 'oci', - digest: `sha256:${'a'.repeat(64)}` - }, - { - name: 'ghcr.io/owner/app2', - kind: 'oci', - digest: `sha256:${'b'.repeat(64)}` - } - ]), - { requireSingleOCI: true } - ) - ).toThrow( - /push-to-registry requires exactly one subject but the discovered artifacts list contains multiple subjects/ + it('should allow multiple OCI subjects when requireOCI is set', () => { + const result = parseArtifactsList( + wrap([ + { + name: 'ghcr.io/owner/app1', + kind: 'oci', + digest: `sha256:${'a'.repeat(64)}` + }, + { + name: 'ghcr.io/owner/app2', + kind: 'oci', + digest: `sha256:${'b'.repeat(64)}` + } + ]), + { requireOCI: true } ) + + expect(result).toHaveLength(2) }) it('should allow empty subjects (no-op for empty list)', () => { const result = parseArtifactsList( wrap([]), - { requireSingleOCI: true } + { requireOCI: true } ) expect(result).toEqual([]) }) - it('should not enforce requireSingleOCI when option is false', () => { + it('should not enforce requireOCI when option is false', () => { const result = parseArtifactsList( wrap([ { @@ -910,13 +908,13 @@ describe('parseArtifactsList', () => { digest: `sha256:${'a'.repeat(64)}` } ]), - { requireSingleOCI: false } + { requireOCI: false } ) expect(result).toHaveLength(1) }) - it('should not enforce requireSingleOCI when options are omitted', () => { + it('should not enforce requireOCI when options are omitted', () => { const result = parseArtifactsList( wrap([ { diff --git a/__tests__/unit/validate-registry-subjects.test.ts b/__tests__/unit/validate-registry-subjects.test.ts index 826e0638..4c0b975b 100644 --- a/__tests__/unit/validate-registry-subjects.test.ts +++ b/__tests__/unit/validate-registry-subjects.test.ts @@ -39,7 +39,7 @@ describe('validateRegistrySubjects', () => { ) }) - it('should fail when multiple subjects are provided', () => { + it('should fail when multiple subjects are provided in default mode', () => { const subjects: Subject[] = [ { name: 'ghcr.io/owner/app1', @@ -65,7 +65,7 @@ describe('validateRegistrySubjects', () => { ] expect(() => validateRegistrySubjects(subjects)).toThrow( - /push-to-registry requires a subject with a SHA-256 digest/ + /push-to-registry requires each subject to have only a SHA-256 digest/ ) }) @@ -78,7 +78,27 @@ describe('validateRegistrySubjects', () => { ] expect(() => validateRegistrySubjects(subjects)).toThrow( - /push-to-registry requires a subject with a SHA-256 digest/ + /push-to-registry requires each subject to have only a SHA-256 digest/ + ) + }) + + it('should allow multiple SHA-256 subjects in single-subject mode', () => { + const subjects: Subject[] = [ + { name: 'ghcr.io/owner/app1', digest: { sha256: 'a'.repeat(64) } }, + { name: 'ghcr.io/owner/app2', digest: { sha256: 'b'.repeat(64) } } + ] + + expect(() => validateRegistrySubjects(subjects, true)).not.toThrow() + }) + + it('should reject a non-SHA-256 member in single-subject mode', () => { + const subjects: Subject[] = [ + { name: 'ghcr.io/owner/app1', digest: { sha256: 'a'.repeat(64) } }, + { name: 'ghcr.io/owner/app2', digest: { sha512: 'b'.repeat(128) } } + ] + + expect(() => validateRegistrySubjects(subjects, true)).toThrow( + /push-to-registry requires each subject to have only a SHA-256 digest/ ) }) }) diff --git a/action.yml b/action.yml index f3d593f3..9c7dcd4f 100644 --- a/action.yml +++ b/action.yml @@ -78,6 +78,13 @@ inputs: Requires that push-to-registry is set to true. Defaults to true. default: true required: false + single-subject-attestations: + description: > + Whether to create one attestation for each resolved subject instead of one + attestation containing all subjects. Limited to 100 subjects. Defaults to + false. + default: false + required: false show-summary: description: > Whether to attach a list of generated attestations to the workflow run @@ -98,6 +105,10 @@ outputs: description: 'The URL for the attestation summary.' storage-record-ids: description: 'The IDs of the storage records created for the artifact.' + results-path: + description: > + The path to the JSON file containing the result for each attempted + attestation. runs: using: node24 diff --git a/dist/index.js b/dist/index.js index de7bd536..d9d993f1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -122458,19 +122458,17 @@ const parseArtifactsList = (content, options) => { digest: { [algorithm]: hex } }); } - // When requireSingleOCI is set (registry push flow), enforce that exactly - // one subject was discovered and that it is OCI-kind. This prevents file - // subjects from leaking into the registry push path. - if (options?.requireSingleOCI && subjects.length > 0) { + // When requireOCI is set (registry push flow), enforce that all discovered + // subjects are OCI-kind. This prevents file subjects from leaking into the + // registry push path. Cardinality enforcement is handled by the caller + // based on the selected attestation mode. + if (options?.requireOCI && subjects.length > 0) { // Re-check kinds from the validated entries — we tracked them in `seen` const kinds = [...seen.values()].map(v => v.kind); const hasNonOCI = kinds.some(k => k !== 'oci'); if (hasNonOCI) { throw new Error('push-to-registry requires an OCI subject but the discovered artifacts list contains file-kind subjects'); } - if (subjects.length > 1) { - throw new Error('push-to-registry requires exactly one subject but the discovered artifacts list contains multiple subjects'); - } } return subjects; }; @@ -128027,7 +128025,7 @@ const subjectFromInputs = async (inputs) => { // No explicit subject input — try the runner-generated artifacts list const discovered = await readArtifactsList({ downcaseOCI: downcaseName, - requireSingleOCI: downcaseName + requireOCI: downcaseName }); if (discovered && discovered.length > 0) { if (discovered.length > MAX_SUBJECT_COUNT) { @@ -128496,6 +128494,10 @@ const mute = (str) => `${COLOR_GRAY}${str}${COLOR_DEFAULT}`; const ATTESTATION_FILE_NAME = 'attestation.json'; const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt'; +const ATTESTATION_RESULTS_FILE_NAME = 'attestation-results.json'; +const ATTESTATION_WRITE_DELAY_MS = 1000; +const MAX_SINGLE_SUBJECT_ATTESTATIONS = 100; +const sleep = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)); /* istanbul ignore next */ const logHandler = (level, ...args) => { // Send any HTTP-related log events to the GitHub Actions debug log @@ -128534,33 +128536,96 @@ async function run(inputs) { ...inputs, downcaseName: inputs.pushToRegistry }); + // Validate single-subject attestation count limit + if (inputs.singleSubjectAttestations && + subjects.length > MAX_SINGLE_SUBJECT_ATTESTATIONS) { + throw new Error(`single-subject-attestations supports at most ${MAX_SINGLE_SUBJECT_ATTESTATIONS} subjects but ${subjects.length} subjects were resolved`); + } // Validate subjects are compatible with registry push requirements if (inputs.pushToRegistry) { - validateRegistrySubjects(subjects); + validateRegistrySubjects(subjects, inputs.singleSubjectAttestations); } // Generate predicate based on attestation type const predicate = await getPredicateForType(attestationType, inputs); - const outputPath = external_path_default().join(await tempDir(), ATTESTATION_FILE_NAME); - setOutput('bundle-path', outputPath); - const att = await createAttestation(subjects, predicate, { + const outputDir = await tempDir(); + const bundlePath = external_path_default().join(outputDir, ATTESTATION_FILE_NAME); + const resultsPath = external_path_default().join(outputDir, ATTESTATION_RESULTS_FILE_NAME); + // Initialize both output files before network activity + await Promise.all([ + promises_default().writeFile(bundlePath, '', 'utf-8'), + promises_default().writeFile(resultsPath, `[]${(external_os_default()).EOL}`, 'utf-8') + ]); + setOutput('bundle-path', bundlePath); + setOutput('results-path', resultsPath); + const opts = { sigstoreInstance, pushToRegistry: inputs.pushToRegistry, createStorageRecord: inputs.createStorageRecord, subjectVersion: inputs.subjectVersion, githubToken: inputs.githubToken - }); - logAttestation(subjects, att, sigstoreInstance); - // Write attestation bundle to output file - await promises_default().writeFile(outputPath, JSON.stringify(att.bundle) + (external_os_default()).EOL, { - encoding: 'utf-8', - flag: 'a' - }); + }; + const subjectGroups = inputs.singleSubjectAttestations + ? subjects.map(subject => [subject]) + : [subjects]; + const results = []; + let bundleLineCount = 0; + for (const [index, attestationSubjects] of subjectGroups.entries()) { + if (index > 0) { + await sleep(ATTESTATION_WRITE_DELAY_MS); + } + try { + const att = await createAttestation(attestationSubjects, predicate, opts); + logAttestation(attestationSubjects, att, sigstoreInstance); + // Append bundle to JSONL file + await promises_default().writeFile(bundlePath, JSON.stringify(att.bundle) + (external_os_default()).EOL, { + encoding: 'utf-8', + flag: 'a' + }); + bundleLineCount++; + const result = { + subjects: attestationSubjects, + status: 'success', + bundleLine: bundleLineCount + }; + /* istanbul ignore else */ + if (att.attestationID) { + result.attestationId = att.attestationID; + result.attestationUrl = attestationURL(att.attestationID); + } + if (att.attestationDigest) { + result.attestationDigest = att.attestationDigest; + } + /* istanbul ignore next */ + if (att.storageRecordIds && att.storageRecordIds.length > 0) { + result.storageRecordIds = att.storageRecordIds; + } + results.push(result); + } + catch (err) { + const message = err instanceof Error + ? err.message + : /* istanbul ignore next */ `${err}`; + results.push({ + subjects: attestationSubjects, + status: 'failure', + error: message + }); + // Log the cause of per-subject errors + /* istanbul ignore if */ + if (err instanceof Error && 'cause' in err) { + const innerErr = err.cause; + info(mute(innerErr instanceof Error ? innerErr.toString() : `${innerErr}`)); + } + } + // Persist results after every attempt + await writeResults(resultsPath, results); + } + // Record the shared bundle path for cross-job discovery const baseDir = process.env.RUNNER_TEMP; /* istanbul ignore else */ if (baseDir) { const outputSummaryPath = external_path_default().join(baseDir, ATTESTATION_PATHS_FILE_NAME); - // Append the output path to the attestations paths file - await promises_default().appendFile(outputSummaryPath, outputPath + (external_os_default()).EOL, { + await promises_default().appendFile(outputSummaryPath, bundlePath + (external_os_default()).EOL, { encoding: 'utf-8', flag: 'a' }); @@ -128568,18 +128633,29 @@ async function run(inputs) { else { warning('RUNNER_TEMP environment variable is not set. Cannot write attestation paths file.'); } - /* istanbul ignore else */ - if (att.attestationID) { - setOutput('attestation-id', att.attestationID); - setOutput('attestation-url', attestationURL(att.attestationID)); - } - /* istanbul ignore if */ - if (att.storageRecordIds) { - setOutput('storage-record-ids', att.storageRecordIds.join(',')); + // Set singular outputs only when exactly one logical attestation was + // attempted and it succeeded + const successResults = results.filter((r) => r.status === 'success'); + if (subjectGroups.length === 1 && successResults.length === 1) { + const result = successResults[0]; + /* istanbul ignore else */ + if (result.attestationId) { + setOutput('attestation-id', result.attestationId); + setOutput('attestation-url', result.attestationUrl); + } + /* istanbul ignore if */ + if (result.storageRecordIds && result.storageRecordIds.length > 0) { + setOutput('storage-record-ids', result.storageRecordIds.join(',')); + } } /* istanbul ignore else */ if (inputs.showSummary) { - await logSummary(att); + await logSummary(results); + } + // Fail the step after all subjects have been processed + const failureCount = results.filter(r => r.status === 'failure').length; + if (failureCount > 0) { + throw new Error(`${failureCount} of ${results.length} attestations failed; see ${resultsPath} for details`); } } catch (err) { @@ -128629,15 +128705,32 @@ const logAttestation = (subjects, attestation, sigstoreInstance) => { } }; // Attach summary information to the GitHub Actions run -const logSummary = async (attestation) => { - const { attestationID } = attestation; - /* istanbul ignore else */ - if (attestationID) { - const url = attestationURL(attestationID); - summary.addHeading('Attestation Created', 3); - summary.addList([`${url}`]); - await summary.write(); - } +const logSummary = async (results) => { + summary.addHeading(results.length === 1 ? 'Attestation Created' : 'Attestations Created', 3); + summary.addTable([ + [ + { data: 'Subject', header: true }, + { data: 'Status', header: true }, + { data: 'Attestation', header: true }, + { data: 'Error', header: true } + ], + ...results.map(result => [ + result.subjects + .map(subject => `${subject.name}@${formatSubjectDigest(subject)}`) + .join('
'), + result.status, + result.status === 'success' && result.attestationUrl + ? `${result.attestationId}` + : '', + result.status === 'failure' ? result.error : '' + ]) + ]); + await summary.write(); +}; +const writeResults = async (resultsPath, results) => { + const temporaryPath = `${resultsPath}.tmp`; + await promises_default().writeFile(temporaryPath, `${JSON.stringify(results, null, 2)}${(external_os_default()).EOL}`, 'utf-8'); + await promises_default().rename(temporaryPath, resultsPath); }; const tempDir = async () => { const basePath = process.env['RUNNER_TEMP']; @@ -128670,17 +128763,19 @@ const getPredicateForType = async (type, inputs) => { return predicateFromInputs(inputs); } }; -// Validate that resolved subjects meet registry push requirements: -// exactly one subject with a SHA-256 digest. -const validateRegistrySubjects = (subjects) => { - if (subjects.length !== 1) { +// Validate that resolved subjects meet registry push requirements. +// In default mode: exactly one subject with a SHA-256 digest. +// In single-subject mode: each subject must have only a SHA-256 digest. +const validateRegistrySubjects = (subjects, singleSubjectAttestations = false) => { + if (!singleSubjectAttestations && subjects.length !== 1) { throw new Error(`push-to-registry requires exactly one subject but ${subjects.length} subjects were resolved`); } - const subject = subjects[0]; - const algorithms = Object.keys(subject.digest); - const hasNonSHA256 = algorithms.some(alg => alg !== 'sha256'); - if (hasNonSHA256 || !algorithms.includes('sha256')) { - throw new Error(`push-to-registry requires a subject with a SHA-256 digest but the subject has: ${algorithms.join(', ')}`); + const invalid = subjects.find(subject => { + const algorithms = Object.keys(subject.digest); + return algorithms.length !== 1 || algorithms[0] !== 'sha256'; + }); + if (invalid) { + throw new Error(`push-to-registry requires each subject to have only a SHA-256 digest but "${invalid.name}" has: ${Object.keys(invalid.digest).join(', ')}`); } }; @@ -128703,6 +128798,7 @@ const inputs = { createStorageRecord: getBooleanInput('create-storage-record'), subjectVersion: getInput('subject-version'), showSummary: getBooleanInput('show-summary'), + singleSubjectAttestations: getBooleanInput('single-subject-attestations'), githubToken: getInput('github-token'), // undocumented -- not part of public interface privateSigning: ['true', 'True', 'TRUE', '1'].includes(getInput('private-signing')) diff --git a/src/artifacts.ts b/src/artifacts.ts index 7acf3ab4..7eed59af 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -26,7 +26,7 @@ export type ArtifactsList = { export type ArtifactsListOptions = { downcaseOCI?: boolean - requireSingleOCI?: boolean + requireOCI?: boolean } /** @@ -185,10 +185,11 @@ export const parseArtifactsList = ( }) } - // When requireSingleOCI is set (registry push flow), enforce that exactly - // one subject was discovered and that it is OCI-kind. This prevents file - // subjects from leaking into the registry push path. - if (options?.requireSingleOCI && subjects.length > 0) { + // When requireOCI is set (registry push flow), enforce that all discovered + // subjects are OCI-kind. This prevents file subjects from leaking into the + // registry push path. Cardinality enforcement is handled by the caller + // based on the selected attestation mode. + if (options?.requireOCI && subjects.length > 0) { // Re-check kinds from the validated entries — we tracked them in `seen` const kinds = [...seen.values()].map(v => v.kind) const hasNonOCI = kinds.some(k => k !== 'oci') @@ -198,11 +199,6 @@ export const parseArtifactsList = ( 'push-to-registry requires an OCI subject but the discovered artifacts list contains file-kind subjects' ) } - if (subjects.length > 1) { - throw new Error( - 'push-to-registry requires exactly one subject but the discovered artifacts list contains multiple subjects' - ) - } } return subjects diff --git a/src/index.ts b/src/index.ts index def53e09..fa18a58d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,9 @@ const inputs: RunInputs = { createStorageRecord: core.getBooleanInput('create-storage-record'), subjectVersion: core.getInput('subject-version'), showSummary: core.getBooleanInput('show-summary'), + singleSubjectAttestations: core.getBooleanInput( + 'single-subject-attestations' + ), githubToken: core.getInput('github-token'), // undocumented -- not part of public interface privateSigning: ['true', 'True', 'TRUE', '1'].includes( diff --git a/src/main.ts b/src/main.ts index 521ead03..52d79b12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,9 @@ import type { Predicate, Subject } from '@actions/attest' const ATTESTATION_FILE_NAME = 'attestation.json' const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt' +const ATTESTATION_RESULTS_FILE_NAME = 'attestation-results.json' +const ATTESTATION_WRITE_DELAY_MS = 1000 +const MAX_SINGLE_SUBJECT_ATTESTATIONS = 100 export type SBOMInputs = { sbomPath: string @@ -38,9 +41,32 @@ export type RunInputs = SubjectInputs & subjectVersion: string githubToken: string showSummary: boolean + singleSubjectAttestations: boolean privateSigning: boolean } +type SuccessfulAttestationResult = { + subjects: Subject[] + status: 'success' + bundleLine: number + attestationId?: string + attestationUrl?: string + attestationDigest?: string + storageRecordIds?: number[] +} + +type FailedAttestationResult = { + subjects: Subject[] + status: 'failure' + error: string +} + +type AttestationRunResult = + SuccessfulAttestationResult | FailedAttestationResult + +const sleep = async (milliseconds: number): Promise => + new Promise(resolve => setTimeout(resolve, milliseconds)) + /* istanbul ignore next */ const logHandler = (level: string, ...args: unknown[]): void => { // Send any HTTP-related log events to the GitHub Actions debug log @@ -88,39 +114,128 @@ export async function run(inputs: RunInputs): Promise { downcaseName: inputs.pushToRegistry }) + // Validate single-subject attestation count limit + if ( + inputs.singleSubjectAttestations && + subjects.length > MAX_SINGLE_SUBJECT_ATTESTATIONS + ) { + throw new Error( + `single-subject-attestations supports at most ${MAX_SINGLE_SUBJECT_ATTESTATIONS} subjects but ${subjects.length} subjects were resolved` + ) + } + // Validate subjects are compatible with registry push requirements if (inputs.pushToRegistry) { - validateRegistrySubjects(subjects) + validateRegistrySubjects(subjects, inputs.singleSubjectAttestations) } // Generate predicate based on attestation type const predicate = await getPredicateForType(attestationType, inputs) - const outputPath = path.join(await tempDir(), ATTESTATION_FILE_NAME) - core.setOutput('bundle-path', outputPath) + const outputDir = await tempDir() + const bundlePath = path.join(outputDir, ATTESTATION_FILE_NAME) + const resultsPath = path.join(outputDir, ATTESTATION_RESULTS_FILE_NAME) + + // Initialize both output files before network activity + await Promise.all([ + fs.writeFile(bundlePath, '', 'utf-8'), + fs.writeFile(resultsPath, `[]${os.EOL}`, 'utf-8') + ]) + + core.setOutput('bundle-path', bundlePath) + core.setOutput('results-path', resultsPath) - const att = await createAttestation(subjects, predicate, { + const opts = { sigstoreInstance, pushToRegistry: inputs.pushToRegistry, createStorageRecord: inputs.createStorageRecord, subjectVersion: inputs.subjectVersion, githubToken: inputs.githubToken - }) + } - logAttestation(subjects, att, sigstoreInstance) + const subjectGroups = inputs.singleSubjectAttestations + ? subjects.map(subject => [subject]) + : [subjects] - // Write attestation bundle to output file - await fs.writeFile(outputPath, JSON.stringify(att.bundle) + os.EOL, { - encoding: 'utf-8', - flag: 'a' - }) + const results: AttestationRunResult[] = [] + let bundleLineCount = 0 + + for (const [index, attestationSubjects] of subjectGroups.entries()) { + if (index > 0) { + await sleep(ATTESTATION_WRITE_DELAY_MS) + } + + try { + const att = await createAttestation( + attestationSubjects, + predicate, + opts + ) + logAttestation(attestationSubjects, att, sigstoreInstance) + + // Append bundle to JSONL file + await fs.writeFile(bundlePath, JSON.stringify(att.bundle) + os.EOL, { + encoding: 'utf-8', + flag: 'a' + }) + bundleLineCount++ + + const result: SuccessfulAttestationResult = { + subjects: attestationSubjects, + status: 'success', + bundleLine: bundleLineCount + } + + /* istanbul ignore else */ + if (att.attestationID) { + result.attestationId = att.attestationID + result.attestationUrl = attestationURL(att.attestationID) + } + + if (att.attestationDigest) { + result.attestationDigest = att.attestationDigest + } + + /* istanbul ignore next */ + if (att.storageRecordIds && att.storageRecordIds.length > 0) { + result.storageRecordIds = att.storageRecordIds + } + + results.push(result) + } catch (err) { + const message = + err instanceof Error + ? err.message + : /* istanbul ignore next */ `${err}` + results.push({ + subjects: attestationSubjects, + status: 'failure', + error: message + }) + + // Log the cause of per-subject errors + /* istanbul ignore if */ + if (err instanceof Error && 'cause' in err) { + const innerErr = err.cause + core.info( + style.mute( + innerErr instanceof Error ? innerErr.toString() : `${innerErr}` + ) + ) + } + } + + // Persist results after every attempt + await writeResults(resultsPath, results) + } + + // Record the shared bundle path for cross-job discovery const baseDir = process.env.RUNNER_TEMP /* istanbul ignore else */ if (baseDir) { const outputSummaryPath = path.join(baseDir, ATTESTATION_PATHS_FILE_NAME) - // Append the output path to the attestations paths file - await fs.appendFile(outputSummaryPath, outputPath + os.EOL, { + await fs.appendFile(outputSummaryPath, bundlePath + os.EOL, { encoding: 'utf-8', flag: 'a' }) @@ -130,20 +245,37 @@ export async function run(inputs: RunInputs): Promise { ) } - /* istanbul ignore else */ - if (att.attestationID) { - core.setOutput('attestation-id', att.attestationID) - core.setOutput('attestation-url', attestationURL(att.attestationID)) - } - - /* istanbul ignore if */ - if (att.storageRecordIds) { - core.setOutput('storage-record-ids', att.storageRecordIds.join(',')) + // Set singular outputs only when exactly one logical attestation was + // attempted and it succeeded + const successResults = results.filter( + (r): r is SuccessfulAttestationResult => r.status === 'success' + ) + if (subjectGroups.length === 1 && successResults.length === 1) { + const result = successResults[0] + + /* istanbul ignore else */ + if (result.attestationId) { + core.setOutput('attestation-id', result.attestationId) + core.setOutput('attestation-url', result.attestationUrl) + } + + /* istanbul ignore if */ + if (result.storageRecordIds && result.storageRecordIds.length > 0) { + core.setOutput('storage-record-ids', result.storageRecordIds.join(',')) + } } /* istanbul ignore else */ if (inputs.showSummary) { - await logSummary(att) + await logSummary(results) + } + + // Fail the step after all subjects have been processed + const failureCount = results.filter(r => r.status === 'failure').length + if (failureCount > 0) { + throw new Error( + `${failureCount} of ${results.length} attestations failed; see ${resultsPath} for details` + ) } } catch (err) { // Fail the workflow run if an error occurs @@ -219,16 +351,43 @@ const logAttestation = ( } // Attach summary information to the GitHub Actions run -const logSummary = async (attestation: AttestResult): Promise => { - const { attestationID } = attestation +const logSummary = async (results: AttestationRunResult[]): Promise => { + core.summary.addHeading( + results.length === 1 ? 'Attestation Created' : 'Attestations Created', + 3 + ) + core.summary.addTable([ + [ + { data: 'Subject', header: true }, + { data: 'Status', header: true }, + { data: 'Attestation', header: true }, + { data: 'Error', header: true } + ], + ...results.map(result => [ + result.subjects + .map(subject => `${subject.name}@${formatSubjectDigest(subject)}`) + .join('
'), + result.status, + result.status === 'success' && result.attestationUrl + ? `${result.attestationId}` + : '', + result.status === 'failure' ? result.error : '' + ]) + ]) + await core.summary.write() +} - /* istanbul ignore else */ - if (attestationID) { - const url = attestationURL(attestationID) - core.summary.addHeading('Attestation Created', 3) - core.summary.addList([`${url}`]) - await core.summary.write() - } +const writeResults = async ( + resultsPath: string, + results: AttestationRunResult[] +): Promise => { + const temporaryPath = `${resultsPath}.tmp` + await fs.writeFile( + temporaryPath, + `${JSON.stringify(results, null, 2)}${os.EOL}`, + 'utf-8' + ) + await fs.rename(temporaryPath, resultsPath) } const tempDir = async (): Promise => { @@ -272,22 +431,27 @@ const getPredicateForType = async ( } } -// Validate that resolved subjects meet registry push requirements: -// exactly one subject with a SHA-256 digest. -export const validateRegistrySubjects = (subjects: Subject[]): void => { - if (subjects.length !== 1) { +// Validate that resolved subjects meet registry push requirements. +// In default mode: exactly one subject with a SHA-256 digest. +// In single-subject mode: each subject must have only a SHA-256 digest. +export const validateRegistrySubjects = ( + subjects: Subject[], + singleSubjectAttestations = false +): void => { + if (!singleSubjectAttestations && subjects.length !== 1) { throw new Error( `push-to-registry requires exactly one subject but ${subjects.length} subjects were resolved` ) } - const subject = subjects[0] - const algorithms = Object.keys(subject.digest) - const hasNonSHA256 = algorithms.some(alg => alg !== 'sha256') + const invalid = subjects.find(subject => { + const algorithms = Object.keys(subject.digest) + return algorithms.length !== 1 || algorithms[0] !== 'sha256' + }) - if (hasNonSHA256 || !algorithms.includes('sha256')) { + if (invalid) { throw new Error( - `push-to-registry requires a subject with a SHA-256 digest but the subject has: ${algorithms.join(', ')}` + `push-to-registry requires each subject to have only a SHA-256 digest but "${invalid.name}" has: ${Object.keys(invalid.digest).join(', ')}` ) } } diff --git a/src/subject.ts b/src/subject.ts index 843f4c3a..cbde31d5 100644 --- a/src/subject.ts +++ b/src/subject.ts @@ -54,7 +54,7 @@ export const subjectFromInputs = async ( // No explicit subject input — try the runner-generated artifacts list const discovered = await readArtifactsList({ downcaseOCI: downcaseName, - requireSingleOCI: downcaseName + requireOCI: downcaseName }) if (discovered && discovered.length > 0) { if (discovered.length > MAX_SUBJECT_COUNT) {