From 3ff4eb4c6906c6141252cf1e8b84fd47092e80f7 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Fri, 24 May 2024 11:01:44 -0700 Subject: [PATCH] centralize collection of action inputs (#72) Signed-off-by: Brian DeHamer --- __tests__/index.test.ts | 5 + __tests__/main.test.ts | 106 +++++++++---------- __tests__/predicate.test.ts | 86 ++++++++------- __tests__/subject.test.ts | 204 +++++++++++++++++++----------------- dist/index.js | 168 ++++++++++++++++------------- src/index.ts | 24 ++++- src/main.ts | 62 ++++++----- src/predicate.ts | 22 ++-- src/subject.ts | 20 ++-- 9 files changed, 381 insertions(+), 316 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 34a4dfe..d1b8b48 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -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') diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 0c99b89..3130965 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -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): 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 { diff --git a/__tests__/predicate.test.ts b/__tests__/predicate.test.ts index 2c64af8..51d52a4 100644 --- a/__tests__/predicate.test.ts +++ b/__tests__/predicate.test.ts @@ -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) }) }) }) diff --git a/__tests__/subject.test.ts b/__tests__/subject.test.ts index 2474e7b..38260e0 100644 --- a/__tests__/subject.test.ts +++ b/__tests__/subject.test.ts @@ -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:"/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:"/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:"/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) diff --git a/dist/index.js b/dist/index.js index 4651416..8be3edd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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} 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__; +/******/ /******/ })() ; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b08f970..8954df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) diff --git a/src/main.ts b/src/main.ts index a0e3489..21d0fcb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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} Resolves when the action is complete. */ -export async function run(): Promise { +export async function run(inputs: RunInputs): Promise { 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 { // 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 { ) } - 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 { } 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 { const createAttestation = async ( subject: Subject, predicate: Predicate, - sigstoreInstance: SigstoreInstance + opts: { + sigstoreInstance: SigstoreInstance + pushToRegistry: boolean + githubToken: string + } ): Promise => { // 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, diff --git a/src/predicate.ts b/src/predicate.ts index aedaf4a..4e5ce04 100644 --- a/src/predicate.ts +++ b/src/predicate.ts @@ -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) } } diff --git a/src/subject.ts b/src/subject.ts index fb11155..4324580 100644 --- a/src/subject.ts +++ b/src/subject.ts @@ -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 => { - 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 => { + 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 => { // 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)