Compare commits

..

1 Commits

Author SHA1 Message Date
Brian DeHamer 6c3e7c389e testing
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2024-06-13 10:50:45 -07:00
32 changed files with 71555 additions and 100455 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"]
}
+3 -3
View File
@@ -28,11 +28,11 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v6.2.0
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
@@ -60,7 +60,7 @@ jobs:
- if: ${{ failure() && steps.diff.outcome == 'failure' }}
name: Upload Artifact
id: upload
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
+3 -3
View File
@@ -21,11 +21,11 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v5.0.1
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1
with:
node-version-file: .node-version
cache: npm
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v5.0.1
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Calculate subject digest
id: subject
env:
+4 -4
View File
@@ -32,19 +32,19 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4
- name: Initialize CodeQL
id: initialize
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
source-root: src
- name: Autobuild
id: autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
id: analyze
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
+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
+27 -118
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
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
CLI][5].
See [Using artifact attestations to establish provenance for builds][9] for more
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
Within the GitHub Actions workflow which builds some artifact you would like to
@@ -52,18 +35,16 @@ attest:
permissions:
id-token: write
attestations: write
artifact-metadata: write
```
The `id-token` permission gives the action the ability to mint the OIDC token
necessary to request a Sigstore signing certificate. The `attestations`
permission is necessary to persist the attestation. The `artifact-metadata`
permission is necessary to create the artifact storage record.
permission is necessary to persist the attestation.
1. Add the following to your workflow after your artifact has been built:
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: '<PATH TO ARTIFACT>'
predicate-type: '<PREDICATE URI>'
@@ -73,35 +54,30 @@ attest:
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
[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
See [action.yml](action.yml)
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
# Path to the artifact serving as the subject of the attestation. Must
# specify exactly one of "subject-path", "subject-digest", or
# "subject-checksums". May contain a glob pattern or list of paths
# (total subject count cannot exceed 1024).
# specify exactly one of "subject-path" or "subject-digest". May contain
# a glob pattern or list of paths (total subject count cannot exceed 2500).
subject-path:
# SHA256 digest of the subject for the attestation. Must be in the form
# "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 name as it should appear in the attestation. Required when
# identifying the subject with the "subject-digest" input.
# Subject name as it should appear in the attestation. Required unless
# "subject-path" is specified, in which case it will be inferred from the
# path.
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.
predicate-type:
@@ -120,16 +96,6 @@ See [action.yml](action.yml)
# the "subject-digest" parameter be specified. Defaults to false.
push-to-registry:
# Whether to create a storage record for the artifact.
# Requires that push-to-registry is set to true.
# Requires that the "subject-name" parameter specify the fully-qualified
# image name. Defaults to true.
create-storage-record:
# 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
# ${{ github.token }}
github-token:
@@ -139,25 +105,26 @@ See [action.yml](action.yml)
<!-- markdownlint-disable MD013 -->
| 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 |
| ------------- | -------------------------------------------------------------- | ------------------------ |
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` |
<!-- markdownlint-enable MD013 -->
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
If multiple subjects are being attested at the same time, a single attestation
will be created with references to each of the supplied subjects.
If multiple subjects are being attested at the same time, each attestation will
be written to the output file on a separate line (using the [JSON Lines][7]
format).
## Attestation 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
@@ -190,20 +157,20 @@ jobs:
- name: Build artifact
run: make my-app
- name: Attest
uses: actions/attest@v2
uses: actions/attest@v1
with:
subject-path: '${{ github.workspace }}/my-app'
predicate-type: 'https://example.com/predicate/v1'
predicate: '{}'
```
### Identify Multiple Subjects
### Identify Subjects by Wildcard
If you are generating multiple artifacts, you can attest all of them at the same
time by using a wildcard in the `subject-path` input.
If you are generating multiple artifacts, you can generate an attestation for
each by using a wildcard in the `subject-path` input.
```yaml
- uses: actions/attest@v2
- uses: actions/attest@v1
with:
subject-path: 'dist/**/my-bin-*'
predicate-type: 'https://example.com/predicate/v1'
@@ -213,60 +180,6 @@ time by using a wildcard in the `subject-path` input.
For supported wildcards along with behavior and documentation, see
[@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
When working with container images you can invoke the action with the
@@ -278,10 +191,6 @@ fully-qualified image name (e.g. "ghcr.io/user/app" or
"acme.azurecr.io/user/app"). Do NOT include a tag as part of the image name --
the specific image being attested is identified by the supplied digest.
If the `push-to-registry` option is set to true, the Action will also
emit an Artifact Metadata Storage Record. If you do not want to emit a
storage record, set `create-storage-record` to `false`.
> **NOTE**: When pushing to Docker Hub, please use "docker.io" as the registry
> portion of the image name.
@@ -300,7 +209,6 @@ jobs:
packages: write
contents: read
attestations: write
artifact-metadata: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -322,7 +230,7 @@ jobs:
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Attest
uses: actions/attest@v2
uses: actions/attest@v1
id: attest
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -340,6 +248,7 @@ jobs:
[5]: https://cli.github.com/manual/gh_attestation_verify
[6]:
https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto
[7]: https://jsonlines.org/
[8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
[9]:
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
+8 -14
View File
@@ -2,26 +2,20 @@
* Unit tests for the action's entrypoint, src/index.ts
*/
import { jest, describe, expect, beforeEach } from '@jest/globals'
import * as core from '@actions/core'
import * as main from '../src/main'
// Mock modules before importing them
const runMock = jest.fn<() => Promise<void>>()
jest.unstable_mockModule('../src/main', () => ({
run: runMock
}))
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(() => ''),
getBooleanInput: jest.fn(() => false)
}))
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
describe('index', () => {
beforeEach(() => {
jest.clearAllMocks()
getBooleanInputMock.mockImplementation(() => false)
})
it('calls run when imported', async () => {
await import('../src/index.js')
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
expect(runMock).toHaveBeenCalled()
})
+117 -292
View File
@@ -5,127 +5,55 @@
* Specifically, the inputs listed in `action.yml` should be set as environment
* variables following the pattern `INPUT_<INPUT_NAME>`.
*/
import {
jest,
describe,
expect,
beforeEach,
afterEach,
it
} from '@jest/globals'
import type { RunInputs } from '../src/main.js'
import * as core from '@actions/core'
import * as github from '@actions/github'
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
import * as oci from '@sigstore/oci'
import fs from 'fs/promises'
import nock from 'nock'
import os from 'os'
import path from 'path'
import { MockAgent, setGlobalDispatcher } from 'undici'
import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints'
import * as main from '../src/main'
// Create mock functions for core
const infoMock = jest.fn()
const warningMock = jest.fn()
const startGroupMock = jest.fn()
const endGroupMock = jest.fn()
const setOutputMock = jest.fn()
const setFailedMock = jest.fn()
const summaryWriteMock = jest.fn<() => Promise<void>>()
// Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info')
const startGroupMock = jest.spyOn(core, 'startGroup')
const setOutputMock = jest.spyOn(core, 'setOutput')
const setFailedMock = jest.spyOn(core, 'setFailed')
// Create a mock summary object
const mockSummary = {
addHeading: jest.fn().mockReturnThis(),
addRaw: jest.fn().mockReturnThis(),
addTable: jest.fn().mockReturnThis(),
addSeparator: jest.fn().mockReturnThis(),
addLink: jest.fn().mockReturnThis(),
addBreak: jest.fn().mockReturnThis(),
addList: jest.fn().mockReturnThis(),
write: summaryWriteMock.mockResolvedValue(undefined)
}
// Ensure that setFailed doesn't set an exit code during tests
setFailedMock.mockImplementation(() => {})
// Mock @actions/core before importing
jest.unstable_mockModule('@actions/core', () => ({
info: infoMock,
warning: warningMock,
startGroup: startGroupMock,
endGroup: endGroupMock,
setOutput: setOutputMock,
setFailed: setFailedMock,
summary: mockSummary
}))
const summaryWriteMock = jest.spyOn(core.summary, 'write')
summaryWriteMock.mockImplementation(async () => Promise.resolve(core.summary))
// Create mocks for OCI and attest modules
/* eslint-disable @typescript-eslint/no-explicit-any */
const getRegistryCredentialsMock = jest.fn<(...args: any[]) => any>()
const attachArtifactToImageMock = jest.fn<(...args: any[]) => any>()
const createStorageRecordMock = jest.fn<(...args: any[]) => any>()
const attestMock = jest.fn<(...args: any[]) => any>()
/* eslint-enable @typescript-eslint/no-explicit-any */
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: getRegistryCredentialsMock,
attachArtifactToImage: attachArtifactToImageMock
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: attestMock,
createStorageRecord: createStorageRecordMock
}))
// Create a mutable context object for @actions/github
const mockContext: Record<string, unknown> = {}
// Mock for getOctokit to return a mock octokit client
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockReposGet = jest.fn<(...args: any[]) => any>()
const mockOctokit = {
rest: {
repos: {
get: mockReposGet
}
}
}
jest.unstable_mockModule('@actions/github', () => ({
context: mockContext,
getOctokit: jest.fn(() => mockOctokit)
}))
// Helper to set the mocked GitHub context
function setGHContext(context: object): void {
Object.keys(mockContext).forEach(key => delete mockContext[key])
Object.assign(mockContext, context)
}
// Now import the modules after mocking
const { mockFulcio, mockRekor, mockTSA } = await import('@sigstore/mock')
const fs = (await import('fs/promises')).default
const nock = (await import('nock')).default
const os = (await import('os')).default
const path = (await import('path')).default
const { MockAgent, setGlobalDispatcher } = await import('undici')
const { SEARCH_PUBLIC_GOOD_URL } = await import('../src/endpoints.js')
const { run } = (await import('../src/main.js')) as {
run: (inputs: RunInputs) => Promise<void>
}
// Mock the action's main function
const runMock = jest.spyOn(main, 'run')
// MockAgent for mocking @actions/github
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const defaultInputs: RunInputs = {
const defaultInputs: main.RunInputs = {
predicate: '',
predicateType: '',
predicatePath: '',
subjectName: '',
subjectDigest: '',
subjectPath: '',
subjectChecksums: '',
pushToRegistry: false,
createStorageRecord: true,
showSummary: true,
githubToken: '',
privateSigning: false
privateSigning: false,
batchSize: 50
}
describe('action', () => {
// Capture original environment variables so we can restore them after each test
// Capture original environment variables and GitHub context so we can restore
// them after each test
const originalEnv = process.env
const originalContext = { ...github.context }
// Mock OIDC token endpoint
const tokenURL = 'https://token.url'
@@ -137,66 +65,30 @@ describe('action', () => {
'base64'
)}.}`
const subjectName = 'ghcr.io/registry/foo/bar'
const subjectName = 'registry/foo/bar'
const subjectDigest =
'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
const predicate = '{}'
const predicateType = 'https://in-toto.io/attestation/release/v0.1'
const attestationID = '1234567890'
const storageRecordID = 987654321
beforeEach(() => {
jest.clearAllMocks()
// Set up default GitHub context with empty payload
setGHContext({
payload: {},
repo: { owner: 'test-owner', repo: 'test-repo' }
})
// Set up default return value for attestMock (without tlogID for private/GitHub sigstore)
attestMock.mockResolvedValue({
attestationID,
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
verificationMaterial: {
certificate: {
rawBytes: Buffer.from(
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
).toString('base64')
},
tlogEntries: []
},
content: {}
},
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
})
// Set up default return value for createStorageRecordMock (returns array of record IDs)
createStorageRecordMock.mockResolvedValue([storageRecordID])
nock(tokenURL)
.get('/')
.query({ audience: 'sigstore' })
.reply(200, { value: oidcToken })
const pool = mockAgent.get('https://api.github.com')
pool
mockAgent
.get('https://api.github.com')
.intercept({
path: /^\/repos\/.*\/.*\/attestations$/,
method: 'post'
})
.reply(201, { id: attestationID })
pool
.intercept({
path: /^\/orgs\/.*\/artifacts\/metadata\/storage-record$/,
method: 'post'
})
.reply(200, { storage_records: [{ id: storageRecordID }] })
process.env = {
...originalEnv,
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
@@ -209,12 +101,12 @@ describe('action', () => {
// Restore the original environment
process.env = originalEnv
// Clear the github context
setGHContext({ payload: {}, repo: { owner: '', repo: '' } })
// Restore the original github.context
setGHContext(originalContext)
})
describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => {
const inputs: RunInputs = {
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -229,8 +121,9 @@ describe('action', () => {
})
it('sets a failed status', async () => {
await run(inputs)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -241,18 +134,17 @@ describe('action', () => {
describe('when no inputs are provided', () => {
it('sets a failed status', async () => {
await run(defaultInputs)
await main.run(defaultInputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'One of subject-path, subject-digest, or subject-checksums must be provided'
)
new Error('One of subject-path or subject-digest must be provided')
)
})
})
describe('when the repository is private', () => {
const inputs: RunInputs = {
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -276,9 +168,10 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await run(inputs)
await main.run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalledWith()
expect(infoMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching(
@@ -304,24 +197,17 @@ describe('action', () => {
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
expect.stringMatching('attestation.jsonl')
)
expect(setFailedMock).not.toHaveBeenCalled()
})
})
describe('when the repository is public', () => {
const inputs: RunInputs = {
const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials')
const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage')
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -344,51 +230,26 @@ describe('action', () => {
})
await mockRekor({ baseURL: 'https://rekor.sigstore.dev' })
getRegistryCredentialsMock.mockImplementation(() => ({
getRegCredsSpy.mockImplementation(() => ({
username: 'username',
password: 'password'
}))
attachArtifactToImageMock.mockResolvedValue({
digest: 'sha256:123456',
mediaType: 'application/vnd.cncf.notary.v2',
size: 123456
})
// Set up attestMock with tlogID for public good sigstore
attestMock.mockResolvedValue({
attestationID,
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
verificationMaterial: {
certificate: {
rawBytes: Buffer.from(
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
).toString('base64')
},
tlogEntries: [{ logIndex: '123' }]
},
content: {}
},
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: '123'
})
// Mock the repos.get API call for repoOwnerIsOrg check
mockReposGet.mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
attachArtifactSpy.mockImplementation(async () =>
Promise.resolve({
digest: 'sha256:123456',
mediaType: 'application/vnd.cncf.notary.v2',
size: 123456
})
)
})
it('invokes the action w/o error', async () => {
await run(inputs)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegistryCredentialsMock).toHaveBeenCalledWith(subjectName)
expect(attachArtifactToImageMock).toHaveBeenCalled()
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled()
expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName)
expect(attachArtifactSpy).toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching(
@@ -419,94 +280,24 @@ describe('action', () => {
6,
expect.stringMatching(attestationID)
)
expect(infoMock).toHaveBeenNthCalledWith(
9,
expect.stringMatching('Storage record created')
)
expect(infoMock).toHaveBeenNthCalledWith(
10,
expect.stringMatching('Storage record IDs: 987654321')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
4,
'storage-record-ids',
expect.stringMatching(storageRecordID.toString())
)
expect(setFailedMock).not.toHaveBeenCalled()
})
it('catches error when storage record creation fails and continues', async () => {
// Mock the createStorageRecord function and throw an error
createStorageRecordMock.mockRejectedValueOnce(
new Error('Failed to persist storage record: Not Found')
)
await run(inputs)
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).toHaveBeenCalled()
expect(setFailedMock).not.toHaveBeenCalled()
expect(warningMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Failed to create storage record')
)
})
it('does not create a storage record when the repo is owned by a user', async () => {
// Mock the repos.get API to return a user-owned repo
mockReposGet.mockResolvedValueOnce({ data: { owner: { type: 'User' } } })
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegistryCredentialsMock).toHaveBeenCalledWith(subjectName)
expect(attachArtifactToImageMock).toHaveBeenCalled()
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).not.toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
expect(infoMock).not.toHaveBeenCalledWith(
expect.stringMatching('Storage record created')
)
expect(infoMock).not.toHaveBeenCalledWith(
expect.stringMatching('Storage record IDs: 987654321')
)
expect(setOutputMock).toHaveBeenCalledWith(
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).not.toHaveBeenCalledWith(
'storage-record-ids',
expect.stringMatching(storageRecordID.toString())
expect.stringMatching('attestation.jsonl')
)
expect(setFailedMock).not.toHaveBeenCalled()
})
})
describe('when the subject count is greater than 1', () => {
describe('when the subject count exceeds the batch size', () => {
let dir = ''
const filename = 'subject'
let scope: nock.Scope
beforeEach(async () => {
// Start from scratch
nock.cleanAll()
const subjectCount = 5
const content = 'file content'
@@ -517,22 +308,38 @@ describe('action', () => {
// Add files for glob testing
for (let i = 0; i < subjectCount; i++) {
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.
setGHContext({
payload: { repository: { visibility: 'private' } },
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 () => {
@@ -541,20 +348,31 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
const inputs: RunInputs = {
const inputs: main.RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
githubToken: 'gh-token',
batchSize: 2
}
await run(inputs)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(
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)
})
})
@@ -563,7 +381,7 @@ describe('action', () => {
const filename = 'subject'
beforeEach(async () => {
const subjectCount = 1025
const subjectCount = 2501
const content = 'file content'
// Set-up temp directory
@@ -588,20 +406,27 @@ describe('action', () => {
})
it('sets a failed status', async () => {
const inputs: RunInputs = {
const inputs: main.RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
}
await run(inputs)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'Too many subjects specified. The maximum number of subjects is 1024.'
'Too many subjects specified. The maximum number of subjects is 2500.'
)
)
})
})
})
// Stubbing the GitHub context is a bit tricky. We need to use
// `Object.defineProperty` because `github.context` is read-only.
function setGHContext(context: object): void {
Object.defineProperty(github, 'context', { value: context })
}
+1 -1
View File
@@ -1,7 +1,7 @@
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { predicateFromInputs, PredicateInputs } from '../src/predicate.js'
import { predicateFromInputs, PredicateInputs } from '../src/predicate'
describe('subjectFromInputs', () => {
const blankInputs: PredicateInputs = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { highlight, mute } from '../src/style.js'
import { highlight, mute } from '../src/style'
describe('style', () => {
describe('highlight', () => {
+11 -244
View File
@@ -2,24 +2,19 @@ import crypto from 'crypto'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import {
formatSubjectDigest,
subjectFromInputs,
SubjectInputs
} from '../src/subject.js'
import { subjectFromInputs, SubjectInputs } from '../src/subject'
describe('subjectFromInputs', () => {
const blankInputs: SubjectInputs = {
subjectPath: '',
subjectName: '',
subjectDigest: '',
subjectChecksums: ''
subjectDigest: ''
}
describe('when no inputs are provided', () => {
it('throws an error', async () => {
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 = {
subjectName: 'foo',
subjectPath: 'path/to/subject',
subjectDigest: 'digest',
subjectChecksums: ''
subjectDigest: 'digest'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path, subject-digest, or subject-checksums 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
/only one of subject-path or subject-digest 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 () => {
const inputs: SubjectInputs = {
...blankInputs,
@@ -264,15 +228,11 @@ describe('subjectFromInputs', () => {
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(3)
subjects.forEach(
(
subject: { name: string; digest: Record<string, string> },
i: number
) => {
expect(subject.name).toEqual(`${filename}-${i}`)
expect(subject.digest).toEqual({ sha256: expectedDigest })
}
)
/* eslint-disable-next-line github/array-foreach */
subjects.forEach((subject, i) => {
expect(subject.name).toEqual(`${filename}-${i}`)
expect(subject.digest).toEqual({ sha256: expectedDigest })
})
})
})
@@ -336,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', () => {
it('returns the multiple subjects', async () => {
const inputs: SubjectInputs = {
@@ -398,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 -32
View File
@@ -9,26 +9,20 @@ inputs:
subject-path:
description: >
Path to the artifact serving as the subject of the attestation. Must
specify exactly one of "subject-path", "subject-digest", or
"subject-checksums". May contain a glob pattern or list of paths (total
subject count cannot exceed 1024).
specify exactly one of "subject-path" or "subject-digest". May contain a
glob pattern or list of paths (total subject count cannot exceed 2500).
required: false
subject-digest:
description: >
Digest of the subject for the attestation. Must be in the form
"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
subject-name:
description: >
Subject name as it should appear in the attestation. Required when
identifying the subject with the "subject-digest" input.
required: false
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".
Subject name as it should appear in the attestation. Required unless
"subject-path" is specified, in which case it will be inferred from the
path.
required: false
predicate-type:
description: >
@@ -53,18 +47,6 @@ inputs:
the "subject-digest" parameter be specified. Defaults to false.
default: false
required: false
create-storage-record:
description: >
Whether to create a storage record for the artifact.
Requires that push-to-registry is set to true. Defaults to true.
default: true
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:
description: >
The GitHub token used to make authenticated API requests.
@@ -72,14 +54,8 @@ inputs:
required: false
outputs:
bundle-path:
description: 'The path to the file containing the attestation bundle.'
attestation-id:
description: 'The ID of the attestation.'
attestation-url:
description: 'The URL for the attestation summary.'
storage-record-ids:
description: 'The IDs of the storage records created for the artifact.'
description: 'The path to the file containing the attestation bundle(s).'
runs:
using: node24
using: node20
main: ./dist/index.js
Generated Vendored
-299
View File
@@ -1,299 +0,0 @@
export const id = 606;
export const ids = [606];
export const 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 signalListener = () => {
reject(signal.reason);
};
const cleanup = () => {
signal?.removeEventListener('abort', signalListener);
};
const resolve = value => {
resolve_(value);
cleanup();
};
const reject = reason => {
isRejected = true;
isResolved = true;
reject_(reason);
cleanup();
};
if (signal) {
if (signal.aborted) {
reject(signal.reason);
}
signal.addEventListener('abort', signalListener, {once: true});
}
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 pendingPromisesCount = 0;
let isDone = false;
let index = 0;
function trySpawn() {
if (isDone || !(pendingPromisesCount < concurrency && promises.length < backpressure)) {
return;
}
pendingPromisesCount++;
const promise = (async () => {
const {done, value} = await iterator.next();
if (done) {
pendingPromisesCount--;
return {done: true};
}
// Spawn if still below concurrency and backpressure limit
trySpawn();
try {
const returnValue = await mapper(await value, index++);
pendingPromisesCount--;
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) {
pendingPromisesCount--;
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
+58424 -93673
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
Generated Vendored
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}
-101
View File
@@ -1,101 +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
}
]
},
settings: {
'import/resolver': {
typescript: {
project: './tsconfig.lint.json'
}
}
}
}
)
-37
View File
@@ -1,37 +0,0 @@
export default {
preset: "ts-jest",
verbose: true,
clearMocks: true,
testEnvironment: 'node',
moduleFileExtensions: ['js', 'ts'],
testMatch: ['**/*.test.ts'],
testPathIgnorePatterns: [
"/node_modules/",
"/dist/"
],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
diagnostics: {
ignoreCodes: [151002]
}
}
]
},
coverageReporters: [
"json-summary",
"text",
"lcov"
],
collectCoverage: true,
collectCoverageFrom: [
"./src/**"
],
extensionsToTreatAsEsm: ['.ts'],
transformIgnorePatterns: ['node_modules/(?!(@actions)/)'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
}
+1
View File
@@ -0,0 +1 @@
process.stdout.write = jest.fn()
+9318 -5286
View File
File diff suppressed because it is too large Load Diff
+63 -32
View File
@@ -1,8 +1,7 @@
{
"name": "actions/attest",
"description": "Generate signed attestations for workflow artifacts",
"version": "3.2.0",
"type": "module",
"version": "1.3.0",
"author": "",
"private": true,
"homepage": "https://github.com/actions/attest",
@@ -21,50 +20,82 @@
".": "./dist/index.js"
},
"engines": {
"node": ">=24"
"node": ">=20"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
"ci-test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"ci-test": "jest",
"format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts",
"lint:eslint": "npx eslint",
"lint:markdown": "npx markdownlint --config .markdown-lint.yml \"*.md\"",
"lint:eslint": "npx eslint . -c ./.github/linters/.eslintrc.yml",
"lint:markdown": "npx markdownlint --config .github/linters/.markdown-lint.yml \"*.md\"",
"lint": "npm run lint:eslint && npm run lint:markdown",
"package": "ncc build src/index.ts --license licenses.txt",
"package:watch": "npm run package -- --watch",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test": "jest",
"all": "npm run format:write && npm run lint && npm run test && npm run package"
},
"license": "MIT",
"jest": {
"preset": "ts-jest",
"setupFilesAfterEnv": [
"./jest.setup.js"
],
"verbose": true,
"clearMocks": true,
"testEnvironment": "node",
"moduleFileExtensions": [
"js",
"ts"
],
"testMatch": [
"**/*.test.ts"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"transform": {
"^.+\\.ts$": "ts-jest"
},
"coverageReporters": [
"json-summary",
"text",
"lcov"
],
"collectCoverage": true,
"collectCoverageFrom": [
"./src/**"
]
},
"dependencies": {
"@actions/attest": "^2.2.1",
"@actions/core": "^2.0.2",
"@actions/github": "^7.0.0",
"@actions/glob": "^0.5.0",
"@sigstore/oci": "^0.6.0",
"csv-parse": "^5.6.0"
"@actions/attest": "^1.3.0",
"@actions/core": "^1.10.1",
"@actions/glob": "^0.4.0",
"@sigstore/oci": "^0.3.6",
"csv-parse": "^5.5.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@jest/globals": "^30.2.0",
"@sigstore/mock": "^0.11.0",
"@types/jest": "^30.0.0",
"@sigstore/mock": "^0.7.4",
"@types/jest": "^29.5.12",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^25.2.0",
"@vercel/ncc": "^0.38.4",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.12.1",
"jest": "^30.2.0",
"js-yaml": "^4.1.1",
"markdownlint-cli": "^0.47.0",
"nock": "^13.5.6",
"prettier": "^3.8.1",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"undici": "^7.20.0"
"@types/node": "^20.14.2",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@vercel/ncc": "^0.38.1",
"eslint": "^8.57.0",
"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",
"markdownlint-cli": "^0.41.0",
"nock": "^13.5.4",
"prettier": "^3.3.1",
"prettier-eslint": "^16.3.0",
"ts-jest": "^29.1.4",
"typescript": "^5.4.5",
"undici": "^5.28.4"
}
}
+20 -89
View File
@@ -1,53 +1,48 @@
import {
Attestation,
Predicate,
Subject,
attest,
createStorageRecord
} from '@actions/attest'
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject.js'
import * as core from '@actions/core'
import * as github from '@actions/github'
const OCI_TIMEOUT = 30000
const OCI_TIMEOUT = 2000
const OCI_RETRY = 3
export type SigstoreInstance = 'public-good' | 'github'
export type AttestResult = Attestation & {
subjectName: string
subjectDigest: string
attestationDigest?: string
storageRecordIds?: number[]
}
export const createAttestation = async (
subjects: Subject[],
subject: Subject,
predicate: Predicate,
opts: {
sigstoreInstance: SigstoreInstance
pushToRegistry: boolean
createStorageRecord: boolean
githubToken: string
}
): Promise<AttestResult> => {
// Sign provenance w/ Sigstore
const attestation = await attest({
subjects,
subjectName: subject.name,
subjectDigest: subject.digest,
predicateType: predicate.type,
predicate: predicate.params,
sigstore: opts.sigstoreInstance,
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) {
const subject = subjects[0]
if (opts.pushToRegistry) {
const credentials = getRegistryCredentials(subject.name)
const subjectDigest = formatSubjectDigest(subject)
const artifact = await attachArtifactToImage({
credentials,
imageName: subject.name,
imageDigest: subjectDigest,
imageDigest: subDigest,
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: attestation.bundle.mediaType,
annotations: {
@@ -59,78 +54,14 @@ export const createAttestation = async (
// Add the attestation's digest to the result
result.attestationDigest = artifact.digest
// Because creating a storage record requires the 'artifact-metadata:write'
// permission, we wrap this in a try/catch to avoid failing the entire
// attestation process if the token does not have the correct permissions.
if (opts.createStorageRecord) {
try {
const token = opts.githubToken
const isOrg = await repoOwnerIsOrg(token)
if (!isOrg) {
// The Artifact Metadata Storage Record API is only available to
// organizations. So if the repo owner is not an organization,
// storage record creation should not be attempted.
return result
}
const registryUrl = getRegistryURL(subject.name)
const artifactOpts = {
name: subject.name,
digest: subjectDigest
}
const packageRegistryOpts = {
registryUrl
}
const records = await createStorageRecord(
artifactOpts,
packageRegistryOpts,
token
)
if (!records || records.length === 0) {
core.warning('No storage records were created.')
}
result.storageRecordIds = records
} catch (error) {
core.warning(`Failed to create storage record: ${error}`)
core.warning(
'Please check that the "artifact-metadata:write" permission has been included'
)
}
}
}
return result
}
// Call the GET /repos/{owner}/{repo} endpoint to determine if the repo
// owner is an organization. This is used to determine if storage
// record creation should be attempted.
export const repoOwnerIsOrg = async (githubToken: string): Promise<boolean> => {
const octokit = github.getOctokit(githubToken)
const { data: repo } = await octokit.rest.repos.get({
owner: github.context.repo.owner,
repo: github.context.repo.repo
})
return repo.owner?.type === 'Organization'
}
function getRegistryURL(subjectName: string): string {
let url: URL
try {
url = new URL(subjectName)
} catch {
url = new URL(`https://${subjectName}`)
}
if (url.protocol !== 'https:') {
throw new Error(
`Unsupported protocol ${url.protocol} in subject name ${subjectName}`
)
}
return url.origin
// 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]}`
}
+7 -6
View File
@@ -2,25 +2,26 @@
* The entrypoint for the action.
*/
import * as core from '@actions/core'
import { run, RunInputs } from './main.js'
import { run, RunInputs } from './main'
const DEFAULT_BATCH_SIZE = 50
const inputs: RunInputs = {
subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'),
subjectChecksums: core.getInput('subject-checksums'),
predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'),
pushToRegistry: core.getBooleanInput('push-to-registry'),
createStorageRecord: core.getBooleanInput('create-storage-record'),
showSummary: core.getBooleanInput('show-summary'),
githubToken: core.getInput('github-token'),
// undocumented -- not part of public interface
privateSigning: ['true', 'True', 'TRUE', '1'].includes(
core.getInput('private-signing')
)
),
// internal only
batchSize: DEFAULT_BATCH_SIZE
}
/* eslint-disable-next-line @typescript-eslint/no-floating-promises */
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run(inputs)
+73 -76
View File
@@ -3,28 +3,22 @@ import * as github from '@actions/github'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { AttestResult, SigstoreInstance, createAttestation } from './attest.js'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints.js'
import { PredicateInputs, predicateFromInputs } from './predicate.js'
import * as style from './style.js'
import {
SubjectInputs,
formatSubjectDigest,
subjectFromInputs
} from './subject.js'
import { AttestResult, SigstoreInstance, createAttestation } from './attest'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate'
import * as style from './style'
import { SubjectInputs, subjectFromInputs } from './subject'
import type { Subject } from '@actions/attest'
const ATTESTATION_FILE_NAME = 'attestation.json'
const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt'
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const DELAY_INTERVAL_MS = 75
const DELAY_MAX_MS = 1200
export type RunInputs = SubjectInputs &
PredicateInputs & {
pushToRegistry: boolean
createStorageRecord: boolean
githubToken: string
showSummary: boolean
privateSigning: boolean
batchSize: number
}
/* istanbul ignore next */
@@ -52,6 +46,7 @@ export async function run(inputs: RunInputs): Promise<void> {
: 'github'
try {
const atts: AttestResult[] = []
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -67,49 +62,41 @@ export async function run(inputs: RunInputs): Promise<void> {
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath)
const att = await createAttestation(subjects, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
createStorageRecord: inputs.createStorageRecord,
githubToken: inputs.githubToken
})
const subjectChunks = chunkArray(subjects, inputs.batchSize)
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
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
// Calculate the delay time for this batch
const delayTime = delay(i)
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
fs.appendFileSync(outputSummaryPath, outputPath + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
} else {
core.warning(
'RUNNER_TEMP environment variable is not set. Cannot write attestation paths file.'
)
for (const subject of subjectChunks[i]) {
// Delay between attestations (only when chunk size > 1)
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, delayTime))
}
const att = await createAttestation(subject, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
atts.push(att)
logAttestation(att, sigstoreInstance)
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
}
}
/* istanbul ignore else */
if (att.attestationID) {
core.setOutput('attestation-id', att.attestationID)
core.setOutput('attestation-url', attestationURL(att.attestationID))
}
if (att.storageRecordIds) {
core.setOutput('storage-record-ids', att.storageRecordIds.join(','))
}
/* istanbul ignore else */
if (inputs.showSummary) {
await logSummary(att)
}
logSummary(atts)
} catch (err) {
// Fail the workflow run if an error occurs
core.setFailed(
@@ -133,17 +120,12 @@ export async function run(inputs: RunInputs): Promise<void> {
// Log details about the attestation to the GitHub Actions run
const logAttestation = (
subjects: Subject[],
attestation: AttestResult,
sigstoreInstance: SigstoreInstance
): void => {
if (subjects.length === 1) {
core.info(
`Attestation created for ${subjects[0].name}@${formatSubjectDigest(subjects[0])}`
)
} else {
core.info(`Attestation created for ${subjects.length} subjects`)
}
core.info(
`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`
)
const instanceName =
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
@@ -164,7 +146,6 @@ const logAttestation = (
core.info(`${SEARCH_PUBLIC_GOOD_URL}?logIndex=${attestation.tlogID}`)
}
/* istanbul ignore else */
if (attestation.attestationID) {
core.info(style.highlight('Attestation uploaded to repository'))
core.info(attestationURL(attestation.attestationID))
@@ -172,25 +153,28 @@ const logAttestation = (
if (attestation.attestationDigest) {
core.info(style.highlight('Attestation uploaded to registry'))
core.info(`${subjects[0].name}@${attestation.attestationDigest}`)
}
if (attestation.storageRecordIds && attestation.storageRecordIds.length > 0) {
core.info(style.highlight('Storage record created'))
core.info(`Storage record IDs: ${attestation.storageRecordIds.join(',')}`)
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`)
}
}
// Attach summary information to the GitHub Actions run
const logSummary = async (attestation: AttestResult): Promise<void> => {
const { attestationID } = attestation
const logSummary = (attestations: AttestResult[]): void => {
if (attestations.length > 0) {
core.summary.addHeading(
/* istanbul ignore next */
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
3
)
/* istanbul ignore else */
if (attestationID) {
const url = attestationURL(attestationID)
core.summary.addHeading('Attestation Created', 3)
core.summary.addList([`<a href="${url}">${url}</a>`])
await core.summary.write()
for (const { subjectName, subjectDigest, attestationID } of attestations) {
if (attestationID) {
core.summary.addLink(
`${subjectName}@${subjectDigest}`,
attestationURL(attestationID)
)
}
}
core.summary.write()
}
}
@@ -205,5 +189,18 @@ const tempDir = (): string => {
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 =>
`${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 assert from 'assert'
import crypto from 'crypto'
import { parse } from 'csv-parse/sync'
import fs from 'fs'
import os from 'os'
import path from 'path'
import type { Subject } from '@actions/attest'
const MAX_SUBJECT_COUNT = 1024
const MAX_SUBJECT_CHECKSUM_SIZE_BYTES = 512 * MAX_SUBJECT_COUNT
const MAX_SUBJECT_COUNT = 2500
const DIGEST_ALGORITHM = 'sha256'
const HEX_STRING_RE = /^[0-9a-fA-F]+$/
export type SubjectInputs = {
subjectPath: string
subjectName: string
subjectDigest: string
subjectChecksums: string
downcaseName?: boolean
}
// Returns the subject specified by the action's inputs. The subject may be
@@ -27,26 +22,15 @@ export type SubjectInputs = {
export const subjectFromInputs = async (
inputs: SubjectInputs
): Promise<Subject[]> => {
const {
subjectPath,
subjectDigest,
subjectName,
subjectChecksums,
downcaseName
} = inputs
const { subjectPath, subjectDigest, subjectName, downcaseName } = inputs
const enabledInputs = [subjectPath, subjectDigest, subjectChecksums].filter(
Boolean
)
if (enabledInputs.length === 0) {
throw new Error(
'One of subject-path, subject-digest, or subject-checksums must be provided'
)
if (!subjectPath && !subjectDigest) {
throw new Error('One of subject-path or subject-digest must be provided')
}
if (enabledInputs.length > 1) {
if (subjectPath && subjectDigest) {
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
const name = downcaseName ? subjectName.toLowerCase() : subjectName
switch (true) {
case !!subjectPath:
return getSubjectFromPath(subjectPath, name)
case !!subjectDigest:
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')
if (subjectPath) {
return await getSubjectFromPath(subjectPath, name)
} else {
return [getSubjectFromDigest(subjectDigest, name)]
}
}
// 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
// calculated and returned along with the subject's name.
const getSubjectFromPath = async (
@@ -86,15 +56,16 @@ const getSubjectFromPath = async (
subjectName?: string
): Promise<Subject[]> => {
const digestedSubjects: Subject[] = []
const files: string[] = []
// 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
const paths = await glob.create(subjectPaths).then(async g => g.glob())
// Filter path list to just the files (not directories)
const files = paths.filter(p => fs.statSync(p).isFile())
// Expand the globbed paths to a list of files
for (const subPath of subjectPaths) {
/* eslint-disable-next-line github/no-then */
files.push(...(await glob.create(subPath).then(async g => g.glob())))
}
if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(
@@ -103,17 +74,15 @@ const getSubjectFromPath = async (
}
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 digest = await digestFile(DIGEST_ALGORITHM, file)
// Only add the subject if it is not already in the list
if (
!digestedSubjects.some(
s => s.name === name && s.digest[DIGEST_ALGORITHM] === digest
)
) {
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
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
// streamed into the digest function to avoid loading the entire file into
// 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 records: string[][] = parse(input, {
@@ -236,14 +143,3 @@ const parseSubjectPathList = (input: string): string[] => {
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 -2
View File
@@ -5,7 +5,6 @@
"module": "NodeNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"isolatedModules": true,
"baseUrl": "./",
"sourceMap": true,
"outDir": "./dist",
@@ -16,5 +15,5 @@
"skipLibCheck": true,
"newLine": "lf"
},
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage", "./jest.config.ts"]
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"]
}
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["./__tests__/**/*", "./src/**/*", "./jest.config.ts"],
"exclude": ["./dist", "./node_modules", "./coverage", "*.json"]
}