From 1b9faf628d85847ca32fcc166378958b5147aed0 Mon Sep 17 00:00:00 2001 From: Conor Sloan Date: Fri, 23 Aug 2024 13:17:07 +0100 Subject: [PATCH] add retries and fix up tests --- __tests__/ghcr-client.test.ts | 160 ++++++++-- __tests__/main.test.ts | 27 +- __tests__/oci-container.test.ts | 37 ++- badges/coverage.svg | 2 +- dist/index.js | 300 +++++++++++-------- src/ghcr-client.ts | 506 ++++++++++++++++++-------------- src/main.ts | 41 ++- 7 files changed, 654 insertions(+), 419 deletions(-) diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index 01fb2ee..fcdb6b8 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -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 => { + 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() + ) + ).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.` ) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index d103bf3..1e1a593 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -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( diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index 781c044..c97267c 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -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 ) } diff --git a/badges/coverage.svg b/badges/coverage.svg index a4f5396..2f3c0cd 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 96.77%Coverage96.77% \ No newline at end of file +Coverage: 98.07%Coverage98.07% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 10e1874..50f4aff 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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- 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) { diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 0566f79..94fda15 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -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, - tag?: string -): Promise { - 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[] = 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, + tag?: string + ): Promise { + const manifestSHA = ociContainer.sha256Digest(manifest) -export async function uploadOCIIndexManifest( - token: string, - registry: URL, - repository: string, - manifest: ociContainer.OCIIndexManifest, - tag: string -): Promise { - 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[] = 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 { + const manifestSHA = ociContainer.sha256Digest(manifest) -async function uploadLayer( - layer: ociContainer.Descriptor, - data: Buffer, - registryURL: URL, - repository: string, - b64Token: string -): Promise { - 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 { + 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 { + 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 { + 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 { + 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 { - 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 => { - 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 - } -} diff --git a/src/main.ts b/src/main.ts index b29a277..b93cd54 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,6 +53,11 @@ export async function run(): Promise { 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 { ) const { attestationSHA, referrerIndexSHA } = await publishAttestation( - options, + ghcrClient, + options.nameWithOwner, bundle, bundleDigest, manifest, @@ -96,19 +102,14 @@ export async function run(): Promise { } 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 )