Move from composite to regular node action.

This involves generating the attestation in the code using the new attest library in the actions toolkit.
This commit is contained in:
Conor Sloan
2024-03-01 16:45:32 +00:00
parent 2c0bfdf7d3
commit 54d9a343c3
18 changed files with 94823 additions and 22734 deletions
-53
View File
@@ -1,53 +0,0 @@
export async function getRepositoryMetadata(
repository: string,
token: string
): Promise<{ repoId: string; ownerId: string }> {
const response = await fetch(
`${process.env.GITHUB_API_URL}/repos/${repository}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
}
)
if (!response.ok) {
throw new Error(
`Failed to fetch repository metadata due to bad status code: ${response.status}`
)
}
const data = await response.json()
// Check that the response contains the expected data
if (!data.id || !data.owner.id) {
throw new Error(
`Failed to fetch repository metadata: unexpected response format`
)
}
return { repoId: String(data.id), ownerId: String(data.owner.id) }
}
export async function getContainerRegistryURL(): Promise<URL> {
const response = await fetch(
`${process.env.GITHUB_API_URL}/packages/container-registry-url`
)
if (!response.ok) {
throw new Error(
`Failed to fetch container registry url due to bad status code: ${response.status}`
)
}
const data = await response.json()
if (!data.url) {
throw new Error(
`Failed to fetch repository metadata: unexpected response format`
)
}
const registryURL: URL = new URL(data.url)
return registryURL
}
+132
View File
@@ -0,0 +1,132 @@
import * as iaToolkit from '@immutable-actions/toolkit'
import * as core from '@actions/core'
import * as github from '@actions/github'
// All the environment options required to run the action
export interface PublishActionOptions {
// The name of the repository in the format owner/repo
nameWithOwner: string
// The GitHub token to use for API requests
token: string
// The commit SHA to reset back to after the action completes
sha: string
// The base URL for the GitHub API
apiBaseUrl: string
// The base URL for the GitHub Container Registry
containerRegistryUrl: URL
// The directory where the action is running, used for git operations
workspaceDir: string
// The directory set up to be used for temporary files by the runner
runnerTempDir: string
// Whether this action is running in enterprise, determined from the github URL
isEnterprise: boolean
// The repository ID of the action repository
repositoryId: string
// The owner ID of the action repository
repositoryOwnerId: string
// The event that triggered the action
event: string
// The ref that triggered the action, associated with the event
ref: string
}
export async function resolvePublishActionOptions(): Promise<PublishActionOptions> {
// Action Inputs
const token: string = core.getInput('github-token') || ''
if (token === '') {
throw new Error(`Could not find GITHUB_TOKEN.`)
}
// Context Inputs
const event: string = github.context.eventName
if (event === '') {
throw new Error(`Could not find event name.`)
}
// Environment Variables
const ref: string = process.env.GITHUB_REF || ''
if (ref === '') {
throw new Error(`Could not find GITHUB_REF.`)
}
const workspaceDir: string = process.env.GITHUB_WORKSPACE || ''
if (workspaceDir === '') {
throw new Error(`Could not find GITHUB_WORKSPACE.`)
}
const nameWithOwner: string = process.env.GITHUB_REPOSITORY || ''
if (nameWithOwner === '') {
throw new Error(`Could not find Repository.`)
}
const apiBaseUrl: string = process.env.GITHUB_API_URL || ''
if (apiBaseUrl === '') {
throw new Error(`Could not find GITHUB_API_URL.`)
}
const runnerTempDir: string = process.env.RUNNER_TEMP || ''
if (runnerTempDir === '') {
throw new Error(`Could not find RUNNER_TEMP.`)
}
const sha: string = process.env.GITHUB_SHA || ''
if (sha === '') {
throw new Error(`Could not find GITHUB_SHA.`)
}
const githubServerUrl = process.env.GITHUB_SERVER_URL || ''
if (githubServerUrl === '') {
throw new Error(`Could not find GITHUB_SERVER_URL.`)
}
const repositoryId = process.env.GITHUB_REPOSITORY_ID || ''
if (repositoryId === '') {
throw new Error(`Could not find GITHUB_REPOSITORY_ID.`)
}
const repositoryOwnerId = process.env.GITHUB_REPOSITORY_OWNER_ID || ''
if (repositoryOwnerId === '') {
throw new Error(`Could not find GITHUB_REPOSITORY_OWNER_ID.`)
}
// Required Values fetched from the GitHub API
const containerRegistryUrl: URL =
await iaToolkit.getContainerRegistryURL(apiBaseUrl)
// TODO: Figure out if there's a better way to do this
const isEnterprise =
!githubServerUrl.endsWith('github.com') &&
!githubServerUrl.endsWith('ghe.com')
return {
event,
ref,
workspaceDir,
nameWithOwner,
token,
apiBaseUrl,
runnerTempDir,
sha,
containerRegistryUrl,
isEnterprise,
repositoryId,
repositoryOwnerId
}
}
// When printing this object, we want to hide some of them from being displayed
const internalKeys = new Set<string>([
'token',
'runnerTempDir',
'repositoryId',
'repositoryOwnerId'
])
export function serializeOptions(options: PublishActionOptions): string {
return JSON.stringify(
options,
(key: string, value: unknown) =>
internalKeys.has(key) ? undefined : value,
2 // 2 spaces for pretty-printing
)
}
-138
View File
@@ -1,138 +0,0 @@
import * as fs from 'fs'
import fsExtra from 'fs-extra'
import * as path from 'path'
import * as tar from 'tar'
import * as archiver from 'archiver'
import * as crypto from 'crypto'
export interface FileMetadata {
path: string
size: number
sha256: string
}
export function createTempDir(subDirName: string): string {
const runnerTempDir: string = process.env.RUNNER_TEMP || ''
const tempDir = path.join(runnerTempDir, subDirName)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir)
}
return tempDir
}
// Creates both a tar.gz and zip archive of the given directory and returns the paths to both archives (stored in the provided target directory)
// as well as the size/sha256 hash of each file.
export async function createArchives(
distPath: string,
archiveTargetPath: string
): Promise<{ zipFile: FileMetadata; tarFile: FileMetadata }> {
const zipPath = path.join(archiveTargetPath, `archive.zip`)
const tarPath = path.join(archiveTargetPath, `archive.tar.gz`)
const createZipPromise = new Promise<FileMetadata>((resolve, reject) => {
const output = fs.createWriteStream(zipPath)
const archive = archiver.create('zip')
output.on('error', (err: Error) => {
reject(err)
})
archive.on('error', (err: Error) => {
reject(err)
})
output.on('close', () => {
resolve(fileMetadata(zipPath))
})
archive.pipe(output)
archive.directory(distPath, false)
archive.finalize()
})
const createTarPromise = new Promise<FileMetadata>((resolve, reject) => {
tar
.c(
{
file: tarPath,
C: distPath,
gzip: true
},
['.']
)
// eslint-disable-next-line github/no-then
.catch(err => {
reject(err)
})
// eslint-disable-next-line github/no-then
.then(() => {
resolve(fileMetadata(tarPath))
})
})
const [zipFile, tarFile] = await Promise.all([
createZipPromise,
createTarPromise
])
return { zipFile, tarFile }
}
export function isDirectory(dirPath: string): boolean {
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory()
}
export function readFileContents(filePath: string): Buffer {
return fs.readFileSync(filePath)
}
// Copy actions files from sourceDir to targetDir, excluding files and folders not relevant to the action
// Errors if the repo appears to not contain any action files, such as an action.yml file
export function stageActionFiles(actionDir: string, targetDir: string): void {
let actionYmlFound = false
fsExtra.copySync(actionDir, targetDir, {
filter: (src: string) => {
const basename = path.basename(src)
if (basename === 'action.yml' || basename === 'action.yaml') {
actionYmlFound = true
}
// Filter out hidden folers like .git and .github
return basename === '.' || !basename.startsWith('.')
}
})
if (!actionYmlFound) {
throw new Error(
`No action.yml or action.yaml file found in source repository`
)
}
}
// Converts a file path to a filemetadata object by querying the fs for relevant metadata.
async function fileMetadata(filePath: string): Promise<FileMetadata> {
const stats = fs.statSync(filePath)
const size = stats.size
const hash = crypto.createHash('sha256')
const fileStream = fs.createReadStream(filePath)
return new Promise((resolve, reject) => {
fileStream.on('data', data => {
hash.update(data)
})
fileStream.on('end', () => {
const sha256 = hash.digest('hex')
resolve({
path: filePath,
size,
sha256: `sha256:${sha256}`
})
})
fileStream.on('error', err => {
reject(err)
})
})
}
-216
View File
@@ -1,216 +0,0 @@
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.github.actions.package.config.v1+json':
return uploadLayer(
layer,
{ path: '', size: 0, 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 (file.size === 0) {
data = Buffer.alloc(0)
} 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
}
}
+75 -75
View File
@@ -1,10 +1,8 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as fsHelper from './fs-helper'
import * as ociContainer from './oci-container'
import * as ghcr from './ghcr-client'
import * as api from './api-client'
import semver from 'semver'
import * as iaToolkit from '@immutable-actions/toolkit'
import * as attest from '@actions/attest'
import * as cfg from './config'
/**
* The main function for the action.
@@ -12,66 +10,45 @@ import semver from 'semver'
*/
export async function run(): Promise<void> {
try {
const workspace: string = process.env.GITHUB_WORKSPACE || ''
if (workspace === '') {
core.setFailed(`Could not find GITHUB_WORKSPACE.`)
return
}
const options: cfg.PublishActionOptions =
await cfg.resolvePublishActionOptions()
const repository: string = process.env.GITHUB_REPOSITORY || ''
if (repository === '') {
core.setFailed(`Could not find Repository.`)
return
}
core.info(`Publishing action package version with options:`)
core.info(cfg.serializeOptions(options))
const token: string = process.env.TOKEN || ''
const sourceCommit: string = process.env.GITHUB_SHA || ''
if (token === '') {
core.setFailed(`Could not find GITHUB_TOKEN.`)
return
}
if (sourceCommit === '') {
core.setFailed(`Could not find source commit.`)
return
}
const semverTag: semver.SemVer = parseSemverTagFromRef(options.ref)
const semanticVersion = parseSourceSemanticVersion()
const stagedActionFilesDir = iaToolkit.createTempDir(
options.runnerTempDir,
'staging'
)
iaToolkit.stageActionFiles(options.workspaceDir, stagedActionFilesDir)
// Create a temporary directory to stage files for packaging in archives
const stagedActionFilesDir = fsHelper.createTempDir('staging')
fsHelper.stageActionFiles(workspace, stagedActionFilesDir)
// Create a temporary directory to store the archives
const archiveDir = fsHelper.createTempDir('archive')
const archives = await fsHelper.createArchives(
const archiveDir = iaToolkit.createTempDir(
options.runnerTempDir,
'archives'
)
const archives = await iaToolkit.createArchives(
stagedActionFilesDir,
archiveDir
)
const { repoId, ownerId } = await api.getRepositoryMetadata(
repository,
token
)
const manifest = ociContainer.createActionPackageManifest(
const manifest = iaToolkit.createActionPackageManifest(
archives.tarFile,
archives.zipFile,
repository,
repoId,
ownerId,
sourceCommit,
semanticVersion.raw,
options.nameWithOwner,
options.repositoryId,
options.repositoryOwnerId,
options.sha,
semverTag.raw,
new Date()
)
const containerRegistryURL = await api.getContainerRegistryURL()
console.log(`Container registry URL: ${containerRegistryURL}`)
const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact(
token,
containerRegistryURL,
repository,
semanticVersion.raw,
const { packageURL, manifestDigest } = await iaToolkit.publishOCIArtifact(
options.token,
options.containerRegistryUrl,
options.nameWithOwner,
semverTag.raw,
archives.zipFile,
archives.tarFile,
manifest
@@ -80,40 +57,63 @@ export async function run(): Promise<void> {
core.setOutput('package-url', packageURL.toString())
core.setOutput('package-manifest', JSON.stringify(manifest))
core.setOutput('package-manifest-sha', manifestDigest)
if (!options.isEnterprise) {
const attestation = await generateAttestation(
manifestDigest,
semverTag.raw,
options
)
if (attestation.attestationID !== undefined) {
core.setOutput('attestation-id', attestation.attestationID)
}
}
} catch (error) {
// Fail the workflow run if an error occurs
if (error instanceof Error) core.setFailed(error.message)
}
}
// This action can be triggered by release events or tag push events.
// In each case, the source event should produce a Semantic Version compliant tag representing the code to be packaged.
function parseSourceSemanticVersion(): semver.SemVer {
const event = github.context.eventName
let semverTag = ''
// This action can be triggered by any workflow that specifies a tag as its GITHUB_REF.
// This includes releases, creating or pushing tags, or workflow_dispatch.
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#about-events-that-trigger-workflows.
function parseSemverTagFromRef(ref: string): semver.SemVer {
if (!ref.startsWith('refs/tags/')) {
throw new Error(`The ref ${ref} is not a valid tag reference.`)
}
// Grab the raw tag
if (event === 'release') semverTag = github.context.payload.release.tag_name
else if (event === 'push' && github.context.ref.startsWith('refs/tags/')) {
semverTag = github.context.ref.replace(/^refs\/tags\//, '')
} else {
const rawTag = ref.replace(/^refs\/tags\//, '')
const semverTag = semver.parse(rawTag)
if (!semverTag) {
throw new Error(
`This action can only be triggered by release events or tag push events.`
`${rawTag} is not a valid semantic version tag, and so cannot be uploaded to the action package.`
)
}
if (semverTag === '') {
throw new Error(
`Could not find a Semantic Version tag in the event payload.`
)
}
return semverTag
}
// Generate an attestation using the actions toolkit
// Subject name will contain the repo/package name and the tag name
async function generateAttestation(
manifestDigest: string,
semverTag: string,
options: cfg.PublishActionOptions
): Promise<attest.Attestation> {
const subjectName = `${options.nameWithOwner}_${semverTag}`
const subjectDigest = removePrefix(manifestDigest, 'sha256:')
return await attest.attestProvenance({
subjectName,
subjectDigest: { sha256: subjectDigest },
token: options.token,
skipWrite: false // TODO: Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account
})
}
const semanticVersion = semver.parse(semverTag.replace(/^v/, ''))
if (!semanticVersion) {
throw new Error(
`${semverTag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.`
)
function removePrefix(str: string, prefix: string): string {
if (str.startsWith(prefix)) {
return str.slice(prefix.length)
}
return semanticVersion
return str
}
-108
View File
@@ -1,108 +0,0 @@
import { FileMetadata } from './fs-helper'
export interface Manifest {
schemaVersion: number
mediaType: string
artifactType: string
config: Layer
layers: Layer[]
annotations: { [key: string]: string }
}
export interface Layer {
mediaType: string
size: number
digest: string
annotations: { [key: string]: string }
}
// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package.
export function createActionPackageManifest(
tarFile: FileMetadata,
zipFile: FileMetadata,
repository: string,
repoId: string,
ownerId: string,
sourceCommit: string,
version: string,
created: Date
): Manifest {
const configLayer = createConfigLayer()
const sanitizedRepo = sanitizeRepository(repository)
const tarLayer = createTarLayer(tarFile, sanitizedRepo, version)
const zipLayer = createZipLayer(zipFile, sanitizedRepo, version)
const manifest: Manifest = {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
artifactType: 'application/vnd.github.actions.package.v1+json',
config: configLayer,
layers: [configLayer, tarLayer, zipLayer],
annotations: {
'org.opencontainers.image.created': created.toISOString(),
'action.tar.gz.digest': tarFile.sha256,
'action.zip.digest': zipFile.sha256,
'com.github.package.type': 'actions_oci_pkg',
'com.github.package.version': version,
'com.github.source.repo.id': repoId,
'com.github.source.repo.owner.id': ownerId,
'com.github.source.commit': sourceCommit
}
}
return manifest
}
function createConfigLayer(): Layer {
const configLayer: Layer = {
mediaType: 'application/vnd.github.actions.package.config.v1+json',
size: 0,
digest:
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
annotations: {
'org.opencontainers.image.title': 'config.json'
}
}
return configLayer
}
function createZipLayer(
zipFile: FileMetadata,
repository: string,
version: string
): Layer {
const zipLayer: Layer = {
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
size: zipFile.size,
digest: zipFile.sha256,
annotations: {
'org.opencontainers.image.title': `${repository}_${version}.zip`
}
}
return zipLayer
}
function createTarLayer(
tarFile: FileMetadata,
repository: string,
version: string
): Layer {
const tarLayer: Layer = {
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
size: tarFile.size,
digest: tarFile.sha256,
annotations: {
'org.opencontainers.image.title': `${repository}_${version}.tar.gz`
}
}
return tarLayer
}
// Remove slashes so we can use the repository in a filename
// repository usually includes the namespace too, e.g. my-org/my-repo
function sanitizeRepository(repository: string): string {
return repository.replace('/', '-')
}