217 lines
5.8 KiB
TypeScript
217 lines
5.8 KiB
TypeScript
import * as core from '@actions/core'
|
|
import { FileMetadata } from './fs-helper'
|
|
import * as ociContainer from './oci-container'
|
|
import * as fsHelper from './fs-helper'
|
|
|
|
// Publish the OCI artifact and return the URL where it can be downloaded
|
|
export async function publishOCIArtifact(
|
|
token: string,
|
|
registry: URL,
|
|
repository: string,
|
|
semver: string,
|
|
zipFile: FileMetadata,
|
|
tarFile: FileMetadata,
|
|
manifest: ociContainer.Manifest
|
|
): Promise<{ packageURL: URL; manifestDigest: string }> {
|
|
const b64Token = Buffer.from(token).toString('base64')
|
|
|
|
const checkBlobEndpoint = new URL(
|
|
`v2/${repository}/blobs/`,
|
|
registry
|
|
).toString()
|
|
const uploadBlobEndpoint = new URL(
|
|
`v2/${repository}/blobs/uploads/`,
|
|
registry
|
|
).toString()
|
|
const manifestEndpoint = new URL(
|
|
`v2/${repository}/manifests/${semver}`,
|
|
registry
|
|
).toString()
|
|
|
|
core.info(
|
|
`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`
|
|
)
|
|
|
|
const layerUploads: Promise<void>[] = manifest.layers.map(async layer => {
|
|
switch (layer.mediaType) {
|
|
case 'application/vnd.github.actions.package.layer.v1.tar+gzip':
|
|
return uploadLayer(
|
|
layer,
|
|
tarFile,
|
|
registry,
|
|
checkBlobEndpoint,
|
|
uploadBlobEndpoint,
|
|
b64Token
|
|
)
|
|
case 'application/vnd.github.actions.package.layer.v1.zip':
|
|
return uploadLayer(
|
|
layer,
|
|
zipFile,
|
|
registry,
|
|
checkBlobEndpoint,
|
|
uploadBlobEndpoint,
|
|
b64Token
|
|
)
|
|
case 'application/vnd.oci.empty.v1+json':
|
|
return uploadLayer(
|
|
layer,
|
|
{ path: '', size: 2, sha256: layer.digest },
|
|
registry,
|
|
checkBlobEndpoint,
|
|
uploadBlobEndpoint,
|
|
b64Token
|
|
)
|
|
default:
|
|
throw new Error(`Unknown media type ${layer.mediaType}`)
|
|
}
|
|
})
|
|
|
|
await Promise.all(layerUploads)
|
|
|
|
const digest = await uploadManifest(
|
|
JSON.stringify(manifest),
|
|
manifestEndpoint,
|
|
b64Token
|
|
)
|
|
|
|
return {
|
|
packageURL: new URL(`${repository}:${semver}`, registry),
|
|
manifestDigest: digest
|
|
}
|
|
}
|
|
|
|
async function uploadLayer(
|
|
layer: ociContainer.Layer,
|
|
file: FileMetadata,
|
|
registryURL: URL,
|
|
checkBlobEndpoint: string,
|
|
uploadBlobEndpoint: string,
|
|
b64Token: string
|
|
): Promise<void> {
|
|
const checkExistsResponse = await fetchWithDebug(
|
|
checkBlobEndpoint + layer.digest,
|
|
{
|
|
method: 'HEAD',
|
|
headers: {
|
|
Authorization: `Bearer ${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(
|
|
`Unexpected response from blob check for layer ${layer.digest}: ${checkExistsResponse.status} ${checkExistsResponse.statusText}`
|
|
)
|
|
}
|
|
|
|
core.info(`Uploading layer ${layer.digest}.`)
|
|
|
|
const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${b64Token}`
|
|
},
|
|
body: JSON.stringify(layer)
|
|
})
|
|
|
|
if (initiateUploadResponse.status !== 202) {
|
|
core.error(
|
|
`Unexpected response from upload post ${uploadBlobEndpoint}: ${initiateUploadResponse.status}`
|
|
)
|
|
throw new Error(
|
|
`Unexpected response from POST upload ${initiateUploadResponse.status}`
|
|
)
|
|
}
|
|
|
|
const locationResponseHeader = initiateUploadResponse.headers.get('location')
|
|
if (locationResponseHeader === undefined) {
|
|
throw new Error(
|
|
`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`
|
|
)
|
|
}
|
|
|
|
const pathname = `${locationResponseHeader}?digest=${layer.digest}`
|
|
const uploadBlobUrl = new URL(pathname, registryURL).toString()
|
|
|
|
// TODO: must we handle the empty config layer? Maybe we can just skip calling this at all
|
|
let data: Buffer
|
|
if (layer.mediaType === 'application/vnd.oci.empty.v1+json') {
|
|
data = Buffer.from('{}')
|
|
} else {
|
|
data = fsHelper.readFileContents(file.path)
|
|
}
|
|
|
|
const putResponse = await fetchWithDebug(uploadBlobUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: `Bearer ${b64Token}`,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Accept-Encoding': 'gzip',
|
|
'Content-Length': layer.size.toString()
|
|
},
|
|
body: data
|
|
})
|
|
|
|
if (putResponse.status !== 201) {
|
|
throw new Error(
|
|
`Unexpected response from PUT upload ${putResponse.status} for layer ${layer.digest}`
|
|
)
|
|
}
|
|
}
|
|
|
|
// Uploads the manifest and returns the digest returned by GHCR
|
|
async function uploadManifest(
|
|
manifestJSON: string,
|
|
manifestEndpoint: string,
|
|
b64Token: string
|
|
): Promise<string> {
|
|
core.info(`Uploading manifest to ${manifestEndpoint}.`)
|
|
|
|
const putResponse = await fetchWithDebug(manifestEndpoint, {
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: `Bearer ${b64Token}`,
|
|
'Content-Type': 'application/vnd.oci.image.manifest.v1+json'
|
|
},
|
|
body: manifestJSON
|
|
})
|
|
|
|
if (putResponse.status !== 201) {
|
|
throw new Error(
|
|
`Unexpected response from PUT manifest ${putResponse.status}`
|
|
)
|
|
}
|
|
|
|
const digestResponseHeader = putResponse.headers.get('docker-content-digest')
|
|
if (digestResponseHeader === undefined || digestResponseHeader === null) {
|
|
throw new Error(
|
|
`No digest header in response from PUT manifest ${manifestEndpoint}`
|
|
)
|
|
}
|
|
|
|
return digestResponseHeader
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|