Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352aff7976 |
@@ -43,7 +43,7 @@ Note that before a PR will be accepted, you must ensure:
|
|||||||
1. In a new branch, create a new Lerna package:
|
1. In a new branch, create a new Lerna package:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ npm run new-package [name]
|
$ npm run create-package new-package
|
||||||
```
|
```
|
||||||
|
|
||||||
This will ask you some questions about the new package. Start with `0.0.0` as the first version (look generally at some of the other packages for how the package.json is structured).
|
This will ask you some questions about the new package. Start with `0.0.0` as the first version (look generally at some of the other packages for how the package.json is structured).
|
||||||
|
|||||||
Generated
+1817
-3445
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -13,7 +13,7 @@
|
|||||||
"lint": "eslint packages/**/*.ts",
|
"lint": "eslint packages/**/*.ts",
|
||||||
"lint-fix": "eslint packages/**/*.ts --fix",
|
"lint-fix": "eslint packages/**/*.ts --fix",
|
||||||
"new-package": "scripts/create-package",
|
"new-package": "scripts/create-package",
|
||||||
"test": "jest --testTimeout 70000"
|
"test": "jest --testTimeout 60000"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.4",
|
"@types/jest": "^29.5.4",
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"flow-bin": "^0.115.0",
|
"flow-bin": "^0.115.0",
|
||||||
"jest": "^29.6.4",
|
"jest": "^29.6.4",
|
||||||
"lerna": "^6.4.1",
|
"lerna": "^7.1.4",
|
||||||
"nx": "16.6.0",
|
"nx": "16.6.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
# @actions/artifact Releases
|
# @actions/artifact Releases
|
||||||
|
|
||||||
### 2.1.6
|
|
||||||
|
|
||||||
- Will retry on invalid request responses.
|
|
||||||
|
|
||||||
### 2.1.5
|
|
||||||
|
|
||||||
- Bumped `archiver` dependency to 7.0.1
|
|
||||||
|
|
||||||
### 2.1.4
|
### 2.1.4
|
||||||
|
|
||||||
- Adds info-level logging for zip extraction
|
- Adds info-level logging for zip extraction
|
||||||
@@ -19,9 +11,9 @@
|
|||||||
### 2.1.2
|
### 2.1.2
|
||||||
|
|
||||||
- Updated the stream extract functionality to use `unzip.Parse()` instead of `unzip.Extract()` for greater control of unzipping artifacts
|
- Updated the stream extract functionality to use `unzip.Parse()` instead of `unzip.Extract()` for greater control of unzipping artifacts
|
||||||
|
|
||||||
### 2.1.1
|
### 2.1.1
|
||||||
|
|
||||||
- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts
|
- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts
|
||||||
|
|
||||||
### 2.1.0
|
### 2.1.0
|
||||||
|
|||||||
@@ -116,54 +116,6 @@ describe('artifact-http-client', () => {
|
|||||||
expect(mockPost).toHaveBeenCalledTimes(2)
|
expect(mockPost).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retry if invalid body response', async () => {
|
|
||||||
const mockPost = jest
|
|
||||||
.fn(() => {
|
|
||||||
const msgSucceeded = new http.IncomingMessage(new net.Socket())
|
|
||||||
msgSucceeded.statusCode = 200
|
|
||||||
return {
|
|
||||||
message: msgSucceeded,
|
|
||||||
readBody: async () => {
|
|
||||||
return Promise.resolve(
|
|
||||||
`{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.mockImplementationOnce(() => {
|
|
||||||
const msgFailed = new http.IncomingMessage(new net.Socket())
|
|
||||||
msgFailed.statusCode = 502
|
|
||||||
msgFailed.statusMessage = 'Bad Gateway'
|
|
||||||
return {
|
|
||||||
message: msgFailed,
|
|
||||||
readBody: async () => {
|
|
||||||
return Promise.resolve('💥')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const mockHttpClient = (
|
|
||||||
HttpClient as unknown as jest.Mock
|
|
||||||
).mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
post: mockPost
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const client = internalArtifactTwirpClient(clientOptions)
|
|
||||||
const artifact = await client.CreateArtifact({
|
|
||||||
workflowRunBackendId: '1234',
|
|
||||||
workflowJobRunBackendId: '5678',
|
|
||||||
name: 'artifact',
|
|
||||||
version: 4
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
|
||||||
expect(artifact).toBeDefined()
|
|
||||||
expect(artifact.ok).toBe(true)
|
|
||||||
expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload')
|
|
||||||
expect(mockPost).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fail if the request fails 5 times', async () => {
|
it('should fail if the request fails 5 times', async () => {
|
||||||
const mockPost = jest.fn(() => {
|
const mockPost = jest.fn(() => {
|
||||||
const msgFailed = new http.IncomingMessage(new net.Socket())
|
const msgFailed = new http.IncomingMessage(new net.Socket())
|
||||||
|
|||||||
@@ -7,21 +7,11 @@ import {Timestamp, ArtifactServiceClientJSON} from '../src/generated'
|
|||||||
import * as blobUpload from '../src/internal/upload/blob-upload'
|
import * as blobUpload from '../src/internal/upload/blob-upload'
|
||||||
import {uploadArtifact} from '../src/internal/upload/upload-artifact'
|
import {uploadArtifact} from '../src/internal/upload/upload-artifact'
|
||||||
import {noopLogs} from './common'
|
import {noopLogs} from './common'
|
||||||
import {
|
import {FilesNotFoundError} from '../src/internal/shared/errors'
|
||||||
FilesNotFoundError,
|
|
||||||
InvalidResponseError
|
|
||||||
} from '../src/internal/shared/errors'
|
|
||||||
class NodeJSError extends Error {
|
|
||||||
code: string
|
|
||||||
|
|
||||||
constructor(message?: string, code?: string) {
|
|
||||||
super(message) // Pass the message to the Error constructor
|
|
||||||
this.code = code || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
describe('upload-artifact', () => {
|
describe('upload-artifact', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// noopLogs()
|
noopLogs()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -361,102 +351,4 @@ describe('upload-artifact', () => {
|
|||||||
|
|
||||||
expect(uploadResp).rejects.toThrow()
|
expect(uploadResp).rejects.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('should respond with non-successful callback on different zipstream lifecycle methods', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
noopLogs()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle ENOENT error', async () => {
|
|
||||||
const mockDate = new Date('2020-01-01')
|
|
||||||
jest
|
|
||||||
.spyOn(uploadZipSpecification, 'validateRootDirectory')
|
|
||||||
.mockReturnValue()
|
|
||||||
jest
|
|
||||||
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
|
|
||||||
.mockReturnValue([
|
|
||||||
{
|
|
||||||
sourcePath: '/home/user/files/plz-upload/file1.txt',
|
|
||||||
destinationPath: 'file1.txt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourcePath: '/home/user/files/plz-upload/file2.txt',
|
|
||||||
destinationPath: 'file2.txt'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const mockZipStream = {
|
|
||||||
entry: jest.fn((source, data, callback) => {
|
|
||||||
const err = (new NodeJSError(
|
|
||||||
"ENOENT: no such file or directory, open '/home/user/files/plz-upload/file1.txt'"
|
|
||||||
).code = 'ENOENT')
|
|
||||||
callback(null, err)
|
|
||||||
}),
|
|
||||||
pipe: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
finalize: jest.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('zip-stream', () => {
|
|
||||||
return {
|
|
||||||
default: jest.fn().mockImplementation(() => mockZipStream)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(zip, 'createZipUploadStream')
|
|
||||||
.mockReturnValue(
|
|
||||||
Promise.reject(
|
|
||||||
new NodeJSError(
|
|
||||||
"ENOENT: no such file or directory, open '/home/user/files/plz-upload/file1.txt'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
jest.spyOn(util, 'getBackendIdsFromToken').mockReturnValue({
|
|
||||||
workflowRunBackendId: '1234',
|
|
||||||
workflowJobRunBackendId: '5678'
|
|
||||||
})
|
|
||||||
jest
|
|
||||||
.spyOn(retention, 'getExpiration')
|
|
||||||
.mockReturnValue(Timestamp.fromDate(mockDate))
|
|
||||||
jest
|
|
||||||
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
|
|
||||||
.mockReturnValue(
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
signedUploadUrl: 'https://signed-upload-url.com'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest.spyOn(blobUpload, 'uploadZipToBlobStorage').mockReturnValue(
|
|
||||||
Promise.resolve({
|
|
||||||
uploadSize: 1234,
|
|
||||||
sha256Hash: 'test-sha256-hash'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest
|
|
||||||
.spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact')
|
|
||||||
.mockReturnValue(Promise.resolve({ok: true, artifactId: '1'}))
|
|
||||||
|
|
||||||
// ArtifactHttpClient mocks
|
|
||||||
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('test-token')
|
|
||||||
jest
|
|
||||||
.spyOn(config, 'getResultsServiceUrl')
|
|
||||||
.mockReturnValue('https://test-url.com')
|
|
||||||
|
|
||||||
const uploadResp = uploadArtifact(
|
|
||||||
'test-artifact',
|
|
||||||
[
|
|
||||||
'/home/user/files/plz-upload/file1.txt',
|
|
||||||
'/home/user/files/plz-upload/file2.txt',
|
|
||||||
'/home/user/files/plz-upload/dir/file3.txt'
|
|
||||||
],
|
|
||||||
'/home/user/files/plz-upload'
|
|
||||||
)
|
|
||||||
expect(uploadResp).rejects.toThrowError(InvalidResponseError)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+149
-868
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "2.1.6",
|
"version": "2.1.4",
|
||||||
"preview": true,
|
"preview": true,
|
||||||
"description": "Actions artifact lib",
|
"description": "Actions artifact lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -50,14 +50,13 @@
|
|||||||
"@octokit/request-error": "^5.0.0",
|
"@octokit/request-error": "^5.0.0",
|
||||||
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"async": "^3.2.5",
|
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"twirp-ts": "^2.5.0",
|
"twirp-ts": "^2.5.0",
|
||||||
"unzip-stream": "^0.3.1",
|
"unzip-stream": "^0.3.1"
|
||||||
"zip-stream": "^6.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/archiver": "^5.3.2",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
"typedoc": "^0.25.4",
|
"typedoc": "^0.25.4",
|
||||||
"typedoc-plugin-markdown": "^3.17.1",
|
"typedoc-plugin-markdown": "^3.17.1",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class ArtifactHttpClient implements Rpc {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
debug(`Raw Body: ${rawBody}`)
|
debug(`Raw Body: ${rawBody}`)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof UsageError) {
|
if (error instanceof UsageError) {
|
||||||
|
|||||||
@@ -24,30 +24,11 @@ export async function uploadZipToBlobStorage(
|
|||||||
zipUploadStream: ZipUploadStream
|
zipUploadStream: ZipUploadStream
|
||||||
): Promise<BlobUploadResponse> {
|
): Promise<BlobUploadResponse> {
|
||||||
let uploadByteCount = 0
|
let uploadByteCount = 0
|
||||||
let lastProgressTime = Date.now()
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined
|
|
||||||
|
|
||||||
const chunkTimer = (timeout: number): NodeJS.Timeout => {
|
|
||||||
// clear the previous timeout
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
// if there's been more than 30 seconds since the
|
|
||||||
// last progress event, then we'll consider the upload stalled
|
|
||||||
if (now - lastProgressTime > timeout) {
|
|
||||||
throw new Error('Upload progress stalled.')
|
|
||||||
}
|
|
||||||
}, timeout)
|
|
||||||
return timeoutId
|
|
||||||
}
|
|
||||||
const maxConcurrency = getConcurrency()
|
const maxConcurrency = getConcurrency()
|
||||||
const bufferSize = getUploadChunkSize()
|
const bufferSize = getUploadChunkSize()
|
||||||
const blobClient = new BlobClient(authenticatedUploadURL)
|
const blobClient = new BlobClient(authenticatedUploadURL)
|
||||||
const blockBlobClient = blobClient.getBlockBlobClient()
|
const blockBlobClient = blobClient.getBlockBlobClient()
|
||||||
const timeoutDuration = 300000 // 30 seconds
|
|
||||||
|
|
||||||
core.debug(
|
core.debug(
|
||||||
`Uploading artifact zip to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}`
|
`Uploading artifact zip to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}`
|
||||||
@@ -56,8 +37,6 @@ export async function uploadZipToBlobStorage(
|
|||||||
const uploadCallback = (progress: TransferProgressEvent): void => {
|
const uploadCallback = (progress: TransferProgressEvent): void => {
|
||||||
core.info(`Uploaded bytes ${progress.loadedBytes}`)
|
core.info(`Uploaded bytes ${progress.loadedBytes}`)
|
||||||
uploadByteCount = progress.loadedBytes
|
uploadByteCount = progress.loadedBytes
|
||||||
chunkTimer(timeoutDuration)
|
|
||||||
lastProgressTime = Date.now()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const options: BlockBlobUploadStreamOptions = {
|
const options: BlockBlobUploadStreamOptions = {
|
||||||
@@ -75,8 +54,6 @@ export async function uploadZipToBlobStorage(
|
|||||||
core.info('Beginning upload of artifact content to blob storage')
|
core.info('Beginning upload of artifact content to blob storage')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start the chunk timer
|
|
||||||
timeoutId = chunkTimer(timeoutDuration)
|
|
||||||
await blockBlobClient.uploadStream(
|
await blockBlobClient.uploadStream(
|
||||||
uploadStream,
|
uploadStream,
|
||||||
bufferSize,
|
bufferSize,
|
||||||
@@ -87,12 +64,8 @@ export async function uploadZipToBlobStorage(
|
|||||||
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
||||||
throw new NetworkError(error?.code)
|
throw new NetworkError(error?.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
|
||||||
// clear the timeout whether or not the upload completes
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
core.info('Finished uploading artifact content to blob storage!')
|
core.info('Finished uploading artifact content to blob storage!')
|
||||||
@@ -106,6 +79,7 @@ export async function uploadZipToBlobStorage(
|
|||||||
`No data was uploaded to blob storage. Reported upload byte count is 0.`
|
`No data was uploaded to blob storage. Reported upload byte count is 0.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadSize: uploadByteCount,
|
uploadSize: uploadByteCount,
|
||||||
sha256Hash
|
sha256Hash
|
||||||
|
|||||||
@@ -67,25 +67,18 @@ export async function uploadArtifact(
|
|||||||
'CreateArtifact: response from backend was not ok'
|
'CreateArtifact: response from backend was not ok'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Create the zipupload stream for use in blob upload
|
|
||||||
const zipUploadStream = await createZipUploadStream(
|
const zipUploadStream = await createZipUploadStream(
|
||||||
zipSpecification,
|
zipSpecification,
|
||||||
options?.compressionLevel
|
options?.compressionLevel
|
||||||
).catch(err => {
|
)
|
||||||
throw new InvalidResponseError(
|
|
||||||
`createZipUploadStream: response from backend was not ok: ${err}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Upload zip to blob storage
|
// Upload zip to blob storage
|
||||||
const uploadResult = await uploadZipToBlobStorage(
|
const uploadResult = await uploadZipToBlobStorage(
|
||||||
createArtifactResp.signedUploadUrl,
|
createArtifactResp.signedUploadUrl,
|
||||||
zipUploadStream
|
zipUploadStream
|
||||||
).catch(err => {
|
)
|
||||||
throw new InvalidResponseError(
|
|
||||||
`uploadZipToBlobStorage: response blob was not ok: ${err}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
// finalize the artifact
|
// finalize the artifact
|
||||||
const finalizeArtifactReq: FinalizeArtifactRequest = {
|
const finalizeArtifactReq: FinalizeArtifactRequest = {
|
||||||
workflowRunBackendId: backendIds.workflowRunBackendId,
|
workflowRunBackendId: backendIds.workflowRunBackendId,
|
||||||
@@ -93,12 +86,15 @@ export async function uploadArtifact(
|
|||||||
name,
|
name,
|
||||||
size: uploadResult.uploadSize ? uploadResult.uploadSize.toString() : '0'
|
size: uploadResult.uploadSize ? uploadResult.uploadSize.toString() : '0'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadResult.sha256Hash) {
|
if (uploadResult.sha256Hash) {
|
||||||
finalizeArtifactReq.hash = StringValue.create({
|
finalizeArtifactReq.hash = StringValue.create({
|
||||||
value: `sha256:${uploadResult.sha256Hash}`
|
value: `sha256:${uploadResult.sha256Hash}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
core.info(`Finalizing artifact upload`)
|
core.info(`Finalizing artifact upload`)
|
||||||
|
|
||||||
const finalizeArtifactResp =
|
const finalizeArtifactResp =
|
||||||
await artifactClient.FinalizeArtifact(finalizeArtifactReq)
|
await artifactClient.FinalizeArtifact(finalizeArtifactReq)
|
||||||
if (!finalizeArtifactResp.ok) {
|
if (!finalizeArtifactResp.ok) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as stream from 'stream'
|
import * as stream from 'stream'
|
||||||
import * as ZipStream from 'zip-stream'
|
import * as archiver from 'archiver'
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import async from 'async'
|
|
||||||
import {createReadStream} from 'fs'
|
import {createReadStream} from 'fs'
|
||||||
import {UploadZipSpecification} from './upload-zip-specification'
|
import {UploadZipSpecification} from './upload-zip-specification'
|
||||||
import {getUploadChunkSize} from '../shared/config'
|
import {getUploadChunkSize} from '../shared/config'
|
||||||
@@ -31,57 +30,31 @@ export async function createZipUploadStream(
|
|||||||
`Creating Artifact archive with compressionLevel: ${compressionLevel}`
|
`Creating Artifact archive with compressionLevel: ${compressionLevel}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const zlibOptions = {
|
const zip = archiver.create('zip', {
|
||||||
zlib: {
|
highWaterMark: getUploadChunkSize(),
|
||||||
level: compressionLevel,
|
zlib: {level: compressionLevel}
|
||||||
bufferSize: getUploadChunkSize()
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
const zip = new ZipStream.default(zlibOptions)
|
|
||||||
|
|
||||||
const bufferSize = getUploadChunkSize()
|
|
||||||
const zipUploadStream = new ZipUploadStream(bufferSize)
|
|
||||||
zip.pipe(zipUploadStream)
|
|
||||||
// register callbacks for various events during the zip lifecycle
|
// register callbacks for various events during the zip lifecycle
|
||||||
zip.on('error', zipErrorCallback)
|
zip.on('error', zipErrorCallback)
|
||||||
zip.on('warning', zipWarningCallback)
|
zip.on('warning', zipWarningCallback)
|
||||||
zip.on('finish', zipFinishCallback)
|
zip.on('finish', zipFinishCallback)
|
||||||
zip.on('end', zipEndCallback)
|
zip.on('end', zipEndCallback)
|
||||||
const addFileToZip = (
|
|
||||||
file: UploadZipSpecification,
|
for (const file of uploadSpecification) {
|
||||||
callback: (error?: Error) => void
|
|
||||||
): void => {
|
|
||||||
if (file.sourcePath !== null) {
|
if (file.sourcePath !== null) {
|
||||||
zip.entry(
|
// Add a normal file to the zip
|
||||||
createReadStream(file.sourcePath),
|
zip.append(createReadStream(file.sourcePath), {
|
||||||
{name: file.destinationPath},
|
name: file.destinationPath
|
||||||
(error: unknown) => {
|
|
||||||
if (error) {
|
|
||||||
callback(error as Error) // Cast the error object to the Error type
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
zip.entry('', {name: file.destinationPath}, (error: unknown) => {
|
|
||||||
if (error) {
|
|
||||||
callback(error as Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callback()
|
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// Add a directory to the zip
|
||||||
|
zip.append('', {name: file.destinationPath})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async.eachSeries(uploadSpecification, addFileToZip, (error: unknown) => {
|
const bufferSize = getUploadChunkSize()
|
||||||
if (error) {
|
const zipUploadStream = new ZipUploadStream(bufferSize)
|
||||||
core.error('Failed to add a file to the zip:')
|
|
||||||
core.info(error.toString()) // Convert error to string
|
|
||||||
return
|
|
||||||
}
|
|
||||||
zip.finalize() // Finalize the archive once all files have been added
|
|
||||||
})
|
|
||||||
|
|
||||||
core.debug(
|
core.debug(
|
||||||
`Zip write high watermark value ${zipUploadStream.writableHighWaterMark}`
|
`Zip write high watermark value ${zipUploadStream.writableHighWaterMark}`
|
||||||
@@ -90,6 +63,9 @@ export async function createZipUploadStream(
|
|||||||
`Zip read high watermark value ${zipUploadStream.readableHighWaterMark}`
|
`Zip read high watermark value ${zipUploadStream.readableHighWaterMark}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
zip.pipe(zipUploadStream)
|
||||||
|
zip.finalize()
|
||||||
|
|
||||||
return zipUploadStream
|
return zipUploadStream
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +76,7 @@ const zipErrorCallback = (error: any): void => {
|
|||||||
|
|
||||||
throw new Error('An error has occurred during zip creation for the artifact')
|
throw new Error('An error has occurred during zip creation for the artifact')
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const zipWarningCallback = (error: any): void => {
|
const zipWarningCallback = (error: any): void => {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
|
|||||||
@@ -112,10 +112,6 @@ export type AttestProvenanceOptions = {
|
|||||||
sigstore?: 'public-good' | 'github'
|
sigstore?: 'public-good' | 'github'
|
||||||
// Whether to skip writing the attestation to the GH attestations API.
|
// Whether to skip writing the attestation to the GH attestations API.
|
||||||
skipWrite?: boolean
|
skipWrite?: boolean
|
||||||
// Issuer URL responsible for minting the OIDC token from which the
|
|
||||||
// provenance data is read. Defaults to
|
|
||||||
// 'https://token.actions.githubusercontent.com".
|
|
||||||
issuer?: string
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
# @actions/attest Releases
|
# @actions/attest Releases
|
||||||
|
|
||||||
### 1.2.0
|
|
||||||
|
|
||||||
- Generate attestations using the v0.3 Sigstore bundle format.
|
|
||||||
- Bump @sigstore/bundle from 2.2.0 to 2.3.0.
|
|
||||||
- Bump @sigstore/sign from 2.2.3 to 2.3.0.
|
|
||||||
- Remove dependency on make-fetch-happen
|
|
||||||
|
|
||||||
### 1.1.0
|
|
||||||
|
|
||||||
- Updates the `attestProvenance` function to retrieve a token from the GitHub OIDC provider and use the token claims to populate the provenance statement.
|
|
||||||
|
|
||||||
### 1.0.0
|
### 1.0.0
|
||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`buildIntotoStatement returns an intoto statement 1`] = `
|
exports[`buildIntotoStatement returns a provenance hydrated from env vars 1`] = `
|
||||||
{
|
{
|
||||||
"_type": "https://in-toto.io/Statement/v1",
|
"_type": "https://in-toto.io/Statement/v1",
|
||||||
"predicate": {
|
"predicate": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = `
|
exports[`buildSLSAProvenancePredicate returns a provenance hydrated from env vars 1`] = `
|
||||||
{
|
{
|
||||||
"params": {
|
"params": {
|
||||||
"buildDefinition": {
|
"buildDefinition": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('buildIntotoStatement', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns an intoto statement', () => {
|
it('returns a provenance hydrated from env vars', () => {
|
||||||
const statement = buildIntotoStatement(subject, predicate)
|
const statement = buildIntotoStatement(subject, predicate)
|
||||||
expect(statement).toMatchSnapshot()
|
expect(statement).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import * as jose from 'jose'
|
|
||||||
import nock from 'nock'
|
|
||||||
import {getIDTokenClaims} from '../src/oidc'
|
|
||||||
|
|
||||||
describe('getIDTokenClaims', () => {
|
|
||||||
const originalEnv = process.env
|
|
||||||
const issuer = 'https://example.com'
|
|
||||||
const audience = 'nobody'
|
|
||||||
const requestToken = 'token'
|
|
||||||
const openidConfigPath = '/.well-known/openid-configuration'
|
|
||||||
const jwksPath = '/.well-known/jwks.json'
|
|
||||||
const tokenPath = '/token'
|
|
||||||
const openIDConfig = {jwks_uri: `${issuer}${jwksPath}`}
|
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
||||||
let key: any
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
process.env = {
|
|
||||||
...originalEnv,
|
|
||||||
ACTIONS_ID_TOKEN_REQUEST_URL: `${issuer}${tokenPath}?`,
|
|
||||||
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT signing key
|
|
||||||
key = await jose.generateKeyPair('PS256')
|
|
||||||
|
|
||||||
// Create JWK and JWKS
|
|
||||||
const jwk = await jose.exportJWK(key.publicKey)
|
|
||||||
const jwks = {keys: [jwk]}
|
|
||||||
|
|
||||||
nock(issuer).get(openidConfigPath).reply(200, openIDConfig)
|
|
||||||
nock(issuer).get(jwksPath).reply(200, jwks)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when ID token is valid', () => {
|
|
||||||
const claims = {
|
|
||||||
iss: issuer,
|
|
||||||
aud: audience,
|
|
||||||
ref: 'ref',
|
|
||||||
sha: 'sha',
|
|
||||||
repository: 'repo',
|
|
||||||
event_name: 'push',
|
|
||||||
workflow_ref: 'main',
|
|
||||||
repository_id: '1',
|
|
||||||
repository_owner_id: '1',
|
|
||||||
runner_environment: 'github-hosted',
|
|
||||||
run_id: '1',
|
|
||||||
run_attempt: '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the ID token claims', async () => {
|
|
||||||
const result = await getIDTokenClaims(issuer)
|
|
||||||
expect(result).toEqual(claims)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when ID token is missing required claims', () => {
|
|
||||||
const claims = {
|
|
||||||
iss: issuer,
|
|
||||||
aud: audience
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws an error', async () => {
|
|
||||||
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/missing claims/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when ID has the wrong issuer', () => {
|
|
||||||
const claims = {foo: 'bar', iss: 'foo', aud: 'nobody'}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws an error', async () => {
|
|
||||||
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/issuer invalid/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when ID has the wrong audience', () => {
|
|
||||||
const claims = {foo: 'bar', iss: issuer, aud: 'bar'}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throw an error', async () => {
|
|
||||||
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/audience invalid/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when openid config cannot be retrieved', () => {
|
|
||||||
const claims = {foo: 'bar', iss: issuer, aud: 'nobody'}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
|
|
||||||
// Disable the openid config endpoint
|
|
||||||
nock.removeInterceptor({
|
|
||||||
proto: 'https',
|
|
||||||
hostname: 'example.com',
|
|
||||||
port: '443',
|
|
||||||
method: 'GET',
|
|
||||||
path: openidConfigPath
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws an error', async () => {
|
|
||||||
await expect(getIDTokenClaims(issuer)).rejects.toThrow(
|
|
||||||
/failed to get id/i
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,252 +1,213 @@
|
|||||||
import * as github from '@actions/github'
|
import * as github from '@actions/github'
|
||||||
import {mockFulcio, mockRekor, mockTSA} from '@sigstore/mock'
|
import {mockFulcio, mockRekor, mockTSA} from '@sigstore/mock'
|
||||||
import * as jose from 'jose'
|
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import {MockAgent, setGlobalDispatcher} from 'undici'
|
|
||||||
import {SIGSTORE_GITHUB, SIGSTORE_PUBLIC_GOOD} from '../src/endpoints'
|
import {SIGSTORE_GITHUB, SIGSTORE_PUBLIC_GOOD} from '../src/endpoints'
|
||||||
import {attestProvenance, buildSLSAProvenancePredicate} from '../src/provenance'
|
import {attestProvenance, buildSLSAProvenancePredicate} from '../src/provenance'
|
||||||
|
|
||||||
describe('provenance functions', () => {
|
// Dummy workflow environment
|
||||||
|
const env = {
|
||||||
|
GITHUB_REPOSITORY: 'owner/repo',
|
||||||
|
GITHUB_REF: 'refs/heads/main',
|
||||||
|
GITHUB_SHA: 'babca52ab0c93ae16539e5923cb0d7403b9a093b',
|
||||||
|
GITHUB_WORKFLOW_REF: 'owner/repo/.github/workflows/main.yml@main',
|
||||||
|
GITHUB_SERVER_URL: 'https://github.com',
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REPOSITORY_ID: 'repo-id',
|
||||||
|
GITHUB_REPOSITORY_OWNER_ID: 'owner-id',
|
||||||
|
GITHUB_RUN_ID: 'run-id',
|
||||||
|
GITHUB_RUN_ATTEMPT: 'run-attempt',
|
||||||
|
RUNNER_ENVIRONMENT: 'github-hosted'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSLSAProvenancePredicate', () => {
|
||||||
|
it('returns a provenance hydrated from env vars', () => {
|
||||||
|
const predicate = buildSLSAProvenancePredicate(env)
|
||||||
|
expect(predicate).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('attestProvenance', () => {
|
||||||
|
// Capture original environment variables so we can restore them after each
|
||||||
|
// test
|
||||||
const originalEnv = process.env
|
const originalEnv = process.env
|
||||||
const issuer = 'https://example.com'
|
|
||||||
const audience = 'nobody'
|
|
||||||
const jwksPath = '/.well-known/jwks.json'
|
|
||||||
const tokenPath = '/token'
|
|
||||||
|
|
||||||
// MockAgent for mocking @actions/github
|
// Subject to attest
|
||||||
const mockAgent = new MockAgent()
|
const subjectName = 'subjective'
|
||||||
setGlobalDispatcher(mockAgent)
|
const subjectDigest = {
|
||||||
|
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
const claims = {
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'nobody',
|
|
||||||
repository: 'owner/repo',
|
|
||||||
ref: 'refs/heads/main',
|
|
||||||
sha: 'babca52ab0c93ae16539e5923cb0d7403b9a093b',
|
|
||||||
workflow_ref: 'owner/repo/.github/workflows/main.yml@main',
|
|
||||||
event_name: 'push',
|
|
||||||
repository_id: 'repo-id',
|
|
||||||
repository_owner_id: 'owner-id',
|
|
||||||
run_id: 'run-id',
|
|
||||||
run_attempt: 'run-attempt',
|
|
||||||
runner_environment: 'github-hosted'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fake an OIDC token
|
||||||
|
const oidcPayload = {sub: '[email protected]', iss: ''}
|
||||||
|
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
||||||
|
'base64'
|
||||||
|
)}.}`
|
||||||
|
|
||||||
|
const tokenURL = 'https://token.url'
|
||||||
|
const attestationID = '1234567890'
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
nock(tokenURL)
|
||||||
|
.get('/')
|
||||||
|
.query({audience: 'sigstore'})
|
||||||
|
.reply(200, {value: oidcToken})
|
||||||
|
|
||||||
|
// Set-up GHA environment variables
|
||||||
process.env = {
|
process.env = {
|
||||||
...originalEnv,
|
...originalEnv,
|
||||||
ACTIONS_ID_TOKEN_REQUEST_URL: `${issuer}${tokenPath}?`,
|
...env,
|
||||||
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token',
|
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
|
||||||
GITHUB_SERVER_URL: 'https://github.com',
|
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token'
|
||||||
GITHUB_REPOSITORY: claims.repository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT signing key
|
|
||||||
const key = await jose.generateKeyPair('PS256')
|
|
||||||
|
|
||||||
// Create JWK, JWKS, and JWT
|
|
||||||
const jwk = await jose.exportJWK(key.publicKey)
|
|
||||||
const jwks = {keys: [jwk]}
|
|
||||||
const jwt = await new jose.SignJWT(claims)
|
|
||||||
.setProtectedHeader({alg: 'PS256'})
|
|
||||||
.sign(key.privateKey)
|
|
||||||
|
|
||||||
// Mock OpenID configuration and JWKS endpoints
|
|
||||||
nock(issuer)
|
|
||||||
.get('/.well-known/openid-configuration')
|
|
||||||
.reply(200, {jwks_uri: `${issuer}${jwksPath}`})
|
|
||||||
nock(issuer).get(jwksPath).reply(200, jwks)
|
|
||||||
|
|
||||||
// Mock OIDC token endpoint for populating the provenance
|
|
||||||
nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
// Restore the original environment
|
||||||
process.env = originalEnv
|
process.env = originalEnv
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildSLSAProvenancePredicate', () => {
|
describe('when using the github Sigstore instance', () => {
|
||||||
it('returns a provenance hydrated from an OIDC token', async () => {
|
const {fulcioURL, tsaServerURL} = SIGSTORE_GITHUB
|
||||||
const predicate = await buildSLSAProvenancePredicate(issuer)
|
|
||||||
expect(predicate).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('attestProvenance', () => {
|
|
||||||
// Subject to attest
|
|
||||||
const subjectName = 'subjective'
|
|
||||||
const subjectDigest = {
|
|
||||||
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fake an OIDC token
|
|
||||||
const oidcPayload = {sub: '[email protected]', iss: ''}
|
|
||||||
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
|
||||||
'base64'
|
|
||||||
)}.}`
|
|
||||||
|
|
||||||
const attestationID = '1234567890'
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
nock(issuer)
|
// Mock Sigstore
|
||||||
.get(tokenPath)
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
.query({audience: 'sigstore'})
|
await mockTSA({baseURL: tsaServerURL})
|
||||||
.reply(200, {value: oidcToken})
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, {id: attestationID})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when using the github Sigstore instance', () => {
|
describe('when the sigstore instance is explicitly set', () => {
|
||||||
const {fulcioURL, tsaServerURL} = SIGSTORE_GITHUB
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
beforeEach(async () => {
|
subjectName,
|
||||||
// Mock Sigstore
|
subjectDigest,
|
||||||
await mockFulcio({baseURL: fulcioURL, strict: false})
|
token: 'token',
|
||||||
await mockTSA({baseURL: tsaServerURL})
|
sigstore: 'github'
|
||||||
|
|
||||||
mockAgent
|
|
||||||
.get('https://api.github.com')
|
|
||||||
.intercept({
|
|
||||||
path: /^\/repos\/.*\/.*\/attestations$/,
|
|
||||||
method: 'post'
|
|
||||||
})
|
|
||||||
.reply(201, {id: attestationID})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when the sigstore instance is explicitly set', () => {
|
|
||||||
it('attests provenance', async () => {
|
|
||||||
const attestation = await attestProvenance({
|
|
||||||
subjectName,
|
|
||||||
subjectDigest,
|
|
||||||
token: 'token',
|
|
||||||
sigstore: 'github',
|
|
||||||
issuer
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(attestation).toBeDefined()
|
|
||||||
expect(attestation.bundle).toBeDefined()
|
|
||||||
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
|
||||||
expect(attestation.tlogID).toBeUndefined()
|
|
||||||
expect(attestation.attestationID).toBe(attestationID)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
|
||||||
const savedRepository = github.context.payload.repository
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
||||||
github.context.payload.repository = {visibility: 'private'} as any
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
expect(attestation).toBeDefined()
|
||||||
github.context.payload.repository = savedRepository
|
expect(attestation.bundle).toBeDefined()
|
||||||
})
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeUndefined()
|
||||||
it('attests provenance', async () => {
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
const attestation = await attestProvenance({
|
|
||||||
subjectName,
|
|
||||||
subjectDigest,
|
|
||||||
token: 'token',
|
|
||||||
issuer
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(attestation).toBeDefined()
|
|
||||||
expect(attestation.bundle).toBeDefined()
|
|
||||||
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
|
||||||
expect(attestation.tlogID).toBeUndefined()
|
|
||||||
expect(attestation.attestationID).toBe(attestationID)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when using the public-good Sigstore instance', () => {
|
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
||||||
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
const savedRepository = github.context.payload.repository
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
// Mock Sigstore
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
await mockFulcio({baseURL: fulcioURL, strict: false})
|
github.context.payload.repository = {visibility: 'private'} as any
|
||||||
await mockRekor({baseURL: rekorURL})
|
|
||||||
|
|
||||||
// Mock GH attestations API
|
|
||||||
mockAgent
|
|
||||||
.get('https://api.github.com')
|
|
||||||
.intercept({
|
|
||||||
path: /^\/repos\/.*\/.*\/attestations$/,
|
|
||||||
method: 'post'
|
|
||||||
})
|
|
||||||
.reply(201, {id: attestationID})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the sigstore instance is explicitly set', () => {
|
afterEach(() => {
|
||||||
it('attests provenance', async () => {
|
github.context.payload.repository = savedRepository
|
||||||
const attestation = await attestProvenance({
|
|
||||||
subjectName,
|
|
||||||
subjectDigest,
|
|
||||||
token: 'token',
|
|
||||||
sigstore: 'public-good',
|
|
||||||
issuer
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(attestation).toBeDefined()
|
|
||||||
expect(attestation.bundle).toBeDefined()
|
|
||||||
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
|
||||||
expect(attestation.tlogID).toBeDefined()
|
|
||||||
expect(attestation.attestationID).toBe(attestationID)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
|
||||||
const savedRepository = github.context.payload.repository
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
||||||
github.context.payload.repository = {visibility: 'public'} as any
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
github.context.payload.repository = savedRepository
|
|
||||||
})
|
|
||||||
|
|
||||||
it('attests provenance', async () => {
|
|
||||||
const attestation = await attestProvenance({
|
|
||||||
subjectName,
|
|
||||||
subjectDigest,
|
|
||||||
token: 'token',
|
|
||||||
issuer
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(attestation).toBeDefined()
|
|
||||||
expect(attestation.bundle).toBeDefined()
|
|
||||||
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
|
||||||
expect(attestation.tlogID).toBeDefined()
|
|
||||||
expect(attestation.attestationID).toBe(attestationID)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when skipWrite is set to true', () => {
|
|
||||||
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Mock Sigstore
|
|
||||||
await mockFulcio({baseURL: fulcioURL, strict: false})
|
|
||||||
await mockRekor({baseURL: rekorURL})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeUndefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when using the public-good Sigstore instance', () => {
|
||||||
|
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock Sigstore
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockRekor({baseURL: rekorURL})
|
||||||
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, {id: attestationID})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is explicitly set', () => {
|
||||||
it('attests provenance', async () => {
|
it('attests provenance', async () => {
|
||||||
const attestation = await attestProvenance({
|
const attestation = await attestProvenance({
|
||||||
subjectName,
|
subjectName,
|
||||||
subjectDigest,
|
subjectDigest,
|
||||||
token: 'token',
|
token: 'token',
|
||||||
sigstore: 'public-good',
|
sigstore: 'public-good'
|
||||||
skipWrite: true,
|
|
||||||
issuer
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(attestation).toBeDefined()
|
expect(attestation).toBeDefined()
|
||||||
expect(attestation.bundle).toBeDefined()
|
expect(attestation.bundle).toBeDefined()
|
||||||
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
expect(attestation.tlogID).toBeDefined()
|
expect(attestation.tlogID).toBeDefined()
|
||||||
expect(attestation.attestationID).toBeUndefined()
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
||||||
|
const savedRepository = github.context.payload.repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
github.context.payload.repository = {visibility: 'public'} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
github.context.payload.repository = savedRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('when skipWrite is set to true', () => {
|
||||||
|
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock Sigstore
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockRekor({baseURL: rekorURL})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token',
|
||||||
|
sigstore: 'public-good',
|
||||||
|
skipWrite: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ describe('signProvenance', () => {
|
|||||||
|
|
||||||
expect(att).toBeDefined()
|
expect(att).toBeDefined()
|
||||||
expect(att.mediaType).toEqual(
|
expect(att.mediaType).toEqual(
|
||||||
'application/vnd.dev.sigstore.bundle.v0.3+json'
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(att.content.$case).toEqual('dsseEnvelope')
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
expect(att.verificationMaterial.content.$case).toEqual('certificate')
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
expect(att.verificationMaterial.tlogEntries).toHaveLength(1)
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(1)
|
||||||
expect(
|
expect(
|
||||||
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
@@ -87,11 +89,13 @@ describe('signProvenance', () => {
|
|||||||
|
|
||||||
expect(att).toBeDefined()
|
expect(att).toBeDefined()
|
||||||
expect(att.mediaType).toEqual(
|
expect(att.mediaType).toEqual(
|
||||||
'application/vnd.dev.sigstore.bundle.v0.3+json'
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(att.content.$case).toEqual('dsseEnvelope')
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
expect(att.verificationMaterial.content.$case).toEqual('certificate')
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
expect(att.verificationMaterial.tlogEntries).toHaveLength(0)
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(0)
|
||||||
expect(
|
expect(
|
||||||
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {MockAgent, setGlobalDispatcher} from 'undici'
|
import nock from 'nock'
|
||||||
import {writeAttestation} from '../src/store'
|
import {writeAttestation} from '../src/store'
|
||||||
|
|
||||||
describe('writeAttestation', () => {
|
describe('writeAttestation', () => {
|
||||||
@@ -6,9 +6,6 @@ describe('writeAttestation', () => {
|
|||||||
const attestation = {foo: 'bar '}
|
const attestation = {foo: 'bar '}
|
||||||
const token = 'token'
|
const token = 'token'
|
||||||
|
|
||||||
const mockAgent = new MockAgent()
|
|
||||||
setGlobalDispatcher(mockAgent)
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = {
|
process.env = {
|
||||||
...originalEnv,
|
...originalEnv,
|
||||||
@@ -22,14 +19,9 @@ describe('writeAttestation', () => {
|
|||||||
|
|
||||||
describe('when the api call is successful', () => {
|
describe('when the api call is successful', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockAgent
|
nock('https://api.github.com')
|
||||||
.get('https://api.github.com')
|
.matchHeader('authorization', `token ${token}`)
|
||||||
.intercept({
|
.post('/repos/foo/bar/attestations', {bundle: attestation})
|
||||||
path: '/repos/foo/bar/attestations',
|
|
||||||
method: 'POST',
|
|
||||||
headers: {authorization: `token ${token}`},
|
|
||||||
body: JSON.stringify({bundle: attestation})
|
|
||||||
})
|
|
||||||
.reply(201, {id: '123'})
|
.reply(201, {id: '123'})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,18 +32,13 @@ describe('writeAttestation', () => {
|
|||||||
|
|
||||||
describe('when the api call fails', () => {
|
describe('when the api call fails', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockAgent
|
nock('https://api.github.com')
|
||||||
.get('https://api.github.com')
|
.matchHeader('authorization', `token ${token}`)
|
||||||
.intercept({
|
.post('/repos/foo/bar/attestations', {bundle: attestation})
|
||||||
path: '/repos/foo/bar/attestations',
|
|
||||||
method: 'POST',
|
|
||||||
headers: {authorization: `token ${token}`},
|
|
||||||
body: JSON.stringify({bundle: attestation})
|
|
||||||
})
|
|
||||||
.reply(500, 'oops')
|
.reply(500, 'oops')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws an error', async () => {
|
it('persists the attestation', async () => {
|
||||||
await expect(writeAttestation(attestation, token)).rejects.toThrow(/oops/)
|
await expect(writeAttestation(attestation, token)).rejects.toThrow(/oops/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+223
-564
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/attest",
|
"name": "@actions/attest",
|
||||||
"version": "1.2.0",
|
"version": "1.0.0",
|
||||||
"description": "Actions attestation lib",
|
"description": "Actions attestation lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"github",
|
"github",
|
||||||
@@ -37,18 +37,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sigstore/mock": "^0.6.5",
|
"@sigstore/mock": "^0.6.5",
|
||||||
"@sigstore/rekor-types": "^2.0.0",
|
"@sigstore/rekor-types": "^2.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/make-fetch-happen": "^10.0.4",
|
||||||
"jose": "^5.2.3",
|
"nock": "^13.5.1"
|
||||||
"nock": "^13.5.1",
|
|
||||||
"undici": "^5.28.4"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
|
||||||
"@actions/github": "^6.0.0",
|
"@actions/github": "^6.0.0",
|
||||||
"@actions/http-client": "^2.2.1",
|
"@sigstore/bundle": "^2.2.0",
|
||||||
"@sigstore/bundle": "^2.3.0",
|
"@sigstore/sign": "^2.2.3",
|
||||||
"@sigstore/sign": "^2.3.0",
|
"make-fetch-happen": "^13.0.0"
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"jwks-rsa": "^3.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {bundleToJSON} from '@sigstore/bundle'
|
import {Bundle, bundleToJSON} from '@sigstore/bundle'
|
||||||
import {X509Certificate} from 'crypto'
|
import {X509Certificate} from 'crypto'
|
||||||
import {SigstoreInstance, signingEndpoints} from './endpoints'
|
import {SigstoreInstance, signingEndpoints} from './endpoints'
|
||||||
import {buildIntotoStatement} from './intoto'
|
import {buildIntotoStatement} from './intoto'
|
||||||
import {Payload, signPayload} from './sign'
|
import {Payload, signPayload} from './sign'
|
||||||
import {writeAttestation} from './store'
|
import {writeAttestation} from './store'
|
||||||
|
|
||||||
import type {Bundle} from '@sigstore/sign'
|
|
||||||
import type {Attestation, Predicate, Subject} from './shared.types'
|
import type {Attestation, Predicate, Subject} from './shared.types'
|
||||||
|
|
||||||
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
|
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import {getIDToken} from '@actions/core'
|
|
||||||
import {HttpClient} from '@actions/http-client'
|
|
||||||
import * as jwt from 'jsonwebtoken'
|
|
||||||
import jwks from 'jwks-rsa'
|
|
||||||
|
|
||||||
const OIDC_AUDIENCE = 'nobody'
|
|
||||||
|
|
||||||
const REQUIRED_CLAIMS = [
|
|
||||||
'iss',
|
|
||||||
'ref',
|
|
||||||
'sha',
|
|
||||||
'repository',
|
|
||||||
'event_name',
|
|
||||||
'workflow_ref',
|
|
||||||
'repository_id',
|
|
||||||
'repository_owner_id',
|
|
||||||
'runner_environment',
|
|
||||||
'run_id',
|
|
||||||
'run_attempt'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type ClaimSet = {[K in (typeof REQUIRED_CLAIMS)[number]]: string}
|
|
||||||
|
|
||||||
type OIDCConfig = {
|
|
||||||
jwks_uri: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getIDTokenClaims = async (issuer: string): Promise<ClaimSet> => {
|
|
||||||
try {
|
|
||||||
const token = await getIDToken(OIDC_AUDIENCE)
|
|
||||||
const claims = await decodeOIDCToken(token, issuer)
|
|
||||||
assertClaimSet(claims)
|
|
||||||
return claims
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to get ID token: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodeOIDCToken = async (
|
|
||||||
token: string,
|
|
||||||
issuer: string
|
|
||||||
): Promise<jwt.JwtPayload> => {
|
|
||||||
// Verify and decode token
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
jwt.verify(
|
|
||||||
token,
|
|
||||||
getPublicKey(issuer),
|
|
||||||
{audience: OIDC_AUDIENCE, issuer},
|
|
||||||
(err, decoded) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else if (!decoded || typeof decoded === 'string') {
|
|
||||||
reject(new Error('No decoded token'))
|
|
||||||
} else {
|
|
||||||
resolve(decoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a callback to locate the public key for the given JWT header. This
|
|
||||||
// involves two calls:
|
|
||||||
// 1. Fetch the OpenID configuration to get the JWKS URI.
|
|
||||||
// 2. Fetch the public key from the JWKS URI.
|
|
||||||
const getPublicKey =
|
|
||||||
(issuer: string): jwt.GetPublicKeyOrSecret =>
|
|
||||||
(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
|
|
||||||
// Look up the JWKS URI from the issuer's OpenID configuration
|
|
||||||
new HttpClient('actions/attest')
|
|
||||||
.getJson<OIDCConfig>(`${issuer}/.well-known/openid-configuration`)
|
|
||||||
.then(data => {
|
|
||||||
if (!data.result) {
|
|
||||||
callback(new Error('No OpenID configuration found'))
|
|
||||||
} else {
|
|
||||||
// Fetch the public key from the JWKS URI
|
|
||||||
jwks({jwksUri: data.result.jwks_uri}).getSigningKey(
|
|
||||||
header.kid,
|
|
||||||
(err, key) => {
|
|
||||||
callback(err, key?.getPublicKey())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
callback(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertClaimSet(claims: jwt.JwtPayload): asserts claims is ClaimSet {
|
|
||||||
const missingClaims: string[] = []
|
|
||||||
|
|
||||||
for (const claim of REQUIRED_CLAIMS) {
|
|
||||||
if (!(claim in claims)) {
|
|
||||||
missingClaims.push(claim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingClaims.length > 0) {
|
|
||||||
throw new Error(`Missing claims: ${missingClaims.join(', ')}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {attest, AttestOptions} from './attest'
|
import {attest, AttestOptions} from './attest'
|
||||||
import {getIDTokenClaims} from './oidc'
|
|
||||||
import type {Attestation, Predicate} from './shared.types'
|
import type {Attestation, Predicate} from './shared.types'
|
||||||
|
|
||||||
const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
|
const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
|
||||||
@@ -8,35 +7,30 @@ const GITHUB_BUILDER_ID_PREFIX = 'https://github.com/actions/runner'
|
|||||||
const GITHUB_BUILD_TYPE =
|
const GITHUB_BUILD_TYPE =
|
||||||
'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
|
'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
|
||||||
|
|
||||||
const DEFAULT_ISSUER = 'https://token.actions.githubusercontent.com'
|
|
||||||
|
|
||||||
export type AttestProvenanceOptions = Omit<
|
export type AttestProvenanceOptions = Omit<
|
||||||
AttestOptions,
|
AttestOptions,
|
||||||
'predicate' | 'predicateType'
|
'predicate' | 'predicateType'
|
||||||
> & {
|
>
|
||||||
issuer?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an SLSA (Supply Chain Levels for Software Artifacts) provenance
|
* Builds an SLSA (Supply Chain Levels for Software Artifacts) provenance
|
||||||
* predicate using the GitHub Actions Workflow build type.
|
* predicate using the GitHub Actions Workflow build type.
|
||||||
* https://slsa.dev/spec/v1.0/provenance
|
* https://slsa.dev/spec/v1.0/provenance
|
||||||
* https://github.com/slsa-framework/github-actions-buildtypes/tree/main/workflow/v1
|
* https://github.com/slsa-framework/github-actions-buildtypes/tree/main/workflow/v1
|
||||||
* @param issuer - URL for the OIDC issuer. Defaults to the GitHub Actions token
|
* @param env - The Node.js process environment variables. Defaults to
|
||||||
* issuer.
|
* `process.env`.
|
||||||
* @returns The SLSA provenance predicate.
|
* @returns The SLSA provenance predicate.
|
||||||
*/
|
*/
|
||||||
export const buildSLSAProvenancePredicate = async (
|
export const buildSLSAProvenancePredicate = (
|
||||||
issuer: string = DEFAULT_ISSUER
|
env: NodeJS.ProcessEnv = process.env
|
||||||
): Promise<Predicate> => {
|
): Predicate => {
|
||||||
const serverURL = process.env.GITHUB_SERVER_URL
|
const workflow = env.GITHUB_WORKFLOW_REF || ''
|
||||||
const claims = await getIDTokenClaims(issuer)
|
|
||||||
|
|
||||||
// Split just the path and ref from the workflow string.
|
// Split just the path and ref from the workflow string.
|
||||||
// owner/repo/.github/workflows/main.yml@main =>
|
// owner/repo/.github/workflows/main.yml@main =>
|
||||||
// .github/workflows/main.yml, main
|
// .github/workflows/main.yml, main
|
||||||
const [workflowPath, workflowRef] = claims.workflow_ref
|
const [workflowPath, workflowRef] = workflow
|
||||||
.replace(`${claims.repository}/`, '')
|
.replace(`${env.GITHUB_REPOSITORY}/`, '')
|
||||||
.split('@')
|
.split('@')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -47,32 +41,32 @@ export const buildSLSAProvenancePredicate = async (
|
|||||||
externalParameters: {
|
externalParameters: {
|
||||||
workflow: {
|
workflow: {
|
||||||
ref: workflowRef,
|
ref: workflowRef,
|
||||||
repository: `${serverURL}/${claims.repository}`,
|
repository: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`,
|
||||||
path: workflowPath
|
path: workflowPath
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
internalParameters: {
|
internalParameters: {
|
||||||
github: {
|
github: {
|
||||||
event_name: claims.event_name,
|
event_name: env.GITHUB_EVENT_NAME,
|
||||||
repository_id: claims.repository_id,
|
repository_id: env.GITHUB_REPOSITORY_ID,
|
||||||
repository_owner_id: claims.repository_owner_id
|
repository_owner_id: env.GITHUB_REPOSITORY_OWNER_ID
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resolvedDependencies: [
|
resolvedDependencies: [
|
||||||
{
|
{
|
||||||
uri: `git+${serverURL}/${claims.repository}@${claims.ref}`,
|
uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`,
|
||||||
digest: {
|
digest: {
|
||||||
gitCommit: claims.sha
|
gitCommit: env.GITHUB_SHA
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
runDetails: {
|
runDetails: {
|
||||||
builder: {
|
builder: {
|
||||||
id: `${GITHUB_BUILDER_ID_PREFIX}/${claims.runner_environment}`
|
id: `${GITHUB_BUILDER_ID_PREFIX}/${env.RUNNER_ENVIRONMENT}`
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
invocationId: `${serverURL}/${claims.repository}/actions/runs/${claims.run_id}/attempts/${claims.run_attempt}`
|
invocationId: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +84,7 @@ export const buildSLSAProvenancePredicate = async (
|
|||||||
export async function attestProvenance(
|
export async function attestProvenance(
|
||||||
options: AttestProvenanceOptions
|
options: AttestProvenanceOptions
|
||||||
): Promise<Attestation> {
|
): Promise<Attestation> {
|
||||||
const predicate = await buildSLSAProvenancePredicate(options.issuer)
|
const predicate = buildSLSAProvenancePredicate(process.env)
|
||||||
return attest({
|
return attest({
|
||||||
...options,
|
...options,
|
||||||
predicateType: predicate.type,
|
predicateType: predicate.type,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import {Bundle} from '@sigstore/bundle'
|
||||||
import {
|
import {
|
||||||
Bundle,
|
|
||||||
BundleBuilder,
|
BundleBuilder,
|
||||||
CIContextProvider,
|
CIContextProvider,
|
||||||
DSSEBundleBuilder,
|
DSSEBundleBuilder,
|
||||||
@@ -103,7 +103,5 @@ const initBundleBuilder = (opts: SignOptions): BundleBuilder => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the bundle with the singleCertificate option which will
|
return new DSSEBundleBuilder({signer, witnesses})
|
||||||
// trigger the creation of v0.3 DSSE bundles
|
|
||||||
return new DSSEBundleBuilder({signer, witnesses, singleCertificate: true})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as github from '@actions/github'
|
import * as github from '@actions/github'
|
||||||
|
import fetch from 'make-fetch-happen'
|
||||||
|
|
||||||
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
|
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export const writeAttestation = async (
|
|||||||
attestation: unknown,
|
attestation: unknown,
|
||||||
token: string
|
token: string
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const octokit = github.getOctokit(token)
|
const octokit = github.getOctokit(token, {request: {fetch}})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
|
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
|
||||||
@@ -22,11 +23,7 @@ export const writeAttestation = async (
|
|||||||
data: {bundle: attestation}
|
data: {bundle: attestation}
|
||||||
})
|
})
|
||||||
|
|
||||||
const data =
|
return response.data?.id
|
||||||
typeof response.data == 'string'
|
|
||||||
? JSON.parse(response.data)
|
|
||||||
: response.data
|
|
||||||
return data?.id
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : err
|
const message = err instanceof Error ? err.message : err
|
||||||
throw new Error(`Failed to persist attestation: ${message}`)
|
throw new Error(`Failed to persist attestation: ${message}`)
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import {DownloadOptions, getDownloadOptions} from '../src/options'
|
|||||||
|
|
||||||
jest.mock('../src/internal/downloadUtils')
|
jest.mock('../src/internal/downloadUtils')
|
||||||
|
|
||||||
test('getCacheVersion does not mutate arguments', async () => {
|
|
||||||
const paths = ['node_modules']
|
|
||||||
getCacheVersion(paths, undefined, true)
|
|
||||||
expect(paths).toEqual(['node_modules'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getCacheVersion with one path returns version', async () => {
|
test('getCacheVersion with one path returns version', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const result = getCacheVersion(paths, undefined, true)
|
const result = getCacheVersion(paths, undefined, true)
|
||||||
|
|||||||
Generated
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/http-client",
|
"name": "@actions/http-client",
|
||||||
"version": "2.2.1",
|
"version": "2.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tunnel": "^0.0.6",
|
"tunnel": "^0.0.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user