Merge pull request #108 from immutable-actions/ddivad195/re-integrate-toolkit

re-integrate toolkit code to main action
This commit is contained in:
David Daly
2024-03-26 10:55:52 +00:00
committed by GitHub
20 changed files with 42473 additions and 68336 deletions
-5
View File
@@ -35,11 +35,6 @@ jobs:
node-version-file: .node-version
cache: npm
- name: setup github npm authentication
run: echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install Dependencies
id: install
run: npm ci
-5
View File
@@ -28,11 +28,6 @@ jobs:
node-version-file: .node-version
cache: npm
- name: setup github npm authentication
run: echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install Dependencies
id: npm-ci
run: npm ci
-5
View File
@@ -30,11 +30,6 @@ jobs:
node-version-file: .node-version
cache: npm
- name: setup github npm authentication
run: echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install Dependencies
id: install
run: npm ci
+84
View File
@@ -0,0 +1,84 @@
import {
getRepositoryMetadata,
getContainerRegistryURL
} from '../src/api-client'
const url = 'https://registry.example.com'
let fetchMock: jest.SpyInstance
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch')
})
afterEach(() => {
fetchMock.mockRestore()
})
describe('getRepositoryMetadata', () => {
it('returns repository metadata when the fetch response is ok', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ id: '123', owner: { id: '456' } }))
)
const result = await getRepositoryMetadata(url, 'repository', 'token')
expect(result).toEqual({ repoId: '123', ownerId: '456' })
})
it('throws an error when the fetch errors', async () => {
fetchMock.mockRejectedValueOnce(new Error('API is down'))
await expect(
getRepositoryMetadata(url, 'repository', 'token')
).rejects.toThrow('API is down')
})
it('throws an error when the response status is not ok', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
await expect(
getRepositoryMetadata(url, 'repository', 'token')
).rejects.toThrow(
'Failed to fetch repository metadata due to bad status code: 500'
)
})
it('throws an error when the response data is in the wrong format', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ wrong: 'format' }))
)
await expect(
getRepositoryMetadata(url, 'repository', 'token')
).rejects.toThrow(
'Failed to fetch repository metadata: unexpected response format'
)
})
})
describe('getContainerRegistryURL', () => {
it('returns container registry URL when the fetch response is ok', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://registry.example.com' }))
)
const result = await getContainerRegistryURL(url)
expect(result).toEqual(new URL('https://registry.example.com'))
})
it('throws an error when the fetch errors', async () => {
fetchMock.mockRejectedValueOnce(new Error('API is down'))
await expect(getContainerRegistryURL(url)).rejects.toThrow('API is down')
})
it('throws an error when the response status is not ok', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
await expect(getContainerRegistryURL(url)).rejects.toThrow(
'Failed to fetch container registry url due to bad status code: 500'
)
})
it('throws an error when the response data is in the wrong format', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ wrong: 'format' }))
)
await expect(getContainerRegistryURL(url)).rejects.toThrow(
'Failed to fetch repository metadata: unexpected response format'
)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
import * as iaToolkit from '@immutable-actions/toolkit'
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as cfg from '../src/config'
import * as apiClient from '../src/api-client'
let getContainerRegistryURLMock: jest.SpyInstance
let getInputMock: jest.SpyInstance
@@ -11,7 +11,7 @@ const ghcrUrl = new URL('https://ghcr.io')
describe('config.resolvePublishActionOptions', () => {
beforeEach(() => {
getContainerRegistryURLMock = jest
.spyOn(iaToolkit, 'getContainerRegistryURL')
.spyOn(apiClient, 'getContainerRegistryURL')
.mockImplementation()
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
+214
View File
@@ -0,0 +1,214 @@
import * as fsHelper from '../src/fs-helper'
import * as fs from 'fs'
import * as os from 'os'
import { execSync } from 'child_process'
const fileContent = 'This is the content of the file'
const tmpFileDir = '/tmp'
describe('stageActionFiles', () => {
let sourceDir: string
let stagingDir: string
beforeEach(() => {
sourceDir = fsHelper.createTempDir(tmpFileDir, 'source')
fs.mkdirSync(`${sourceDir}/src`)
fs.writeFileSync(`${sourceDir}/src/main.js`, fileContent)
fs.writeFileSync(`${sourceDir}/src/other.js`, fileContent)
stagingDir = fsHelper.createTempDir(tmpFileDir, 'staging')
})
afterEach(() => {
fs.rmSync(sourceDir, { recursive: true })
fs.rmSync(stagingDir, { recursive: true })
})
it('returns an error if no action.yml file is present', () => {
expect(() => fsHelper.stageActionFiles(sourceDir, stagingDir)).toThrow(
/^No action.yml or action.yaml file found in source repository/
)
})
it('copies all non-hidden files to the staging directory', () => {
fs.writeFileSync(`${sourceDir}/action.yml`, fileContent)
fs.mkdirSync(`${sourceDir}/.git`)
fs.writeFileSync(`${sourceDir}/.git/HEAD`, fileContent)
fs.mkdirSync(`${sourceDir}/.github/workflows`, { recursive: true })
fs.writeFileSync(`${sourceDir}/.github/workflows/workflow.yml`, fileContent)
fsHelper.stageActionFiles(sourceDir, stagingDir)
expect(fs.existsSync(`${stagingDir}/action.yml`)).toBe(true)
expect(fs.existsSync(`${stagingDir}/src/main.js`)).toBe(true)
expect(fs.existsSync(`${stagingDir}/src/other.js`)).toBe(true)
// Hidden files should not be copied
expect(fs.existsSync(`${stagingDir}/.git`)).toBe(false)
expect(fs.existsSync(`${stagingDir}/.github`)).toBe(false)
})
it('copies all non-hidden files to the staging directory, even if action.yml is in a subdirectory', () => {
fs.mkdirSync(`${sourceDir}/my-sub-action`, { recursive: true })
fs.writeFileSync(`${sourceDir}/my-sub-action/action.yml`, fileContent)
fsHelper.stageActionFiles(sourceDir, stagingDir)
expect(fs.existsSync(`${stagingDir}/src/main.js`)).toBe(true)
expect(fs.existsSync(`${stagingDir}/src/other.js`)).toBe(true)
expect(fs.existsSync(`${stagingDir}/my-sub-action/action.yml`)).toBe(true)
})
it('accepts action.yaml as a valid action file as well as action.yml', () => {
fs.writeFileSync(`${sourceDir}/action.yaml`, fileContent)
fsHelper.stageActionFiles(sourceDir, stagingDir)
expect(fs.existsSync(`${stagingDir}/action.yaml`)).toBe(true)
})
})
describe('createArchives', () => {
let stageDir: string
let archiveDir: string
beforeAll(() => {
stageDir = fsHelper.createTempDir(tmpFileDir, 'staging')
fs.writeFileSync(`${stageDir}/hello.txt`, fileContent)
fs.writeFileSync(`${stageDir}/world.txt`, fileContent)
})
beforeEach(() => {
archiveDir = fsHelper.createTempDir(tmpFileDir, 'archive')
})
afterEach(() => {
fs.rmSync(archiveDir, { recursive: true })
})
afterAll(() => {
fs.rmSync(stageDir, { recursive: true })
})
it('creates archives', async () => {
const { zipFile, tarFile } = await fsHelper.createArchives(
stageDir,
archiveDir
)
expect(zipFile.path).toEqual(`${archiveDir}/archive.zip`)
expect(fs.existsSync(zipFile.path)).toEqual(true)
expect(fs.statSync(zipFile.path).size).toBeGreaterThan(0)
expect(zipFile.sha256.startsWith('sha256:')).toEqual(true)
expect(tarFile.path).toEqual(`${archiveDir}/archive.tar.gz`)
expect(fs.existsSync(tarFile.path)).toEqual(true)
expect(fs.statSync(tarFile.path).size).toBeGreaterThan(0)
expect(tarFile.sha256.startsWith('sha256:')).toEqual(true)
// Validate the hashes by comparing to the output of the system's hashing utility
const zipSHA = zipFile.sha256.substring(7) // remove "sha256:" prefix
const tarSHA = tarFile.sha256.substring(7) // remove "sha256:" prefix
// sha256 hash is 64 characters long
expect(zipSHA).toHaveLength(64)
expect(tarSHA).toHaveLength(64)
let systemZipHash: string
let systemTarHash: string
if (os.platform() === 'win32') {
// Windows
systemZipHash = execSync(`CertUtil -hashfile ${zipFile.path} SHA256`)
.toString()
.split(' ')[1]
.trim()
systemTarHash = execSync(`CertUtil -hashfile ${tarFile.path} SHA256`)
.toString()
.split(' ')[1]
.trim()
} else {
// Unix-based systems
systemZipHash = execSync(`shasum -a 256 ${zipFile.path}`)
.toString()
.split(' ')[0]
systemTarHash = execSync(`shasum -a 256 ${tarFile.path}`)
.toString()
.split(' ')[0]
}
expect(zipSHA).toEqual(systemZipHash)
expect(tarSHA).toEqual(systemTarHash)
})
})
describe('createTempDir', () => {
let dirs: string[] = []
beforeEach(() => {
dirs = []
})
afterEach(() => {
for (const dir of dirs) {
fs.rmSync(dir, { recursive: true })
}
})
it('creates a temporary directory', () => {
const tmpDir = fsHelper.createTempDir(tmpFileDir, 'subdir')
expect(fs.existsSync(tmpDir)).toEqual(true)
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
})
it('creates a unique temporary directory', () => {
const dir1 = fsHelper.createTempDir(tmpFileDir, 'dir1')
dirs.push(dir1)
const dir2 = fsHelper.createTempDir(tmpFileDir, 'dir2')
dirs.push(dir2)
expect(dir1).not.toEqual(dir2)
})
})
describe('isDirectory', () => {
let dir: string
beforeEach(() => {
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
})
afterEach(() => {
fs.rmSync(dir, { recursive: true })
})
it('returns true if the path is a directory', () => {
expect(fsHelper.isDirectory(dir)).toEqual(true)
})
it('returns false if the path is not a directory', () => {
const tempFile = `${dir}/file.txt`
fs.writeFileSync(tempFile, fileContent)
expect(fsHelper.isDirectory(tempFile)).toEqual(false)
})
})
describe('readFileContents', () => {
let dir: string
beforeEach(() => {
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
})
afterEach(() => {
fs.rmSync(dir, { recursive: true })
})
it('reads the contents of a file', () => {
const tempFile = `${dir}/file.txt`
fs.writeFileSync(tempFile, fileContent)
expect(fsHelper.readFileContents(tempFile).toString()).toEqual(fileContent)
})
})
+501
View File
@@ -0,0 +1,501 @@
import { publishOCIArtifact } from '../src/ghcr-client'
import * as fsHelper from '../src/fs-helper'
import * as ociContainer from '../src/oci-container'
// Mocks
let fsReadFileSyncMock: jest.SpyInstance
let fetchMock: jest.SpyInstance
const token = 'test-token'
const registry = new URL('https://ghcr.io')
const repository = 'test-org/test-repo'
const semver = '1.2.3'
const genericSha = '1234567890' // We should look at using different shas here to catch bug, but that make location validation harder
const zipFile: fsHelper.FileMetadata = {
path: `test-repo-${semver}.zip`,
size: 123,
sha256: genericSha
}
const tarFile: fsHelper.FileMetadata = {
path: `test-repo-${semver}.tar.gz`,
size: 456,
sha256: genericSha
}
const headMockNoExistingBlobs = (): object => {
// Simulate none of the blobs existing currently
return {
status: 404
}
}
const headMockAllExistingBlobs = (): object => {
// Simulate all of the blobs existing currently
return {
status: 200
}
}
let count = 0
const headMockSomeExistingBlobs = (): object => {
count++
// report one as existing
if (count === 1) {
return {
status: 200
}
} else {
// report all others are missing
return {
status: 404
}
}
}
const headMockFailure = (): object => {
return {
status: 503
}
}
const postMockSuccessfulIniationForAllBlobs = (): object => {
// Simulate successful initiation of uploads for all blobs & return location
return {
status: 202,
headers: {
get: (header: string) => {
if (header === 'location') {
return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}`
}
}
}
}
}
const postMockFailure = (): object => {
// Simulate failed initiation of uploads
return {
status: 503
}
}
const postMockNoLocationHeader = (): object => {
return {
status: 202,
headers: {
get: () => {}
}
}
}
const putMockSuccessfulBlobUpload = (url: string): object => {
// Simulate successful upload of all blobs & then the manifest
if (url.includes('manifest')) {
return {
status: 201,
headers: {
get: (header: string) => {
if (header === 'docker-content-digest') {
return '1234567678'
}
}
}
}
}
return {
status: 201
}
}
const putMockFailure = (): object => {
// Simulate fails upload of all blobs & manifest
return {
status: 500
}
}
const putMockFailureManifestUpload = (url: string): object => {
// Simulate unsuccessful upload of all blobs & then the manifest
if (url.includes('manifest')) {
return {
status: 500
}
}
return {
status: 201
}
}
type MethodHandlers = {
getMock?: (url: string, options: { method: string }) => object
headMock?: (url: string, options: { method: string }) => object
postMock?: (url: string, options: { method: string }) => object
putMock?: (url: string, options: { method: string }) => object
}
function configureFetchMock(
fetchMockInstance: jest.SpyInstance,
methodHandlers: MethodHandlers
): void {
fetchMockInstance.mockImplementation(
async (url: string, options: { method: string }) => {
validateRequestConfig(url, options)
switch (options.method) {
case 'GET':
return methodHandlers.getMock?.(url, options)
case 'HEAD':
return methodHandlers.headMock?.(url, options)
case 'POST':
return methodHandlers.postMock?.(url, options)
case 'PUT':
return methodHandlers.putMock?.(url, options)
}
}
)
}
const testManifest: ociContainer.Manifest = {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
artifactType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.oci.empty.v1+json',
size: 2,
digest:
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
},
layers: [
{
mediaType: 'application/vnd.oci.empty.v1+json',
size: 2,
digest:
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
},
{
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
size: tarFile.size,
digest: `sha256:${tarFile.sha256}`,
annotations: {
'org.opencontainers.image.title': tarFile.path
}
},
{
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
size: zipFile.size,
digest: `sha256:${zipFile.sha256}`,
annotations: {
'org.opencontainers.image.title': zipFile.path
}
}
],
annotations: {
'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z',
'action.tar.gz.digest': tarFile.sha256,
'action.zip.digest': zipFile.sha256,
'com.github.package.type': 'actions_oci_pkg'
}
}
describe('publishOCIArtifact', () => {
beforeEach(() => {
jest.clearAllMocks()
fsReadFileSyncMock = jest
.spyOn(fsHelper, 'readFileContents')
.mockImplementation()
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
})
it('publishes layer blobs & then a manifest to the provided registry', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockSuccessfulBlobUpload
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
return Buffer.from('test')
})
await publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
expect(fetchMock).toHaveBeenCalledTimes(10)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
).toHaveLength(3)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
).toHaveLength(3)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
).toHaveLength(4)
})
it('skips uploading all layer blobs when they all already exist', async () => {
configureFetchMock(fetchMock, {
headMock: headMockAllExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockSuccessfulBlobUpload
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
return Buffer.from('test')
})
await publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
// We should only head all the blobs and then upload the manifest
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
).toHaveLength(3)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
).toHaveLength(0)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
).toHaveLength(1)
})
it('skips uploading layer blobs that already exist', async () => {
configureFetchMock(fetchMock, {
headMock: headMockSomeExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockSuccessfulBlobUpload
})
count = 0
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
return Buffer.from('test')
})
await publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
expect(fetchMock).toHaveBeenCalledTimes(8)
// We should only head all the blobs and then upload the missing blobs and manifest
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
).toHaveLength(3)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
).toHaveLength(2)
expect(
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
).toHaveLength(3)
})
it('throws an error if checking for existing blobs fails', async () => {
configureFetchMock(fetchMock, { headMock: headMockFailure })
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from blob check for layer/)
})
it('throws an error if initiating layer upload fails', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockFailure
})
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow('Unexpected response from POST upload 503')
})
it('throws an error if the upload endpoint does not return a location', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockNoLocationHeader
})
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^No location header in response from upload post/)
})
it('throws an error if a layer upload fails', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockFailure
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
return Buffer.from('test')
})
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from PUT upload 500/)
})
it('throws an error if a manifest upload fails', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockFailureManifestUpload
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
return Buffer.from('test')
})
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from PUT manifest 500/)
})
it('throws an error if reading one of the files fails', async () => {
configureFetchMock(fetchMock, {
headMock: headMockNoExistingBlobs,
postMock: postMockSuccessfulIniationForAllBlobs,
putMock: putMockSuccessfulBlobUpload
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(() => {
throw new Error('failed to read a file: test')
})
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow('failed to read a file: test')
})
it('throws an error if one of the layers has the wrong media type', async () => {
const modifiedTestManifest = { ...testManifest } // This is _NOT_ a deep clone
modifiedTestManifest.layers = cloneLayers(modifiedTestManifest.layers)
modifiedTestManifest.layers[0].mediaType = 'application/json'
// just checking to make sure we are not changing the shared object
expect(modifiedTestManifest.layers[0].mediaType).not.toEqual(
testManifest.layers[0].mediaType
)
await expect(
publishOCIArtifact(
token,
registry,
repository,
semver,
zipFile,
tarFile,
modifiedTestManifest
)
).rejects.toThrow('Unknown media type application/json')
})
})
// We expect all fetch calls to have auth headers set
// This function verifies that given an request config.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateRequestConfig(url: string, config: any): void {
// Basic URL checks
expect(url).toBeDefined()
if (!url.startsWith(registry.toString())) {
console.log(`${url} does not start with ${registry}`)
}
// if these expect fails, run the test again with `-- --silent=false`
// the console.log above should give a clue about which URL is failing
expect(url.startsWith(registry.toString())).toBeTruthy()
// Config checks
expect(config).toBeDefined()
expect(config.headers).toBeDefined()
if (config.headers) {
// Check the auth header is set
expect(config.headers.Authorization).toBeDefined()
// Check the auth header is the base 64 encoded token
expect(config.headers.Authorization).toBe(
`Bearer ${Buffer.from(token).toString('base64')}`
)
}
}
function cloneLayers(layers: ociContainer.Layer[]): ociContainer.Layer[] {
const result: ociContainer.Layer[] = []
for (const layer of layers) {
result.push({ ...layer }) // this is _NOT_ a deep clone
}
return result
}
+6 -5
View File
@@ -9,8 +9,9 @@
import * as core from '@actions/core'
import * as attest from '@actions/attest'
import * as main from '../src/main'
import * as iaToolkit from '@immutable-actions/toolkit'
import * as cfg from '../src/config'
import * as fsHelper from '../src/fs-helper'
import * as ghcr from '../src/ghcr-client'
const ghcrUrl = new URL('https://ghcr.io')
@@ -40,18 +41,18 @@ describe('run', () => {
// FS mocks
createTempDirMock = jest
.spyOn(iaToolkit, 'createTempDir')
.spyOn(fsHelper, 'createTempDir')
.mockImplementation()
createArchivesMock = jest
.spyOn(iaToolkit, 'createArchives')
.spyOn(fsHelper, 'createArchives')
.mockImplementation()
stageActionFilesMock = jest
.spyOn(iaToolkit, 'stageActionFiles')
.spyOn(fsHelper, 'stageActionFiles')
.mockImplementation()
// GHCR Client mocks
publishOCIArtifactMock = jest
.spyOn(iaToolkit, 'publishOCIArtifact')
.spyOn(ghcr, 'publishOCIArtifact')
.mockImplementation()
// Config mocks
+90
View File
@@ -0,0 +1,90 @@
import { createActionPackageManifest } from '../src/oci-container'
import { FileMetadata } from '../src/fs-helper'
describe('createActionPackageManifest', () => {
it('creates a manifest containing the provided information', () => {
const date = new Date()
const repo = 'test-org/test-repo'
const sanitizedRepo = 'test-org-test-repo'
const version = '1.2.3'
const repoId = '123'
const ownerId = '456'
const sourceCommit = 'abc'
const tarFile: FileMetadata = {
path: '/test/test/test.tar.gz',
sha256: 'tarSha',
size: 123
}
const zipFile: FileMetadata = {
path: '/test/test/test.zip',
sha256: 'zipSha',
size: 456
}
const expectedJSON = `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.github.actions.package.v1+json",
"config": {
"mediaType":"application/vnd.oci.empty.v1+json",
"size":2,
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
},
"layers":[
{
"mediaType":"application/vnd.oci.empty.v1+json",
"size":2,
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
},
{
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
"size":${tarFile.size},
"digest":"${tarFile.sha256}",
"annotations":{
"org.opencontainers.image.title":"${sanitizedRepo}_${version}.tar.gz"
}
},
{
"mediaType":"application/vnd.github.actions.package.layer.v1.zip",
"size":${zipFile.size},
"digest":"${zipFile.sha256}",
"annotations":{
"org.opencontainers.image.title":"${sanitizedRepo}_${version}.zip"
}
}
],
"annotations":{
"org.opencontainers.image.created":"${date.toISOString()}",
"action.tar.gz.digest":"${tarFile.sha256}",
"action.zip.digest":"${zipFile.sha256}",
"com.github.package.type":"actions_oci_pkg",
"com.github.package.version":"1.2.3",
"com.github.source.repo.id":"123",
"com.github.source.repo.owner.id":"456",
"com.github.source.commit":"abc"
}
}`
const manifest = createActionPackageManifest(
{
path: 'test.tar.gz',
size: tarFile.size,
sha256: tarFile.sha256
},
{
path: 'test.zip',
size: zipFile.size,
sha256: zipFile.sha256
},
repo,
repoId,
ownerId,
sourceCommit,
version,
date
)
const manifestJSON = JSON.stringify(manifest)
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
})
})
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 98.82%"><title>Coverage: 98.82%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">98.82%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">98.82%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 96.52%"><title>Coverage: 96.52%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">96.52%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">96.52%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Generated Vendored
+39813 -68254
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+1239 -27
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -13,7 +13,6 @@
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"@immutable-actions/toolkit": "^0.0.7",
"@types/fs-extra": "^11.0.4",
"archiver": "^6.0.1",
"fs-extra": "^11.2.0",
@@ -891,24 +890,6 @@
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
"dev": true
},
"node_modules/@immutable-actions/toolkit": {
"version": "0.0.7",
"resolved": "https://npm.pkg.github.com/download/@immutable-actions/toolkit/0.0.7/3f93d28484aedf0b46d76f059553f82a30b49bdb",
"integrity": "sha512-4Eaj9ytgv674wUHeVAQVty+NF/8X7MxeVukZ9G7EggDR6cTmeJG3SHQsegjDtD5Tx5b4yqaheSAgVJlj9YgHtQ==",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"@types/fs-extra": "^11.0.4",
"archiver": "^6.0.1",
"fs-extra": "^11.2.0",
"tar": "^6.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
-1
View File
@@ -71,7 +71,6 @@
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"@immutable-actions/toolkit": "^0.0.7",
"@types/fs-extra": "^11.0.4",
"archiver": "^6.0.1",
"fs-extra": "^11.2.0",
+53
View File
@@ -0,0 +1,53 @@
export async function getRepositoryMetadata(
githubAPIURL: string,
repository: string,
token: string
): Promise<{ repoId: string; ownerId: string }> {
const response = await fetch(`${githubAPIURL}/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(
githubAPIURL: string
): Promise<URL> {
const response = await fetch(
`${githubAPIURL}/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
}
+2 -2
View File
@@ -1,4 +1,4 @@
import * as iaToolkit from '@immutable-actions/toolkit'
import * as apiClient from './api-client'
import * as core from '@actions/core'
import * as github from '@actions/github'
@@ -91,7 +91,7 @@ export async function resolvePublishActionOptions(): Promise<PublishActionOption
// Required Values fetched from the GitHub API
const containerRegistryUrl: URL =
await iaToolkit.getContainerRegistryURL(apiBaseUrl)
await apiClient.getContainerRegistryURL(apiBaseUrl)
const isEnterprise =
!githubServerUrl.includes('https://github.com') &&
+138
View File
@@ -0,0 +1,138 @@
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
}
// Simple convenience around creating subdirectories in the same base temporary directory
export function createTempDir(tmpDirPath: string, subDirName: string): string {
const tempDir = path.join(tmpDirPath, subDirName)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
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
@@ -0,0 +1,216 @@
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
}
}
+9 -10
View File
@@ -1,6 +1,8 @@
import * as core from '@actions/core'
import semver from 'semver'
import * as iaToolkit from '@immutable-actions/toolkit'
import * as fsHelper from './fs-helper'
import * as ociContainer from './oci-container'
import * as ghcr from './ghcr-client'
import * as attest from '@actions/attest'
import * as cfg from './config'
@@ -18,22 +20,19 @@ export async function run(): Promise<void> {
const semverTag: semver.SemVer = parseSemverTagFromRef(options.ref)
const stagedActionFilesDir = iaToolkit.createTempDir(
const stagedActionFilesDir = fsHelper.createTempDir(
options.runnerTempDir,
'staging'
)
iaToolkit.stageActionFiles(options.workspaceDir, stagedActionFilesDir)
fsHelper.stageActionFiles(options.workspaceDir, stagedActionFilesDir)
const archiveDir = iaToolkit.createTempDir(
options.runnerTempDir,
'archives'
)
const archives = await iaToolkit.createArchives(
const archiveDir = fsHelper.createTempDir(options.runnerTempDir, 'archives')
const archives = await fsHelper.createArchives(
stagedActionFilesDir,
archiveDir
)
const manifest = iaToolkit.createActionPackageManifest(
const manifest = ociContainer.createActionPackageManifest(
archives.tarFile,
archives.zipFile,
options.nameWithOwner,
@@ -44,7 +43,7 @@ export async function run(): Promise<void> {
new Date()
)
const { packageURL, manifestDigest } = await iaToolkit.publishOCIArtifact(
const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact(
options.token,
options.containerRegistryUrl,
options.nameWithOwner,
+105
View File
@@ -0,0 +1,105 @@
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.oci.empty.v1+json',
size: 2,
digest:
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
}
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('/', '-')
}