add retries and fix up tests
This commit is contained in:
+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) {
|
||||
|
||||
Reference in New Issue
Block a user