add retries and fix up tests
This commit is contained in:
+132
-28
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
uploadOCIImageManifest,
|
||||
uploadOCIIndexManifest
|
||||
// uploadOCIIndexManifest
|
||||
} from '../src/ghcr-client'
|
||||
import { Client } from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Mocks
|
||||
let fetchMock: jest.SpyInstance
|
||||
|
||||
let client: Client
|
||||
|
||||
const token = 'test-token'
|
||||
const registry = new URL('https://ghcr.io')
|
||||
const repository = 'test-org/test-repo'
|
||||
@@ -156,22 +154,75 @@ type MethodHandlers = {
|
||||
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 'HEAD':
|
||||
if (forcedRetries.checkBlob > 0) {
|
||||
forcedRetries.checkBlob--
|
||||
return retriableError(forcedRetries.checkBlob)
|
||||
}
|
||||
|
||||
return methodHandlers.checkBlobMock?.(url, options)
|
||||
case 'POST':
|
||||
if (forcedRetries.initiateBlobUpload > 0) {
|
||||
forcedRetries.initiateBlobUpload--
|
||||
return retriableError(forcedRetries.initiateBlobUpload)
|
||||
}
|
||||
|
||||
return methodHandlers.initiateBlobUploadMock?.(url, options)
|
||||
case 'PUT':
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -183,6 +234,11 @@ describe('uploadOCIIndexManifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
||||
|
||||
client = new Client(token, registry, {
|
||||
retries: 5,
|
||||
backoff: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads the tagged manifest with the appropriate tag', async () => {
|
||||
@@ -193,7 +249,7 @@ describe('uploadOCIIndexManifest', () => {
|
||||
putManifestMock: putManifestSuccessful(sha, tag)
|
||||
})
|
||||
|
||||
await uploadOCIIndexManifest(token, registry, repository, manifest, tag)
|
||||
await client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
@@ -212,24 +268,26 @@ describe('uploadOCIIndexManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
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, blobs } = testImageManifest()
|
||||
const { manifest, sha } = testIndexManifest()
|
||||
|
||||
const tag = 'sha-1234'
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', sha)
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', tag)
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
@@ -252,7 +310,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
@@ -276,14 +334,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
putManifestMock: putManifestSuccessful(sha, semver)
|
||||
})
|
||||
|
||||
await uploadOCIImageManifest(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
manifest,
|
||||
blobs,
|
||||
semver
|
||||
)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs, semver)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
@@ -297,6 +348,40 @@ describe('uploadOCIImageManifest', () => {
|
||||
).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()
|
||||
|
||||
@@ -307,7 +392,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
@@ -331,7 +416,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8)
|
||||
expect(
|
||||
@@ -356,12 +441,31 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
/^Unexpected 503 Service Unavailable response from check blob/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if a blob file is not provided', async () => {
|
||||
const { manifest, sha } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(
|
||||
repository,
|
||||
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()
|
||||
|
||||
@@ -373,7 +477,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.'
|
||||
)
|
||||
@@ -390,7 +494,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^No location header in response from upload post/)
|
||||
})
|
||||
|
||||
@@ -405,7 +509,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/)
|
||||
})
|
||||
|
||||
@@ -420,7 +524,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
||||
)
|
||||
@@ -437,7 +541,7 @@ describe('uploadOCIImageManifest', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
uploadOCIImageManifest(token, registry, repository, manifest, blobs)
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
|
||||
+15
-12
@@ -31,6 +31,9 @@ let readFileContentsMock: jest.SpyInstance
|
||||
let calculateManifestDigestMock: jest.SpyInstance
|
||||
|
||||
// Mock GHCR client
|
||||
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
|
||||
|
||||
@@ -44,6 +47,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()
|
||||
@@ -71,11 +76,15 @@ describe('run', () => {
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
createGHCRClient = jest
|
||||
.spyOn(ghcr, 'Client')
|
||||
.mockImplementation(() => client)
|
||||
|
||||
uploadOCIImageManifestMock = jest
|
||||
.spyOn(ghcr, 'uploadOCIImageManifest')
|
||||
.spyOn(client, 'uploadOCIImageManifest')
|
||||
.mockImplementation()
|
||||
uploadOCIIndexManifestMock = jest
|
||||
.spyOn(ghcr, 'uploadOCIIndexManifest')
|
||||
.spyOn(client, 'uploadOCIIndexManifest')
|
||||
.mockImplementation()
|
||||
|
||||
// Config mocks
|
||||
@@ -428,7 +437,7 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(token, registry, repo, manifest, blobs, tag) => {
|
||||
(repo, manifest, blobs, tag) => {
|
||||
if (tag === undefined) {
|
||||
return 'attestation-digest'
|
||||
} else {
|
||||
@@ -485,9 +494,7 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(token, registry, repository, manifest, blobs, tag) => {
|
||||
expect(token).toBe(options.token)
|
||||
expect(registry).toBe(options.containerRegistryUrl)
|
||||
(repository, manifest, blobs, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('1.2.3')
|
||||
expect(blobs.size).toBe(3)
|
||||
@@ -572,9 +579,7 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(
|
||||
async (token, registry, repository, manifest, tag) => {
|
||||
expect(token).toBe(options.token)
|
||||
expect(registry).toBe(options.containerRegistryUrl)
|
||||
async (repository, manifest, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('sha256-my-test-digest')
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageIndexMediaType)
|
||||
@@ -583,7 +588,7 @@ describe('run', () => {
|
||||
)
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(token, registry, repository, manifest, blobs, tag) => {
|
||||
(repository, manifest, blobs, tag) => {
|
||||
let expectedBlobKeys: string[] = []
|
||||
let expectedAnnotationValue = ''
|
||||
let expectedTagValue: string | undefined = undefined
|
||||
@@ -606,8 +611,6 @@ describe('run', () => {
|
||||
returnValue = 'sha256:my-test-digest'
|
||||
}
|
||||
|
||||
expect(token).toBe(options.token)
|
||||
expect(registry).toBe(options.containerRegistryUrl)
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
|
||||
@@ -76,6 +76,13 @@ describe('createActionPackageManifest', () => {
|
||||
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', () => {
|
||||
@@ -116,6 +123,13 @@ describe('createSigstoreAttestationManifest', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -151,9 +165,16 @@ describe('createReferrerIndexManifest', () => {
|
||||
|
||||
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(): {
|
||||
function testActionPackageManifest(setCreated = true): {
|
||||
manifest: OCIImageManifest
|
||||
tarFile: FileMetadata
|
||||
zipFile: FileMetadata
|
||||
@@ -183,7 +204,7 @@ function testActionPackageManifest(): {
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
date
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -193,21 +214,23 @@ function testActionPackageManifest(): {
|
||||
}
|
||||
}
|
||||
|
||||
function testAttestationManifest(): OCIImageManifest {
|
||||
function testAttestationManifest(setCreated = true): OCIImageManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createSigstoreAttestationManifest(
|
||||
10,
|
||||
'bundleDigest',
|
||||
100,
|
||||
'subjectDigest',
|
||||
new Date(createdTimestamp)
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function testReferrerIndexManifest(): OCIIndexManifest {
|
||||
function testReferrerIndexManifest(setCreated = true): OCIIndexManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createReferrerTagManifest(
|
||||
'attDigest',
|
||||
100,
|
||||
new Date(createdTimestamp),
|
||||
new Date(createdTimestamp)
|
||||
date,
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
|
||||
+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: 96.77%"><title>Coverage: 96.77%</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">96.77%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">96.77%</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: 98.07%"><title>Coverage: 98.07%</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">98.07%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">98.07%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+172
-128
@@ -106669,113 +106669,181 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.uploadOCIImageManifest = uploadOCIImageManifest;
|
||||
exports.uploadOCIIndexManifest = uploadOCIIndexManifest;
|
||||
exports.Client = void 0;
|
||||
const core = __importStar(__nccwpck_require__(42186));
|
||||
const ociContainer = __importStar(__nccwpck_require__(33207));
|
||||
async function uploadOCIImageManifest(token, registry, repository, manifest, blobs, tag) {
|
||||
const b64Token = Buffer.from(token).toString('base64');
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest);
|
||||
if (tag) {
|
||||
core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
|
||||
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;
|
||||
}
|
||||
else {
|
||||
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`);
|
||||
}
|
||||
// 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`);
|
||||
async uploadOCIImageManifest(repository, manifest, blobs, tag) {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest);
|
||||
if (tag) {
|
||||
core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
|
||||
}
|
||||
return uploadLayer(layer, blob, registry, repository, b64Token);
|
||||
});
|
||||
await Promise.all(layerUploads);
|
||||
const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag || manifestSHA, b64Token);
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
|
||||
}
|
||||
return manifestSHA;
|
||||
}
|
||||
async function uploadOCIIndexManifest(token, registry, repository, manifest, tag) {
|
||||
const b64Token = Buffer.from(token).toString('base64');
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest);
|
||||
core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
|
||||
const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag, b64Token);
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
|
||||
}
|
||||
return manifestSHA;
|
||||
}
|
||||
async function uploadLayer(layer, data, registryURL, repository, b64Token) {
|
||||
const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint(registryURL, repository, 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 initiateUploadBlobURL = uploadBlobEndpoint(registryURL, repository);
|
||||
const initiateUploadResponse = await fetchWithDebug(initiateUploadBlobURL, {
|
||||
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 ${initiateUploadBlobURL} 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();
|
||||
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));
|
||||
checkBlobEndpoint(repository, digest) {
|
||||
return new URL(`v2/${repository}/blobs/${digest}`, this._registry).toString();
|
||||
}
|
||||
uploadBlobEndpoint(repository) {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString();
|
||||
}
|
||||
manifestEndpoint(repository, version) {
|
||||
return new URL(`v2/${repository}/manifests/${version}`, this._registry).toString();
|
||||
}
|
||||
// TODO: Add retries with backoff
|
||||
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, manifestMediaType, registry, repository, version, b64Token) {
|
||||
const manifestUrl = manifestEndpoint(registry, repository, version);
|
||||
core.info(`Uploading manifest to ${manifestUrl}.`);
|
||||
const putResponse = await fetchWithDebug(manifestUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${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');
|
||||
if (digestResponseHeader === undefined || digestResponseHeader === null) {
|
||||
throw new Error(`No digest header in response from PUT manifest ${manifestUrl}`);
|
||||
}
|
||||
return digestResponseHeader;
|
||||
}
|
||||
exports.Client = Client;
|
||||
// Generate an error message for a failed HTTP request
|
||||
async function errorMessageForFailedRequest(requestDescription, response) {
|
||||
const bodyText = await response.text();
|
||||
@@ -106810,28 +106878,6 @@ function isGHCRError(obj) {
|
||||
'message' in obj &&
|
||||
typeof obj.message === 'string');
|
||||
}
|
||||
function checkBlobEndpoint(registry, repository, digest) {
|
||||
return new URL(`v2/${repository}/blobs/${digest}`, registry).toString();
|
||||
}
|
||||
function uploadBlobEndpoint(registry, repository) {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, registry).toString();
|
||||
}
|
||||
function manifestEndpoint(registry, repository, version) {
|
||||
return new URL(`v2/${repository}/manifests/${version}`, registry).toString();
|
||||
}
|
||||
// TODO: Add retries with backoff
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
@@ -106895,13 +106941,14 @@ 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);
|
||||
const ghcrClient = new ghcr.Client(options.token, options.containerRegistryUrl);
|
||||
// Attestations are not supported in GHES.
|
||||
if (!options.isEnterprise) {
|
||||
const { bundle, bundleDigest } = await generateAttestation(manifestDigest, semverTag.raw, options);
|
||||
const attestationCreated = new Date();
|
||||
const attestationManifest = ociContainer.createSigstoreAttestationManifest(bundle.length, bundleDigest, ociContainer.sizeInBytes(manifest), manifestDigest, attestationCreated);
|
||||
const referrerIndexManifest = ociContainer.createReferrerTagManifest(ociContainer.sha256Digest(attestationManifest), ociContainer.sizeInBytes(attestationManifest), attestationCreated);
|
||||
const { attestationSHA, referrerIndexSHA } = await publishAttestation(options, bundle, bundleDigest, manifest, attestationManifest, referrerIndexManifest);
|
||||
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);
|
||||
@@ -106911,10 +106958,7 @@ async function run() {
|
||||
core.setOutput('referrer-index-manifest-sha', referrerIndexSHA);
|
||||
}
|
||||
}
|
||||
const publishedDigest = await publishImmutableActionVersion(options, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
|
||||
if (manifestDigest !== publishedDigest) {
|
||||
throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`);
|
||||
}
|
||||
const publishedDigest = await publishImmutableActionVersion(ghcrClient, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
|
||||
core.setOutput('package-manifest-sha', publishedDigest);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -106938,16 +106982,16 @@ function parseSemverTagFromRef(opts) {
|
||||
}
|
||||
return semverTag;
|
||||
}
|
||||
async function publishImmutableActionVersion(options, semverTag, zipFile, tarFile, manifest) {
|
||||
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 ghcr.uploadOCIImageManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, manifest, files, semverTag);
|
||||
return await client.uploadOCIImageManifest(nameWithOwner, manifest, files, semverTag);
|
||||
}
|
||||
async function publishAttestation(options, bundle, bundleDigest, subjectManifest, attestationManifest, referrerIndexManifest) {
|
||||
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);
|
||||
@@ -106955,11 +106999,11 @@ async function publishAttestation(options, bundle, bundleDigest, subjectManifest
|
||||
const files = new Map();
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'));
|
||||
files.set(bundleDigest, bundle);
|
||||
const attestationSHA = await ghcr.uploadOCIImageManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, attestationManifest, files);
|
||||
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 ghcr.uploadOCIIndexManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, referrerIndexManifest, referrerTag);
|
||||
const referrerIndexSHA = await client.uploadOCIIndexManifest(nameWithOwner, referrerIndexManifest, referrerTag);
|
||||
return { attestationSHA, referrerIndexSHA };
|
||||
}
|
||||
async function generateAttestation(manifestDigest, semverTag, options) {
|
||||
|
||||
+285
-221
@@ -1,210 +1,310 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as ociContainer from './oci-container'
|
||||
|
||||
export async function uploadOCIImageManifest(
|
||||
token: string,
|
||||
registry: URL,
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIImageManifest,
|
||||
blobs: Map<string, Buffer>,
|
||||
tag?: string
|
||||
): Promise<string> {
|
||||
const b64Token = Buffer.from(token).toString('base64')
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
const defaultRetries = 5
|
||||
const defaultBackoff = 1000
|
||||
const retryableStatusCodes = [408, 429, 500, 502, 503, 504]
|
||||
|
||||
if (tag) {
|
||||
core.info(
|
||||
`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
} else {
|
||||
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`)
|
||||
}
|
||||
export interface RetryOptions {
|
||||
retries: number
|
||||
backoff: number
|
||||
}
|
||||
|
||||
// We must also upload the config layer
|
||||
const layersToUpload = manifest.layers.concat(manifest.config)
|
||||
export class Client {
|
||||
private _b64Token: string
|
||||
private _registry: URL
|
||||
private _retryOptions: RetryOptions
|
||||
|
||||
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`)
|
||||
constructor(
|
||||
token: string,
|
||||
registry: URL,
|
||||
retryOptions: RetryOptions = {
|
||||
retries: defaultRetries,
|
||||
backoff: defaultBackoff
|
||||
}
|
||||
return uploadLayer(layer, blob, registry, repository, b64Token)
|
||||
})
|
||||
|
||||
await Promise.all(layerUploads)
|
||||
|
||||
const publishedDigest = await uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
registry,
|
||||
repository,
|
||||
tag || manifestSHA,
|
||||
b64Token
|
||||
)
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
)
|
||||
) {
|
||||
this._b64Token = Buffer.from(token).toString('base64')
|
||||
this._registry = registry
|
||||
this._retryOptions = retryOptions
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
async uploadOCIImageManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIImageManifest,
|
||||
blobs: Map<string, Buffer>,
|
||||
tag?: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
export async function uploadOCIIndexManifest(
|
||||
token: string,
|
||||
registry: URL,
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIIndexManifest,
|
||||
tag: string
|
||||
): Promise<string> {
|
||||
const b64Token = Buffer.from(token).toString('base64')
|
||||
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}.`)
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
// We must also upload the config layer
|
||||
const layersToUpload = manifest.layers.concat(manifest.config)
|
||||
|
||||
const publishedDigest = await uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
b64Token
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
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
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
async uploadOCIIndexManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIIndexManifest,
|
||||
tag: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
async function uploadLayer(
|
||||
layer: ociContainer.Descriptor,
|
||||
data: Buffer,
|
||||
registryURL: URL,
|
||||
repository: string,
|
||||
b64Token: string
|
||||
): Promise<void> {
|
||||
const checkExistsResponse = await fetchWithDebug(
|
||||
checkBlobEndpoint(registryURL, repository, layer.digest),
|
||||
{
|
||||
method: 'HEAD',
|
||||
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()
|
||||
}
|
||||
|
||||
// TODO: Add retries with backoff
|
||||
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 initiateUploadBlobURL = uploadBlobEndpoint(registryURL, repository)
|
||||
|
||||
const initiateUploadResponse = await fetchWithDebug(initiateUploadBlobURL, {
|
||||
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 ${initiateUploadBlobURL} for layer ${layer.digest}`
|
||||
)
|
||||
}
|
||||
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`
|
||||
const uploadBlobUrl = new URL(pathname, registryURL).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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
async function uploadManifest(
|
||||
manifestJSON: string,
|
||||
manifestMediaType: string,
|
||||
registry: URL,
|
||||
repository: string,
|
||||
version: string,
|
||||
b64Token: string
|
||||
): Promise<string> {
|
||||
const manifestUrl = manifestEndpoint(registry, repository, version)
|
||||
|
||||
core.info(`Uploading manifest to ${manifestUrl}.`)
|
||||
|
||||
const putResponse = await fetchWithDebug(manifestUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${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')
|
||||
if (digestResponseHeader === undefined || digestResponseHeader === null) {
|
||||
throw new Error(
|
||||
`No digest header in response from PUT manifest ${manifestUrl}`
|
||||
)
|
||||
}
|
||||
|
||||
return digestResponseHeader
|
||||
}
|
||||
|
||||
interface ghcrError {
|
||||
@@ -257,39 +357,3 @@ function isGHCRError(obj: unknown): boolean {
|
||||
typeof (obj as { message: unknown }).message === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function checkBlobEndpoint(
|
||||
registry: URL,
|
||||
repository: string,
|
||||
digest: string
|
||||
): string {
|
||||
return new URL(`v2/${repository}/blobs/${digest}`, registry).toString()
|
||||
}
|
||||
|
||||
function uploadBlobEndpoint(registry: URL, repository: string): string {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, registry).toString()
|
||||
}
|
||||
|
||||
function manifestEndpoint(
|
||||
registry: URL,
|
||||
repository: string,
|
||||
version: string
|
||||
): string {
|
||||
return new URL(`v2/${repository}/manifests/${version}`, registry).toString()
|
||||
}
|
||||
|
||||
// TODO: Add retries with backoff
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+19
-22
@@ -53,6 +53,11 @@ export async function run(): Promise<void> {
|
||||
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest)
|
||||
|
||||
const ghcrClient = new ghcr.Client(
|
||||
options.token,
|
||||
options.containerRegistryUrl
|
||||
)
|
||||
|
||||
// Attestations are not supported in GHES.
|
||||
if (!options.isEnterprise) {
|
||||
const { bundle, bundleDigest } = await generateAttestation(
|
||||
@@ -77,7 +82,8 @@ export async function run(): Promise<void> {
|
||||
)
|
||||
|
||||
const { attestationSHA, referrerIndexSHA } = await publishAttestation(
|
||||
options,
|
||||
ghcrClient,
|
||||
options.nameWithOwner,
|
||||
bundle,
|
||||
bundleDigest,
|
||||
manifest,
|
||||
@@ -96,19 +102,14 @@ export async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
const publishedDigest = await publishImmutableActionVersion(
|
||||
options,
|
||||
ghcrClient,
|
||||
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-manifest-sha', publishedDigest)
|
||||
} catch (error) {
|
||||
// Fail the workflow run if an error occurs
|
||||
@@ -138,7 +139,8 @@ function parseSemverTagFromRef(opts: cfg.PublishActionOptions): semver.SemVer {
|
||||
}
|
||||
|
||||
async function publishImmutableActionVersion(
|
||||
options: cfg.PublishActionOptions,
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
semverTag: string,
|
||||
zipFile: fsHelper.FileMetadata,
|
||||
tarFile: fsHelper.FileMetadata,
|
||||
@@ -155,10 +157,8 @@ async function publishImmutableActionVersion(
|
||||
files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path))
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
|
||||
return await ghcr.uploadOCIImageManifest(
|
||||
options.token,
|
||||
options.containerRegistryUrl,
|
||||
options.nameWithOwner,
|
||||
return await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
manifest,
|
||||
files,
|
||||
semverTag
|
||||
@@ -166,7 +166,8 @@ async function publishImmutableActionVersion(
|
||||
}
|
||||
|
||||
async function publishAttestation(
|
||||
options: cfg.PublishActionOptions,
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
bundle: Buffer,
|
||||
bundleDigest: string,
|
||||
subjectManifest: ociContainer.OCIImageManifest,
|
||||
@@ -191,10 +192,8 @@ async function publishAttestation(
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
files.set(bundleDigest, bundle)
|
||||
|
||||
const attestationSHA = await ghcr.uploadOCIImageManifest(
|
||||
options.token,
|
||||
options.containerRegistryUrl,
|
||||
options.nameWithOwner,
|
||||
const attestationSHA = await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
attestationManifest,
|
||||
files
|
||||
)
|
||||
@@ -206,10 +205,8 @@ async function publishAttestation(
|
||||
`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`
|
||||
)
|
||||
|
||||
const referrerIndexSHA = await ghcr.uploadOCIIndexManifest(
|
||||
options.token,
|
||||
options.containerRegistryUrl,
|
||||
options.nameWithOwner,
|
||||
const referrerIndexSHA = await client.uploadOCIIndexManifest(
|
||||
nameWithOwner,
|
||||
referrerIndexManifest,
|
||||
referrerTag
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user