Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andbdehamer 0554dfbc8c Build package with updated changes
Co-authored-by: bdehamer <[email protected]>
2026-02-17 16:02:50 +00:00
copilot-swe-agent[bot]andbdehamer 4734ea3b9b Fix parseSBOMFromPath to check file size before reading
Co-authored-by: bdehamer <[email protected]>
2026-02-17 16:01:11 +00:00
copilot-swe-agent[bot] d2ffba3269 Initial plan 2026-02-17 15:58:33 +00:00
Brian DeHamer 4578f5b3b1 remove stray istanbul ignore
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-17 07:55:08 -08:00
Brian DeHamer 628abdee2b use experimental flag for jest in ci
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:07 -08:00
Brian DeHamer 36d44dca36 rebuild package-lock.json
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:07 -08:00
Brian DeHamer 9fd6a44d89 update @actions/attest
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:07 -08:00
Brian DeHamer a9a0a2c826 update @actions/github
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:07 -08:00
Brian DeHamer 0415dcc0d0 async all file functions
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:06 -08:00
Brian DeHamer 02c1da5b1d glob updated
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:06 -08:00
Brian DeHamer 8cd8ec4ddf debug mock
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:06 -08:00
Brian DeHamer 2a9eeeb3e1 lint issues
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:08:06 -08:00
Brian DeHamer e1605dcab6 esm'ify jest tests
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:07:58 -08:00
Brian DeHamer 9645b20a6a initial esm conversion
Signed-off-by: Brian DeHamer <[email protected]>
2026-02-13 16:07:30 -08:00
17 changed files with 39417 additions and 48797 deletions
+71 -95
View File
@@ -1,8 +1,36 @@
import * as attest from '@actions/attest' import { jest } from '@jest/globals'
import * as github from '@actions/github' import type { Descriptor } from '@sigstore/oci'
import * as oci from '@sigstore/oci' // Mock functions
import * as localAttest from '../src/attest' const mockGetOctokit = jest.fn()
import { createAttestation, repoOwnerIsOrg } from '../src/attest' // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockAttest = jest.fn<() => Promise<any>>()
const mockCreateStorageRecord = jest.fn<() => Promise<number[]>>()
const mockGetRegistryCredentials = jest.fn()
const mockAttachArtifactToImage = jest.fn<() => Promise<Descriptor>>()
// Mock @actions/github
jest.unstable_mockModule('@actions/github', () => ({
getOctokit: mockGetOctokit,
context: {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: mockAttest,
createStorageRecord: mockCreateStorageRecord
}))
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: mockGetRegistryCredentials,
attachArtifactToImage: mockAttachArtifactToImage
}))
// Dynamic imports after mocking
const { createAttestation, repoOwnerIsOrg } = await import('../src/attest')
const subjectName = 'ghcr.io/foo/bar' const subjectName = 'ghcr.io/foo/bar'
const subjectDigest = const subjectDigest =
@@ -14,46 +42,38 @@ const predicate = {
} }
describe('repoOwnerIsOrg', () => { describe('repoOwnerIsOrg', () => {
const originalContext = { ...github.context } beforeEach(() => {
jest.clearAllMocks()
afterEach(() => {
setGHContext(originalContext)
jest.restoreAllMocks()
}) })
it('returns true when repo owner is an organization', async () => { it('returns true when repo owner is an organization', async () => {
setGHContext({ mockGetOctokit.mockReturnValue({
repo: { owner: 'my-org', repo: 'my-repo' }
})
jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: { rest: {
repos: { repos: {
get: jest.fn().mockResolvedValue({ get: jest
.fn<() => Promise<{ data: { owner: { type: string } } }>>()
.mockResolvedValue({
data: { owner: { type: 'Organization' } } data: { owner: { type: 'Organization' } }
}) })
} }
} }
} as unknown as ReturnType<typeof github.getOctokit>) })
const result = await repoOwnerIsOrg('gh-token') const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(true) expect(result).toBe(true)
}) })
it('returns false when repo owner is a user', async () => { it('returns false when repo owner is a user', async () => {
setGHContext({ mockGetOctokit.mockReturnValue({
repo: { owner: 'my-user', repo: 'my-repo' }
})
jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: { rest: {
repos: { repos: {
get: jest.fn().mockResolvedValue({ // eslint-disable-next-line @typescript-eslint/no-explicit-any
get: jest.fn<() => Promise<any>>().mockResolvedValue({
data: { owner: { type: 'User' } } data: { owner: { type: 'User' } }
}) })
} }
} }
} as unknown as ReturnType<typeof github.getOctokit>) })
const result = await repoOwnerIsOrg('gh-token') const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(false) expect(result).toBe(false)
@@ -61,49 +81,31 @@ describe('repoOwnerIsOrg', () => {
}) })
describe('createAttestation', () => { describe('createAttestation', () => {
const originalEnv = process.env
const originalContext = { ...github.context }
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
setGHContext({ // Default mock implementations
payload: { repository: { visibility: 'private' } }, mockAttest.mockResolvedValue({
repo: { owner: 'foo', repo: 'bar' } bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
})
})
afterEach(() => {
process.env = originalEnv
setGHContext(originalContext)
})
describe('when createStorageRecord is false', () => {
beforeEach(() => {
// Mock the core attest function
jest.spyOn(attest, 'attest').mockResolvedValue({
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json'
},
certificate: 'cert', certificate: 'cert',
tlogID: 'tlog-123', tlogID: 'tlog-123',
attestationID: 'att-123' attestationID: 'att-123'
} as attest.Attestation) })
// Mock OCI functions mockGetRegistryCredentials.mockReturnValue({
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user', username: 'user',
password: 'pass' password: 'pass'
}) })
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({
mockAttachArtifactToImage.mockResolvedValue({
digest: 'sha256:abc123', digest: 'sha256:abc123',
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json', mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100 size: 100
}) })
}) })
describe('when createStorageRecord is false', () => {
it('skips storage record creation', async () => { it('skips storage record creation', async () => {
const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord')
const subjects = [ const subjects = [
{ {
name: subjectName, name: subjectName,
@@ -119,34 +121,23 @@ describe('createAttestation', () => {
}) })
expect(result.attestationDigest).toBe('sha256:abc123') expect(result.attestationDigest).toBe('sha256:abc123')
expect(createStorageRecordSpy).not.toHaveBeenCalled() expect(mockCreateStorageRecord).not.toHaveBeenCalled()
}) })
}) })
describe('when storage records are empty', () => { describe('when storage records are empty', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(attest, 'attest').mockResolvedValue({ mockGetOctokit.mockReturnValue({
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }, rest: {
certificate: 'cert', repos: {
tlogID: 'tlog-123', // eslint-disable-next-line @typescript-eslint/no-explicit-any
attestationID: 'att-123' get: jest.fn<() => Promise<any>>().mockResolvedValue({
} as attest.Attestation) data: { owner: { type: 'Organization' } }
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user',
password: 'pass'
}) })
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({ }
digest: 'sha256:abc123', }
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
}) })
mockCreateStorageRecord.mockResolvedValue([])
// Mock repoOwnerIsOrg
jest.spyOn(localAttest, 'repoOwnerIsOrg').mockResolvedValue(true)
// Mock createStorageRecord to return empty array
jest.spyOn(attest, 'createStorageRecord').mockResolvedValue([])
}) })
it('handles empty storage records gracefully', async () => { it('handles empty storage records gracefully', async () => {
@@ -157,7 +148,6 @@ describe('createAttestation', () => {
} }
] ]
// This exercises the empty records code path for coverage
const result = await createAttestation(subjects, predicate, { const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github', sigstoreInstance: 'github',
pushToRegistry: true, pushToRegistry: true,
@@ -171,25 +161,17 @@ describe('createAttestation', () => {
describe('when subject has unsupported protocol', () => { describe('when subject has unsupported protocol', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(attest, 'attest').mockResolvedValue({ mockGetOctokit.mockReturnValue({
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }, rest: {
certificate: 'cert', repos: {
tlogID: 'tlog-123', // eslint-disable-next-line @typescript-eslint/no-explicit-any
attestationID: 'att-123' get: jest.fn<() => Promise<any>>().mockResolvedValue({
} as attest.Attestation) data: { owner: { type: 'Organization' } }
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user',
password: 'pass'
}) })
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({ }
digest: 'sha256:abc123', }
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
}) })
mockCreateStorageRecord.mockResolvedValue([123])
// Mock repoOwnerIsOrg
jest.spyOn(localAttest, 'repoOwnerIsOrg').mockResolvedValue(true)
}) })
it('handles unsupported protocol gracefully', async () => { it('handles unsupported protocol gracefully', async () => {
@@ -200,7 +182,6 @@ describe('createAttestation', () => {
} }
] ]
// This exercises the unsupported protocol code path for coverage
const result = await createAttestation(subjects, predicate, { const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github', sigstoreInstance: 'github',
pushToRegistry: true, pushToRegistry: true,
@@ -208,12 +189,7 @@ describe('createAttestation', () => {
githubToken: 'gh-token' githubToken: 'gh-token'
}) })
// Should complete without throwing (error is caught and logged as warning)
expect(result.attestationDigest).toBe('sha256:abc123') expect(result.attestationDigest).toBe('sha256:abc123')
}) })
}) })
}) })
function setGHContext(context: object): void {
Object.defineProperty(github, 'context', { value: context })
}
+23 -10
View File
@@ -1,22 +1,35 @@
/** /**
* Unit tests for the action's entrypoint, src/index.ts * Unit tests for the action's entrypoint, src/index.ts
*/ */
import { jest } from '@jest/globals'
import * as core from '@actions/core' // Mock functions
import * as main from '../src/main' const mockRun = jest.fn()
const mockGetInput = jest.fn()
const mockGetBooleanInput = jest.fn()
// Mock the action's entrypoint // Mock @actions/core
const runMock = jest.spyOn(main, 'run').mockImplementation() jest.unstable_mockModule('@actions/core', () => ({
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput') getInput: mockGetInput,
getBooleanInput: mockGetBooleanInput
}))
// Mock ../src/main
jest.unstable_mockModule('../src/main', () => ({
run: mockRun
}))
describe('index', () => { describe('index', () => {
beforeEach(() => { beforeEach(() => {
getBooleanInputMock.mockImplementation(() => false) jest.clearAllMocks()
mockGetBooleanInput.mockReturnValue(false)
mockGetInput.mockReturnValue('')
}) })
it('calls run when imported', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
expect(runMock).toHaveBeenCalled() it('calls run when imported', async () => {
// Dynamic import after mocking
await import('../src/index')
expect(mockRun).toHaveBeenCalled()
}) })
}) })
+228 -234
View File
@@ -5,42 +5,118 @@
* Specifically, the inputs listed in `action.yml` should be set as environment * Specifically, the inputs listed in `action.yml` should be set as environment
* variables following the pattern `INPUT_<INPUT_NAME>`. * variables following the pattern `INPUT_<INPUT_NAME>`.
*/ */
import * as attest from '@actions/attest' import type { Predicate } from '@actions/attest'
import * as core from '@actions/core' import { jest } from '@jest/globals'
import * as github from '@actions/github' import type { RunInputs } from '../src/main'
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
import * as oci from '@sigstore/oci'
import fs from 'fs/promises'
import nock from 'nock'
import os from 'os'
import path from 'path'
import { MockAgent, setGlobalDispatcher } from 'undici'
import * as localAttest from '../src/attest'
import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints'
import * as main from '../src/main'
import * as provenance from '../src/provenance'
// Mock the GitHub Actions core library // Create mock functions before mocking modules
const infoMock = jest.spyOn(core, 'info') const infoMock = jest.fn()
const warningMock = jest.spyOn(core, 'warning') const warningMock = jest.fn()
const startGroupMock = jest.spyOn(core, 'startGroup') const startGroupMock = jest.fn()
const setOutputMock = jest.spyOn(core, 'setOutput') const endGroupMock = jest.fn()
const setFailedMock = jest.spyOn(core, 'setFailed') const setOutputMock = jest.fn()
const setFailedMock = jest.fn()
const debugMock = jest.fn()
// Ensure that setFailed doesn't set an exit code during tests // OCI mocks
setFailedMock.mockImplementation(() => {}) const getRegCredsMock = jest.fn()
const attachArtifactMock = jest.fn()
const summaryWriteMock = jest.spyOn(core.summary, 'write') // Attest mocks
summaryWriteMock.mockResolvedValue(core.summary) const attestMock = jest.fn()
const createStorageRecordMock = jest.fn()
// Mock the action's main function // Local attest mocks
const runMock = jest.spyOn(main, 'run') // eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAttestationMock = jest.fn<() => Promise<any>>()
const repoOwnerIsOrgMock = jest.fn()
// Provenance mock
const generateProvenancePredicateMock = jest.fn<() => Promise<Predicate>>()
// GitHub context mock
const mockContext = {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
const mockGetOctokit = jest.fn()
// Summary mock with chainable methods
const summaryMock = {
write: jest.fn().mockReturnThis(),
addRaw: jest.fn().mockReturnThis(),
addHeading: jest.fn().mockReturnThis(),
addLink: jest.fn().mockReturnThis(),
addTable: jest.fn().mockReturnThis(),
addBreak: jest.fn().mockReturnThis(),
addSeparator: jest.fn().mockReturnThis(),
addQuote: jest.fn().mockReturnThis(),
addCodeBlock: jest.fn().mockReturnThis(),
addList: jest.fn().mockReturnThis(),
addImage: jest.fn().mockReturnThis(),
addDetails: jest.fn().mockReturnThis(),
addEOL: jest.fn().mockReturnThis(),
emptyBuffer: jest.fn().mockReturnThis(),
stringify: jest.fn().mockReturnValue(''),
isEmptyBuffer: jest.fn().mockReturnValue(true),
clear: jest.fn().mockReturnThis()
}
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
info: infoMock,
warning: warningMock,
startGroup: startGroupMock,
endGroup: endGroupMock,
setOutput: setOutputMock,
setFailed: setFailedMock,
debug: debugMock,
summary: summaryMock
}))
// Mock @actions/github
jest.unstable_mockModule('@actions/github', () => ({
context: mockContext,
getOctokit: mockGetOctokit
}))
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: getRegCredsMock,
attachArtifactToImage: attachArtifactMock
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: attestMock,
createStorageRecord: createStorageRecordMock
}))
// Mock ../src/attest
jest.unstable_mockModule('../src/attest', () => ({
createAttestation: createAttestationMock,
repoOwnerIsOrg: repoOwnerIsOrgMock
}))
// Mock ../src/provenance
jest.unstable_mockModule('../src/provenance', () => ({
generateProvenancePredicate: generateProvenancePredicateMock
}))
// Dynamic imports after mocking
const { mockFulcio, mockRekor, mockTSA } = await import('@sigstore/mock')
const fs = (await import('fs/promises')).default
const nock = (await import('nock')).default
const os = (await import('os')).default
const path = (await import('path')).default
const { MockAgent, setGlobalDispatcher } = await import('undici')
const { run } = await import('../src/main')
// MockAgent for mocking @actions/github // MockAgent for mocking @actions/github
const mockAgent = new MockAgent() const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent) setGlobalDispatcher(mockAgent)
const defaultInputs: main.RunInputs = { const defaultInputs: RunInputs = {
predicate: '', predicate: '',
predicateType: '', predicateType: '',
predicatePath: '', predicatePath: '',
@@ -57,10 +133,12 @@ const defaultInputs: main.RunInputs = {
} }
describe('action', () => { describe('action', () => {
// Capture original environment variables and GitHub context so we can restore // Capture original environment variables so we can restore after each test
// them after each test
const originalEnv = process.env const originalEnv = process.env
const originalContext = { ...github.context } const originalContext = {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
// Mock OIDC token endpoint // Mock OIDC token endpoint
const tokenURL = 'https://token.url' const tokenURL = 'https://token.url'
@@ -121,7 +199,7 @@ describe('action', () => {
}) })
describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => { describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -136,9 +214,8 @@ describe('action', () => {
}) })
it('sets a failed status', async () => { it('sets a failed status', async () => {
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.' 'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -149,9 +226,8 @@ describe('action', () => {
describe('when no inputs are provided', () => { describe('when no inputs are provided', () => {
it('sets a failed status', async () => { it('sets a failed status', async () => {
await main.run(defaultInputs) await run(defaultInputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error(
'One of subject-path, subject-digest, or subject-checksums must be provided' 'One of subject-path, subject-digest, or subject-checksums must be provided'
@@ -161,7 +237,7 @@ describe('action', () => {
}) })
describe('when the repository is private', () => { describe('when the repository is private', () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -177,6 +253,16 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' } repo: { owner: 'foo', repo: 'bar' }
}) })
// Mock createAttestation to return expected values
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
await mockFulcio({ await mockFulcio({
baseURL: 'https://fulcio.githubapp.com', baseURL: 'https://fulcio.githubapp.com',
strict: false strict: false
@@ -185,60 +271,21 @@ describe('action', () => {
}) })
it('invokes the action w/o error', async () => { it('invokes the action w/o error', async () => {
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalled()
expect(setFailedMock).not.toHaveBeenCalledWith() expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom') expect(infoMock).toHaveBeenCalledWith(
expect(infoMock).toHaveBeenNthCalledWith(
2,
expect.stringMatching( expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}` `Attestation created for ${subjectName}@${subjectDigest}`
) )
) )
expect(startGroupMock).toHaveBeenNthCalledWith( expect(createAttestationMock).toHaveBeenCalled()
1,
expect.stringMatching('GitHub Sigstore')
)
expect(infoMock).toHaveBeenNthCalledWith(
3,
expect.stringMatching('-----BEGIN CERTIFICATE-----')
)
expect(infoMock).toHaveBeenNthCalledWith(
4,
expect.stringMatching(/attestation uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
5,
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
)
expect(setFailedMock).not.toHaveBeenCalled()
}) })
}) })
describe('when the repository is public', () => { describe('when the repository is public', () => {
const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials') const inputs: RunInputs = {
const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage')
const repoOwnerIsOrgSpy = jest.spyOn(localAttest, 'repoOwnerIsOrg')
const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord')
const createAttestationSpy = jest.spyOn(localAttest, 'createAttestation')
const inputs: main.RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -255,150 +302,88 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' } repo: { owner: 'foo', repo: 'bar' }
}) })
// Setup createAttestation mock
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
storageRecordIds: [storageRecordID]
})
await mockFulcio({ await mockFulcio({
baseURL: 'https://fulcio.sigstore.dev', baseURL: 'https://fulcio.sigstore.dev',
strict: false strict: false
}) })
await mockRekor({ baseURL: 'https://rekor.sigstore.dev' }) await mockRekor({ baseURL: 'https://rekor.sigstore.dev' })
getRegCredsSpy.mockImplementation(() => ({ mockGetOctokit.mockReturnValue({
username: 'username', rest: {
password: 'password' repos: {
})) get: jest
attachArtifactSpy.mockResolvedValue({ .fn<() => Promise<{ data: { owner: { type: string } } }>>()
digest: 'sha256:123456', .mockResolvedValue({
mediaType: 'application/vnd.cncf.notary.v2', data: { owner: { type: 'Organization' } }
size: 123456 })
}
}
}) })
repoOwnerIsOrgSpy.mockResolvedValue(true)
}) })
it('invokes the action w/o error', async () => { it('invokes the action w/o error', async () => {
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName) expect(createAttestationMock).toHaveBeenCalled()
expect(attachArtifactSpy).toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
expect(createAttestationSpy).toHaveBeenCalled()
expect(repoOwnerIsOrgSpy).toHaveBeenCalled()
expect(createStorageRecordSpy).toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith(
2,
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
expect(startGroupMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Public Good Sigstore')
)
expect(infoMock).toHaveBeenNthCalledWith(
3,
expect.stringMatching('-----BEGIN CERTIFICATE-----')
)
expect(infoMock).toHaveBeenNthCalledWith(
4,
expect.stringMatching(/signature uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
5,
expect.stringMatching(SEARCH_PUBLIC_GOOD_URL)
)
expect(infoMock).toHaveBeenNthCalledWith(
6,
expect.stringMatching(/attestation uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
7,
expect.stringMatching(attestationID)
)
expect(infoMock).toHaveBeenNthCalledWith(
10,
expect.stringMatching('Storage record created')
)
expect(infoMock).toHaveBeenNthCalledWith(
11,
expect.stringMatching('Storage record IDs: 987654321')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
4,
'storage-record-ids',
expect.stringMatching(storageRecordID.toString())
)
expect(setFailedMock).not.toHaveBeenCalled()
})
it('catches error when storage record creation fails and continues', async () => {
// Mock the createStorageRecord function and throw an error
createStorageRecordSpy.mockRejectedValueOnce(
new Error('Failed to persist storage record: Not Found')
)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(createAttestationSpy).toHaveBeenCalled()
expect(repoOwnerIsOrgSpy).toHaveBeenCalled()
expect(createStorageRecordSpy).toHaveBeenCalled()
expect(setFailedMock).not.toHaveBeenCalled()
expect(warningMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Failed to create storage record')
)
})
it('does not create a storage record when the repo is owned by a user', async () => {
repoOwnerIsOrgSpy.mockResolvedValueOnce(false)
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName)
expect(attachArtifactSpy).toHaveBeenCalled()
expect(createAttestationSpy).toHaveBeenCalled()
expect(repoOwnerIsOrgSpy).toHaveBeenCalled()
expect(createStorageRecordSpy).not.toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith( expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching( expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}` `Attestation created for ${subjectName}@${subjectDigest}`
) )
) )
expect(infoMock).not.toHaveBeenCalledWith( })
expect.stringMatching('Storage record created')
) it('catches error when storage record creation fails and continues', async () => {
expect(infoMock).not.toHaveBeenCalledWith( // Mock createAttestation to simulate storage record failure (but still succeed overall)
expect.stringMatching('Storage record IDs: 987654321') createAttestationMock.mockResolvedValue({
) attestationID,
expect(setOutputMock).toHaveBeenCalledWith( certificate:
'attestation-id', '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
expect.stringMatching(attestationID) tlogID: 'tlog-123',
) attestationDigest: 'sha256:123456',
expect(setOutputMock).not.toHaveBeenCalledWith( bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
'storage-record-ids', // No storageRecordIDs - simulates empty/failed storage record
expect.stringMatching(storageRecordID.toString()) })
)
await run(inputs)
expect(createAttestationMock).toHaveBeenCalled()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
}) })
it('does not create a storage record when the repo is owned by a user', async () => {
// Mock createAttestation to not return storage record IDs
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(createAttestationMock).toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
})
}) })
describe('when the subject count is greater than 1', () => { describe('when the subject count is greater than 1', () => {
@@ -440,16 +425,15 @@ describe('action', () => {
}) })
it('invokes the action w/o error', async () => { it('invokes the action w/o error', async () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`), subjectPath: path.join(dir, `${filename}-*`),
predicateType, predicateType,
predicate, predicate,
githubToken: 'gh-token' githubToken: 'gh-token'
} }
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom') expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
@@ -489,16 +473,15 @@ describe('action', () => {
}) })
it('sets a failed status', async () => { it('sets a failed status', async () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`), subjectPath: path.join(dir, `${filename}-*`),
predicateType, predicateType,
predicate, predicate,
githubToken: 'gh-token' githubToken: 'gh-token'
} }
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error(
'Too many subjects specified (1025). The maximum number of subjects is 1024.' 'Too many subjects specified (1025). The maximum number of subjects is 1024.'
@@ -510,7 +493,7 @@ describe('action', () => {
describe('attestation type detection', () => { describe('attestation type detection', () => {
describe('when sbom-path is provided with predicate inputs', () => { describe('when sbom-path is provided with predicate inputs', () => {
it('sets a failed status for conflicting inputs', async () => { it('sets a failed status for conflicting inputs', async () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -519,9 +502,8 @@ describe('action', () => {
githubToken: 'gh-token' githubToken: 'gh-token'
} }
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error(
'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path' 'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path'
@@ -532,7 +514,7 @@ describe('action', () => {
describe('when predicate is provided without predicate-type', () => { describe('when predicate is provided without predicate-type', () => {
it('sets a failed status for missing predicate-type', async () => { it('sets a failed status for missing predicate-type', async () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -540,9 +522,8 @@ describe('action', () => {
githubToken: 'gh-token' githubToken: 'gh-token'
} }
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( new Error(
'predicate-type is required when using predicate or predicate-path' 'predicate-type is required when using predicate or predicate-path'
@@ -552,7 +533,7 @@ describe('action', () => {
}) })
describe('when custom attestation inputs are provided', () => { describe('when custom attestation inputs are provided', () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -575,16 +556,15 @@ describe('action', () => {
}) })
it('logs the attestation type as Custom', async () => { it('logs the attestation type as Custom', async () => {
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom') expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
}) })
}) })
describe('when provenance attestation is detected', () => { describe('when provenance attestation is detected', () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -597,9 +577,18 @@ describe('action', () => {
} }
beforeEach(async () => { beforeEach(async () => {
jest // Configure mock for provenance predicate
.spyOn(provenance, 'generateProvenancePredicate') generateProvenancePredicateMock.mockResolvedValue(mockProvPredicate)
.mockResolvedValue(mockProvPredicate)
// Configure mock for createAttestation
createAttestationMock.mockResolvedValue({
attestationID: '1234567890',
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
setGHContext({ setGHContext({
payload: { repository: { visibility: 'private' } }, payload: { repository: { visibility: 'private' } },
@@ -614,9 +603,8 @@ describe('action', () => {
}) })
it('logs the attestation type as Build Provenance and generates predicate', async () => { it('logs the attestation type as Build Provenance and generates predicate', async () => {
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith( expect(infoMock).toHaveBeenCalledWith(
'Attestation type: Build Provenance' 'Attestation type: Build Provenance'
@@ -657,7 +645,7 @@ describe('action', () => {
}) })
it('logs the attestation type as SBOM and generates predicate', async () => { it('logs the attestation type as SBOM and generates predicate', async () => {
const inputs: main.RunInputs = { const inputs: RunInputs = {
...defaultInputs, ...defaultInputs,
subjectDigest, subjectDigest,
subjectName, subjectName,
@@ -665,9 +653,8 @@ describe('action', () => {
githubToken: 'gh-token' githubToken: 'gh-token'
} }
await main.run(inputs) await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: SBOM') expect(infoMock).toHaveBeenCalledWith('Attestation type: SBOM')
}) })
@@ -675,8 +662,15 @@ describe('action', () => {
}) })
}) })
// Stubbing the GitHub context is a bit tricky. We need to use // Helper to update the mock context
// `Object.defineProperty` because `github.context` is read-only. function setGHContext(context: {
function setGHContext(context: object): void { repo?: { owner: string; repo: string }
Object.defineProperty(github, 'context', { value: context }) payload?: { repository?: { visibility: string } }
}): void {
if (context.repo) {
mockContext.repo = context.repo
}
if (context.payload) {
mockContext.payload = context.payload as typeof mockContext.payload
}
} }
+18 -14
View File
@@ -11,33 +11,35 @@ describe('subjectFromInputs', () => {
} }
describe('when no inputs are provided', () => { describe('when no inputs are provided', () => {
it('throws an error', () => { it('throws an error', async () => {
expect(() => predicateFromInputs(blankInputs)).toThrow(/predicate-type/i) await expect(predicateFromInputs(blankInputs)).rejects.toThrow(
/predicate-type/i
)
}) })
}) })
describe('when neither predicate path nor predicate are provided', () => { describe('when neither predicate path nor predicate are provided', () => {
it('throws an error', () => { it('throws an error', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
...blankInputs, ...blankInputs,
predicateType: 'https://example.com/predicate' predicateType: 'https://example.com/predicate'
} }
expect(() => predicateFromInputs(inputs)).toThrow( await expect(predicateFromInputs(inputs)).rejects.toThrow(
/one of predicate-path or predicate must be provided/i /one of predicate-path or predicate must be provided/i
) )
}) })
}) })
describe('when both predicate path and predicate are provided', () => { describe('when both predicate path and predicate are provided', () => {
it('throws an error', () => { it('throws an error', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
predicateType: 'https://example.com/predicate', predicateType: 'https://example.com/predicate',
predicate: '{}', predicate: '{}',
predicatePath: 'path/to/predicate' predicatePath: 'path/to/predicate'
} }
expect(() => predicateFromInputs(inputs)).toThrow( await expect(predicateFromInputs(inputs)).rejects.toThrow(
/only one of predicate-path or predicate may be provided/i /only one of predicate-path or predicate may be provided/i
) )
}) })
@@ -65,13 +67,13 @@ describe('subjectFromInputs', () => {
await fs.rm(path.parse(predicatePath).dir, { recursive: true }) await fs.rm(path.parse(predicatePath).dir, { recursive: true })
}) })
it('returns the predicate', () => { it('returns the predicate', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
...blankInputs, ...blankInputs,
predicateType, predicateType,
predicatePath predicatePath
} }
expect(predicateFromInputs(inputs)).toEqual({ await expect(predicateFromInputs(inputs)).resolves.toEqual({
type: predicateType, type: predicateType,
params: JSON.parse(content) params: JSON.parse(content)
}) })
@@ -82,13 +84,15 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate' const predicateType = 'https://example.com/predicate'
const predicatePath = 'foo' const predicatePath = 'foo'
it('returns the predicate', () => { it('returns the predicate', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
...blankInputs, ...blankInputs,
predicateType, predicateType,
predicatePath predicatePath
} }
expect(() => predicateFromInputs(inputs)).toThrow(/file not found/) await expect(predicateFromInputs(inputs)).rejects.toThrow(
/file not found/
)
}) })
}) })
@@ -96,14 +100,14 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate' const predicateType = 'https://example.com/predicate'
const content = '{}' const content = '{}'
it('returns the predicate', () => { it('returns the predicate', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
...blankInputs, ...blankInputs,
predicateType, predicateType,
predicate: content predicate: content
} }
expect(predicateFromInputs(inputs)).toEqual({ await expect(predicateFromInputs(inputs)).resolves.toEqual({
type: predicateType, type: predicateType,
params: JSON.parse(content) params: JSON.parse(content)
}) })
@@ -114,14 +118,14 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate' const predicateType = 'https://example.com/predicate'
const content = JSON.stringify({ a: 'a'.repeat(16 * 1024 * 1024) }) const content = JSON.stringify({ a: 'a'.repeat(16 * 1024 * 1024) })
it('throws an error', () => { it('throws an error', async () => {
const inputs: PredicateInputs = { const inputs: PredicateInputs = {
...blankInputs, ...blankInputs,
predicateType, predicateType,
predicate: content predicate: content
} }
expect(() => predicateFromInputs(inputs)).toThrow( await expect(predicateFromInputs(inputs)).rejects.toThrow(
/predicate string exceeds maximum/ /predicate string exceeds maximum/
) )
}) })
+14 -9
View File
@@ -1,10 +1,17 @@
import { generateProvenancePredicate } from '../src/provenance' import type { Predicate } from '@actions/attest'
import { buildSLSAProvenancePredicate } from '@actions/attest' import { jest } from '@jest/globals'
jest.mock('@actions/attest', () => ({ // Mock function
buildSLSAProvenancePredicate: jest.fn() const mockBuildSLSAProvenancePredicate = jest.fn<() => Promise<Predicate>>()
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
buildSLSAProvenancePredicate: mockBuildSLSAProvenancePredicate
})) }))
// Dynamic import after mocking
const { generateProvenancePredicate } = await import('../src/provenance')
describe('generateProvenancePredicate', () => { describe('generateProvenancePredicate', () => {
const mockPredicate = { const mockPredicate = {
type: 'https://slsa.dev/provenance/v1', type: 'https://slsa.dev/provenance/v1',
@@ -20,21 +27,19 @@ describe('generateProvenancePredicate', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
;(buildSLSAProvenancePredicate as jest.Mock).mockResolvedValue( mockBuildSLSAProvenancePredicate.mockResolvedValue(mockPredicate)
mockPredicate
)
}) })
it('returns the SLSA provenance predicate', async () => { it('returns the SLSA provenance predicate', async () => {
const result = await generateProvenancePredicate() const result = await generateProvenancePredicate()
expect(buildSLSAProvenancePredicate).toHaveBeenCalledTimes(1) expect(mockBuildSLSAProvenancePredicate).toHaveBeenCalledTimes(1)
expect(result).toEqual(mockPredicate) expect(result).toEqual(mockPredicate)
}) })
it('propagates errors from buildSLSAProvenancePredicate', async () => { it('propagates errors from buildSLSAProvenancePredicate', async () => {
const error = new Error('Failed to build provenance') const error = new Error('Failed to build provenance')
;(buildSLSAProvenancePredicate as jest.Mock).mockRejectedValue(error) mockBuildSLSAProvenancePredicate.mockRejectedValue(error)
await expect(generateProvenancePredicate()).rejects.toThrow( await expect(generateProvenancePredicate()).rejects.toThrow(
'Failed to build provenance' 'Failed to build provenance'
Generated Vendored
+3 -5
View File
@@ -1,7 +1,6 @@
"use strict"; export const id = 606;
exports.id = 606; export const ids = [606];
exports.ids = [606]; export const modules = {
exports.modules = {
/***/ 606: /***/ 606:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
@@ -298,4 +297,3 @@ const pMapSkip = Symbol('skip');
/***/ }) /***/ })
}; };
;
Generated Vendored
+38569 -48043
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+2
View File
@@ -1 +1,3 @@
import { jest } from '@jest/globals'
process.stdout.write = jest.fn() process.stdout.write = jest.fn()
+398 -329
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -4,6 +4,7 @@
"version": "3.2.0", "version": "3.2.0",
"author": "", "author": "",
"private": true, "private": true,
"type": "module",
"homepage": "https://github.com/actions/attest", "homepage": "https://github.com/actions/attest",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -24,7 +25,7 @@
}, },
"scripts": { "scripts": {
"bundle": "npm run format:write && npm run package", "bundle": "npm run format:write && npm run package",
"ci-test": "jest", "ci-test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"format:write": "prettier --write **/*.ts", "format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts", "format:check": "prettier --check **/*.ts",
"lint:eslint": "npx eslint", "lint:eslint": "npx eslint",
@@ -32,12 +33,15 @@
"lint": "npm run lint:eslint && npm run lint:markdown", "lint": "npm run lint:eslint && npm run lint:markdown",
"package": "ncc build src/index.ts --license licenses.txt", "package": "ncc build src/index.ts --license licenses.txt",
"package:watch": "npm run package -- --watch", "package:watch": "npm run package -- --watch",
"test": "jest", "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"all": "npm run format:write && npm run lint && npm run test && npm run package" "all": "npm run format:write && npm run lint && npm run test && npm run package"
}, },
"license": "MIT", "license": "MIT",
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest/presets/default-esm",
"extensionsToTreatAsEsm": [
".ts"
],
"setupFilesAfterEnv": [ "setupFilesAfterEnv": [
"./jest.setup.js" "./jest.setup.js"
], ],
@@ -56,7 +60,12 @@
"/dist/" "/dist/"
], ],
"transform": { "transform": {
"^.+\\.ts$": "ts-jest" "^.+\\.ts$": [
"ts-jest",
{
"useESM": true
}
]
}, },
"coverageReporters": [ "coverageReporters": [
"json-summary", "json-summary",
@@ -69,15 +78,16 @@
] ]
}, },
"dependencies": { "dependencies": {
"@actions/attest": "^2.2.1", "@actions/attest": "^3.0.0",
"@actions/core": "^2.0.2", "@actions/core": "^3.0.0",
"@actions/github": "^7.0.0", "@actions/github": "^9.0.0",
"@actions/glob": "^0.5.0", "@actions/glob": "^0.6.1",
"@sigstore/oci": "^0.6.0", "@sigstore/oci": "^0.6.0",
"csv-parse": "^5.6.0" "csv-parse": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@jest/globals": "^30.2.0",
"@sigstore/mock": "^0.11.0", "@sigstore/mock": "^0.11.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/make-fetch-happen": "^10.0.4", "@types/make-fetch-happen": "^10.0.4",
+2 -2
View File
@@ -5,10 +5,10 @@ import {
attest, attest,
createStorageRecord createStorageRecord
} from '@actions/attest' } from '@actions/attest'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject'
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject'
const OCI_TIMEOUT = 30000 const OCI_TIMEOUT = 30000
const OCI_RETRY = 3 const OCI_RETRY = 3
+8 -7
View File
@@ -1,6 +1,6 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import fs from 'fs' import fs from 'fs/promises'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { AttestResult, SigstoreInstance, createAttestation } from './attest' import { AttestResult, SigstoreInstance, createAttestation } from './attest'
@@ -13,7 +13,7 @@ import {
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints' import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate' import { PredicateInputs, predicateFromInputs } from './predicate'
import { generateProvenancePredicate } from './provenance' import { generateProvenancePredicate } from './provenance'
import { parseSBOMFromPath, generateSBOMPredicate } from './sbom' import { generateSBOMPredicate, parseSBOMFromPath } from './sbom'
import * as style from './style' import * as style from './style'
import { import {
SubjectInputs, SubjectInputs,
@@ -90,7 +90,7 @@ export async function run(inputs: RunInputs): Promise<void> {
// Generate predicate based on attestation type // Generate predicate based on attestation type
const predicate = await getPredicateForType(attestationType, inputs) const predicate = await getPredicateForType(attestationType, inputs)
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME) const outputPath = path.join(await tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath) core.setOutput('bundle-path', outputPath)
const att = await createAttestation(subjects, predicate, { const att = await createAttestation(subjects, predicate, {
@@ -103,7 +103,7 @@ export async function run(inputs: RunInputs): Promise<void> {
logAttestation(subjects, att, sigstoreInstance) logAttestation(subjects, att, sigstoreInstance)
// Write attestation bundle to output file // Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, { await fs.writeFile(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8', encoding: 'utf-8',
flag: 'a' flag: 'a'
}) })
@@ -113,7 +113,7 @@ export async function run(inputs: RunInputs): Promise<void> {
if (baseDir) { if (baseDir) {
const outputSummaryPath = path.join(baseDir, ATTESTATION_PATHS_FILE_NAME) const outputSummaryPath = path.join(baseDir, ATTESTATION_PATHS_FILE_NAME)
// Append the output path to the attestations paths file // Append the output path to the attestations paths file
fs.appendFileSync(outputSummaryPath, outputPath + os.EOL, { await fs.appendFile(outputSummaryPath, outputPath + os.EOL, {
encoding: 'utf-8', encoding: 'utf-8',
flag: 'a' flag: 'a'
}) })
@@ -128,6 +128,7 @@ export async function run(inputs: RunInputs): Promise<void> {
core.setOutput('attestation-id', att.attestationID) core.setOutput('attestation-id', att.attestationID)
core.setOutput('attestation-url', attestationURL(att.attestationID)) core.setOutput('attestation-url', attestationURL(att.attestationID))
} }
if (att.storageRecordIds) { if (att.storageRecordIds) {
core.setOutput('storage-record-ids', att.storageRecordIds.join(',')) core.setOutput('storage-record-ids', att.storageRecordIds.join(','))
} }
@@ -220,7 +221,7 @@ const logSummary = async (attestation: AttestResult): Promise<void> => {
} }
} }
const tempDir = (): string => { const tempDir = async (): Promise<string> => {
const basePath = process.env['RUNNER_TEMP'] const basePath = process.env['RUNNER_TEMP']
/* istanbul ignore if */ /* istanbul ignore if */
@@ -228,7 +229,7 @@ const tempDir = (): string => {
throw new Error('Missing RUNNER_TEMP environment variable') throw new Error('Missing RUNNER_TEMP environment variable')
} }
return fs.mkdtempSync(path.join(basePath, path.sep)) return fs.mkdtemp(path.join(basePath, path.sep))
} }
const attestationURL = (id: string): string => const attestationURL = (id: string): string =>
+11 -5
View File
@@ -1,4 +1,4 @@
import fs from 'fs' import fs from 'fs/promises'
import type { Predicate } from '@actions/attest' import type { Predicate } from '@actions/attest'
@@ -12,7 +12,9 @@ const MAX_PREDICATE_SIZE_BYTES = 16 * 1024 * 1024
// Returns the predicate specified by the action's inputs. The predicate value // 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. // may be specified as a path to a file or as a string.
export const predicateFromInputs = (inputs: PredicateInputs): Predicate => { export const predicateFromInputs = async (
inputs: PredicateInputs
): Promise<Predicate> => {
const { predicateType, predicate, predicatePath } = inputs const { predicateType, predicate, predicatePath } = inputs
if (!predicateType) { if (!predicateType) {
@@ -30,18 +32,22 @@ export const predicateFromInputs = (inputs: PredicateInputs): Predicate => {
let params: string = predicate let params: string = predicate
if (predicatePath) { if (predicatePath) {
if (!fs.existsSync(predicatePath)) { try {
await fs.access(predicatePath)
} catch {
throw new Error(`predicate file not found: ${predicatePath}`) throw new Error(`predicate file not found: ${predicatePath}`)
} }
const stat = await fs.stat(predicatePath)
/* istanbul ignore next */ /* istanbul ignore next */
if (fs.statSync(predicatePath).size > MAX_PREDICATE_SIZE_BYTES) { if (stat.size > MAX_PREDICATE_SIZE_BYTES) {
throw new Error( throw new Error(
`predicate file exceeds maximum allowed size: ${MAX_PREDICATE_SIZE_BYTES} bytes` `predicate file exceeds maximum allowed size: ${MAX_PREDICATE_SIZE_BYTES} bytes`
) )
} }
params = fs.readFileSync(predicatePath, 'utf-8') params = await fs.readFile(predicatePath, 'utf-8')
} else { } else {
if (predicate.length > MAX_PREDICATE_SIZE_BYTES) { if (predicate.length > MAX_PREDICATE_SIZE_BYTES) {
throw new Error( throw new Error(
+11 -5
View File
@@ -1,4 +1,4 @@
import fs from 'fs' import fs from 'fs/promises'
import type { Predicate } from '@actions/attest' import type { Predicate } from '@actions/attest'
@@ -11,18 +11,24 @@ export type SBOM = {
const MAX_SBOM_SIZE_BYTES = 16 * 1024 * 1024 const MAX_SBOM_SIZE_BYTES = 16 * 1024 * 1024
export const parseSBOMFromPath = async (filePath: string): Promise<SBOM> => { export const parseSBOMFromPath = async (filePath: string): Promise<SBOM> => {
if (!fs.existsSync(filePath)) { let stats
throw new Error(`SBOM file not found: ${filePath}`) try {
stats = await fs.stat(filePath)
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
throw new Error('SBOM file not found')
}
throw error
} }
const stats = fs.statSync(filePath)
if (stats.size > MAX_SBOM_SIZE_BYTES) { if (stats.size > MAX_SBOM_SIZE_BYTES) {
throw new Error( throw new Error(
`SBOM file exceeds maximum allowed size: ${MAX_SBOM_SIZE_BYTES} bytes` `SBOM file exceeds maximum allowed size: ${MAX_SBOM_SIZE_BYTES} bytes`
) )
} }
const fileContent = await fs.promises.readFile(filePath, 'utf8') const fileContent = await fs.readFile(filePath, 'utf8')
const sbom = JSON.parse(fileContent) as object const sbom = JSON.parse(fileContent) as object
if (checkIsSPDX(sbom)) { if (checkIsSPDX(sbom)) {
+17 -10
View File
@@ -2,7 +2,8 @@ import * as glob from '@actions/glob'
import assert from 'assert' import assert from 'assert'
import crypto from 'crypto' import crypto from 'crypto'
import { parse } from 'csv-parse/sync' import { parse } from 'csv-parse/sync'
import fs from 'fs' import { createReadStream } from 'fs'
import fs from 'fs/promises'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
@@ -64,7 +65,7 @@ export const subjectFromInputs = async (
case !!subjectDigest: case !!subjectDigest:
return [getSubjectFromDigest(subjectDigest, name)] return [getSubjectFromDigest(subjectDigest, name)]
case !!subjectChecksums: case !!subjectChecksums:
return getSubjectFromChecksums(subjectChecksums) return await getSubjectFromChecksums(subjectChecksums)
/* istanbul ignore next */ /* istanbul ignore next */
default: default:
// This should be unreachable, but TS requires a default case // This should be unreachable, but TS requires a default case
@@ -94,7 +95,8 @@ const getSubjectFromPath = async (
const paths = await glob.create(subjectPaths).then(async g => g.glob()) const paths = await glob.create(subjectPaths).then(async g => g.glob())
// Filter path list to just the files (not directories) // Filter path list to just the files (not directories)
const files = paths.filter(p => fs.statSync(p).isFile()) const stats = await Promise.all(paths.map(async p => fs.stat(p)))
const files = paths.filter((_, i) => stats[i].isFile())
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error( throw new Error(
@@ -142,16 +144,21 @@ const getSubjectFromDigest = (
} }
} }
const getSubjectFromChecksums = (subjectChecksums: string): Subject[] => { const getSubjectFromChecksums = async (
if (fs.existsSync(subjectChecksums)) { subjectChecksums: string
): Promise<Subject[]> => {
try {
await fs.access(subjectChecksums)
return getSubjectFromChecksumsFile(subjectChecksums) return getSubjectFromChecksumsFile(subjectChecksums)
} else { } catch {
return getSubjectFromChecksumsString(subjectChecksums) return getSubjectFromChecksumsString(subjectChecksums)
} }
} }
const getSubjectFromChecksumsFile = (checksumsPath: string): Subject[] => { const getSubjectFromChecksumsFile = async (
const stats = fs.statSync(checksumsPath) checksumsPath: string
): Promise<Subject[]> => {
const stats = await fs.stat(checksumsPath)
if (!stats.isFile()) { if (!stats.isFile()) {
throw new Error(`subject checksums file not found: ${checksumsPath}`) throw new Error(`subject checksums file not found: ${checksumsPath}`)
} }
@@ -163,7 +170,7 @@ const getSubjectFromChecksumsFile = (checksumsPath: string): Subject[] => {
) )
} }
const checksums = fs.readFileSync(checksumsPath, 'utf-8') const checksums = await fs.readFile(checksumsPath, 'utf-8')
return getSubjectFromChecksumsString(checksums) return getSubjectFromChecksumsString(checksums)
} }
@@ -218,7 +225,7 @@ const digestFile = async (
): Promise<string> => { ): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash(algorithm).setEncoding('hex') const hash = crypto.createHash(algorithm).setEncoding('hex')
fs.createReadStream(filePath) createReadStream(filePath)
.once('error', reject) .once('error', reject)
.pipe(hash) .pipe(hash)
.once('finish', () => resolve(hash.read())) .once('finish', () => resolve(hash.read()))
+2 -2
View File
@@ -2,9 +2,9 @@
"$schema": "https://json.schemastore.org/tsconfig", "$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "ESNext",
"rootDir": "./src", "rootDir": "./src",
"moduleResolution": "NodeNext", "moduleResolution": "Bundler",
"isolatedModules": true, "isolatedModules": true,
"baseUrl": "./", "baseUrl": "./",
"sourceMap": true, "sourceMap": true,