centralize collection of action inputs (#72)

Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
Brian DeHamer
2024-05-24 11:01:44 -07:00
committed by GitHub
parent 074a7714de
commit 3ff4eb4c69
9 changed files with 381 additions and 316 deletions

View File

@@ -2,12 +2,17 @@
* Unit tests for the action's entrypoint, src/index.ts
*/
import * as core from '@actions/core'
import * as main from '../src/main'
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
describe('index', () => {
beforeEach(() => {
getBooleanInputMock.mockImplementation(() => false)
})
it('calls run when imported', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')

View File

@@ -20,8 +20,6 @@ import * as main from '../src/main'
// Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info')
const startGroupMock = jest.spyOn(core, 'startGroup')
const getInputMock = jest.spyOn(core, 'getInput')
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
const setOutputMock = jest.spyOn(core, 'setOutput')
const setFailedMock = jest.spyOn(core, 'setFailed')
@@ -38,6 +36,20 @@ const runMock = jest.spyOn(main, 'run')
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const defaultInputs: main.RunInputs = {
predicate: '',
predicateType: '',
predicatePath: '',
subjectName: '',
subjectDigest: '',
subjectPath: '',
pushToRegistry: false,
githubToken: '',
privateSigning: false,
batchSize: 50,
batchDelay: 5000
}
describe('action', () => {
// Capture original environment variables and GitHub context so we can restore
// them after each test
@@ -95,24 +107,22 @@ describe('action', () => {
})
describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => {
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token'
}
beforeEach(() => {
// Nullify the OIDC token URL
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ''
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
})
it('sets a failed status', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
@@ -124,12 +134,8 @@ describe('action', () => {
})
describe('when no inputs are provided', () => {
beforeEach(() => {
getInputMock.mockImplementation(() => '')
})
it('sets a failed status', async () => {
await main.run()
await main.run(defaultInputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
@@ -139,12 +145,13 @@ describe('action', () => {
})
describe('when the repository is private', () => {
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token'
}
beforeEach(async () => {
@@ -154,9 +161,6 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
await mockFulcio({
baseURL: 'https://fulcio.githubapp.com',
strict: false
@@ -165,7 +169,7 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalledWith()
@@ -204,12 +208,14 @@ describe('action', () => {
const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials')
const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage')
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token',
pushToRegistry: true
}
beforeEach(async () => {
@@ -219,11 +225,6 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
// Mock the action's inputs
getInputMock.mockImplementation(mockInput(inputs))
// This is where we mock the push-to-registry input
getBooleanInputMock.mockImplementation(() => true)
await mockFulcio({
baseURL: 'https://fulcio.sigstore.dev',
strict: false
@@ -244,7 +245,7 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
@@ -291,6 +292,7 @@ describe('action', () => {
describe('when the subject count exceeds the batch size', () => {
let dir = ''
const filename = 'subject'
let scope: nock.Scope
beforeEach(async () => {
@@ -298,7 +300,6 @@ describe('action', () => {
nock.cleanAll()
const subjectCount = 5
const filename = 'subject'
const content = 'file content'
// Set-up temp directory
@@ -340,17 +341,6 @@ describe('action', () => {
payload: { repository: { visibility: 'private' } },
repo: { owner: 'foo', repo: 'bar' }
})
const inputs = {
'subject-path': path.join(dir, `${filename}-*`),
'predicate-type': predicateType,
predicate,
'github-token': 'gh-token',
'batch-size': '2',
'batch-delay': '500'
}
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
})
afterEach(async () => {
@@ -359,7 +349,16 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
const inputs: main.RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token',
batchSize: 2,
batchDelay: 500
}
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
@@ -380,15 +379,6 @@ describe('action', () => {
})
})
function mockInput(inputs: Record<string, string>): typeof core.getInput {
return (name: string): string => {
if (name in inputs) {
return inputs[name]
}
return ''
}
}
// 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 {

View File

@@ -1,91 +1,97 @@
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { predicateFromInputs } from '../src/predicate'
import { predicateFromInputs, PredicateInputs } from '../src/predicate'
describe('subjectFromInputs', () => {
afterEach(() => {
process.env['INPUT_PREDICATE'] = ''
process.env['INPUT_PREDICATE-PATH'] = ''
process.env['INPUT_PREDICATE-TYPE'] = ''
})
const blankInputs: PredicateInputs = {
predicateType: '',
predicate: '',
predicatePath: ''
}
describe('when no inputs are provided', () => {
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(/predicate-type/i)
expect(() => predicateFromInputs(blankInputs)).toThrow(/predicate-type/i)
})
})
describe('when neither predicate path nor predicate are provided', () => {
beforeEach(() => {
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(
const inputs: PredicateInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
/one of predicate-path or predicate must be provided/i
)
})
})
describe('when both predicate path and predicate are provided', () => {
beforeEach(() => {
process.env['INPUT_PREDICATE-PATH'] = 'path/to/predicate'
process.env['INPUT_PREDICATE'] = '{}'
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(
const inputs: PredicateInputs = {
predicateType: 'https://example.com/predicate',
predicate: '{}',
predicatePath: 'path/to/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
/only one of predicate-path or predicate may be provided/i
)
})
})
describe('when specifying a predicate path', () => {
let dir = ''
const filename = 'subject'
const predicateType = 'https://example.com/predicate'
const content = '{}'
let predicatePath = ''
beforeEach(async () => {
// Set-up temp directory
const tmpDir = await fs.realpath(os.tmpdir())
dir = await fs.mkdtemp(tmpDir + path.sep)
const dir = await fs.mkdtemp(tmpDir + path.sep)
const filename = 'subject'
predicatePath = path.join(dir, filename)
// Write file to temp directory
await fs.writeFile(path.join(dir, filename), content)
await fs.writeFile(predicatePath, content)
})
afterEach(async () => {
// Clean-up temp directory
await fs.rm(dir, { recursive: true })
})
beforeEach(() => {
process.env['INPUT_PREDICATE-PATH'] = path.join(dir, filename)
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
await fs.rm(path.parse(predicatePath).dir, { recursive: true })
})
it('returns the predicate', () => {
expect(predicateFromInputs()).toEqual({
type: 'https://example.com/predicate',
params: {}
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicatePath
}
expect(predicateFromInputs(inputs)).toEqual({
type: predicateType,
params: JSON.parse(content)
})
})
})
describe('when specifying a predicate value', () => {
const predicateType = 'https://example.com/predicate'
const content = '{}'
beforeEach(() => {
process.env['INPUT_PREDICATE'] = content
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('returns the predicate', () => {
expect(predicateFromInputs()).toEqual({
type: 'https://example.com/predicate',
params: {}
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicate: content
}
expect(predicateFromInputs(inputs)).toEqual({
type: predicateType,
params: JSON.parse(content)
})
})
})

View File

@@ -2,47 +2,45 @@ import crypto from 'crypto'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { subjectFromInputs } from '../src/subject'
import { subjectFromInputs, SubjectInputs } from '../src/subject'
describe('subjectFromInputs', () => {
beforeEach(() => {
process.env['INPUT_PUSH-TO-REGISTRY'] = 'false'
})
afterEach(() => {
process.env['INPUT_SUBJECT-PATH'] = ''
process.env['INPUT_SUBJECT-DIGEST'] = ''
process.env['INPUT_SUBJECT-NAME'] = ''
})
const blankInputs: SubjectInputs = {
subjectPath: '',
subjectName: '',
subjectDigest: ''
}
describe('when no inputs are provided', () => {
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
await expect(subjectFromInputs(blankInputs)).rejects.toThrow(
/one of subject-path or subject-digest must be provided/i
)
})
})
describe('when both subject path and subject digest are provided', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = 'path/to/subject'
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
subjectName: 'foo',
subjectPath: 'path/to/subject',
subjectDigest: 'digest'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path or subject-digest may be provided/i
)
})
})
describe('when subject digest is provided but not the name', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'digest'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-name must be provided when using subject-digest/i
)
})
@@ -52,39 +50,42 @@ describe('subjectFromInputs', () => {
const name = 'Subject'
describe('when the digest is malformed', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'digest',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
})
describe('when the alogrithm is not supported', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'md5:deadbeef'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'md5:deadbeef',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
})
describe('when the sha256 digest is malformed', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'sha256:deadbeef'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'sha256:deadbeef',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
@@ -95,13 +96,14 @@ describe('subjectFromInputs', () => {
const digest =
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = `${alg}:${digest}`
process.env['INPUT_SUBJECT-NAME'] = name
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: `${alg}:${digest}`,
subjectName: name
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -110,20 +112,21 @@ describe('subjectFromInputs', () => {
})
})
describe('when the push-to-registry is true', () => {
describe('when the downcaseName is true', () => {
const imageName = 'ghcr.io/FOO/bar'
const alg = 'sha256'
const digest =
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = `${alg}:${digest}`
process.env['INPUT_SUBJECT-NAME'] = imageName
process.env['INPUT_PUSH-TO-REGISTRY'] = 'true'
})
it('returns the subject (with name downcased)', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: `${alg}:${digest}`,
subjectName: imageName,
downcaseName: true
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -135,12 +138,13 @@ describe('subjectFromInputs', () => {
describe('when specifying a subject path', () => {
describe('when the file does NOT exist', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = '/f/a/k/e'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: '/f/a/k/e'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/could not find subject at path/i
)
})
@@ -178,12 +182,13 @@ describe('subjectFromInputs', () => {
})
describe('when no name is provided', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename)
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, filename)
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -195,13 +200,14 @@ describe('subjectFromInputs', () => {
describe('when a name is provided', () => {
const name = 'mysubject'
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename)
process.env['INPUT_SUBJECT-NAME'] = name
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, filename),
subjectName: name
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -211,12 +217,13 @@ describe('subjectFromInputs', () => {
})
describe('when a file glob is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, 'subject-*')
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, 'subject-*')
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(3)
@@ -230,12 +237,13 @@ describe('subjectFromInputs', () => {
})
describe('when a file glob is supplied which also matches non-files', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] = `${dir}*`
})
it('returns the subjects (excluding non-files)', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${dir}*`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(7)
@@ -243,13 +251,13 @@ describe('subjectFromInputs', () => {
})
describe('when a comma-separated list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-1')},${path.join(dir, 'subject-2')}`
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-1')},${path.join(dir, 'subject-2')}`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
@@ -266,13 +274,13 @@ describe('subjectFromInputs', () => {
})
describe('when a multi-line list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-0')}\n${path.join(dir, 'subject-2')}`
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-0')}\n${path.join(dir, 'subject-2')}`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
@@ -289,13 +297,13 @@ describe('subjectFromInputs', () => {
})
describe('when a multi-line glob list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-*')}\n ${path.join(dir, 'other-*')} `
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-*')}\n ${path.join(dir, 'other-*')} `
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(6)

168
dist/index.js generated vendored
View File

@@ -79908,6 +79908,63 @@ exports.SEARCH_PUBLIC_GOOD_URL = void 0;
exports.SEARCH_PUBLIC_GOOD_URL = 'https://search.sigstore.dev';
/***/ }),
/***/ 6144:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
/**
* The entrypoint for the action.
*/
const core = __importStar(__nccwpck_require__(42186));
const main_1 = __nccwpck_require__(70399);
const DEFAULT_BATCH_SIZE = 50;
const DEFAULT_BATCH_DELAY = 5000;
const inputs = {
subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'),
predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'),
pushToRegistry: core.getBooleanInput('push-to-registry'),
githubToken: core.getInput('github-token'),
// undocumented -- not part of public interface
privateSigning: core.getBooleanInput('private-signing'),
// internal only
batchSize: DEFAULT_BATCH_SIZE,
batchDelay: DEFAULT_BATCH_DELAY
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(0, main_1.run)(inputs);
/***/ }),
/***/ 70399:
@@ -79957,8 +80014,6 @@ const COLOR_CYAN = '\x1B[36m';
const COLOR_GRAY = '\x1B[38;5;244m';
const COLOR_DEFAULT = '\x1B[39m';
const ATTESTATION_FILE_NAME = 'attestation.jsonl';
const DEFAULT_BATCH_SIZE = 50;
const DEFAULT_BATCH_DELAY = 5000;
const OCI_TIMEOUT = 2000;
const OCI_RETRY = 3;
/* istanbul ignore next */
@@ -79972,13 +80027,13 @@ const logHandler = (level, ...args) => {
* The main function for the action.
* @returns {Promise<void>} Resolves when the action is complete.
*/
async function run() {
async function run(inputs) {
process.on('log', logHandler);
// Provenance visibility will be public ONLY if we can confirm that the
// repository is public AND the undocumented "private-signing" arg is NOT set.
// Otherwise, it will be private.
const sigstoreInstance = github.context.payload.repository?.visibility === 'public' &&
core.getInput('private-signing') !== 'true'
!inputs.privateSigning
? 'public-good'
: 'github';
try {
@@ -79986,25 +80041,29 @@ async function run() {
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
}
const subjects = await (0, subject_1.subjectFromInputs)();
const predicate = (0, predicate_1.predicateFromInputs)();
const subjects = await (0, subject_1.subjectFromInputs)({
...inputs,
downcaseName: inputs.pushToRegistry
});
const predicate = (0, predicate_1.predicateFromInputs)(inputs);
const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME);
// Batch size and delay for rate limiting
const batchSize = parseInt(core.getInput('batch-size')) || DEFAULT_BATCH_SIZE;
const batchDelay = parseInt(core.getInput('batch-delay')) || DEFAULT_BATCH_DELAY;
const subjectChunks = chunkArray(subjects, batchSize);
const subjectChunks = chunkArray(subjects, inputs.batchSize);
let chunkCount = 0;
// Generate attestations for each subject serially, working in batches
for (const subjectChunk of subjectChunks) {
// Delay between batches (only when chunkCount > 0)
if (chunkCount++) {
await new Promise(resolve => setTimeout(resolve, batchDelay));
await new Promise(resolve => setTimeout(resolve, inputs.batchDelay));
}
if (subjectChunks.length > 1) {
core.info(`Processing subject batch ${chunkCount}/${subjectChunks.length}`);
}
for (const subject of subjectChunk) {
const att = await createAttestation(subject, predicate, sigstoreInstance);
const att = await createAttestation(subject, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
});
// Write attestation bundle to output file
fs_1.default.writeFileSync(outputPath, JSON.stringify(att.bundle) + os_1.default.EOL, {
encoding: 'utf-8',
@@ -80041,18 +80100,18 @@ async function run() {
}
}
exports.run = run;
const createAttestation = async (subject, predicate, sigstoreInstance) => {
const createAttestation = async (subject, predicate, opts) => {
// Sign provenance w/ Sigstore
const attestation = await (0, attest_1.attest)({
subjectName: subject.name,
subjectDigest: subject.digest,
predicateType: predicate.type,
predicate: predicate.params,
sigstore: sigstoreInstance,
token: core.getInput('github-token')
sigstore: opts.sigstoreInstance,
token: opts.githubToken
});
core.info(`Attestation created for ${subject.name}@${subjectDigest(subject)}`);
const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub';
const instanceName = opts.sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub';
core.startGroup(highlight(`Attestation signed using certificate from ${instanceName} Sigstore instance`));
core.info(attestation.certificate);
core.endGroup();
@@ -80064,7 +80123,7 @@ const createAttestation = async (subject, predicate, sigstoreInstance) => {
core.info(highlight('Attestation uploaded to repository'));
core.info(attestationURL(attestation.attestationID));
}
if (core.getBooleanInput('push-to-registry', { required: false })) {
if (opts.pushToRegistry) {
const credentials = (0, oci_1.getRegistryCredentials)(subject.name);
const artifact = await (0, oci_1.attachArtifactToImage)({
credentials,
@@ -80117,51 +80176,28 @@ const attestationURL = (id) => `${github.context.serverUrl}/${github.context.rep
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.predicateFromInputs = void 0;
const core = __importStar(__nccwpck_require__(42186));
const fs_1 = __importDefault(__nccwpck_require__(57147));
// Returns the predicate specified by the action's inputs. The predicate value
// may be specified as a path to a file or as a string.
const predicateFromInputs = () => {
const predicateType = core.getInput('predicate-type', { required: true });
const predicateStr = core.getInput('predicate', { required: false });
const predicatePath = core.getInput('predicate-path', { required: false });
if (!predicatePath && !predicateStr) {
const predicateFromInputs = (inputs) => {
const { predicateType, predicate, predicatePath } = inputs;
if (!predicateType) {
throw new Error('predicate-type must be provided');
}
if (!predicatePath && !predicate) {
throw new Error('One of predicate-path or predicate must be provided');
}
if (predicatePath && predicateStr) {
if (predicatePath && predicate) {
throw new Error('Only one of predicate-path or predicate may be provided');
}
const params = predicatePath
? fs_1.default.readFileSync(predicatePath, 'utf-8')
: predicateStr;
: predicate;
return { type: predicateType, params: JSON.parse(params) };
};
exports.predicateFromInputs = predicateFromInputs;
@@ -80202,7 +80238,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.subjectFromInputs = void 0;
const core = __importStar(__nccwpck_require__(42186));
const glob = __importStar(__nccwpck_require__(28090));
const crypto_1 = __importDefault(__nccwpck_require__(6113));
const sync_1 = __nccwpck_require__(74393);
@@ -80213,13 +80248,8 @@ const DIGEST_ALGORITHM = 'sha256';
// specified as a path to a file or as a digest. If a path is provided, the
// file's digest is calculated and returned along with the subject's name. If a
// digest is provided, the name must also be provided.
const subjectFromInputs = async () => {
const subjectPath = core.getInput('subject-path', { required: false });
const subjectDigest = core.getInput('subject-digest', { required: false });
const subjectName = core.getInput('subject-name', { required: false });
const pushToRegistry = core.getBooleanInput('push-to-registry', {
required: false
});
const subjectFromInputs = async (inputs) => {
const { subjectPath, subjectDigest, subjectName, downcaseName } = inputs;
if (!subjectPath && !subjectDigest) {
throw new Error('One of subject-path or subject-digest must be provided');
}
@@ -80231,7 +80261,7 @@ const subjectFromInputs = async () => {
}
// If push-to-registry is enabled, ensure the subject name is lowercase
// to conform to OCI image naming conventions
const name = pushToRegistry ? subjectName.toLowerCase() : subjectName;
const name = downcaseName ? subjectName.toLowerCase() : subjectName;
if (subjectPath) {
return await getSubjectFromPath(subjectPath, name);
}
@@ -94365,22 +94395,12 @@ module.exports = {"i8":"3.0.4"};
/******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/";
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be in strict mode.
(() => {
"use strict";
var exports = __webpack_exports__;
Object.defineProperty(exports, "__esModule", ({ value: true }));
/**
* The entrypoint for the action.
*/
const main_1 = __nccwpck_require__(70399);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(0, main_1.run)();
})();
module.exports = __webpack_exports__;
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module is referenced by other modules so it can't be inlined
/******/ var __webpack_exports__ = __nccwpck_require__(6144);
/******/ module.exports = __webpack_exports__;
/******/
/******/ })()
;

View File

@@ -1,7 +1,27 @@
/**
* The entrypoint for the action.
*/
import { run } from './main'
import * as core from '@actions/core'
import { run, RunInputs } from './main'
const DEFAULT_BATCH_SIZE = 50
const DEFAULT_BATCH_DELAY = 5000
const inputs: RunInputs = {
subjectPath: core.getInput('subject-path'),
subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'),
predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'),
pushToRegistry: core.getBooleanInput('push-to-registry'),
githubToken: core.getInput('github-token'),
// undocumented -- not part of public interface
privateSigning: core.getBooleanInput('private-signing'),
// internal only
batchSize: DEFAULT_BATCH_SIZE,
batchDelay: DEFAULT_BATCH_DELAY
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run()
run(inputs)

View File

@@ -6,8 +6,8 @@ import fs from 'fs'
import os from 'os'
import path from 'path'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { predicateFromInputs } from './predicate'
import { subjectFromInputs } from './subject'
import { PredicateInputs, predicateFromInputs } from './predicate'
import { SubjectInputs, subjectFromInputs } from './subject'
type SigstoreInstance = 'public-good' | 'github'
type AttestedSubject = { subject: Subject; attestationID: string }
@@ -17,12 +17,19 @@ const COLOR_GRAY = '\x1B[38;5;244m'
const COLOR_DEFAULT = '\x1B[39m'
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const DEFAULT_BATCH_SIZE = 50
const DEFAULT_BATCH_DELAY = 5000
const OCI_TIMEOUT = 2000
const OCI_RETRY = 3
export type RunInputs = SubjectInputs &
PredicateInputs & {
pushToRegistry: boolean
githubToken: string
// undocumented
privateSigning: boolean
batchSize: number
batchDelay: number
}
/* istanbul ignore next */
const logHandler = (level: string, ...args: unknown[]): void => {
// Send any HTTP-related log events to the GitHub Actions debug log
@@ -35,7 +42,7 @@ const logHandler = (level: string, ...args: unknown[]): void => {
* The main function for the action.
* @returns {Promise<void>} Resolves when the action is complete.
*/
export async function run(): Promise<void> {
export async function run(inputs: RunInputs): Promise<void> {
process.on('log', logHandler)
// Provenance visibility will be public ONLY if we can confirm that the
@@ -43,7 +50,7 @@ export async function run(): Promise<void> {
// Otherwise, it will be private.
const sigstoreInstance: SigstoreInstance =
github.context.payload.repository?.visibility === 'public' &&
core.getInput('private-signing') !== 'true'
!inputs.privateSigning
? 'public-good'
: 'github'
@@ -55,24 +62,21 @@ export async function run(): Promise<void> {
)
}
const subjects = await subjectFromInputs()
const predicate = predicateFromInputs()
const subjects = await subjectFromInputs({
...inputs,
downcaseName: inputs.pushToRegistry
})
const predicate = predicateFromInputs(inputs)
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
// Batch size and delay for rate limiting
const batchSize =
parseInt(core.getInput('batch-size')) || DEFAULT_BATCH_SIZE
const batchDelay =
parseInt(core.getInput('batch-delay')) || DEFAULT_BATCH_DELAY
const subjectChunks = chunkArray(subjects, batchSize)
const subjectChunks = chunkArray(subjects, inputs.batchSize)
let chunkCount = 0
// Generate attestations for each subject serially, working in batches
for (const subjectChunk of subjectChunks) {
// Delay between batches (only when chunkCount > 0)
if (chunkCount++) {
await new Promise(resolve => setTimeout(resolve, batchDelay))
await new Promise(resolve => setTimeout(resolve, inputs.batchDelay))
}
if (subjectChunks.length > 1) {
@@ -82,11 +86,11 @@ export async function run(): Promise<void> {
}
for (const subject of subjectChunk) {
const att = await createAttestation(
subject,
predicate,
sigstoreInstance
)
const att = await createAttestation(subject, predicate, {
sigstoreInstance,
pushToRegistry: inputs.pushToRegistry,
githubToken: inputs.githubToken
})
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
@@ -139,7 +143,11 @@ export async function run(): Promise<void> {
const createAttestation = async (
subject: Subject,
predicate: Predicate,
sigstoreInstance: SigstoreInstance
opts: {
sigstoreInstance: SigstoreInstance
pushToRegistry: boolean
githubToken: string
}
): Promise<Attestation> => {
// Sign provenance w/ Sigstore
const attestation = await attest({
@@ -147,14 +155,14 @@ const createAttestation = async (
subjectDigest: subject.digest,
predicateType: predicate.type,
predicate: predicate.params,
sigstore: sigstoreInstance,
token: core.getInput('github-token')
sigstore: opts.sigstoreInstance,
token: opts.githubToken
})
core.info(`Attestation created for ${subject.name}@${subjectDigest(subject)}`)
const instanceName =
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
opts.sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
core.startGroup(
highlight(
`Attestation signed using certificate from ${instanceName} Sigstore instance`
@@ -175,7 +183,7 @@ const createAttestation = async (
core.info(attestationURL(attestation.attestationID))
}
if (core.getBooleanInput('push-to-registry', { required: false })) {
if (opts.pushToRegistry) {
const credentials = getRegistryCredentials(subject.name)
const artifact = await attachArtifactToImage({
credentials,

View File

@@ -1,26 +1,32 @@
import * as core from '@actions/core'
import fs from 'fs'
import type { Predicate } from '@actions/attest'
export type PredicateInputs = {
predicateType: string
predicate: string
predicatePath: string
}
// Returns the predicate specified by the action's inputs. The predicate value
// may be specified as a path to a file or as a string.
export const predicateFromInputs = (): Predicate => {
const predicateType = core.getInput('predicate-type', { required: true })
const predicateStr = core.getInput('predicate', { required: false })
const predicatePath = core.getInput('predicate-path', { required: false })
export const predicateFromInputs = (inputs: PredicateInputs): Predicate => {
const { predicateType, predicate, predicatePath } = inputs
if (!predicatePath && !predicateStr) {
if (!predicateType) {
throw new Error('predicate-type must be provided')
}
if (!predicatePath && !predicate) {
throw new Error('One of predicate-path or predicate must be provided')
}
if (predicatePath && predicateStr) {
if (predicatePath && predicate) {
throw new Error('Only one of predicate-path or predicate may be provided')
}
const params = predicatePath
? fs.readFileSync(predicatePath, 'utf-8')
: predicateStr
: predicate
return { type: predicateType, params: JSON.parse(params) }
}

View File

@@ -1,4 +1,3 @@
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import crypto from 'crypto'
import { parse } from 'csv-parse/sync'
@@ -9,17 +8,20 @@ import type { Subject } from '@actions/attest'
const DIGEST_ALGORITHM = 'sha256'
export type SubjectInputs = {
subjectPath: string
subjectName: string
subjectDigest: string
downcaseName?: boolean
}
// Returns the subject specified by the action's inputs. The subject may be
// specified as a path to a file or as a digest. If a path is provided, the
// file's digest is calculated and returned along with the subject's name. If a
// digest is provided, the name must also be provided.
export const subjectFromInputs = async (): Promise<Subject[]> => {
const subjectPath = core.getInput('subject-path', { required: false })
const subjectDigest = core.getInput('subject-digest', { required: false })
const subjectName = core.getInput('subject-name', { required: false })
const pushToRegistry = core.getBooleanInput('push-to-registry', {
required: false
})
export const subjectFromInputs = async (
inputs: SubjectInputs
): Promise<Subject[]> => {
const { subjectPath, subjectDigest, subjectName, downcaseName } = inputs
if (!subjectPath && !subjectDigest) {
throw new Error('One of subject-path or subject-digest must be provided')
@@ -37,7 +39,7 @@ export const subjectFromInputs = async (): Promise<Subject[]> => {
// If push-to-registry is enabled, ensure the subject name is lowercase
// to conform to OCI image naming conventions
const name = pushToRegistry ? subjectName.toLowerCase() : subjectName
const name = downcaseName ? subjectName.toLowerCase() : subjectName
if (subjectPath) {
return await getSubjectFromPath(subjectPath, name)