Merge pull request #179 from actions/conorsloan/upload-attestations-to-ghcr
Upload attestations to GHCR instead of Attestations API
This commit is contained in:
+370
-262
@@ -1,28 +1,19 @@
|
||||
import { publishOCIArtifact } from '../src/ghcr-client'
|
||||
import * as fsHelper from '../src/fs-helper'
|
||||
import { Client } from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Mocks
|
||||
let fsReadFileSyncMock: jest.SpyInstance
|
||||
let fetchMock: jest.SpyInstance
|
||||
|
||||
let client: Client
|
||||
|
||||
const token = 'test-token'
|
||||
const registry = new URL('https://ghcr.io')
|
||||
const repository = 'test-org/test-repo'
|
||||
const semver = '1.2.3'
|
||||
const genericSha = '1234567890' // We should look at using different shas here to catch bug, but that make location validation harder
|
||||
const zipFile: fsHelper.FileMetadata = {
|
||||
path: `test-repo-${semver}.zip`,
|
||||
size: 123,
|
||||
sha256: genericSha
|
||||
}
|
||||
const tarFile: fsHelper.FileMetadata = {
|
||||
path: `test-repo-${semver}.tar.gz`,
|
||||
size: 456,
|
||||
sha256: genericSha
|
||||
}
|
||||
|
||||
const headMockNoExistingBlobs = (): object => {
|
||||
const checkBlobNoExistingBlobs = (): object => {
|
||||
// Simulate none of the blobs existing currently
|
||||
return {
|
||||
text() {
|
||||
@@ -33,7 +24,7 @@ const headMockNoExistingBlobs = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const headMockAllExistingBlobs = (): object => {
|
||||
const checkBlobAllExistingBlobs = (): object => {
|
||||
// Simulate all of the blobs existing currently
|
||||
return {
|
||||
status: 200,
|
||||
@@ -42,7 +33,7 @@ const headMockAllExistingBlobs = (): object => {
|
||||
}
|
||||
|
||||
let count = 0
|
||||
const headMockSomeExistingBlobs = (): object => {
|
||||
const checkBlobSomeExistingBlobs = (): object => {
|
||||
count++
|
||||
// report one as existing
|
||||
if (count === 1) {
|
||||
@@ -62,7 +53,7 @@ const headMockSomeExistingBlobs = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const headMockFailure = (): object => {
|
||||
const checkBlobFailure = (): object => {
|
||||
return {
|
||||
text() {
|
||||
// In this case we'll simulate a response which does not use the expected error format
|
||||
@@ -73,7 +64,7 @@ const headMockFailure = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const postMockSuccessfulIniationForAllBlobs = (): object => {
|
||||
const initiateBlobUploadSuccessForAllBlobs = (): object => {
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
return {
|
||||
status: 202,
|
||||
@@ -87,7 +78,7 @@ const postMockSuccessfulIniationForAllBlobs = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const postMockFailure = (): object => {
|
||||
const initiateBlobUploadFailureForAllBlobs = (): object => {
|
||||
// Simulate failed initiation of uploads
|
||||
return {
|
||||
text() {
|
||||
@@ -99,7 +90,7 @@ const postMockFailure = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const postMockNoLocationHeader = (): object => {
|
||||
const initiateBlobUploadNoLocationHeader = (): object => {
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
@@ -108,26 +99,33 @@ const postMockNoLocationHeader = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const putMockSuccessfulBlobUpload = (url: string): object => {
|
||||
// Simulate successful upload of all blobs & then the manifest
|
||||
if (url.includes('manifest')) {
|
||||
const putManifestSuccessful = (
|
||||
digestToReturn: string,
|
||||
expectedVersion: string
|
||||
): ((url: string) => object) => {
|
||||
return (url: string): object => {
|
||||
expect(url.endsWith(`manifests/${expectedVersion}`)).toBeTruthy()
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'docker-content-digest') {
|
||||
return '1234567678'
|
||||
return digestToReturn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const putBlobSuccess = (): object => {
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
}
|
||||
|
||||
const putMockFailure = (): object => {
|
||||
const putManifestFailure = (): object => {
|
||||
// Simulate fails upload of all blobs & manifest
|
||||
return {
|
||||
text() {
|
||||
@@ -138,124 +136,181 @@ const putMockFailure = (): object => {
|
||||
}
|
||||
}
|
||||
|
||||
const putMockFailureManifestUpload = (url: string): object => {
|
||||
// Simulate unsuccessful upload of all blobs & then the manifest
|
||||
if (url.includes('manifest')) {
|
||||
return {
|
||||
text() {
|
||||
return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}'
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
}
|
||||
}
|
||||
const putBlobFailure = (): object => {
|
||||
// Simulate fails upload of all blobs & manifest
|
||||
return {
|
||||
status: 201
|
||||
text() {
|
||||
return '{"errors": [{"code": "BAD_REQUEST", "message": "digest issue."}]}'
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
}
|
||||
}
|
||||
|
||||
type MethodHandlers = {
|
||||
getMock?: (url: string, options: { method: string }) => object
|
||||
headMock?: (url: string, options: { method: string }) => object
|
||||
postMock?: (url: string, options: { method: string }) => object
|
||||
putMock?: (url: string, options: { method: string }) => object
|
||||
checkBlobMock?: (url: string, options: { method: string }) => object
|
||||
initiateBlobUploadMock?: (url: string, options: { method: string }) => object
|
||||
putManifestMock?: (url: string, options: { method: string }) => object
|
||||
putBlobMock?: (url: string, options: { method: string }) => object
|
||||
}
|
||||
|
||||
type ForcedRetries = {
|
||||
checkBlob: number
|
||||
initiateBlobUpload: number
|
||||
putBlob: number
|
||||
putManifest: number
|
||||
}
|
||||
|
||||
function configureFetchMock(
|
||||
fetchMockInstance: jest.SpyInstance,
|
||||
methodHandlers: MethodHandlers
|
||||
methodHandlers: MethodHandlers,
|
||||
forcedRetries: ForcedRetries = {
|
||||
checkBlob: 0,
|
||||
initiateBlobUpload: 0,
|
||||
putBlob: 0,
|
||||
putManifest: 0
|
||||
}
|
||||
): void {
|
||||
const retriableError = async (retries: number): Promise<object> => {
|
||||
if (retries % 2 === 0) {
|
||||
throw new Error('Network Error')
|
||||
} else {
|
||||
return {
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'retry-after') {
|
||||
return '0.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchMockInstance.mockImplementation(
|
||||
async (url: string, options: { method: string }) => {
|
||||
// Simulate retries for every request until the number of forced retries is exhausted.
|
||||
// We'll simulate both failing status codes and network errors for full coverage.
|
||||
validateRequestConfig(url, options)
|
||||
switch (options.method) {
|
||||
case 'GET':
|
||||
return methodHandlers.getMock?.(url, options)
|
||||
case 'HEAD':
|
||||
return methodHandlers.headMock?.(url, options)
|
||||
if (forcedRetries.checkBlob > 0) {
|
||||
forcedRetries.checkBlob--
|
||||
return retriableError(forcedRetries.checkBlob)
|
||||
}
|
||||
|
||||
return methodHandlers.checkBlobMock?.(url, options)
|
||||
case 'POST':
|
||||
return methodHandlers.postMock?.(url, options)
|
||||
if (forcedRetries.initiateBlobUpload > 0) {
|
||||
forcedRetries.initiateBlobUpload--
|
||||
return retriableError(forcedRetries.initiateBlobUpload)
|
||||
}
|
||||
|
||||
return methodHandlers.initiateBlobUploadMock?.(url, options)
|
||||
case 'PUT':
|
||||
return methodHandlers.putMock?.(url, options)
|
||||
if (url.includes('manifest')) {
|
||||
if (forcedRetries.putManifest > 0) {
|
||||
forcedRetries.putManifest--
|
||||
return retriableError(forcedRetries.putManifest)
|
||||
}
|
||||
|
||||
return methodHandlers.putManifestMock?.(url, options)
|
||||
} else {
|
||||
if (forcedRetries.putBlob > 0) {
|
||||
forcedRetries.putBlob--
|
||||
return retriableError(forcedRetries.putBlob)
|
||||
}
|
||||
|
||||
return methodHandlers.putBlobMock?.(url, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const testManifest: ociContainer.Manifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
artifactType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
config: {
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
},
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
|
||||
size: tarFile.size,
|
||||
digest: `sha256:${tarFile.sha256}`,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': tarFile.path
|
||||
}
|
||||
},
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
|
||||
size: zipFile.size,
|
||||
digest: `sha256:${zipFile.sha256}`,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': zipFile.path
|
||||
}
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z',
|
||||
'action.tar.gz.digest': tarFile.sha256,
|
||||
'action.zip.digest': zipFile.sha256,
|
||||
'com.github.package.type': 'actions_oci_pkg'
|
||||
}
|
||||
}
|
||||
|
||||
describe('publishOCIArtifact', () => {
|
||||
describe('uploadOCIIndexManifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
||||
|
||||
fsReadFileSyncMock = jest
|
||||
.spyOn(fsHelper, 'readFileContents')
|
||||
.mockImplementation()
|
||||
client = new Client(token, registry, {
|
||||
retries: 5,
|
||||
backoff: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads the tagged manifest with the appropriate tag', async () => {
|
||||
const { manifest, sha } = testIndexManifest()
|
||||
const tag = 'sha-1234'
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
putManifestMock: putManifestSuccessful(sha, tag)
|
||||
})
|
||||
|
||||
await client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws an error if a manifest upload fails', async () => {
|
||||
const { manifest, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestFailure
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
||||
const { manifest, sha } = testIndexManifest()
|
||||
|
||||
const tag = 'sha-1234'
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', tag)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadOCIImageManifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
||||
})
|
||||
|
||||
it('publishes layer blobs & then a manifest to the provided registry', async () => {
|
||||
it('uploads blobs then untagged manifest to the provided registry', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockSuccessfulBlobUpload
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
await publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
@@ -269,29 +324,76 @@ describe('publishOCIArtifact', () => {
|
||||
).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('skips uploading all layer blobs when they all already exist', async () => {
|
||||
it('uploads blobs then tagged manifest to the provided registry', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockAllExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockSuccessfulBlobUpload
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, semver)
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs, semver)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('uploads everything to the provided registry by retrying requests', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(
|
||||
fetchMock,
|
||||
{
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
},
|
||||
{
|
||||
checkBlob: 2,
|
||||
initiateBlobUpload: 2,
|
||||
putBlob: 2,
|
||||
putManifest: 2
|
||||
}
|
||||
) // Fail each request twice before succeeding
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
// 8 Additional requests - 2 for each of the 4 failed request types
|
||||
expect(fetchMock).toHaveBeenCalledTimes(18)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(5)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(5)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('skips blob uploads if all blobs already exist', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
// We should only head all the blobs and then upload the manifest
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
@@ -304,31 +406,19 @@ describe('publishOCIArtifact', () => {
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips uploading layer blobs that already exist', async () => {
|
||||
it('skips blob uploads if some blobs already exist', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockSomeExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockSuccessfulBlobUpload
|
||||
})
|
||||
count = 0
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
checkBlobMock: checkBlobSomeExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8)
|
||||
// We should only head all the blobs and then upload the missing blobs and manifest
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
@@ -341,164 +431,190 @@ describe('publishOCIArtifact', () => {
|
||||
})
|
||||
|
||||
it('throws an error if checking for existing blobs fails', async () => {
|
||||
configureFetchMock(fetchMock, { headMock: headMockFailure })
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobFailure,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
/^Unexpected 503 Service Unavailable response from check blob/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if initiating layer upload fails', async () => {
|
||||
it('throws an error if a blob file is not provided', async () => {
|
||||
const { manifest, sha } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockFailure
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
client.uploadOCIImageManifest(
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
manifest,
|
||||
new Map<string, Buffer>()
|
||||
)
|
||||
).rejects.toThrow(/^Blob for layer sha256:[a-zA-Z0-9]+ not found/)
|
||||
})
|
||||
|
||||
it('throws an error if initiating layer upload fails', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadFailureForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the upload endpoint does not return a location', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockNoLocationHeader
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadNoLocationHeader,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^No location header in response from upload post/)
|
||||
})
|
||||
|
||||
it('throws an error if a layer upload fails', async () => {
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockFailure
|
||||
})
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobFailure,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/)
|
||||
})
|
||||
|
||||
it('throws an error if a manifest upload fails', async () => {
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockFailureManifestUpload
|
||||
})
|
||||
const { manifest, blobs } = testImageManifest()
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestFailure
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if reading one of the files fails', async () => {
|
||||
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
headMock: headMockNoExistingBlobs,
|
||||
postMock: postMockSuccessfulIniationForAllBlobs,
|
||||
putMock: putMockSuccessfulBlobUpload
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(() => {
|
||||
throw new Error('failed to read a file: test')
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow('failed to read a file: test')
|
||||
})
|
||||
|
||||
it('throws an error if one of the layers has the wrong media type', async () => {
|
||||
const modifiedTestManifest = { ...testManifest } // This is _NOT_ a deep clone
|
||||
modifiedTestManifest.layers = cloneLayers(modifiedTestManifest.layers)
|
||||
modifiedTestManifest.layers[0].mediaType = 'application/json'
|
||||
|
||||
// just checking to make sure we are not changing the shared object
|
||||
expect(modifiedTestManifest.layers[0].mediaType).not.toEqual(
|
||||
testManifest.layers[0].mediaType
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
|
||||
await expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
modifiedTestManifest
|
||||
)
|
||||
).rejects.toThrow('Unknown media type application/json')
|
||||
})
|
||||
})
|
||||
|
||||
function testImageManifest(): {
|
||||
manifest: ociContainer.OCIImageManifest
|
||||
sha: string
|
||||
blobs: Map<string, Buffer>
|
||||
} {
|
||||
const blobs = new Map<string, Buffer>()
|
||||
blobs.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
|
||||
const firstFile = Buffer.from('test1')
|
||||
const secondFile = Buffer.from('test2')
|
||||
|
||||
const firstFileDigest = `sha256:${crypto
|
||||
.createHash('sha256')
|
||||
.update(firstFile)
|
||||
.digest('hex')}`
|
||||
|
||||
const secondFileDigest = `sha256:${crypto
|
||||
.createHash('sha256')
|
||||
.update(secondFile)
|
||||
.digest('hex')}`
|
||||
|
||||
blobs.set(firstFileDigest, firstFile)
|
||||
blobs.set(secondFileDigest, secondFile)
|
||||
|
||||
const manifest: ociContainer.OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: ociContainer.imageManifestMediaType,
|
||||
artifactType: ociContainer.imageManifestMediaType,
|
||||
config: ociContainer.createEmptyConfigLayer(),
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/octet-stream',
|
||||
size: firstFile.length,
|
||||
digest: firstFileDigest
|
||||
},
|
||||
{
|
||||
mediaType: 'application/octet-stream',
|
||||
size: secondFile.length,
|
||||
digest: secondFileDigest
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const sha = ociContainer.sha256Digest(manifest)
|
||||
|
||||
return { manifest, sha, blobs }
|
||||
}
|
||||
|
||||
function testIndexManifest(): {
|
||||
manifest: ociContainer.OCIIndexManifest
|
||||
sha: string
|
||||
} {
|
||||
const manifest = ociContainer.createReferrerTagManifest(
|
||||
'attestation-digest',
|
||||
1234,
|
||||
'bundle-media-type',
|
||||
'bundle-predicate-type',
|
||||
new Date(),
|
||||
new Date()
|
||||
)
|
||||
const sha = ociContainer.sha256Digest(manifest)
|
||||
return { manifest, sha }
|
||||
}
|
||||
|
||||
// We expect all fetch calls to have auth headers set
|
||||
// This function verifies that given an request config.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -525,11 +641,3 @@ function validateRequestConfig(url: string, config: any): void {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function cloneLayers(layers: ociContainer.Layer[]): ociContainer.Layer[] {
|
||||
const result: ociContainer.Layer[] = []
|
||||
for (const layer of layers) {
|
||||
result.push({ ...layer }) // this is _NOT_ a deep clone
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
+284
-100
@@ -15,6 +15,8 @@ import * as ghcr from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
const predicateType = 'https://slsa.dev/provenance/v1'
|
||||
const bundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json'
|
||||
|
||||
// Mock the GitHub Actions core library
|
||||
let setFailedMock: jest.SpyInstance
|
||||
@@ -25,12 +27,17 @@ let createTempDirMock: jest.SpyInstance
|
||||
let createArchivesMock: jest.SpyInstance
|
||||
let stageActionFilesMock: jest.SpyInstance
|
||||
let ensureCorrectShaCheckedOutMock: jest.SpyInstance
|
||||
let readFileContentsMock: jest.SpyInstance
|
||||
|
||||
// Mock OCI container lib
|
||||
let calculateManifestDigestMock: jest.SpyInstance
|
||||
|
||||
// Mock GHCR client
|
||||
let publishOCIArtifactMock: jest.SpyInstance
|
||||
let client: ghcr.Client
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let createGHCRClient: jest.SpyInstance
|
||||
let uploadOCIImageManifestMock: jest.SpyInstance
|
||||
let uploadOCIIndexManifestMock: jest.SpyInstance
|
||||
|
||||
// Mock the config resolution
|
||||
let resolvePublishActionOptionsMock: jest.SpyInstance
|
||||
@@ -42,6 +49,8 @@ describe('run', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
client = new ghcr.Client('token', ghcrUrl)
|
||||
|
||||
// Core mocks
|
||||
setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
|
||||
setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()
|
||||
@@ -59,6 +68,9 @@ describe('run', () => {
|
||||
ensureCorrectShaCheckedOutMock = jest
|
||||
.spyOn(fsHelper, 'ensureTagAndRefCheckedOut')
|
||||
.mockImplementation()
|
||||
readFileContentsMock = jest
|
||||
.spyOn(fsHelper, 'readFileContents')
|
||||
.mockImplementation()
|
||||
|
||||
// OCI Container mocks
|
||||
calculateManifestDigestMock = jest
|
||||
@@ -66,8 +78,15 @@ describe('run', () => {
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
publishOCIArtifactMock = jest
|
||||
.spyOn(ghcr, 'publishOCIArtifact')
|
||||
createGHCRClient = jest
|
||||
.spyOn(ghcr, 'Client')
|
||||
.mockImplementation(() => client)
|
||||
|
||||
uploadOCIImageManifestMock = jest
|
||||
.spyOn(client, 'uploadOCIImageManifest')
|
||||
.mockImplementation()
|
||||
uploadOCIIndexManifestMock = jest
|
||||
.spyOn(client, 'uploadOCIIndexManifest')
|
||||
.mockImplementation()
|
||||
|
||||
// Config mocks
|
||||
@@ -211,6 +230,10 @@ describe('run', () => {
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
@@ -226,8 +249,11 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async () => {
|
||||
@@ -241,7 +267,7 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if publishing OCI artifact fails', async () => {
|
||||
it('fails if uploading attestation to GHCR fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
@@ -272,23 +298,26 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
@@ -299,7 +328,7 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if unexpected digest returned from GHCR', async () => {
|
||||
it('fails if uploading referrer index manifest to GHCR fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
@@ -330,36 +359,113 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:some-other-digest'
|
||||
}
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
return 'attestation-digest'
|
||||
})
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'Unexpected digest returned for manifest. Expected sha256:my-test-digest, got sha256:some-other-digest'
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if publishing action package version fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
readFileContentsMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repo, manifest, blobs, tag) => {
|
||||
if (tag === undefined) {
|
||||
return 'attestation-digest'
|
||||
} else {
|
||||
throw new Error('Something went wrong')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(() => {
|
||||
return 'referrer-index-digest'
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => {
|
||||
@@ -375,6 +481,74 @@ describe('run', () => {
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'zip',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'tar',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
readFileContentsMock.mockImplementation(filepath => {
|
||||
return Buffer.from(`${filepath}`)
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repository, manifest, blobs, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('1.2.3')
|
||||
expect(blobs.size).toBe(3)
|
||||
expect(blobs.has(ociContainer.emptyConfigSha)).toBeTruthy()
|
||||
expect(blobs.has('123')).toBeTruthy()
|
||||
expect(blobs.has('1234')).toBeTruthy()
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType)
|
||||
expect(manifest.layers.length).toBe(2)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
ociContainer.actionPackageAnnotationValue
|
||||
)
|
||||
|
||||
return 'sha256:my-test-digest'
|
||||
}
|
||||
)
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
resolvePublishActionOptionsMock.mockReturnValue(options)
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
@@ -390,124 +564,134 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-url',
|
||||
'https://ghcr.io/v2/test-org/test-repo:1.2.3'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest',
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise for public repo', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
readFileContentsMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
generateAttestationMock.mockImplementation(async opts => {
|
||||
expect(opts).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(
|
||||
async (repository, manifest, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('sha256-my-test-digest')
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageIndexMediaType)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
ociContainer.actionPackageReferrerTagAnnotationValue
|
||||
)
|
||||
expect(manifest.manifests.length).toBe(1)
|
||||
expect(manifest.manifests[0].mediaType).toBe(
|
||||
ociContainer.imageManifestMediaType
|
||||
)
|
||||
expect(manifest.manifests[0].artifactType).toBe(bundleMediaType)
|
||||
expect(
|
||||
manifest.manifests[0].annotations['dev.sigstore.bundle.predicateType']
|
||||
).toBe(predicateType)
|
||||
expect(
|
||||
manifest.manifests[0].annotations['com.github.package.type']
|
||||
).toBe(ociContainer.actionPackageAttestationAnnotationValue)
|
||||
|
||||
return 'sha256:referrer-index-digest'
|
||||
}
|
||||
)
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repository, manifest, blobs, tag) => {
|
||||
let expectedBlobKeys: string[] = []
|
||||
let expectedAnnotationValue = ''
|
||||
let expectedTagValue: string | undefined = undefined
|
||||
let returnValue = ''
|
||||
let expectedPredicateTypeValue: string | undefined = undefined
|
||||
|
||||
let expectedSubjectMediaType: string | undefined = undefined
|
||||
|
||||
if (tag === undefined) {
|
||||
expectedAnnotationValue =
|
||||
ociContainer.actionPackageAttestationAnnotationValue
|
||||
const sigStoreLayer = manifest.layers.find(
|
||||
(layer: ociContainer.Descriptor) =>
|
||||
layer.mediaType === bundleMediaType
|
||||
)
|
||||
expectedPredicateTypeValue = predicateType
|
||||
|
||||
expectedBlobKeys = [sigStoreLayer.digest, ociContainer.emptyConfigSha]
|
||||
|
||||
expectedSubjectMediaType = ociContainer.imageManifestMediaType
|
||||
|
||||
returnValue = 'sha256:attestation-digest'
|
||||
} else {
|
||||
expectedAnnotationValue = ociContainer.actionPackageAnnotationValue
|
||||
expectedTagValue = '1.2.3'
|
||||
expectedBlobKeys = ['123', '1234', ociContainer.emptyConfigSha]
|
||||
returnValue = 'sha256:my-test-digest'
|
||||
}
|
||||
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
expectedAnnotationValue
|
||||
)
|
||||
expect(manifest.annotations['dev.sigstore.bundle.predicateType']).toBe(
|
||||
expectedPredicateTypeValue
|
||||
)
|
||||
expect(tag).toBe(expectedTagValue)
|
||||
expect(manifest.subject?.mediaType).toBe(expectedSubjectMediaType)
|
||||
|
||||
expect(manifest.layers.length).toBe(expectedBlobKeys.length - 1) // Minus config layer
|
||||
expect(blobs.size).toBe(expectedBlobKeys.length)
|
||||
for (const expectedBlobKey of expectedBlobKeys) {
|
||||
expect(blobs.has(expectedBlobKey)).toBeTruthy()
|
||||
}
|
||||
|
||||
return returnValue
|
||||
}
|
||||
)
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1)
|
||||
expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(2)
|
||||
expect(uploadOCIIndexManifestMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(4)
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-url',
|
||||
'https://ghcr.io/v2/test-org/test-repo:1.2.3'
|
||||
'attestation-manifest-sha',
|
||||
'sha256:attestation-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest',
|
||||
expect.any(String)
|
||||
'referrer-index-manifest-sha',
|
||||
'sha256:referrer-index-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'attestation-id',
|
||||
'test-attestation-id'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+186
-74
@@ -1,63 +1,38 @@
|
||||
import { createActionPackageManifest, sha256Digest } from '../src/oci-container'
|
||||
import {
|
||||
createActionPackageManifest,
|
||||
sha256Digest,
|
||||
sizeInBytes,
|
||||
OCIImageManifest,
|
||||
createSigstoreAttestationManifest,
|
||||
OCIIndexManifest,
|
||||
createReferrerTagManifest
|
||||
} from '../src/oci-container'
|
||||
import { FileMetadata } from '../src/fs-helper'
|
||||
|
||||
const createdTimestamp = '2021-01-01T00:00:00.000Z'
|
||||
|
||||
describe('sha256Digest', () => {
|
||||
it('calculates the SHA256 digest of the provided manifest', () => {
|
||||
const date = new Date('2021-01-01T00:00:00Z')
|
||||
const repo = 'test-org/test-repo'
|
||||
const version = '1.2.3'
|
||||
const repoId = '123'
|
||||
const ownerId = '456'
|
||||
const sourceCommit = 'abc'
|
||||
const tarFile: FileMetadata = {
|
||||
path: '/test/test/test.tar.gz',
|
||||
sha256: 'tarSha',
|
||||
size: 123
|
||||
}
|
||||
const zipFile: FileMetadata = {
|
||||
path: '/test/test/test.zip',
|
||||
sha256: 'zipSha',
|
||||
size: 456
|
||||
}
|
||||
|
||||
const manifest = createActionPackageManifest(
|
||||
tarFile,
|
||||
zipFile,
|
||||
repo,
|
||||
repoId,
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
date
|
||||
)
|
||||
|
||||
const { manifest } = testActionPackageManifest()
|
||||
const digest = sha256Digest(manifest)
|
||||
const expectedDigest =
|
||||
'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5'
|
||||
'sha256:1af9bf993bf068a51fbb54822471ab7507b07c553bcac09a7c91328740d8ed69'
|
||||
|
||||
expect(digest).toEqual(expectedDigest)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size', () => {
|
||||
it('returns the total size of the provided manifest', () => {
|
||||
const { manifest } = testActionPackageManifest()
|
||||
const size = sizeInBytes(manifest)
|
||||
expect(size).toBe(991)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createActionPackageManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const date = new Date()
|
||||
const repo = 'test-org/test-repo'
|
||||
const sanitizedRepo = 'test-org-test-repo'
|
||||
const version = '1.2.3'
|
||||
const repoId = '123'
|
||||
const ownerId = '456'
|
||||
const sourceCommit = 'abc'
|
||||
const tarFile: FileMetadata = {
|
||||
path: '/test/test/test.tar.gz',
|
||||
sha256: 'tarSha',
|
||||
size: 123
|
||||
}
|
||||
const zipFile: FileMetadata = {
|
||||
path: '/test/test/test.zip',
|
||||
sha256: 'zipSha',
|
||||
size: 456
|
||||
}
|
||||
const { manifest, zipFile, tarFile } = testActionPackageManifest()
|
||||
|
||||
const expectedJSON = `{
|
||||
"schemaVersion": 2,
|
||||
@@ -69,17 +44,12 @@ describe('createActionPackageManifest', () => {
|
||||
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
"layers":[
|
||||
{
|
||||
"mediaType":"application/vnd.oci.empty.v1+json",
|
||||
"size":2,
|
||||
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
|
||||
"size":${tarFile.size},
|
||||
"digest":"${tarFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"${sanitizedRepo}_${version}.tar.gz"
|
||||
"org.opencontainers.image.title":"test-org-test-repo_1.2.3.tar.gz"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,12 +57,12 @@ describe('createActionPackageManifest', () => {
|
||||
"size":${zipFile.size},
|
||||
"digest":"${zipFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"${sanitizedRepo}_${version}.zip"
|
||||
"org.opencontainers.image.title":"test-org-test-repo_1.2.3.zip"
|
||||
}
|
||||
}
|
||||
],
|
||||
"annotations":{
|
||||
"org.opencontainers.image.created":"${date.toISOString()}",
|
||||
"org.opencontainers.image.created":"${createdTimestamp}",
|
||||
"action.tar.gz.digest":"${tarFile.sha256}",
|
||||
"action.zip.digest":"${zipFile.sha256}",
|
||||
"com.github.package.type":"actions_oci_pkg",
|
||||
@@ -103,26 +73,168 @@ describe('createActionPackageManifest', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const manifest = createActionPackageManifest(
|
||||
{
|
||||
path: 'test.tar.gz',
|
||||
size: tarFile.size,
|
||||
sha256: tarFile.sha256
|
||||
},
|
||||
{
|
||||
path: 'test.zip',
|
||||
size: zipFile.size,
|
||||
sha256: zipFile.sha256
|
||||
},
|
||||
repo,
|
||||
repoId,
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
date
|
||||
)
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const { manifest } = testActionPackageManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSigstoreAttestationManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const manifest = testAttestationManifest()
|
||||
|
||||
const expectedJSON = `{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.empty.v1+json",
|
||||
"size": 2,
|
||||
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"size": 10,
|
||||
"digest": "bundleDigest"
|
||||
}
|
||||
],
|
||||
"subject": {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"size": 100,
|
||||
"digest": "subjectDigest"
|
||||
},
|
||||
"annotations": {
|
||||
"dev.sigstore.bundle.content": "dsse-envelope",
|
||||
"dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1",
|
||||
"com.github.package.type": "actions_oci_pkg_attestation",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const manifest = testAttestationManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createReferrerIndexManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const manifest = testReferrerIndexManifest()
|
||||
|
||||
const expectedJSON = `
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"size": 100,
|
||||
"digest": "attDigest",
|
||||
"annotations": {
|
||||
"com.github.package.type": "actions_oci_pkg_attestation",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z",
|
||||
"dev.sigstore.bundle.content": "dsse-envelope",
|
||||
"dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"com.github.package.type": "actions_oci_pkg_referrer_index",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const manifest = testReferrerIndexManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
function testActionPackageManifest(setCreated = true): {
|
||||
manifest: OCIImageManifest
|
||||
tarFile: FileMetadata
|
||||
zipFile: FileMetadata
|
||||
} {
|
||||
const date = new Date('2021-01-01T00:00:00Z')
|
||||
const repo = 'test-org/test-repo'
|
||||
const version = '1.2.3'
|
||||
const repoId = '123'
|
||||
const ownerId = '456'
|
||||
const sourceCommit = 'abc'
|
||||
const tarFile: FileMetadata = {
|
||||
path: '/test/test/test.tar.gz',
|
||||
sha256: 'tarSha',
|
||||
size: 123
|
||||
}
|
||||
const zipFile: FileMetadata = {
|
||||
path: '/test/test/test.zip',
|
||||
sha256: 'zipSha',
|
||||
size: 456
|
||||
}
|
||||
|
||||
const manifest = createActionPackageManifest(
|
||||
tarFile,
|
||||
zipFile,
|
||||
repo,
|
||||
repoId,
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
|
||||
return {
|
||||
manifest,
|
||||
tarFile,
|
||||
zipFile
|
||||
}
|
||||
}
|
||||
|
||||
function testAttestationManifest(setCreated = true): OCIImageManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createSigstoreAttestationManifest(
|
||||
10,
|
||||
'bundleDigest',
|
||||
'application/vnd.dev.sigstore.bundle.v0.3+json',
|
||||
'https://slsa.dev/provenance/v1',
|
||||
100,
|
||||
'subjectDigest',
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function testReferrerIndexManifest(setCreated = true): OCIIndexManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createReferrerTagManifest(
|
||||
'attDigest',
|
||||
100,
|
||||
'application/vnd.dev.sigstore.bundle.v0.3+json',
|
||||
'https://slsa.dev/provenance/v1',
|
||||
date,
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
|
||||
+4
-6
@@ -11,14 +11,12 @@ inputs:
|
||||
description: 'The GitHub actions token used to authenticate with GitHub APIs'
|
||||
|
||||
outputs:
|
||||
package-url:
|
||||
description: 'The name of package published to GHCR along with semver. For example, https://ghcr.io/actions/package-action:1.0.1'
|
||||
package-manifest:
|
||||
description: 'The package manifest of the published package in JSON format'
|
||||
package-manifest-sha:
|
||||
description: 'A sha256 hash of the package manifest'
|
||||
attestation-id:
|
||||
description: 'The attestation id of the generated provenance attestation. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
attestation-manifest-sha:
|
||||
description: 'The sha256 of the provenance attestation uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
referrer-index-manifest-sha:
|
||||
description: 'The sha256 of the referrer index uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
|
||||
runs:
|
||||
using: node20
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 97.06%"><title>Coverage: 97.06%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">97.06%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">97.06%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 97.56%"><title>Coverage: 97.56%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">97.56%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">97.56%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+310
-133
@@ -106687,109 +106687,180 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.publishOCIArtifact = publishOCIArtifact;
|
||||
exports.Client = void 0;
|
||||
const core = __importStar(__nccwpck_require__(42186));
|
||||
const fsHelper = __importStar(__nccwpck_require__(76642));
|
||||
// Publish the OCI artifact and return the URL where it can be downloaded
|
||||
async function publishOCIArtifact(token, registry, repository, semver, zipFile, tarFile, manifest) {
|
||||
const b64Token = Buffer.from(token).toString('base64');
|
||||
const checkBlobEndpoint = new URL(`v2/${repository}/blobs/`, registry).toString();
|
||||
const uploadBlobEndpoint = new URL(`v2/${repository}/blobs/uploads/`, registry).toString();
|
||||
const manifestEndpoint = new URL(`v2/${repository}/manifests/${semver}`, registry).toString();
|
||||
core.info(`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`);
|
||||
const layerUploads = manifest.layers.map(async (layer) => {
|
||||
switch (layer.mediaType) {
|
||||
case 'application/vnd.github.actions.package.layer.v1.tar+gzip':
|
||||
return uploadLayer(layer, tarFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token);
|
||||
case 'application/vnd.github.actions.package.layer.v1.zip':
|
||||
return uploadLayer(layer, zipFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token);
|
||||
case 'application/vnd.oci.empty.v1+json':
|
||||
return uploadLayer(layer, { path: '', size: 2, sha256: layer.digest }, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token);
|
||||
default:
|
||||
throw new Error(`Unknown media type ${layer.mediaType}`);
|
||||
const ociContainer = __importStar(__nccwpck_require__(33207));
|
||||
const defaultRetries = 5;
|
||||
const defaultBackoff = 1000;
|
||||
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
||||
class Client {
|
||||
_b64Token;
|
||||
_registry;
|
||||
_retryOptions;
|
||||
constructor(token, registry, retryOptions = {
|
||||
retries: defaultRetries,
|
||||
backoff: defaultBackoff
|
||||
}) {
|
||||
this._b64Token = Buffer.from(token).toString('base64');
|
||||
this._registry = registry;
|
||||
this._retryOptions = retryOptions;
|
||||
}
|
||||
async uploadOCIImageManifest(repository, manifest, blobs, tag) {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest);
|
||||
if (tag) {
|
||||
core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
|
||||
}
|
||||
});
|
||||
await Promise.all(layerUploads);
|
||||
const digest = await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token);
|
||||
return {
|
||||
packageURL: new URL(`${repository}:${semver}`, registry),
|
||||
publishedDigest: digest
|
||||
};
|
||||
}
|
||||
async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBlobEndpoint, b64Token) {
|
||||
const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint + layer.digest, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`
|
||||
else {
|
||||
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`);
|
||||
}
|
||||
});
|
||||
if (checkExistsResponse.status === 200 ||
|
||||
checkExistsResponse.status === 202) {
|
||||
core.info(`Layer ${layer.digest} already exists. Skipping upload.`);
|
||||
return;
|
||||
// We must also upload the config layer
|
||||
const layersToUpload = manifest.layers.concat(manifest.config);
|
||||
const layerUploads = layersToUpload.map(async (layer) => {
|
||||
const blob = blobs.get(layer.digest);
|
||||
if (!blob) {
|
||||
throw new Error(`Blob for layer ${layer.digest} not found`);
|
||||
}
|
||||
return this.uploadLayer(layer, blob, repository);
|
||||
});
|
||||
await Promise.all(layerUploads);
|
||||
const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag || manifestSHA);
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
|
||||
}
|
||||
return manifestSHA;
|
||||
}
|
||||
if (checkExistsResponse.status !== 404) {
|
||||
throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse));
|
||||
async uploadOCIIndexManifest(repository, manifest, tag) {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest);
|
||||
core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
|
||||
const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag);
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
|
||||
}
|
||||
return manifestSHA;
|
||||
}
|
||||
core.info(`Uploading layer ${layer.digest}.`);
|
||||
const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`
|
||||
},
|
||||
body: JSON.stringify(layer)
|
||||
});
|
||||
if (initiateUploadResponse.status !== 202) {
|
||||
throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse));
|
||||
async uploadLayer(layer, data, repository) {
|
||||
const checkExistsResponse = await this.fetchWithRetries(this.checkBlobEndpoint(repository, layer.digest), {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
}
|
||||
});
|
||||
if (checkExistsResponse.status === 200 ||
|
||||
checkExistsResponse.status === 202) {
|
||||
core.info(`Layer ${layer.digest} already exists. Skipping upload.`);
|
||||
return;
|
||||
}
|
||||
if (checkExistsResponse.status !== 404) {
|
||||
throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse));
|
||||
}
|
||||
core.info(`Uploading layer ${layer.digest}.`);
|
||||
const initiateUploadBlobURL = this.uploadBlobEndpoint(repository);
|
||||
const initiateUploadResponse = await this.fetchWithRetries(initiateUploadBlobURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
},
|
||||
body: JSON.stringify(layer)
|
||||
});
|
||||
if (initiateUploadResponse.status !== 202) {
|
||||
throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse));
|
||||
}
|
||||
const locationResponseHeader = initiateUploadResponse.headers.get('location');
|
||||
if (locationResponseHeader === undefined) {
|
||||
throw new Error(`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`);
|
||||
}
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`;
|
||||
const uploadBlobUrl = new URL(pathname, this._registry).toString();
|
||||
const putResponse = await this.fetchWithRetries(uploadBlobUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Content-Length': layer.size.toString()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse));
|
||||
}
|
||||
}
|
||||
const locationResponseHeader = initiateUploadResponse.headers.get('location');
|
||||
if (locationResponseHeader === undefined) {
|
||||
throw new Error(`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`);
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
async uploadManifest(manifestJSON, manifestMediaType, repository, version) {
|
||||
const manifestUrl = this.manifestEndpoint(repository, version);
|
||||
core.info(`Uploading manifest to ${manifestUrl}.`);
|
||||
const putResponse = await this.fetchWithRetries(manifestUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': manifestMediaType
|
||||
},
|
||||
body: manifestJSON
|
||||
});
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse));
|
||||
}
|
||||
const digestResponseHeader = putResponse.headers.get('docker-content-digest') || '';
|
||||
return digestResponseHeader;
|
||||
}
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`;
|
||||
const uploadBlobUrl = new URL(pathname, registryURL).toString();
|
||||
// TODO: must we handle the empty config layer? Maybe we can just skip calling this at all
|
||||
let data;
|
||||
if (layer.mediaType === 'application/vnd.oci.empty.v1+json') {
|
||||
data = Buffer.from('{}');
|
||||
checkBlobEndpoint(repository, digest) {
|
||||
return new URL(`v2/${repository}/blobs/${digest}`, this._registry).toString();
|
||||
}
|
||||
else {
|
||||
data = fsHelper.readFileContents(file.path);
|
||||
uploadBlobEndpoint(repository) {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString();
|
||||
}
|
||||
const putResponse = await fetchWithDebug(uploadBlobUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Content-Length': layer.size.toString()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse));
|
||||
manifestEndpoint(repository, version) {
|
||||
return new URL(`v2/${repository}/manifests/${version}`, this._registry).toString();
|
||||
}
|
||||
async fetchWithDebug(url, config = {}) {
|
||||
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`);
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
core.debug(`Response with ${JSON.stringify(response)}`);
|
||||
return response;
|
||||
}
|
||||
catch (error) {
|
||||
core.debug(`Error with ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async fetchWithRetries(url, config = {}) {
|
||||
const allowedAttempts = this._retryOptions.retries + 1; // Initial attempt + retries
|
||||
for (let attemptNumber = 1; attemptNumber <= allowedAttempts; attemptNumber++) {
|
||||
let backoff = this._retryOptions.backoff;
|
||||
try {
|
||||
const response = await this.fetchWithDebug(url, config);
|
||||
// If this is the last attempt, just return it
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
return response;
|
||||
}
|
||||
// If the response is retryable, backoff and retry
|
||||
if (retryableStatusCodes.includes(response.status)) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
backoff = parseInt(retryAfter) * 1000; // convert to ms
|
||||
}
|
||||
core.info(`Received ${response.status} response. Retrying after ${backoff}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||
continue;
|
||||
}
|
||||
// Otherwise, just return the response
|
||||
return response;
|
||||
}
|
||||
catch (error) {
|
||||
// If this is the last attempt, throw the error
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
throw error;
|
||||
}
|
||||
core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||
}
|
||||
}
|
||||
// Should be unreachable
|
||||
throw new Error('Exhausted retries without a successful response');
|
||||
}
|
||||
}
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) {
|
||||
core.info(`Uploading manifest to ${manifestEndpoint}.`);
|
||||
const putResponse = await fetchWithDebug(manifestEndpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`,
|
||||
'Content-Type': 'application/vnd.oci.image.manifest.v1+json'
|
||||
},
|
||||
body: manifestJSON
|
||||
});
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse));
|
||||
}
|
||||
const digestResponseHeader = putResponse.headers.get('docker-content-digest');
|
||||
if (digestResponseHeader === undefined || digestResponseHeader === null) {
|
||||
throw new Error(`No digest header in response from PUT manifest ${manifestEndpoint}`);
|
||||
}
|
||||
return digestResponseHeader;
|
||||
}
|
||||
exports.Client = Client;
|
||||
// Generate an error message for a failed HTTP request
|
||||
async function errorMessageForFailedRequest(requestDescription, response) {
|
||||
const bodyText = await response.text();
|
||||
@@ -106824,18 +106895,6 @@ function isGHCRError(obj) {
|
||||
'message' in obj &&
|
||||
typeof obj.message === 'string');
|
||||
}
|
||||
const fetchWithDebug = async (url, config = {}) => {
|
||||
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`);
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
core.debug(`Response with ${JSON.stringify(response)}`);
|
||||
return response;
|
||||
}
|
||||
catch (error) {
|
||||
core.debug(`Error with ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
@@ -106880,6 +106939,7 @@ const ociContainer = __importStar(__nccwpck_require__(33207));
|
||||
const ghcr = __importStar(__nccwpck_require__(62894));
|
||||
const attest = __importStar(__nccwpck_require__(74113));
|
||||
const cfg = __importStar(__nccwpck_require__(96373));
|
||||
const crypto = __importStar(__nccwpck_require__(6113));
|
||||
/**
|
||||
* The main function for the action.
|
||||
* @returns {Promise<void>} Resolves when the action is complete.
|
||||
@@ -106898,19 +106958,24 @@ async function run() {
|
||||
const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir);
|
||||
const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date());
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest);
|
||||
// Attestations are not currently supported in GHES.
|
||||
const ghcrClient = new ghcr.Client(options.token, options.containerRegistryUrl);
|
||||
// Attestations are not supported in GHES.
|
||||
if (!options.isEnterprise) {
|
||||
const attestation = await generateAttestation(manifestDigest, semverTag.raw, options);
|
||||
if (attestation.attestationID !== undefined) {
|
||||
core.setOutput('attestation-id', attestation.attestationID);
|
||||
const { bundle, bundleDigest, bundleMediaType, bundlePredicateType } = await generateAttestation(manifestDigest, semverTag.raw, options);
|
||||
const attestationCreated = new Date();
|
||||
const attestationManifest = ociContainer.createSigstoreAttestationManifest(bundle.length, bundleDigest, bundleMediaType, bundlePredicateType, ociContainer.sizeInBytes(manifest), manifestDigest, attestationCreated);
|
||||
const referrerIndexManifest = ociContainer.createReferrerTagManifest(ociContainer.sha256Digest(attestationManifest), ociContainer.sizeInBytes(attestationManifest), bundleMediaType, bundlePredicateType, attestationCreated);
|
||||
const { attestationSHA, referrerIndexSHA } = await publishAttestation(ghcrClient, options.nameWithOwner, bundle, bundleDigest, manifest, attestationManifest, referrerIndexManifest);
|
||||
if (attestationSHA !== undefined) {
|
||||
core.info(`Uploaded attestation ${attestationSHA}`);
|
||||
core.setOutput('attestation-manifest-sha', attestationSHA);
|
||||
}
|
||||
if (referrerIndexSHA !== undefined) {
|
||||
core.info(`Uploaded referrer index ${referrerIndexSHA}`);
|
||||
core.setOutput('referrer-index-manifest-sha', referrerIndexSHA);
|
||||
}
|
||||
}
|
||||
const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
|
||||
if (manifestDigest !== publishedDigest) {
|
||||
throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`);
|
||||
}
|
||||
core.setOutput('package-url', packageURL.toString());
|
||||
core.setOutput('package-manifest', JSON.stringify(manifest));
|
||||
const publishedDigest = await publishImmutableActionVersion(ghcrClient, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
|
||||
core.setOutput('package-manifest-sha', publishedDigest);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -106934,22 +106999,61 @@ function parseSemverTagFromRef(opts) {
|
||||
}
|
||||
return semverTag;
|
||||
}
|
||||
// Generate an attestation using the actions toolkit
|
||||
// Subject name will contain the repo/package name and the tag name
|
||||
async function publishImmutableActionVersion(client, nameWithOwner, semverTag, zipFile, tarFile, manifest) {
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest);
|
||||
core.info(`Creating GHCR package ${manifestDigest} for release with semver: ${semver_1.default}.`);
|
||||
const files = new Map();
|
||||
files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path));
|
||||
files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path));
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'));
|
||||
return await client.uploadOCIImageManifest(nameWithOwner, manifest, files, semverTag);
|
||||
}
|
||||
async function publishAttestation(client, nameWithOwner, bundle, bundleDigest, subjectManifest, attestationManifest, referrerIndexManifest) {
|
||||
const attestationManifestDigest = ociContainer.sha256Digest(attestationManifest);
|
||||
const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest);
|
||||
const referrerIndexManifestDigest = ociContainer.sha256Digest(referrerIndexManifest);
|
||||
core.info(`Publishing attestation ${attestationManifestDigest} for subject ${subjectManifestDigest}.`);
|
||||
const files = new Map();
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'));
|
||||
files.set(bundleDigest, bundle);
|
||||
const attestationSHA = await client.uploadOCIImageManifest(nameWithOwner, attestationManifest, files);
|
||||
// The referrer index is tagged with the subject's digest in format sha256-<digest>
|
||||
const referrerTag = subjectManifestDigest.replace(':', '-');
|
||||
core.info(`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`);
|
||||
const referrerIndexSHA = await client.uploadOCIIndexManifest(nameWithOwner, referrerIndexManifest, referrerTag);
|
||||
return { attestationSHA, referrerIndexSHA };
|
||||
}
|
||||
async function generateAttestation(manifestDigest, semverTag, options) {
|
||||
const subjectName = `${options.nameWithOwner}@${semverTag}`;
|
||||
const subjectDigest = removePrefix(manifestDigest, 'sha256:');
|
||||
core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`);
|
||||
return await attest.attestProvenance({
|
||||
const attestation = await attest.attestProvenance({
|
||||
subjectName,
|
||||
subjectDigest: { sha256: subjectDigest },
|
||||
token: options.token,
|
||||
sigstore: 'github',
|
||||
// Always store the attestation using the GitHub Attestations API
|
||||
skipWrite: false,
|
||||
// Identify the attestation to our API as an Immutable Action
|
||||
headers: { 'X-GitHub-Publish-Action': subjectName }
|
||||
skipWrite: true // We will upload attestations to GHCR
|
||||
});
|
||||
const bundleArtifact = Buffer.from(JSON.stringify(attestation.bundle));
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(bundleArtifact);
|
||||
const bundleSHA = hash.digest('hex');
|
||||
// We must base64 decode the dsse envelope to grab the predicate type
|
||||
const dsseEnvelopeArtifact = attestation.bundle.dsseEnvelope;
|
||||
if (dsseEnvelopeArtifact === undefined) {
|
||||
throw new Error('Attestation bundle is missing dsseEnvelope artifact');
|
||||
}
|
||||
const dsseEnvelope = JSON.parse(Buffer.from(dsseEnvelopeArtifact.payload, 'base64').toString('utf-8'));
|
||||
const predicateType = dsseEnvelope.predicateType;
|
||||
if (predicateType === undefined) {
|
||||
throw new Error('Attestation bundle is missing predicateType');
|
||||
}
|
||||
return {
|
||||
bundle: bundleArtifact,
|
||||
bundleDigest: `sha256:${bundleSHA}`,
|
||||
bundleMediaType: attestation.bundle.mediaType,
|
||||
bundlePredicateType: predicateType
|
||||
};
|
||||
}
|
||||
function removePrefix(str, prefix) {
|
||||
if (str.startsWith(prefix)) {
|
||||
@@ -106990,26 +107094,42 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.emptyConfigSha = exports.emptyConfigSize = exports.ociEmptyMediaType = exports.actionPackageReferrerTagAnnotationValue = exports.actionPackageAttestationAnnotationValue = exports.actionPackageAnnotationValue = exports.actionsPackageZipLayerMediaType = exports.actionsPackageTarLayerMediaType = exports.actionsPackageMediaType = exports.imageManifestMediaType = exports.imageIndexMediaType = void 0;
|
||||
exports.createActionPackageManifest = createActionPackageManifest;
|
||||
exports.createSigstoreAttestationManifest = createSigstoreAttestationManifest;
|
||||
exports.createReferrerTagManifest = createReferrerTagManifest;
|
||||
exports.sha256Digest = sha256Digest;
|
||||
exports.sizeInBytes = sizeInBytes;
|
||||
exports.createEmptyConfigLayer = createEmptyConfigLayer;
|
||||
const crypto = __importStar(__nccwpck_require__(6113));
|
||||
exports.imageIndexMediaType = 'application/vnd.oci.image.index.v1+json';
|
||||
exports.imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json';
|
||||
exports.actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json';
|
||||
exports.actionsPackageTarLayerMediaType = 'application/vnd.github.actions.package.layer.v1.tar+gzip';
|
||||
exports.actionsPackageZipLayerMediaType = 'application/vnd.github.actions.package.layer.v1.zip';
|
||||
exports.actionPackageAnnotationValue = 'actions_oci_pkg';
|
||||
exports.actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation';
|
||||
exports.actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_index';
|
||||
exports.ociEmptyMediaType = 'application/vnd.oci.empty.v1+json';
|
||||
exports.emptyConfigSize = 2;
|
||||
exports.emptyConfigSha = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package.
|
||||
function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created) {
|
||||
const configLayer = createConfigLayer();
|
||||
function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created = new Date()) {
|
||||
const configLayer = createEmptyConfigLayer();
|
||||
const sanitizedRepo = sanitizeRepository(repository);
|
||||
const tarLayer = createTarLayer(tarFile, sanitizedRepo, version);
|
||||
const zipLayer = createZipLayer(zipFile, sanitizedRepo, version);
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
artifactType: 'application/vnd.github.actions.package.v1+json',
|
||||
mediaType: exports.imageManifestMediaType,
|
||||
artifactType: exports.actionsPackageMediaType,
|
||||
config: configLayer,
|
||||
layers: [configLayer, tarLayer, zipLayer],
|
||||
layers: [tarLayer, zipLayer],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': created.toISOString(),
|
||||
'action.tar.gz.digest': tarFile.sha256,
|
||||
'action.zip.digest': zipFile.sha256,
|
||||
'com.github.package.type': 'actions_oci_pkg',
|
||||
'com.github.package.type': exports.actionPackageAnnotationValue,
|
||||
'com.github.package.version': version,
|
||||
'com.github.source.repo.id': repoId,
|
||||
'com.github.source.repo.owner.id': ownerId,
|
||||
@@ -107018,6 +107138,59 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner
|
||||
};
|
||||
return manifest;
|
||||
}
|
||||
function createSigstoreAttestationManifest(bundleSize, bundleDigest, bundleMediaType, bundlePredicateType, subjectSize, subjectDigest, created = new Date()) {
|
||||
const configLayer = createEmptyConfigLayer();
|
||||
const sigstoreAttestationLayer = {
|
||||
mediaType: bundleMediaType,
|
||||
size: bundleSize,
|
||||
digest: bundleDigest
|
||||
};
|
||||
const subject = {
|
||||
mediaType: exports.imageManifestMediaType,
|
||||
size: subjectSize,
|
||||
digest: subjectDigest
|
||||
};
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: exports.imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
config: configLayer,
|
||||
layers: [sigstoreAttestationLayer],
|
||||
subject,
|
||||
annotations: {
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType,
|
||||
'com.github.package.type': exports.actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
};
|
||||
return manifest;
|
||||
}
|
||||
function createReferrerTagManifest(attestationDigest, attestationSize, bundleMediaType, bundlePredicateType, attestationCreated, created = new Date()) {
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: exports.imageIndexMediaType,
|
||||
manifests: [
|
||||
{
|
||||
mediaType: exports.imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
size: attestationSize,
|
||||
digest: attestationDigest,
|
||||
annotations: {
|
||||
'com.github.package.type': exports.actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': attestationCreated.toISOString(),
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType
|
||||
}
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'com.github.package.type': exports.actionPackageReferrerTagAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
};
|
||||
return manifest;
|
||||
}
|
||||
// Calculate the SHA256 digest of a given manifest.
|
||||
// This should match the digest which the GitHub container registry calculates for this manifest.
|
||||
function sha256Digest(manifest) {
|
||||
@@ -107028,17 +107201,21 @@ function sha256Digest(manifest) {
|
||||
const hexHash = hash.digest('hex');
|
||||
return `sha256:${hexHash}`;
|
||||
}
|
||||
function createConfigLayer() {
|
||||
function sizeInBytes(manifest) {
|
||||
const data = JSON.stringify(manifest);
|
||||
return Buffer.byteLength(data, 'utf8');
|
||||
}
|
||||
function createEmptyConfigLayer() {
|
||||
const configLayer = {
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
mediaType: exports.ociEmptyMediaType,
|
||||
size: exports.emptyConfigSize,
|
||||
digest: exports.emptyConfigSha
|
||||
};
|
||||
return configLayer;
|
||||
}
|
||||
function createZipLayer(zipFile, repository, version) {
|
||||
const zipLayer = {
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
|
||||
mediaType: exports.actionsPackageZipLayerMediaType,
|
||||
size: zipFile.size,
|
||||
digest: zipFile.sha256,
|
||||
annotations: {
|
||||
@@ -107049,7 +107226,7 @@ function createZipLayer(zipFile, repository, version) {
|
||||
}
|
||||
function createTarLayer(tarFile, repository, version) {
|
||||
const tarLayer = {
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
|
||||
mediaType: exports.actionsPackageTarLayerMediaType,
|
||||
size: tarFile.size,
|
||||
digest: tarFile.sha256,
|
||||
annotations: {
|
||||
|
||||
Generated
+13
@@ -13,6 +13,7 @@
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@sigstore/oci": "^0.3.7",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"archiver": "^7.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -1680,6 +1681,18 @@
|
||||
"node": "^16.14.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sigstore/oci": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@sigstore/oci/-/oci-0.3.7.tgz",
|
||||
"integrity": "sha512-1JmebwEXil+NVzugFURbC+D3Vzj6WyTI1B+7damUk94dWXamE9cJ057iSo72rupiSozM6N7lVMjtD1c/P5Rrrw==",
|
||||
"dependencies": {
|
||||
"make-fetch-happen": "^13.0.1",
|
||||
"proc-log": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.14.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sigstore/protobuf-specs": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz",
|
||||
|
||||
+3
-2
@@ -71,11 +71,12 @@
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@sigstore/oci": "^0.3.7",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"archiver": "^7.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"tar": "^7.4.3",
|
||||
"simple-git": "^3.22.0"
|
||||
"simple-git": "^3.22.0",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.2",
|
||||
|
||||
+295
-210
@@ -1,209 +1,309 @@
|
||||
import * as core from '@actions/core'
|
||||
import { FileMetadata } from './fs-helper'
|
||||
import * as ociContainer from './oci-container'
|
||||
import * as fsHelper from './fs-helper'
|
||||
|
||||
// Publish the OCI artifact and return the URL where it can be downloaded
|
||||
export async function publishOCIArtifact(
|
||||
token: string,
|
||||
registry: URL,
|
||||
repository: string,
|
||||
semver: string,
|
||||
zipFile: FileMetadata,
|
||||
tarFile: FileMetadata,
|
||||
manifest: ociContainer.Manifest
|
||||
): Promise<{ packageURL: URL; publishedDigest: string }> {
|
||||
const b64Token = Buffer.from(token).toString('base64')
|
||||
const defaultRetries = 5
|
||||
const defaultBackoff = 1000
|
||||
const retryableStatusCodes = [408, 429, 500, 502, 503, 504]
|
||||
|
||||
const checkBlobEndpoint = new URL(
|
||||
`v2/${repository}/blobs/`,
|
||||
registry
|
||||
).toString()
|
||||
const uploadBlobEndpoint = new URL(
|
||||
`v2/${repository}/blobs/uploads/`,
|
||||
registry
|
||||
).toString()
|
||||
const manifestEndpoint = new URL(
|
||||
`v2/${repository}/manifests/${semver}`,
|
||||
registry
|
||||
).toString()
|
||||
|
||||
core.info(
|
||||
`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`
|
||||
)
|
||||
|
||||
const layerUploads: Promise<void>[] = manifest.layers.map(async layer => {
|
||||
switch (layer.mediaType) {
|
||||
case 'application/vnd.github.actions.package.layer.v1.tar+gzip':
|
||||
return uploadLayer(
|
||||
layer,
|
||||
tarFile,
|
||||
registry,
|
||||
checkBlobEndpoint,
|
||||
uploadBlobEndpoint,
|
||||
b64Token
|
||||
)
|
||||
case 'application/vnd.github.actions.package.layer.v1.zip':
|
||||
return uploadLayer(
|
||||
layer,
|
||||
zipFile,
|
||||
registry,
|
||||
checkBlobEndpoint,
|
||||
uploadBlobEndpoint,
|
||||
b64Token
|
||||
)
|
||||
case 'application/vnd.oci.empty.v1+json':
|
||||
return uploadLayer(
|
||||
layer,
|
||||
{ path: '', size: 2, sha256: layer.digest },
|
||||
registry,
|
||||
checkBlobEndpoint,
|
||||
uploadBlobEndpoint,
|
||||
b64Token
|
||||
)
|
||||
default:
|
||||
throw new Error(`Unknown media type ${layer.mediaType}`)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(layerUploads)
|
||||
|
||||
const digest = await uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifestEndpoint,
|
||||
b64Token
|
||||
)
|
||||
|
||||
return {
|
||||
packageURL: new URL(`${repository}:${semver}`, registry),
|
||||
publishedDigest: digest
|
||||
}
|
||||
export interface RetryOptions {
|
||||
retries: number
|
||||
backoff: number
|
||||
}
|
||||
|
||||
async function uploadLayer(
|
||||
layer: ociContainer.Layer,
|
||||
file: FileMetadata,
|
||||
registryURL: URL,
|
||||
checkBlobEndpoint: string,
|
||||
uploadBlobEndpoint: string,
|
||||
b64Token: string
|
||||
): Promise<void> {
|
||||
const checkExistsResponse = await fetchWithDebug(
|
||||
checkBlobEndpoint + layer.digest,
|
||||
{
|
||||
method: 'HEAD',
|
||||
export class Client {
|
||||
private _b64Token: string
|
||||
private _registry: URL
|
||||
private _retryOptions: RetryOptions
|
||||
|
||||
constructor(
|
||||
token: string,
|
||||
registry: URL,
|
||||
retryOptions: RetryOptions = {
|
||||
retries: defaultRetries,
|
||||
backoff: defaultBackoff
|
||||
}
|
||||
) {
|
||||
this._b64Token = Buffer.from(token).toString('base64')
|
||||
this._registry = registry
|
||||
this._retryOptions = retryOptions
|
||||
}
|
||||
|
||||
async uploadOCIImageManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIImageManifest,
|
||||
blobs: Map<string, Buffer>,
|
||||
tag?: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
if (tag) {
|
||||
core.info(
|
||||
`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
} else {
|
||||
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`)
|
||||
}
|
||||
|
||||
// We must also upload the config layer
|
||||
const layersToUpload = manifest.layers.concat(manifest.config)
|
||||
|
||||
const layerUploads: Promise<void>[] = layersToUpload.map(async layer => {
|
||||
const blob = blobs.get(layer.digest)
|
||||
if (!blob) {
|
||||
throw new Error(`Blob for layer ${layer.digest} not found`)
|
||||
}
|
||||
return this.uploadLayer(layer, blob, repository)
|
||||
})
|
||||
|
||||
await Promise.all(layerUploads)
|
||||
|
||||
const publishedDigest = await this.uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
repository,
|
||||
tag || manifestSHA
|
||||
)
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
)
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
|
||||
async uploadOCIIndexManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIIndexManifest,
|
||||
tag: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
core.info(
|
||||
`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
|
||||
const publishedDigest = await this.uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
repository,
|
||||
tag
|
||||
)
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
)
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
|
||||
private async uploadLayer(
|
||||
layer: ociContainer.Descriptor,
|
||||
data: Buffer,
|
||||
repository: string
|
||||
): Promise<void> {
|
||||
const checkExistsResponse = await this.fetchWithRetries(
|
||||
this.checkBlobEndpoint(repository, layer.digest),
|
||||
{
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
checkExistsResponse.status === 200 ||
|
||||
checkExistsResponse.status === 202
|
||||
) {
|
||||
core.info(`Layer ${layer.digest} already exists. Skipping upload.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (checkExistsResponse.status !== 404) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`check blob (${layer.digest}) exists`,
|
||||
checkExistsResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
core.info(`Uploading layer ${layer.digest}.`)
|
||||
|
||||
const initiateUploadBlobURL = this.uploadBlobEndpoint(repository)
|
||||
|
||||
const initiateUploadResponse = await this.fetchWithRetries(
|
||||
initiateUploadBlobURL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
},
|
||||
body: JSON.stringify(layer)
|
||||
}
|
||||
)
|
||||
|
||||
if (initiateUploadResponse.status !== 202) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`initiate layer upload`,
|
||||
initiateUploadResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const locationResponseHeader =
|
||||
initiateUploadResponse.headers.get('location')
|
||||
if (locationResponseHeader === undefined) {
|
||||
throw new Error(
|
||||
`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`
|
||||
)
|
||||
}
|
||||
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`
|
||||
const uploadBlobUrl = new URL(pathname, this._registry).toString()
|
||||
|
||||
const putResponse = await this.fetchWithRetries(uploadBlobUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Content-Length': layer.size.toString()
|
||||
},
|
||||
body: data
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`layer (${layer.digest}) upload`,
|
||||
putResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
private async uploadManifest(
|
||||
manifestJSON: string,
|
||||
manifestMediaType: string,
|
||||
repository: string,
|
||||
version: string
|
||||
): Promise<string> {
|
||||
const manifestUrl = this.manifestEndpoint(repository, version)
|
||||
|
||||
core.info(`Uploading manifest to ${manifestUrl}.`)
|
||||
|
||||
const putResponse = await this.fetchWithRetries(manifestUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': manifestMediaType
|
||||
},
|
||||
body: manifestJSON
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(`manifest upload`, putResponse)
|
||||
)
|
||||
}
|
||||
|
||||
const digestResponseHeader =
|
||||
putResponse.headers.get('docker-content-digest') || ''
|
||||
|
||||
return digestResponseHeader
|
||||
}
|
||||
|
||||
private checkBlobEndpoint(repository: string, digest: string): string {
|
||||
return new URL(
|
||||
`v2/${repository}/blobs/${digest}`,
|
||||
this._registry
|
||||
).toString()
|
||||
}
|
||||
|
||||
private uploadBlobEndpoint(repository: string): string {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString()
|
||||
}
|
||||
|
||||
private manifestEndpoint(repository: string, version: string): string {
|
||||
return new URL(
|
||||
`v2/${repository}/manifests/${version}`,
|
||||
this._registry
|
||||
).toString()
|
||||
}
|
||||
|
||||
private async fetchWithDebug(
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`)
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
core.debug(`Response with ${JSON.stringify(response)}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
core.debug(`Error with ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithRetries(
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const allowedAttempts = this._retryOptions.retries + 1 // Initial attempt + retries
|
||||
|
||||
for (
|
||||
let attemptNumber = 1;
|
||||
attemptNumber <= allowedAttempts;
|
||||
attemptNumber++
|
||||
) {
|
||||
let backoff = this._retryOptions.backoff
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithDebug(url, config)
|
||||
|
||||
// If this is the last attempt, just return it
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
return response
|
||||
}
|
||||
|
||||
// If the response is retryable, backoff and retry
|
||||
if (retryableStatusCodes.includes(response.status)) {
|
||||
const retryAfter = response.headers.get('retry-after')
|
||||
if (retryAfter) {
|
||||
backoff = parseInt(retryAfter) * 1000 // convert to ms
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Received ${response.status} response. Retrying after ${backoff}ms...`
|
||||
)
|
||||
await new Promise(resolve => setTimeout(resolve, backoff))
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, just return the response
|
||||
return response
|
||||
} catch (error) {
|
||||
// If this is the last attempt, throw the error
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
throw error
|
||||
}
|
||||
|
||||
core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`)
|
||||
await new Promise(resolve => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
checkExistsResponse.status === 200 ||
|
||||
checkExistsResponse.status === 202
|
||||
) {
|
||||
core.info(`Layer ${layer.digest} already exists. Skipping upload.`)
|
||||
return
|
||||
// Should be unreachable
|
||||
throw new Error('Exhausted retries without a successful response')
|
||||
}
|
||||
|
||||
if (checkExistsResponse.status !== 404) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`check blob (${layer.digest}) exists`,
|
||||
checkExistsResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
core.info(`Uploading layer ${layer.digest}.`)
|
||||
|
||||
const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`
|
||||
},
|
||||
body: JSON.stringify(layer)
|
||||
})
|
||||
|
||||
if (initiateUploadResponse.status !== 202) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`initiate layer upload`,
|
||||
initiateUploadResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const locationResponseHeader = initiateUploadResponse.headers.get('location')
|
||||
if (locationResponseHeader === undefined) {
|
||||
throw new Error(
|
||||
`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`
|
||||
)
|
||||
}
|
||||
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`
|
||||
const uploadBlobUrl = new URL(pathname, registryURL).toString()
|
||||
|
||||
// TODO: must we handle the empty config layer? Maybe we can just skip calling this at all
|
||||
let data: Buffer
|
||||
if (layer.mediaType === 'application/vnd.oci.empty.v1+json') {
|
||||
data = Buffer.from('{}')
|
||||
} else {
|
||||
data = fsHelper.readFileContents(file.path)
|
||||
}
|
||||
|
||||
const putResponse = await fetchWithDebug(uploadBlobUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Content-Length': layer.size.toString()
|
||||
},
|
||||
body: data
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`layer (${layer.digest}) upload`,
|
||||
putResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
async function uploadManifest(
|
||||
manifestJSON: string,
|
||||
manifestEndpoint: string,
|
||||
b64Token: string
|
||||
): Promise<string> {
|
||||
core.info(`Uploading manifest to ${manifestEndpoint}.`)
|
||||
|
||||
const putResponse = await fetchWithDebug(manifestEndpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${b64Token}`,
|
||||
'Content-Type': 'application/vnd.oci.image.manifest.v1+json'
|
||||
},
|
||||
body: manifestJSON
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(`manifest upload`, putResponse)
|
||||
)
|
||||
}
|
||||
|
||||
const digestResponseHeader = putResponse.headers.get('docker-content-digest')
|
||||
if (digestResponseHeader === undefined || digestResponseHeader === null) {
|
||||
throw new Error(
|
||||
`No digest header in response from PUT manifest ${manifestEndpoint}`
|
||||
)
|
||||
}
|
||||
|
||||
return digestResponseHeader
|
||||
}
|
||||
|
||||
interface ghcrError {
|
||||
@@ -256,18 +356,3 @@ function isGHCRError(obj: unknown): boolean {
|
||||
typeof (obj as { message: unknown }).message === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
const fetchWithDebug = async (
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<Response> => {
|
||||
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`)
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
core.debug(`Response with ${JSON.stringify(response)}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
core.debug(`Error with ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
+159
-26
@@ -5,6 +5,7 @@ import * as ociContainer from './oci-container'
|
||||
import * as ghcr from './ghcr-client'
|
||||
import * as attest from '@actions/attest'
|
||||
import * as cfg from './config'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* The main function for the action.
|
||||
@@ -52,21 +53,58 @@ export async function run(): Promise<void> {
|
||||
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest)
|
||||
|
||||
// Attestations are not currently supported in GHES.
|
||||
const ghcrClient = new ghcr.Client(
|
||||
options.token,
|
||||
options.containerRegistryUrl
|
||||
)
|
||||
|
||||
// Attestations are not supported in GHES.
|
||||
if (!options.isEnterprise) {
|
||||
const attestation = await generateAttestation(
|
||||
manifestDigest,
|
||||
semverTag.raw,
|
||||
options
|
||||
const { bundle, bundleDigest, bundleMediaType, bundlePredicateType } =
|
||||
await generateAttestation(manifestDigest, semverTag.raw, options)
|
||||
|
||||
const attestationCreated = new Date()
|
||||
const attestationManifest =
|
||||
ociContainer.createSigstoreAttestationManifest(
|
||||
bundle.length,
|
||||
bundleDigest,
|
||||
bundleMediaType,
|
||||
bundlePredicateType,
|
||||
ociContainer.sizeInBytes(manifest),
|
||||
manifestDigest,
|
||||
attestationCreated
|
||||
)
|
||||
|
||||
const referrerIndexManifest = ociContainer.createReferrerTagManifest(
|
||||
ociContainer.sha256Digest(attestationManifest),
|
||||
ociContainer.sizeInBytes(attestationManifest),
|
||||
bundleMediaType,
|
||||
bundlePredicateType,
|
||||
attestationCreated
|
||||
)
|
||||
if (attestation.attestationID !== undefined) {
|
||||
core.setOutput('attestation-id', attestation.attestationID)
|
||||
|
||||
const { attestationSHA, referrerIndexSHA } = await publishAttestation(
|
||||
ghcrClient,
|
||||
options.nameWithOwner,
|
||||
bundle,
|
||||
bundleDigest,
|
||||
manifest,
|
||||
attestationManifest,
|
||||
referrerIndexManifest
|
||||
)
|
||||
|
||||
if (attestationSHA !== undefined) {
|
||||
core.info(`Uploaded attestation ${attestationSHA}`)
|
||||
core.setOutput('attestation-manifest-sha', attestationSHA)
|
||||
}
|
||||
if (referrerIndexSHA !== undefined) {
|
||||
core.info(`Uploaded referrer index ${referrerIndexSHA}`)
|
||||
core.setOutput('referrer-index-manifest-sha', referrerIndexSHA)
|
||||
}
|
||||
}
|
||||
|
||||
const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(
|
||||
options.token,
|
||||
options.containerRegistryUrl,
|
||||
const publishedDigest = await publishImmutableActionVersion(
|
||||
ghcrClient,
|
||||
options.nameWithOwner,
|
||||
semverTag.raw,
|
||||
archives.zipFile,
|
||||
@@ -74,14 +112,6 @@ export async function run(): Promise<void> {
|
||||
manifest
|
||||
)
|
||||
|
||||
if (manifestDigest !== publishedDigest) {
|
||||
throw new Error(
|
||||
`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`
|
||||
)
|
||||
}
|
||||
|
||||
core.setOutput('package-url', packageURL.toString())
|
||||
core.setOutput('package-manifest', JSON.stringify(manifest))
|
||||
core.setOutput('package-manifest-sha', publishedDigest)
|
||||
} catch (error) {
|
||||
// Fail the workflow run if an error occurs
|
||||
@@ -110,28 +140,131 @@ function parseSemverTagFromRef(opts: cfg.PublishActionOptions): semver.SemVer {
|
||||
return semverTag
|
||||
}
|
||||
|
||||
// Generate an attestation using the actions toolkit
|
||||
// Subject name will contain the repo/package name and the tag name
|
||||
async function publishImmutableActionVersion(
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
semverTag: string,
|
||||
zipFile: fsHelper.FileMetadata,
|
||||
tarFile: fsHelper.FileMetadata,
|
||||
manifest: ociContainer.OCIImageManifest
|
||||
): Promise<string> {
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest)
|
||||
|
||||
core.info(
|
||||
`Creating GHCR package ${manifestDigest} for release with semver: ${semver}.`
|
||||
)
|
||||
|
||||
const files = new Map<string, Buffer>()
|
||||
files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path))
|
||||
files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path))
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
|
||||
return await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
manifest,
|
||||
files,
|
||||
semverTag
|
||||
)
|
||||
}
|
||||
|
||||
async function publishAttestation(
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
bundle: Buffer,
|
||||
bundleDigest: string,
|
||||
subjectManifest: ociContainer.OCIImageManifest,
|
||||
attestationManifest: ociContainer.OCIImageManifest,
|
||||
referrerIndexManifest: ociContainer.OCIIndexManifest
|
||||
): Promise<{
|
||||
attestationSHA: string
|
||||
referrerIndexSHA: string
|
||||
}> {
|
||||
const attestationManifestDigest =
|
||||
ociContainer.sha256Digest(attestationManifest)
|
||||
const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest)
|
||||
const referrerIndexManifestDigest = ociContainer.sha256Digest(
|
||||
referrerIndexManifest
|
||||
)
|
||||
|
||||
core.info(
|
||||
`Publishing attestation ${attestationManifestDigest} for subject ${subjectManifestDigest}.`
|
||||
)
|
||||
|
||||
const files = new Map<string, Buffer>()
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
files.set(bundleDigest, bundle)
|
||||
|
||||
const attestationSHA = await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
attestationManifest,
|
||||
files
|
||||
)
|
||||
|
||||
// The referrer index is tagged with the subject's digest in format sha256-<digest>
|
||||
const referrerTag = subjectManifestDigest.replace(':', '-')
|
||||
|
||||
core.info(
|
||||
`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`
|
||||
)
|
||||
|
||||
const referrerIndexSHA = await client.uploadOCIIndexManifest(
|
||||
nameWithOwner,
|
||||
referrerIndexManifest,
|
||||
referrerTag
|
||||
)
|
||||
|
||||
return { attestationSHA, referrerIndexSHA }
|
||||
}
|
||||
|
||||
async function generateAttestation(
|
||||
manifestDigest: string,
|
||||
semverTag: string,
|
||||
options: cfg.PublishActionOptions
|
||||
): Promise<attest.Attestation> {
|
||||
): Promise<{
|
||||
bundle: Buffer
|
||||
bundleDigest: string
|
||||
bundleMediaType: string
|
||||
bundlePredicateType: string
|
||||
}> {
|
||||
const subjectName = `${options.nameWithOwner}@${semverTag}`
|
||||
const subjectDigest = removePrefix(manifestDigest, 'sha256:')
|
||||
|
||||
core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`)
|
||||
|
||||
return await attest.attestProvenance({
|
||||
const attestation = await attest.attestProvenance({
|
||||
subjectName,
|
||||
subjectDigest: { sha256: subjectDigest },
|
||||
token: options.token,
|
||||
sigstore: 'github',
|
||||
// Always store the attestation using the GitHub Attestations API
|
||||
skipWrite: false,
|
||||
// Identify the attestation to our API as an Immutable Action
|
||||
headers: { 'X-GitHub-Publish-Action': subjectName }
|
||||
skipWrite: true // We will upload attestations to GHCR
|
||||
})
|
||||
|
||||
const bundleArtifact = Buffer.from(JSON.stringify(attestation.bundle))
|
||||
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(bundleArtifact)
|
||||
const bundleSHA = hash.digest('hex')
|
||||
|
||||
// We must base64 decode the dsse envelope to grab the predicate type
|
||||
const dsseEnvelopeArtifact = attestation.bundle.dsseEnvelope
|
||||
if (dsseEnvelopeArtifact === undefined) {
|
||||
throw new Error('Attestation bundle is missing dsseEnvelope artifact')
|
||||
}
|
||||
|
||||
const dsseEnvelope = JSON.parse(
|
||||
Buffer.from(dsseEnvelopeArtifact.payload, 'base64').toString('utf-8')
|
||||
)
|
||||
const predicateType = dsseEnvelope.predicateType
|
||||
if (predicateType === undefined) {
|
||||
throw new Error('Attestation bundle is missing predicateType')
|
||||
}
|
||||
|
||||
return {
|
||||
bundle: bundleArtifact,
|
||||
bundleDigest: `sha256:${bundleSHA}`,
|
||||
bundleMediaType: attestation.bundle.mediaType,
|
||||
bundlePredicateType: predicateType
|
||||
}
|
||||
}
|
||||
|
||||
function removePrefix(str: string, prefix: string): string {
|
||||
|
||||
+139
-25
@@ -1,19 +1,49 @@
|
||||
import { FileMetadata } from './fs-helper'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
export interface Manifest {
|
||||
export const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json'
|
||||
export const imageManifestMediaType =
|
||||
'application/vnd.oci.image.manifest.v1+json'
|
||||
export const actionsPackageMediaType =
|
||||
'application/vnd.github.actions.package.v1+json'
|
||||
export const actionsPackageTarLayerMediaType =
|
||||
'application/vnd.github.actions.package.layer.v1.tar+gzip'
|
||||
export const actionsPackageZipLayerMediaType =
|
||||
'application/vnd.github.actions.package.layer.v1.zip'
|
||||
|
||||
export const actionPackageAnnotationValue = 'actions_oci_pkg'
|
||||
export const actionPackageAttestationAnnotationValue =
|
||||
'actions_oci_pkg_attestation'
|
||||
export const actionPackageReferrerTagAnnotationValue =
|
||||
'actions_oci_pkg_referrer_index'
|
||||
|
||||
export const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json'
|
||||
export const emptyConfigSize = 2
|
||||
export const emptyConfigSha =
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
|
||||
export interface OCIImageManifest {
|
||||
schemaVersion: number
|
||||
mediaType: string
|
||||
artifactType: string
|
||||
config: Layer
|
||||
layers: Layer[]
|
||||
config: Descriptor
|
||||
layers: Descriptor[]
|
||||
subject?: Descriptor
|
||||
annotations: { [key: string]: string }
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
export interface OCIIndexManifest {
|
||||
schemaVersion: number
|
||||
mediaType: string
|
||||
manifests: Descriptor[]
|
||||
annotations: { [key: string]: string }
|
||||
}
|
||||
|
||||
export interface Descriptor {
|
||||
mediaType: string
|
||||
size: number
|
||||
digest: string
|
||||
artifactType?: string
|
||||
annotations?: { [key: string]: string }
|
||||
}
|
||||
|
||||
@@ -26,24 +56,24 @@ export function createActionPackageManifest(
|
||||
ownerId: string,
|
||||
sourceCommit: string,
|
||||
version: string,
|
||||
created: Date
|
||||
): Manifest {
|
||||
const configLayer = createConfigLayer()
|
||||
created: Date = new Date()
|
||||
): OCIImageManifest {
|
||||
const configLayer = createEmptyConfigLayer()
|
||||
const sanitizedRepo = sanitizeRepository(repository)
|
||||
const tarLayer = createTarLayer(tarFile, sanitizedRepo, version)
|
||||
const zipLayer = createZipLayer(zipFile, sanitizedRepo, version)
|
||||
|
||||
const manifest: Manifest = {
|
||||
const manifest: OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
artifactType: 'application/vnd.github.actions.package.v1+json',
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: actionsPackageMediaType,
|
||||
config: configLayer,
|
||||
layers: [configLayer, tarLayer, zipLayer],
|
||||
layers: [tarLayer, zipLayer],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': created.toISOString(),
|
||||
'action.tar.gz.digest': tarFile.sha256,
|
||||
'action.zip.digest': zipFile.sha256,
|
||||
'com.github.package.type': 'actions_oci_pkg',
|
||||
'com.github.package.type': actionPackageAnnotationValue,
|
||||
'com.github.package.version': version,
|
||||
'com.github.source.repo.id': repoId,
|
||||
'com.github.source.repo.owner.id': ownerId,
|
||||
@@ -54,9 +84,87 @@ export function createActionPackageManifest(
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function createSigstoreAttestationManifest(
|
||||
bundleSize: number,
|
||||
bundleDigest: string,
|
||||
bundleMediaType: string,
|
||||
bundlePredicateType: string,
|
||||
subjectSize: number,
|
||||
subjectDigest: string,
|
||||
created: Date = new Date()
|
||||
): OCIImageManifest {
|
||||
const configLayer = createEmptyConfigLayer()
|
||||
|
||||
const sigstoreAttestationLayer: Descriptor = {
|
||||
mediaType: bundleMediaType,
|
||||
size: bundleSize,
|
||||
digest: bundleDigest
|
||||
}
|
||||
|
||||
const subject: Descriptor = {
|
||||
mediaType: imageManifestMediaType,
|
||||
size: subjectSize,
|
||||
digest: subjectDigest
|
||||
}
|
||||
|
||||
const manifest: OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
config: configLayer,
|
||||
layers: [sigstoreAttestationLayer],
|
||||
subject,
|
||||
|
||||
annotations: {
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType,
|
||||
'com.github.package.type': actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function createReferrerTagManifest(
|
||||
attestationDigest: string,
|
||||
attestationSize: number,
|
||||
bundleMediaType: string,
|
||||
bundlePredicateType: string,
|
||||
attestationCreated: Date,
|
||||
created: Date = new Date()
|
||||
): OCIIndexManifest {
|
||||
const manifest: OCIIndexManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: imageIndexMediaType,
|
||||
manifests: [
|
||||
{
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
size: attestationSize,
|
||||
digest: attestationDigest,
|
||||
annotations: {
|
||||
'com.github.package.type': actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': attestationCreated.toISOString(),
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType
|
||||
}
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'com.github.package.type': actionPackageReferrerTagAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
// Calculate the SHA256 digest of a given manifest.
|
||||
// This should match the digest which the GitHub container registry calculates for this manifest.
|
||||
export function sha256Digest(manifest: Manifest): string {
|
||||
export function sha256Digest(
|
||||
manifest: OCIImageManifest | OCIIndexManifest
|
||||
): string {
|
||||
const data = JSON.stringify(manifest)
|
||||
const buffer = Buffer.from(data, 'utf8')
|
||||
const hash = crypto.createHash('sha256')
|
||||
@@ -65,12 +173,18 @@ export function sha256Digest(manifest: Manifest): string {
|
||||
return `sha256:${hexHash}`
|
||||
}
|
||||
|
||||
function createConfigLayer(): Layer {
|
||||
const configLayer: Layer = {
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
export function sizeInBytes(
|
||||
manifest: OCIImageManifest | OCIIndexManifest
|
||||
): number {
|
||||
const data = JSON.stringify(manifest)
|
||||
return Buffer.byteLength(data, 'utf8')
|
||||
}
|
||||
|
||||
export function createEmptyConfigLayer(): Descriptor {
|
||||
const configLayer: Descriptor = {
|
||||
mediaType: ociEmptyMediaType,
|
||||
size: emptyConfigSize,
|
||||
digest: emptyConfigSha
|
||||
}
|
||||
|
||||
return configLayer
|
||||
@@ -80,9 +194,9 @@ function createZipLayer(
|
||||
zipFile: FileMetadata,
|
||||
repository: string,
|
||||
version: string
|
||||
): Layer {
|
||||
const zipLayer: Layer = {
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
|
||||
): Descriptor {
|
||||
const zipLayer: Descriptor = {
|
||||
mediaType: actionsPackageZipLayerMediaType,
|
||||
size: zipFile.size,
|
||||
digest: zipFile.sha256,
|
||||
annotations: {
|
||||
@@ -97,9 +211,9 @@ function createTarLayer(
|
||||
tarFile: FileMetadata,
|
||||
repository: string,
|
||||
version: string
|
||||
): Layer {
|
||||
const tarLayer: Layer = {
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
|
||||
): Descriptor {
|
||||
const tarLayer: Descriptor = {
|
||||
mediaType: actionsPackageTarLayerMediaType,
|
||||
size: tarFile.size,
|
||||
digest: tarFile.sha256,
|
||||
annotations: {
|
||||
|
||||
Reference in New Issue
Block a user