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
27 changed files with 28004 additions and 29610 deletions
+4
View File
@@ -0,0 +1,4 @@
lib/
dist/
node_modules/
coverage/
+85
View File
@@ -0,0 +1,85 @@
env:
node: true
es6: true
jest: true
globals:
Atomics: readonly
SharedArrayBuffer: readonly
ignorePatterns:
- '!.*'
- '**/node_modules/.*'
- '**/dist/.*'
- '**/coverage/.*'
- '*.json'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 2023
sourceType: module
project:
- './.github/linters/tsconfig.json'
- './tsconfig.json'
plugins:
- jest
- '@typescript-eslint'
extends:
- eslint:recommended
- plugin:@typescript-eslint/eslint-recommended
- plugin:@typescript-eslint/recommended
- plugin:github/recommended
- plugin:jest/recommended
rules:
{
'camelcase': 'off',
'eslint-comments/no-use': 'off',
'eslint-comments/no-unused-disable': 'off',
'i18n-text/no-en': 'off',
'import/no-namespace': 'off',
'import/no-unresolved':
['error', { 'ignore': ['csv-parse/sync']}],
'no-console': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
'semi': 'off',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/explicit-member-accessibility':
['error', { 'accessibility': 'no-public' }],
'@typescript-eslint/explicit-function-return-type':
['error', { 'allowExpressions': true }],
'@typescript-eslint/func-call-spacing': ['error', 'never'],
'@typescript-eslint/no-array-constructor': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-for-in-array': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'warn',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/semi': ['error', 'never'],
'@typescript-eslint/space-before-function-paren': 'off',
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unbound-method': 'error'
}
+10
View File
@@ -0,0 +1,10 @@
rules:
document-end: disable
document-start:
level: warning
present: false
line-length:
level: warning
max: 80
allow-non-breakable-words: true
allow-non-breakable-inline-mappings: true
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["../../__tests__/**/*", "../../src/**/*"],
"exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"]
}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
id: checkout id: checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
id: setup-node id: setup-node
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
id: checkout id: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Node.js - name: Setup Node.js
id: setup-node id: setup-node
@@ -58,7 +58,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
id: checkout id: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Calculate subject digest - name: Calculate subject digest
id: subject id: subject
env: env:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
id: checkout id: checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
id: initialize id: initialize
+49
View File
@@ -0,0 +1,49 @@
name: Lint Codebase
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
packages: read
statuses: write
jobs:
lint:
name: Lint Codebase
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- name: Install Dependencies
id: install
run: npm ci
- name: Lint Codebase
id: super-linter
uses: super-linter/super-linter/slim@v6
env:
DEFAULT_BRANCH: main
FILTER_REGEX_EXCLUDE: dist/**/*
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TYPESCRIPT_DEFAULT_STYLE: prettier
VALIDATE_ALL_CODEBASE: true
VALIDATE_JAVASCRIPT_STANDARD: false
VALIDATE_JSCPD: false
+1 -1
View File
@@ -1 +1 @@
24.5.0 20.6.0
+26 -103
View File
@@ -18,29 +18,12 @@ Once the attestation has been created and signed, it will be uploaded to the GH
attestations API and associated with the repository from which the workflow was attestations API and associated with the repository from which the workflow was
initiated. initiated.
When an attestation is created, the attestation is stored on the local
filesystem used by the runner. For each attestation created, the filesystem path
will be appended to the file `${RUNNER_TEMP}/created_attestation_paths.txt`.
This can be used to gather all attestations created by all jobs during a the
workflow.
Attestations can be verified using the [`attestation` command in the GitHub Attestations can be verified using the [`attestation` command in the GitHub
CLI][5]. CLI][5].
See [Using artifact attestations to establish provenance for builds][9] for more See [Using artifact attestations to establish provenance for builds][9] for more
information on artifact attestations. information on artifact attestations.
<!-- prettier-ignore-start -->
> [!NOTE]
> Artifact attestations are available in public repositories for all
> current GitHub plans.
>
> To use artifact attestations in private or internal repositories, you must
> be on a GitHub Enterprise Cloud plan.
>
> Artifact attestations are NOT supported on GitHub Enterprise Server.
<!-- prettier-ignore-end -->
## Usage ## Usage
Within the GitHub Actions workflow which builds some artifact you would like to Within the GitHub Actions workflow which builds some artifact you would like to
@@ -61,7 +44,7 @@ attest:
1. Add the following to your workflow after your artifact has been built: 1. Add the following to your workflow after your artifact has been built:
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v1
with: with:
subject-path: '<PATH TO ARTIFACT>' subject-path: '<PATH TO ARTIFACT>'
predicate-type: '<PREDICATE URI>' predicate-type: '<PREDICATE URI>'
@@ -71,35 +54,30 @@ attest:
The `subject-path` parameter should identify the artifact for which you want The `subject-path` parameter should identify the artifact for which you want
to generate an attestation. The `predicate-type` can be any of the the to generate an attestation. The `predicate-type` can be any of the the
[vetted predicate types][3] or a custom value. The `predicate-path` [vetted predicate types][3] or a custom value. The `predicate-path`
identifies a file containing the JSON-encoded predicate parameters. identifies a file containg the JSON-encoded predicate parameters.
### Inputs ### Inputs
See [action.yml](action.yml) See [action.yml](action.yml)
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v1
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", "subject-digest", or # specify exactly one of "subject-path" or "subject-digest". May contain
# "subject-checksums". May contain a glob pattern or list of paths # a glob pattern or list of paths (total subject count cannot exceed 2500).
# (total subject count cannot exceed 1024).
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
# "sha256:hex_digest" (e.g. "sha256:abc123..."). Must specify exactly one # "sha256:hex_digest" (e.g. "sha256:abc123..."). Must specify exactly one
# of "subject-path", "subject-digest", or "subject-checksums". # of "subject-path" or "subject-digest".
subject-digest: subject-digest:
# Subject name as it should appear in the attestation. Required when # Subject name as it should appear in the attestation. Required unless
# identifying the subject with the "subject-digest" input. # "subject-path" is specified, in which case it will be inferred from the
# path.
subject-name: subject-name:
# Path to checksums file containing digest and name of subjects for
# attestation. Must specify exactly one of "subject-path", "subject-digest",
# or "subject-checksums".
subject-checksums:
# URI identifying the type of the predicate. # URI identifying the type of the predicate.
predicate-type: predicate-type:
@@ -118,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:
@@ -131,24 +105,26 @@ See [action.yml](action.yml)
<!-- markdownlint-disable MD013 --> <!-- markdownlint-disable MD013 -->
| Name | Description | Example | | Name | Description | Example |
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------ | | ------------- | -------------------------------------------------------------- | ------------------------ |
| `attestation-id` | GitHub ID for the attestation | `123456` | | `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` |
| `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` |
<!-- 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
@@ -181,20 +157,20 @@ jobs:
- name: Build artifact - name: Build artifact
run: make my-app run: make my-app
- name: Attest - name: Attest
uses: actions/attest@v2 uses: actions/attest@v1
with: with:
subject-path: '${{ github.workspace }}/my-app' subject-path: '${{ github.workspace }}/my-app'
predicate-type: 'https://example.com/predicate/v1' predicate-type: 'https://example.com/predicate/v1'
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@v2 - uses: actions/attest@v1
with: with:
subject-path: 'dist/**/my-bin-*' subject-path: 'dist/**/my-bin-*'
predicate-type: 'https://example.com/predicate/v1' predicate-type: 'https://example.com/predicate/v1'
@@ -204,60 +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@v2
with:
subject-path: 'dist/foo, dist/bar'
```
```yaml
- uses: actions/attest@v2
with:
subject-path: |
dist/foo
dist/bar
```
### Identify Subjects with Checksums File
If you are using tools like
[goreleaser](https://goreleaser.com/customization/checksum/) or
[jreleaser](https://jreleaser.org/guide/latest/reference/checksum.html) which
generate a checksums file you can identify the attestation subjects by passing
the path of the checksums file to the `subject-checksums` input. Each of the
artifacts identified in the checksums file will be listed as a subject for the
attestation.
```yaml
- name: Calculate artifact digests
run: |
shasum -a 256 foo_0.0.1_* > subject.checksums.txt
- uses: actions/attest@v2
with:
subject-checksums: subject.checksums.txt
predicate-type: 'https://example.com/predicate/v1'
predicate: '{}'
```
<!-- markdownlint-disable MD038 -->
The file referenced by the `subject-checksums` input must conform to the same
format used by the shasum tools. Each subject should be listed on a separate
line including the hex-encoded digest (either SHA256 or SHA512), a space, a
single character flag indicating either binary (`*`) or text (` `) input mode,
and the filename.
<!-- markdownlint-enable MD038 -->
```text
b569bf992b287f55d78bf8ee476497e9b7e9d2bf1c338860bfb905016218c740 foo_0.0.1_darwin_amd64
a54fc515e616cac7fcf11a49d5c5ec9ec315948a5935c1e11dd610b834b14dde foo_0.0.1_darwin_arm64
```
### 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
@@ -308,7 +230,7 @@ jobs:
push: true push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Attest - name: Attest
uses: actions/attest@v2 uses: actions/attest@v1
id: attest id: attest
with: with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -326,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
+1 -1
View File
@@ -13,7 +13,7 @@ describe('index', () => {
beforeEach(() => { beforeEach(() => {
getBooleanInputMock.mockImplementation(() => false) getBooleanInputMock.mockImplementation(() => false)
}) })
it('calls run when imported', () => { it('calls run when imported', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index') require('../src/index')
+49 -42
View File
@@ -43,11 +43,10 @@ const defaultInputs: main.RunInputs = {
subjectName: '', subjectName: '',
subjectDigest: '', subjectDigest: '',
subjectPath: '', subjectPath: '',
subjectChecksums: '',
pushToRegistry: false, pushToRegistry: false,
showSummary: true,
githubToken: '', githubToken: '',
privateSigning: false privateSigning: false,
batchSize: 50
} }
describe('action', () => { describe('action', () => {
@@ -139,9 +138,7 @@ describe('action', () => {
expect(runMock).toHaveReturned() expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error('One of subject-path or subject-digest must be provided')
'One of subject-path, subject-digest, or subject-checksums must be provided'
)
) )
}) })
}) })
@@ -200,17 +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(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
) )
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
}) })
@@ -296,27 +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(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
) )
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'
@@ -327,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 () => {
@@ -356,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)
@@ -364,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)
}) })
}) })
@@ -374,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
@@ -411,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.'
) )
) )
}) })
+7 -235
View File
@@ -2,24 +2,19 @@ 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 = {
subjectPath: '', subjectPath: '',
subjectName: '', subjectName: '',
subjectDigest: '', subjectDigest: ''
subjectChecksums: ''
} }
describe('when no inputs are provided', () => { describe('when no inputs are provided', () => {
it('throws an error', async () => { it('throws an error', async () => {
await expect(subjectFromInputs(blankInputs)).rejects.toThrow( await expect(subjectFromInputs(blankInputs)).rejects.toThrow(
/one of subject-path, subject-digest, or subject-checksums must be provided/i /one of subject-path or subject-digest must be provided/i
) )
}) })
}) })
@@ -29,42 +24,11 @@ describe('subjectFromInputs', () => {
const inputs: SubjectInputs = { const inputs: SubjectInputs = {
subjectName: 'foo', subjectName: 'foo',
subjectPath: 'path/to/subject', subjectPath: 'path/to/subject',
subjectDigest: 'digest', subjectDigest: 'digest'
subjectChecksums: ''
} }
await expect(subjectFromInputs(inputs)).rejects.toThrow( await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path, subject-digest, or subject-checksums may be provided/i /only one of subject-path or subject-digest may be provided/i
)
})
})
describe('when both subject path and subject checksums are provided', () => {
it('throws an error', async () => {
const inputs: SubjectInputs = {
subjectName: '',
subjectPath: 'path/to/subject',
subjectDigest: '',
subjectChecksums: 'path/to/checksums'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path, subject-digest, or subject-checksums may be provided/i
)
})
})
describe('when both subject digest and subject checksums are provided', () => {
it('throws an error', async () => {
const inputs: SubjectInputs = {
subjectName: 'foo',
subjectPath: '',
subjectDigest: 'digest',
subjectChecksums: 'path/to/checksums'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path, subject-digest, or subject-checksums may be provided/i
) )
}) })
}) })
@@ -99,7 +63,7 @@ describe('subjectFromInputs', () => {
}) })
}) })
describe('when the algorithm is not supported', () => { describe('when the alogrithm is not supported', () => {
it('throws an error', async () => { it('throws an error', async () => {
const inputs: SubjectInputs = { const inputs: SubjectInputs = {
...blankInputs, ...blankInputs,
@@ -264,6 +228,7 @@ describe('subjectFromInputs', () => {
expect(subjects).toBeDefined() expect(subjects).toBeDefined()
expect(subjects).toHaveLength(3) expect(subjects).toHaveLength(3)
/* eslint-disable-next-line github/array-foreach */
subjects.forEach((subject, i) => { subjects.forEach((subject, i) => {
expect(subject.name).toEqual(`${filename}-${i}`) expect(subject.name).toEqual(`${filename}-${i}`)
expect(subject.digest).toEqual({ sha256: expectedDigest }) expect(subject.digest).toEqual({ sha256: expectedDigest })
@@ -331,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 = {
@@ -393,175 +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('when specifying a subject checksums file', () => {
const checksums = `
187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d demo_0.0.1_linux_amd64
badline
5d8b4751ef31f9440d843fcfa4e53ca2e25b1cb1f13fd355fdc7c24b41fe645293291ea9297ba3989078abb77ebbaac66be073618a9e4974dbd0361881d4c718 demo_0.0.1_darwin_arm64`
let dir = ''
const filename = 'checksums'
beforeEach(async () => {
// Set-up temp directory
const tmpDir = await fs.realpath(os.tmpdir())
dir = await fs.mkdtemp(tmpDir + path.sep)
// Write file to temp directory
await fs.writeFile(path.join(dir, filename), checksums)
})
afterEach(async () => {
// Clean-up temp directory
await fs.rm(dir, { recursive: true })
})
describe('when the specified path is NOT a file', () => {
it('throws an error', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: dir
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject checksums file not found/i
)
})
})
describe('when the specific path is a file', () => {
it('returns the multiple subjects', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: path.join(dir, filename)
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_amd64',
digest: {
sha256:
'187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d'
}
})
expect(subjects).toContainEqual({
name: 'demo_0.0.1_darwin_arm64',
digest: {
sha512:
'5d8b4751ef31f9440d843fcfa4e53ca2e25b1cb1f13fd355fdc7c24b41fe645293291ea9297ba3989078abb77ebbaac66be073618a9e4974dbd0361881d4c718'
}
})
})
})
})
describe('when specifying a subject checksums string', () => {
const checksums = `
f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e demo_0.0.1_linux_386
187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d *demo_0.0.1_linux_amd64
9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5 demo_0.0.1_linux_arm64`
it('returns the multiple subjects', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: checksums
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(3)
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_386',
digest: {
sha256:
'f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e'
}
})
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_amd64',
digest: {
sha256:
'187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d'
}
})
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_arm64',
digest: {
sha256:
'9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5'
}
})
})
})
describe('when specifying a subject checksums string with an unrecognized digest', () => {
const checksums = `f861e demo_0.0.1_linux_386`
it('throws an error', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: checksums
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/unknown digest algorithm/i
)
})
})
describe('when specifying a subject checksums string with an invalid digest', () => {
const checksums =
'!!!!e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e demo_0.0.1_linux_386'
it('throws an error', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: checksums
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(/invalid digest/i)
})
})
})
describe('subjectDigest', () => {
it('returns the digest', () => {
const subject = {
name: 'foo',
digest: { sha1: 'deadbeef' }
}
const digest = formatSubjectDigest(subject)
expect(digest).toEqual('sha1:deadbeef')
}) })
}) })
+8 -24
View File
@@ -9,26 +9,20 @@ inputs:
subject-path: subject-path:
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", "subject-digest", or specify exactly one of "subject-path" or "subject-digest". May contain a
"subject-checksums". May contain a glob pattern or list of paths (total glob pattern or list of paths (total subject count cannot exceed 2500).
subject count cannot exceed 1024).
required: false required: false
subject-digest: subject-digest:
description: > description: >
Digest of the subject for the attestation. Must be in the form Digest of the subject for the attestation. Must be in the form
"algorithm:hex_digest" (e.g. "sha256:abc123..."). Must specify exactly one "algorithm:hex_digest" (e.g. "sha256:abc123..."). Must specify exactly one
of "subject-path", "subject-digest", or "subject-checksums". of "subject-path" or "subject-digest".
required: false required: false
subject-name: subject-name:
description: > description: >
Subject name as it should appear in the attestation. Required when Subject name as it should appear in the attestation. Required unless
identifying the subject with the "subject-digest" input. "subject-path" is specified, in which case it will be inferred from the
required: false path.
subject-checksums:
description: >
Path to checksums file containing digest and name of subjects for
attestation. Must specify exactly one of "subject-path", "subject-digest",
or "subject-checksums".
required: false required: false
predicate-type: predicate-type:
description: > description: >
@@ -53,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.
@@ -66,12 +54,8 @@ 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).'
attestation-id:
description: 'The ID of the attestation.'
attestation-url:
description: 'The URL for the attestation summary.'
runs: runs:
using: node24 using: node20
main: ./dist/index.js main: ./dist/index.js
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
+18539 -20938
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
-94
View File
@@ -1,94 +0,0 @@
import eslint from '@eslint/js'
import importplugin from 'eslint-plugin-import'
import jestplugin from 'eslint-plugin-jest'
import path from 'node:path'
import tseslint from 'typescript-eslint'
export default tseslint.config(
// Ignore non-project files
{
name: 'ignore',
ignores: ['.github', 'dist', 'coverage', '**/*.json', 'jest.setup.js', 'eslint.config.mjs']
},
// Use recommended rules from ESLint, TypeScript, and other plugins
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
jestplugin.configs['flat/recommended'],
importplugin.flatConfigs.recommended,
importplugin.flatConfigs.typescript,
// Override some rules
{
name: 'project-settings',
languageOptions: {
ecmaVersion: 2023,
parserOptions: {
project: [ './tsconfig.lint.json' ]
}
},
rules: {
// eslint rules
eqeqeq: ['error', 'smart'],
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': 'off',
'no-implicit-globals': 'error',
'no-inner-declarations': 'error',
'no-invalid-this': 'error',
'no-return-assign': 'error',
'no-sequences': 'error',
'no-shadow': 'error',
'no-useless-concat': 'error',
'object-shorthand': ['error', 'always', { avoidQuotes: true }],
'one-var': ['error', 'never'],
'prefer-template': 'error',
// typescript-eslint rules
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/explicit-function-return-type': [
'error',
{ allowExpressions: true }
],
'@typescript-eslint/explicit-member-accessibility': [
'error',
{ accessibility: 'no-public' }
],
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'warn',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/restrict-template-expressions': 'off',
// eslint-plugin-import rules
'import/extensions': 'error',
'import/first': 'error',
'import/no-absolute-path': 'error',
'import/no-commonjs': 'error',
'import/no-deprecated': 'warn',
'import/no-dynamic-require': 'error',
'import/no-extraneous-dependencies': 'error',
'import/no-mutable-exports': 'error',
'import/no-namespace': 'off',
'import/no-unresolved': ['error', { ignore: ['csv-parse/sync'] }],
'import/no-anonymous-default-export': [
'error',
{
allowAnonymousClass: false,
allowAnonymousFunction: false,
allowArray: true,
allowArrowFunction: false,
allowLiteral: true,
allowObject: true
}
]
}
}
)
+5780 -7642
View File
File diff suppressed because it is too large Load Diff
+28 -26
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": "3.0.0", "version": "1.3.0",
"author": "", "author": "",
"private": true, "private": true,
"homepage": "https://github.com/actions/attest", "homepage": "https://github.com/actions/attest",
@@ -20,15 +20,15 @@
".": "./dist/index.js" ".": "./dist/index.js"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=20"
}, },
"scripts": { "scripts": {
"bundle": "npm run format:write && npm run package", "bundle": "npm run format:write && npm run package",
"ci-test": "jest", "ci-test": "jest",
"format:write": "prettier --write **/*.ts", "format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts", "format:check": "prettier --check **/*.ts",
"lint:eslint": "npx eslint", "lint:eslint": "npx eslint . -c ./.github/linters/.eslintrc.yml",
"lint:markdown": "npx markdownlint --config .markdown-lint.yml \"*.md\"", "lint:markdown": "npx markdownlint --config .github/linters/.markdown-lint.yml \"*.md\"",
"lint": "npm run lint:eslint && npm run lint:markdown", "lint": "npm run lint:eslint && npm run lint:markdown",
"package": "ncc build src/index.ts --license licenses.txt", "package": "ncc build src/index.ts --license licenses.txt",
"package:watch": "npm run package -- --watch", "package:watch": "npm run package -- --watch",
@@ -69,31 +69,33 @@
] ]
}, },
"dependencies": { "dependencies": {
"@actions/attest": "^1.6.0", "@actions/attest": "^1.3.0",
"@actions/core": "^1.11.1", "@actions/core": "^1.10.1",
"@actions/github": "^6.0.1", "@actions/glob": "^0.4.0",
"@actions/glob": "^0.5.0", "@sigstore/oci": "^0.3.6",
"@sigstore/oci": "^0.6.0", "csv-parse": "^5.5.6"
"csv-parse": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.34.0", "@sigstore/mock": "^0.7.4",
"@sigstore/mock": "^0.11.0", "@types/jest": "^29.5.12",
"@types/jest": "^30.0.0",
"@types/make-fetch-happen": "^10.0.4", "@types/make-fetch-happen": "^10.0.4",
"@types/node": "^24.3.0", "@types/node": "^20.14.2",
"@vercel/ncc": "^0.38.3", "@typescript-eslint/eslint-plugin": "^7.13.0",
"eslint": "^9.34.0", "@typescript-eslint/parser": "^7.13.0",
"eslint-plugin-import": "^2.32.0", "@vercel/ncc": "^0.38.1",
"eslint-plugin-jest": "^29.0.1", "eslint": "^8.57.0",
"jest": "^30.0.5", "eslint-plugin-github": "^5.0.1",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"markdownlint-cli": "^0.45.0", "markdownlint-cli": "^0.41.0",
"nock": "^13.5.6", "nock": "^13.5.4",
"prettier": "^3.6.2", "prettier": "^3.3.1",
"ts-jest": "^29.4.1", "prettier-eslint": "^16.3.0",
"typescript": "^5.9.2", "ts-jest": "^29.1.4",
"typescript-eslint": "^8.41.0", "typescript": "^5.4.5",
"undici": "^5.29.0" "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]}`
}
+6 -4
View File
@@ -4,22 +4,24 @@
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'),
subjectDigest: core.getInput('subject-digest'), subjectDigest: core.getInput('subject-digest'),
subjectChecksums: core.getInput('subject-checksums'),
predicateType: core.getInput('predicate-type'), predicateType: core.getInput('predicate-type'),
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
run(inputs) run(inputs)
+69 -62
View File
@@ -7,23 +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
const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt'
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 */
@@ -51,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.'
@@ -66,45 +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'
})
const baseDir = process.env.RUNNER_TEMP for (const subject of subjectChunks[i]) {
/* istanbul ignore else */ // Delay between attestations (only when chunk size > 1)
if (baseDir) { if (i > 0) {
const outputSummaryPath = path.join(baseDir, ATTESTATION_PATHS_FILE_NAME) await new Promise(resolve => setTimeout(resolve, delayTime))
// Append the output path to the attestations paths file }
fs.appendFileSync(outputSummaryPath, outputPath + os.EOL, {
encoding: 'utf-8', const att = await createAttestation(subject, predicate, {
flag: 'a' sigstoreInstance,
}) pushToRegistry: inputs.pushToRegistry,
} else { githubToken: inputs.githubToken
core.warning( })
'RUNNER_TEMP environment variable is not set. Cannot write attestation paths file.' 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'
})
}
} }
/* istanbul ignore else */ logSummary(atts)
if (att.attestationID) {
core.setOutput('attestation-id', att.attestationID)
core.setOutput('attestation-url', attestationURL(att.attestationID))
}
/* istanbul ignore else */
if (inputs.showSummary) {
await logSummary(att)
}
} catch (err) { } catch (err) {
// Fail the workflow run if an error occurs // Fail the workflow run if an error occurs
core.setFailed( core.setFailed(
@@ -128,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'
@@ -159,7 +146,6 @@ const logAttestation = (
core.info(`${SEARCH_PUBLIC_GOOD_URL}?logIndex=${attestation.tlogID}`) core.info(`${SEARCH_PUBLIC_GOOD_URL}?logIndex=${attestation.tlogID}`)
} }
/* istanbul ignore else */
if (attestation.attestationID) { if (attestation.attestationID) {
core.info(style.highlight('Attestation uploaded to repository')) core.info(style.highlight('Attestation uploaded to repository'))
core.info(attestationURL(attestation.attestationID)) core.info(attestationURL(attestation.attestationID))
@@ -167,20 +153,28 @@ 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 = async (attestation: AttestResult): Promise<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
)
/* istanbul ignore else */ for (const { subjectName, subjectDigest, attestationID } of attestations) {
if (attestationID) { if (attestationID) {
const url = attestationURL(attestationID) core.summary.addLink(
core.summary.addHeading('Attestation Created', 3) `${subjectName}@${subjectDigest}`,
core.summary.addList([`<a href="${url}">${url}</a>`]) attestationURL(attestationID)
await core.summary.write() )
}
}
core.summary.write()
} }
} }
@@ -195,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}`
+24 -128
View File
@@ -1,23 +1,18 @@
import * as glob from '@actions/glob' import * as glob from '@actions/glob'
import assert from 'assert'
import crypto from 'crypto' import crypto from 'crypto'
import { parse } from 'csv-parse/sync' import { parse } from 'csv-parse/sync'
import fs from 'fs' import fs from 'fs'
import os from 'os'
import path from 'path' 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 MAX_SUBJECT_CHECKSUM_SIZE_BYTES = 512 * MAX_SUBJECT_COUNT
const DIGEST_ALGORITHM = 'sha256' const DIGEST_ALGORITHM = 'sha256'
const HEX_STRING_RE = /^[0-9a-fA-F]+$/
export type SubjectInputs = { export type SubjectInputs = {
subjectPath: string subjectPath: string
subjectName: string subjectName: string
subjectDigest: string subjectDigest: string
subjectChecksums: string
downcaseName?: boolean downcaseName?: boolean
} }
// 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
@@ -27,26 +22,15 @@ export type SubjectInputs = {
export const subjectFromInputs = async ( export const subjectFromInputs = async (
inputs: SubjectInputs inputs: SubjectInputs
): Promise<Subject[]> => { ): Promise<Subject[]> => {
const { const { subjectPath, subjectDigest, subjectName, downcaseName } = inputs
subjectPath,
subjectDigest,
subjectName,
subjectChecksums,
downcaseName
} = inputs
const enabledInputs = [subjectPath, subjectDigest, subjectChecksums].filter( if (!subjectPath && !subjectDigest) {
Boolean throw new Error('One of subject-path or subject-digest must be provided')
)
if (enabledInputs.length === 0) {
throw new Error(
'One of subject-path, subject-digest, or subject-checksums must be provided'
)
} }
if (enabledInputs.length > 1) { if (subjectPath && subjectDigest) {
throw new Error( throw new Error(
'Only one of subject-path, subject-digest, or subject-checksums may be provided' 'Only one of subject-path or subject-digest may be provided'
) )
} }
@@ -58,27 +42,13 @@ export const subjectFromInputs = async (
// to conform to OCI image naming conventions // to conform to OCI image naming conventions
const name = downcaseName ? subjectName.toLowerCase() : subjectName const name = downcaseName ? subjectName.toLowerCase() : subjectName
switch (true) { if (subjectPath) {
case !!subjectPath: return await getSubjectFromPath(subjectPath, name)
return getSubjectFromPath(subjectPath, name) } else {
case !!subjectDigest: return [getSubjectFromDigest(subjectDigest, name)]
return [getSubjectFromDigest(subjectDigest, name)]
case !!subjectChecksums:
return getSubjectFromChecksums(subjectChecksums)
/* istanbul ignore next */
default:
// This should be unreachable, but TS requires a default case
assert.fail('unreachable')
} }
} }
// 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 (
@@ -86,15 +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 = parseSubjectPathList(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
const paths = await glob.create(subjectPaths).then(async g => g.glob()) for (const subPath of subjectPaths) {
/* eslint-disable-next-line github/no-then */
// Filter path list to just the files (not directories) files.push(...(await glob.create(subPath).then(async g => g.glob())))
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(
@@ -103,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) {
@@ -142,68 +111,6 @@ const getSubjectFromDigest = (
} }
} }
const getSubjectFromChecksums = (subjectChecksums: string): Subject[] => {
if (fs.existsSync(subjectChecksums)) {
return getSubjectFromChecksumsFile(subjectChecksums)
} else {
return getSubjectFromChecksumsString(subjectChecksums)
}
}
const getSubjectFromChecksumsFile = (checksumsPath: string): Subject[] => {
const stats = fs.statSync(checksumsPath)
if (!stats.isFile()) {
throw new Error(`subject checksums file not found: ${checksumsPath}`)
}
/* istanbul ignore next */
if (stats.size > MAX_SUBJECT_CHECKSUM_SIZE_BYTES) {
throw new Error(
`subject checksums file exceeds maximum allowed size: ${MAX_SUBJECT_CHECKSUM_SIZE_BYTES} bytes`
)
}
const checksums = fs.readFileSync(checksumsPath, 'utf-8')
return getSubjectFromChecksumsString(checksums)
}
const getSubjectFromChecksumsString = (checksums: string): Subject[] => {
const subjects: Subject[] = []
const records: string[] = checksums.split(os.EOL).filter(Boolean)
for (const record of records) {
// Find the space delimiter following the digest
const delimIndex = record.indexOf(' ')
// Skip any line that doesn't have a delimiter
if (delimIndex === -1) {
continue
}
// It's common for checksum records to have a leading flag character before
// the artifact name. It will be either a '*' or a space.
const flag_and_name = record.slice(delimIndex + 1)
const name =
flag_and_name.startsWith('*') || flag_and_name.startsWith(' ')
? flag_and_name.slice(1)
: flag_and_name
const digest = record.slice(0, delimIndex)
if (!HEX_STRING_RE.test(digest)) {
throw new Error(`Invalid digest: ${digest}`)
}
subjects.push({
name,
digest: { [digestAlgorithm(digest)]: digest }
})
}
return subjects
}
// Calculates the digest of a file using the specified algorithm. The file is // Calculates the digest of a file using the specified algorithm. The file is
// streamed into the digest function to avoid loading the entire file into // streamed into the digest function to avoid loading the entire file into
// memory. The returned digest is a hex string. // memory. The returned digest is a hex string.
@@ -220,7 +127,7 @@ const digestFile = async (
}) })
} }
const parseSubjectPathList = (input: string): string[] => { const parseList = (input: string): string[] => {
const res: string[] = [] const res: string[] = []
const records: string[][] = parse(input, { const records: string[][] = parse(input, {
@@ -236,14 +143,3 @@ const parseSubjectPathList = (input: string): string[] => {
return res.filter(item => item).map(pat => pat.trim()) return res.filter(item => item).map(pat => pat.trim())
} }
const digestAlgorithm = (digest: string): string => {
switch (digest.length) {
case 64:
return 'sha256'
case 128:
return 'sha512'
default:
throw new Error(`Unknown digest algorithm: ${digest}`)
}
}
-1
View File
@@ -5,7 +5,6 @@
"module": "NodeNext", "module": "NodeNext",
"rootDir": "./src", "rootDir": "./src",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"isolatedModules": true,
"baseUrl": "./", "baseUrl": "./",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./__tests__/**/*", "./src/**/*"],
"exclude": ["./dist", "./node_modules", "./coverage", "*.json"]
}