Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd38b497a | ||
|
|
b14bf545fc | ||
|
|
a2d6fee37e | ||
|
|
9f6c991ce4 | ||
|
|
cb2b61665b | ||
|
|
01d09e4735 | ||
|
|
50f7ee9fc0 | ||
|
|
85e94cb741 | ||
|
|
b485edd412 | ||
|
|
dd499c2535 | ||
|
|
0b9e351b03 | ||
|
|
cd876d0598 | ||
|
|
03d1442c2b | ||
|
|
a1e57e5e7d | ||
|
|
97d213a059 | ||
|
|
62269dcd0a | ||
|
|
81a79f22f8 | ||
|
|
f83cd62ee9 | ||
|
|
ca4c0d7bd2 | ||
|
|
c15cb6aedc | ||
|
|
f7f9fcaabc | ||
|
|
95cefe0358 | ||
|
|
f04a32dbbd | ||
|
|
67422f5511 | ||
|
|
9a1607877e | ||
|
|
ac63f56931 | ||
|
|
34f130d3f6 | ||
|
|
87bfc7b513 | ||
|
|
3dc8e36755 | ||
|
|
9c1d4ce2f7 | ||
|
|
fa63d16379 | ||
|
|
2da0b13672 | ||
|
|
97f7cf8914 | ||
|
|
af3e2e79a8 | ||
|
|
f1338058bc | ||
|
|
8a5620929d | ||
|
|
d54d1f1179 | ||
|
|
7305951e90 | ||
|
|
eedca7cd2b | ||
|
|
68a047fd01 | ||
|
|
7fc0e943d8 | ||
|
|
be7daec55a | ||
|
|
da36b5f14b | ||
|
|
8afbcf6e5e | ||
|
|
0fdba851bc | ||
|
|
b24527d9cb |
3
.github/linters/.eslintrc.yml
vendored
3
.github/linters/.eslintrc.yml
vendored
@@ -41,8 +41,7 @@ rules:
|
||||
'eslint-comments/no-unused-disable': 'off',
|
||||
'i18n-text/no-en': 'off',
|
||||
'import/no-namespace': 'off',
|
||||
'import/no-unresolved':
|
||||
['error', { 'ignore': ['csv-parse/sync']}],
|
||||
'import/no-unresolved': ['error', { 'ignore': ['csv-parse/sync'] }],
|
||||
'no-console': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
3
.github/workflows/linter.yml
vendored
3
.github/workflows/linter.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Lint Codebase
|
||||
id: super-linter
|
||||
uses: super-linter/super-linter/slim@v6
|
||||
uses: super-linter/super-linter/slim@v7
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
FILTER_REGEX_EXCLUDE: dist/**/*
|
||||
@@ -46,4 +46,5 @@ jobs:
|
||||
TYPESCRIPT_DEFAULT_STYLE: prettier
|
||||
VALIDATE_ALL_CODEBASE: true
|
||||
VALIDATE_JAVASCRIPT_STANDARD: false
|
||||
VALIDATE_TYPESCRIPT_STANDARD: false
|
||||
VALIDATE_JSCPD: false
|
||||
|
||||
22
.github/workflows/publish-immutable-actions.yml
vendored
Normal file
22
.github/workflows/publish-immutable-actions.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: 'Publish Immutable Action Version'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checking out
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
id: publish
|
||||
uses: actions/publish-immutable-action@v0.0.4
|
||||
46
README.md
46
README.md
@@ -65,7 +65,7 @@ See [action.yml](action.yml)
|
||||
with:
|
||||
# Path to the artifact serving as the subject of the attestation. Must
|
||||
# specify exactly one of "subject-path" or "subject-digest". May contain
|
||||
# a glob pattern or list of paths (total subject count cannot exceed 2500).
|
||||
# a glob pattern or list of paths (total subject count cannot exceed 1024).
|
||||
subject-path:
|
||||
|
||||
# SHA256 digest of the subject for the attestation. Must be in the form
|
||||
@@ -96,6 +96,10 @@ See [action.yml](action.yml)
|
||||
# the "subject-digest" parameter be specified. Defaults to false.
|
||||
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
|
||||
# ${{ github.token }}
|
||||
github-token:
|
||||
@@ -105,26 +109,22 @@ See [action.yml](action.yml)
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
| Name | Description | Example |
|
||||
| ------------- | -------------------------------------------------------------- | ------------------------ |
|
||||
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` |
|
||||
| Name | Description | Example |
|
||||
| ------------- | -------------------------------------------------------------- | ----------------------- |
|
||||
| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` |
|
||||
|
||||
<!-- markdownlint-enable MD013 -->
|
||||
|
||||
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
|
||||
|
||||
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).
|
||||
If multiple subjects are being attested at the same time, a single attestation
|
||||
will be created with references to each of the supplied subjects.
|
||||
|
||||
## Attestation Limits
|
||||
|
||||
### Subject Limits
|
||||
|
||||
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.
|
||||
No more than 1024 subjects can be attested at the same time.
|
||||
|
||||
### Predicate Limits
|
||||
|
||||
@@ -164,10 +164,10 @@ jobs:
|
||||
predicate: '{}'
|
||||
```
|
||||
|
||||
### Identify Subjects by Wildcard
|
||||
### Identify Multiple Subjects
|
||||
|
||||
If you are generating multiple artifacts, you can generate an attestation for
|
||||
each by using a wildcard in the `subject-path` input.
|
||||
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.
|
||||
|
||||
```yaml
|
||||
- uses: actions/attest@v1
|
||||
@@ -180,6 +180,23 @@ each 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@v1
|
||||
with:
|
||||
subject-path: 'dist/foo, dist/bar'
|
||||
```
|
||||
|
||||
```yaml
|
||||
- uses: actions/attest@v1
|
||||
with:
|
||||
subject-path: |
|
||||
dist/foo
|
||||
dist/bar
|
||||
```
|
||||
|
||||
### Container Image
|
||||
|
||||
When working with container images you can invoke the action with the
|
||||
@@ -248,7 +265,6 @@ 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
|
||||
|
||||
@@ -44,9 +44,9 @@ const defaultInputs: main.RunInputs = {
|
||||
subjectDigest: '',
|
||||
subjectPath: '',
|
||||
pushToRegistry: false,
|
||||
showSummary: true,
|
||||
githubToken: '',
|
||||
privateSigning: false,
|
||||
batchSize: 50
|
||||
privateSigning: false
|
||||
}
|
||||
|
||||
describe('action', () => {
|
||||
@@ -197,7 +197,7 @@ describe('action', () => {
|
||||
expect(setOutputMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bundle-path',
|
||||
expect.stringMatching('attestation.jsonl')
|
||||
expect.stringMatching('attestation.json')
|
||||
)
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -283,21 +283,17 @@ describe('action', () => {
|
||||
expect(setOutputMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bundle-path',
|
||||
expect.stringMatching('attestation.jsonl')
|
||||
expect.stringMatching('attestation.json')
|
||||
)
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the subject count exceeds the batch size', () => {
|
||||
describe('when the subject count is greater than 1', () => {
|
||||
let dir = ''
|
||||
const filename = 'subject'
|
||||
let scope: nock.Scope
|
||||
|
||||
beforeEach(async () => {
|
||||
// Start from scratch
|
||||
nock.cleanAll()
|
||||
|
||||
const subjectCount = 5
|
||||
const content = 'file content'
|
||||
|
||||
@@ -308,38 +304,22 @@ 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 () => {
|
||||
@@ -353,8 +333,7 @@ describe('action', () => {
|
||||
subjectPath: path.join(dir, `${filename}-*`),
|
||||
predicateType,
|
||||
predicate,
|
||||
githubToken: 'gh-token',
|
||||
batchSize: 2
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
await main.run(inputs)
|
||||
|
||||
@@ -362,17 +341,8 @@ describe('action', () => {
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringMatching('Processing subject batch 1/3')
|
||||
expect.stringMatching('Attestation created for 5 subjects')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
10,
|
||||
expect.stringMatching('Processing subject batch 2/3')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
19,
|
||||
expect.stringMatching('Processing subject batch 3/3')
|
||||
)
|
||||
expect(scope.isDone()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -381,7 +351,7 @@ describe('action', () => {
|
||||
const filename = 'subject'
|
||||
|
||||
beforeEach(async () => {
|
||||
const subjectCount = 2501
|
||||
const subjectCount = 1025
|
||||
const content = 'file content'
|
||||
|
||||
// Set-up temp directory
|
||||
@@ -418,7 +388,7 @@ describe('action', () => {
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
new Error(
|
||||
'Too many subjects specified. The maximum number of subjects is 2500.'
|
||||
'Too many subjects specified. The maximum number of subjects is 1024.'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ import crypto from 'crypto'
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { subjectFromInputs, SubjectInputs } from '../src/subject'
|
||||
import {
|
||||
formatSubjectDigest,
|
||||
subjectFromInputs,
|
||||
SubjectInputs
|
||||
} from '../src/subject'
|
||||
|
||||
describe('subjectFromInputs', () => {
|
||||
const blankInputs: SubjectInputs = {
|
||||
@@ -296,6 +300,29 @@ 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 = {
|
||||
@@ -337,3 +364,15 @@ describe('subjectFromInputs', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('subjectDigest', () => {
|
||||
it('returns the digest', () => {
|
||||
const subject = {
|
||||
name: 'foo',
|
||||
digest: { sha1: 'deadbeef' }
|
||||
}
|
||||
|
||||
const digest = formatSubjectDigest(subject)
|
||||
expect(digest).toEqual('sha1:deadbeef')
|
||||
})
|
||||
})
|
||||
|
||||
10
action.yml
10
action.yml
@@ -10,7 +10,7 @@ inputs:
|
||||
description: >
|
||||
Path to the artifact serving as the subject of the attestation. Must
|
||||
specify exactly one of "subject-path" or "subject-digest". May contain a
|
||||
glob pattern or list of paths (total subject count cannot exceed 2500).
|
||||
glob pattern or list of paths (total subject count cannot exceed 1024).
|
||||
required: false
|
||||
subject-digest:
|
||||
description: >
|
||||
@@ -47,6 +47,12 @@ inputs:
|
||||
the "subject-digest" parameter be specified. Defaults to false.
|
||||
default: 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:
|
||||
description: >
|
||||
The GitHub token used to make authenticated API requests.
|
||||
@@ -54,7 +60,7 @@ inputs:
|
||||
required: false
|
||||
outputs:
|
||||
bundle-path:
|
||||
description: 'The path to the file containing the attestation bundle(s).'
|
||||
description: 'The path to the file containing the attestation bundle.'
|
||||
|
||||
runs:
|
||||
using: node20
|
||||
|
||||
287
dist/606.index.js
generated
vendored
Normal file
287
dist/606.index.js
generated
vendored
Normal file
@@ -0,0 +1,287 @@
|
||||
"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');
|
||||
|
||||
|
||||
/***/ })
|
||||
|
||||
};
|
||||
;
|
||||
28666
dist/index.js
generated
vendored
28666
dist/index.js
generated
vendored
File diff suppressed because one or more lines are too long
3283
dist/licenses.txt
generated
vendored
3283
dist/licenses.txt
generated
vendored
File diff suppressed because it is too large
Load Diff
3526
package-lock.json
generated
3526
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "actions/attest",
|
||||
"description": "Generate signed attestations for workflow artifacts",
|
||||
"version": "1.2.1",
|
||||
"version": "2.0.0",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/actions/attest",
|
||||
@@ -69,33 +69,33 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/attest": "^1.2.1",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/glob": "^0.4.0",
|
||||
"@sigstore/oci": "^0.3.6",
|
||||
"csv-parse": "^5.5.6"
|
||||
"@actions/attest": "^1.5.0",
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/glob": "^0.5.0",
|
||||
"@sigstore/oci": "^0.4.0",
|
||||
"csv-parse": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sigstore/mock": "^0.7.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@sigstore/mock": "^0.8.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/make-fetch-happen": "^10.0.4",
|
||||
"@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",
|
||||
"@types/node": "^22.9.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-github": "^5.1.2",
|
||||
"eslint-plugin-jest": "^28.9.0",
|
||||
"eslint-plugin-jsonc": "^2.18.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdownlint-cli": "^0.41.0",
|
||||
"nock": "^13.5.4",
|
||||
"prettier": "^3.3.1",
|
||||
"markdownlint-cli": "^0.43.0",
|
||||
"nock": "^13.5.6",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-eslint": "^16.3.0",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.4.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.2",
|
||||
"undici": "^5.28.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
|
||||
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
|
||||
import { formatSubjectDigest } from './subject'
|
||||
|
||||
const OCI_TIMEOUT = 2000
|
||||
const OCI_TIMEOUT = 30000
|
||||
const OCI_RETRY = 3
|
||||
|
||||
export type SigstoreInstance = 'public-good' | 'github'
|
||||
export type AttestResult = Attestation & {
|
||||
subjectName: string
|
||||
subjectDigest: string
|
||||
attestationDigest?: string
|
||||
}
|
||||
|
||||
export const createAttestation = async (
|
||||
subject: Subject,
|
||||
subjects: Subject[],
|
||||
predicate: Predicate,
|
||||
opts: {
|
||||
sigstoreInstance: SigstoreInstance
|
||||
@@ -22,27 +21,22 @@ export const createAttestation = async (
|
||||
): Promise<AttestResult> => {
|
||||
// Sign provenance w/ Sigstore
|
||||
const attestation = await attest({
|
||||
subjectName: subject.name,
|
||||
subjectDigest: subject.digest,
|
||||
subjects,
|
||||
predicateType: predicate.type,
|
||||
predicate: predicate.params,
|
||||
sigstore: opts.sigstoreInstance,
|
||||
token: opts.githubToken
|
||||
})
|
||||
|
||||
const subDigest = subjectDigest(subject)
|
||||
const result: AttestResult = {
|
||||
...attestation,
|
||||
subjectName: subject.name,
|
||||
subjectDigest: subDigest
|
||||
}
|
||||
const result: AttestResult = attestation
|
||||
|
||||
if (opts.pushToRegistry) {
|
||||
if (subjects.length === 1 && opts.pushToRegistry) {
|
||||
const subject = subjects[0]
|
||||
const credentials = getRegistryCredentials(subject.name)
|
||||
const artifact = await attachArtifactToImage({
|
||||
credentials,
|
||||
imageName: subject.name,
|
||||
imageDigest: subDigest,
|
||||
imageDigest: formatSubjectDigest(subject),
|
||||
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
||||
mediaType: attestation.bundle.mediaType,
|
||||
annotations: {
|
||||
@@ -58,10 +52,3 @@ export const createAttestation = async (
|
||||
|
||||
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]}`
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
import * as core from '@actions/core'
|
||||
import { run, RunInputs } from './main'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
const inputs: RunInputs = {
|
||||
subjectPath: core.getInput('subject-path'),
|
||||
subjectName: core.getInput('subject-name'),
|
||||
@@ -14,13 +12,12 @@ const inputs: RunInputs = {
|
||||
predicate: core.getInput('predicate'),
|
||||
predicatePath: core.getInput('predicate-path'),
|
||||
pushToRegistry: core.getBooleanInput('push-to-registry'),
|
||||
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
|
||||
|
||||
106
src/main.ts
106
src/main.ts
@@ -7,18 +7,22 @@ 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 {
|
||||
SubjectInputs,
|
||||
formatSubjectDigest,
|
||||
subjectFromInputs
|
||||
} from './subject'
|
||||
|
||||
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
|
||||
const DELAY_INTERVAL_MS = 75
|
||||
const DELAY_MAX_MS = 1200
|
||||
import type { Subject } from '@actions/attest'
|
||||
|
||||
const ATTESTATION_FILE_NAME = 'attestation.json'
|
||||
|
||||
export type RunInputs = SubjectInputs &
|
||||
PredicateInputs & {
|
||||
pushToRegistry: boolean
|
||||
githubToken: string
|
||||
showSummary: boolean
|
||||
privateSigning: boolean
|
||||
batchSize: number
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
@@ -46,7 +50,6 @@ 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.'
|
||||
@@ -62,41 +65,23 @@ export async function run(inputs: RunInputs): Promise<void> {
|
||||
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
|
||||
core.setOutput('bundle-path', outputPath)
|
||||
|
||||
const subjectChunks = chunkArray(subjects, inputs.batchSize)
|
||||
const att = await createAttestation(subjects, predicate, {
|
||||
sigstoreInstance,
|
||||
pushToRegistry: inputs.pushToRegistry,
|
||||
githubToken: inputs.githubToken
|
||||
})
|
||||
|
||||
// Generate attestations for each subject serially, working in batches
|
||||
for (let i = 0; i < subjectChunks.length; i++) {
|
||||
if (subjectChunks.length > 1) {
|
||||
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`)
|
||||
}
|
||||
logAttestation(subjects, att, sigstoreInstance)
|
||||
|
||||
// Calculate the delay time for this batch
|
||||
const delayTime = delay(i)
|
||||
// Write attestation bundle to output file
|
||||
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
|
||||
encoding: 'utf-8',
|
||||
flag: 'a'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
if (inputs.showSummary) {
|
||||
logSummary(att)
|
||||
}
|
||||
|
||||
logSummary(atts)
|
||||
} catch (err) {
|
||||
// Fail the workflow run if an error occurs
|
||||
core.setFailed(
|
||||
@@ -120,12 +105,17 @@ 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 => {
|
||||
core.info(
|
||||
`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`
|
||||
)
|
||||
if (subjects.length === 1) {
|
||||
core.info(
|
||||
`Attestation created for ${subjects[0].name}@${formatSubjectDigest(subjects[0])}`
|
||||
)
|
||||
} else {
|
||||
core.info(`Attestation created for ${subjects.length} subjects`)
|
||||
}
|
||||
|
||||
const instanceName =
|
||||
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
|
||||
@@ -153,27 +143,18 @@ const logAttestation = (
|
||||
|
||||
if (attestation.attestationDigest) {
|
||||
core.info(style.highlight('Attestation uploaded to registry'))
|
||||
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`)
|
||||
core.info(`${subjects[0].name}@${attestation.attestationDigest}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach summary information to the GitHub Actions run
|
||||
const logSummary = (attestations: AttestResult[]): void => {
|
||||
if (attestations.length > 0) {
|
||||
core.summary.addHeading(
|
||||
/* istanbul ignore next */
|
||||
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
|
||||
3
|
||||
)
|
||||
const logSummary = (attestation: AttestResult): void => {
|
||||
const { attestationID } = attestation
|
||||
|
||||
for (const { subjectName, subjectDigest, attestationID } of attestations) {
|
||||
if (attestationID) {
|
||||
core.summary.addLink(
|
||||
`${subjectName}@${subjectDigest}`,
|
||||
attestationURL(attestationID)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (attestationID) {
|
||||
const url = attestationURL(attestationID)
|
||||
core.summary.addHeading('Attestation Created', 3)
|
||||
core.summary.addList([`<a href="${url}">${url}</a>`])
|
||||
core.summary.write()
|
||||
}
|
||||
}
|
||||
@@ -189,18 +170,5 @@ 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}`
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from 'path'
|
||||
|
||||
import type { Subject } from '@actions/attest'
|
||||
|
||||
const MAX_SUBJECT_COUNT = 2500
|
||||
const MAX_SUBJECT_COUNT = 1024
|
||||
const DIGEST_ALGORITHM = 'sha256'
|
||||
|
||||
export type SubjectInputs = {
|
||||
@@ -49,6 +49,13 @@ export const subjectFromInputs = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the subject's digest as a formatted string of the form
|
||||
// "<algorithm>:<digest>".
|
||||
export const formatSubjectDigest = (subject: Subject): string => {
|
||||
const alg = Object.keys(subject.digest).sort()[0]
|
||||
return `${alg}:${subject.digest[alg]}`
|
||||
}
|
||||
|
||||
// Returns the subject specified by the path to a file. The file's digest is
|
||||
// calculated and returned along with the subject's name.
|
||||
const getSubjectFromPath = async (
|
||||
@@ -56,16 +63,16 @@ const getSubjectFromPath = async (
|
||||
subjectName?: string
|
||||
): Promise<Subject[]> => {
|
||||
const digestedSubjects: Subject[] = []
|
||||
const files: string[] = []
|
||||
|
||||
// Parse the list of subject paths
|
||||
const subjectPaths = parseList(subjectPath)
|
||||
const subjectPaths = parseList(subjectPath).join('\n')
|
||||
|
||||
// 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())))
|
||||
}
|
||||
// Expand the globbed paths to a list of actual paths
|
||||
/* eslint-disable-next-line github/no-then */
|
||||
const paths = await glob.create(subjectPaths).then(async g => g.glob())
|
||||
|
||||
// Filter path list to just the files (not directories)
|
||||
const files = paths.filter(p => fs.statSync(p).isFile())
|
||||
|
||||
if (files.length > MAX_SUBJECT_COUNT) {
|
||||
throw new Error(
|
||||
@@ -74,11 +81,6 @@ 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user