initial mvp version
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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'
|
||||
|
||||
describe('createArchives', () => {
|
||||
let tmpDir: string
|
||||
let distDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
distDir = fsHelper.createTempDir()
|
||||
fs.writeFileSync(`${distDir}/hello.txt`, fileContent)
|
||||
fs.writeFileSync(`${distDir}/world.txt`, fileContent)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fsHelper.createTempDir()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(distDir, { recursive: true })
|
||||
})
|
||||
|
||||
it('creates archives', async () => {
|
||||
const { zipFile, tarFile } = await fsHelper.createArchives(distDir, tmpDir)
|
||||
|
||||
expect(zipFile.path).toEqual(`${tmpDir}/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(`${tmpDir}/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
|
||||
let zipSHA = zipFile.sha256.substring(7) // remove "sha256:" prefix
|
||||
let 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)
|
||||
})
|
||||
|
||||
// TODO: Test the failure cases
|
||||
})
|
||||
|
||||
describe('createTempDir', () => {
|
||||
let dirs: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
dirs = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dirs.forEach(dir => {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a temporary directory in the OS temporary dir', () => {
|
||||
let tmpDir = fsHelper.createTempDir()
|
||||
dirs.push(tmpDir)
|
||||
|
||||
expect(fs.existsSync(tmpDir)).toEqual(true)
|
||||
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
|
||||
expect(tmpDir.startsWith(os.tmpdir())).toEqual(true)
|
||||
})
|
||||
|
||||
it('creates a unique temporary directory', () => {
|
||||
let dir1 = fsHelper.createTempDir()
|
||||
dirs.push(dir1)
|
||||
|
||||
let dir2 = fsHelper.createTempDir()
|
||||
dirs.push(dir2)
|
||||
|
||||
expect(dir1).not.toEqual(dir2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirectory', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeDir', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a directory', () => {
|
||||
fsHelper.removeDir(dir)
|
||||
expect(fs.existsSync(dir)).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
import { publishOCIArtifact } from '../src/ghcr-client'
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import * as fs from 'fs'
|
||||
import * as fsHelper from '../src/fs-helper'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
|
||||
// Mocks
|
||||
let fsReadFileSyncMock: jest.SpyInstance
|
||||
let axiosPostMock: jest.SpyInstance
|
||||
let axiosPutMock: jest.SpyInstance
|
||||
let axiosHeadMock: jest.SpyInstance
|
||||
|
||||
const token = '1234567890'
|
||||
const registry = new URL('https://ghcr.io')
|
||||
const repository = 'test/test'
|
||||
const releaseId = '1234567890'
|
||||
const semver = '1.0.0'
|
||||
const zipFile: fsHelper.FileMetadata = {
|
||||
path: 'test-repo-1.0.0.zip',
|
||||
size: 100,
|
||||
sha256: '1234567890'
|
||||
}
|
||||
const tarFile: fsHelper.FileMetadata = {
|
||||
path: 'test-repo-1.0.0.tar.gz',
|
||||
size: 100,
|
||||
sha256: '1234567890'
|
||||
}
|
||||
|
||||
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.github.actions.package.config.v1+json',
|
||||
size: 0,
|
||||
digest:
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'config.json'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.config.v1+json',
|
||||
size: 0,
|
||||
digest:
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'config.json'
|
||||
}
|
||||
},
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
|
||||
size: 100,
|
||||
digest: 'sha256:1234567890',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'test-repo-1.0.0.tar.gz'
|
||||
}
|
||||
},
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
|
||||
size: 100,
|
||||
digest: 'sha256:1234567890',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'test-repo-1.0.0.zip'
|
||||
}
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z',
|
||||
'action.tar.gz.digest': '1234567890',
|
||||
'action.zip.digest': '1234567890',
|
||||
'com.github.package.type': 'actions_oci_pkg'
|
||||
}
|
||||
}
|
||||
|
||||
describe('publishOCIArtifact', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
fsReadFileSyncMock = jest
|
||||
.spyOn(fsHelper, 'readFileContents')
|
||||
.mockImplementation()
|
||||
|
||||
axiosPostMock = jest.spyOn(axios, 'post').mockImplementation()
|
||||
axiosPutMock = jest.spyOn(axios, 'put').mockImplementation()
|
||||
axiosHeadMock = jest.spyOn(axios, 'head').mockImplementation()
|
||||
})
|
||||
|
||||
it('publishes layer blobs & then a manifest to the provided registry', async () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(
|
||||
async (url: string, config: AxiosRequestConfig) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(async path => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
// Simulate successful upload of all blobs & then the manifest
|
||||
axiosPutMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(201, url, config)
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
})
|
||||
|
||||
await publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
|
||||
expect(axiosHeadMock).toHaveBeenCalledTimes(3)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(3)
|
||||
expect(axiosPutMock).toHaveBeenCalledTimes(4)
|
||||
|
||||
// TODO: Check that the base64 encoded token is sent in the Authorization header
|
||||
})
|
||||
|
||||
it('skips uploading layer blobs that already exist', async () => {
|
||||
// Simulate all blobs already existing
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(200, url, config)
|
||||
return {
|
||||
status: 200
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(async path => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
// Simulate successful upload of all blobs & then the manifest
|
||||
axiosPutMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(201, url, config)
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
})
|
||||
|
||||
await publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
|
||||
// We should only head all the blobs and then upload the manifest
|
||||
expect(axiosHeadMock).toHaveBeenCalledTimes(3)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(0)
|
||||
expect(axiosPutMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws an error if checking for existing blobs fails', async () => {
|
||||
// Simulate failed response code
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(503, url, config)
|
||||
return {
|
||||
status: 503
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow(/^Unexpected response from blob check for layer/)
|
||||
})
|
||||
|
||||
it('throws an error if initiating layer upload fails', async () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate failed initiation of uploads
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(503, url, config)
|
||||
return {
|
||||
status: 503
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
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 () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful response code but no location header
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow(/^No location header in response from upload post/)
|
||||
})
|
||||
|
||||
it('throws an error if a layer upload fails', async () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(async path => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
// Simulate fails upload of all blobs & manifest
|
||||
axiosPutMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(500, url, config)
|
||||
return {
|
||||
status: 500
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow(/^Unexpected response from PUT upload 500/)
|
||||
})
|
||||
|
||||
it('throws an error if a manifest upload fails', async () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(async path => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
// Simulate successful upload of all blobs & then the manifest
|
||||
axiosPutMock.mockImplementation(async (url, data, config) => {
|
||||
if (url.includes('manifest')) {
|
||||
validateRequestConfig(500, url, config)
|
||||
return {
|
||||
status: 500
|
||||
}
|
||||
}
|
||||
|
||||
validateRequestConfig(201, url, config)
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow(/^Unexpected response from PUT manifest 500/)
|
||||
})
|
||||
|
||||
it('throws an error if reading one of the files fails', async () => {
|
||||
// Simulate none of the blobs existing currently
|
||||
axiosHeadMock.mockImplementation(async (url, config) => {
|
||||
validateRequestConfig(404, url, config)
|
||||
return {
|
||||
status: 404
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
axiosPostMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(202, url, config)
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate successful reading of all the files
|
||||
fsReadFileSyncMock.mockImplementation(path => {
|
||||
throw new Error('failed to read a file: test')
|
||||
})
|
||||
|
||||
// Simulate successful upload of all blobs & then the manifest
|
||||
axiosPutMock.mockImplementation(async (url, data, config) => {
|
||||
validateRequestConfig(201, url, config)
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
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 () => {
|
||||
let modifiedTestManifest = testManifest
|
||||
modifiedTestManifest.layers[0].mediaType = 'application/json'
|
||||
|
||||
expect(
|
||||
publishOCIArtifact(
|
||||
token,
|
||||
registry,
|
||||
repository,
|
||||
releaseId,
|
||||
semver,
|
||||
zipFile,
|
||||
tarFile,
|
||||
testManifest
|
||||
)
|
||||
).rejects.toThrow('Unknown media type application/json')
|
||||
})
|
||||
})
|
||||
|
||||
// We expect all axios calls to have auth headers set and to not intercept any status codes so we can handle them.
|
||||
// This function verifies that given an axios request config.
|
||||
function validateRequestConfig(
|
||||
status: number,
|
||||
url: string,
|
||||
config: AxiosRequestConfig
|
||||
) {
|
||||
// Basic URL checks
|
||||
expect(url).toBeDefined()
|
||||
|
||||
if (!url.startsWith(registry.toString())) {
|
||||
console.log(url)
|
||||
}
|
||||
|
||||
expect(url.startsWith(registry.toString())).toBe(true)
|
||||
|
||||
// Config checks
|
||||
expect(config).toBeDefined()
|
||||
|
||||
expect(config.validateStatus).toBeDefined()
|
||||
if (config.validateStatus) {
|
||||
// Check axios will not intercept this status
|
||||
expect(config.validateStatus(status)).toBe(true)
|
||||
}
|
||||
|
||||
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')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Unit tests for the action's entrypoint, src/index.ts
|
||||
*/
|
||||
|
||||
import * as main from '../src/main'
|
||||
|
||||
// Mock the action's entrypoint
|
||||
const runMock = jest.spyOn(main, 'run').mockImplementation()
|
||||
|
||||
describe('index', () => {
|
||||
it('calls run when imported', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../src/index')
|
||||
|
||||
expect(runMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Unit tests for the action's main functionality, src/main.ts
|
||||
*
|
||||
* These should be run as if the action was called from a workflow.
|
||||
* Specifically, the inputs listed in `action.yml` should be set as environment
|
||||
* variables following the pattern `INPUT_<INPUT_NAME>`.
|
||||
*/
|
||||
|
||||
import * as core from '@actions/core'
|
||||
import * as main from '../src/main'
|
||||
import * as github from '@actions/github'
|
||||
|
||||
import * as fsHelper from '../src/fs-helper'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
import * as ghcr from '../src/ghcr-client'
|
||||
|
||||
// Mock the action's main function
|
||||
const runMock = jest.spyOn(main, 'run')
|
||||
|
||||
// Mock the GitHub Actions core library
|
||||
let debugMock: jest.SpyInstance
|
||||
let errorMock: jest.SpyInstance
|
||||
let getInputMock: jest.SpyInstance
|
||||
let setFailedMock: jest.SpyInstance
|
||||
let setOutputMock: jest.SpyInstance
|
||||
|
||||
// Mock the filesystem helper
|
||||
let createTempDirMock: jest.SpyInstance
|
||||
let isDirectoryMock: jest.SpyInstance
|
||||
let createArchivesMock: jest.SpyInstance
|
||||
let removeDirMock: jest.SpyInstance
|
||||
|
||||
// Mock the GHCR Client
|
||||
let publishOCIArtifactMock: jest.SpyInstance
|
||||
|
||||
describe('action', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Core mocks
|
||||
debugMock = jest.spyOn(core, 'debug').mockImplementation()
|
||||
errorMock = jest.spyOn(core, 'error').mockImplementation()
|
||||
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
|
||||
setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
|
||||
setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()
|
||||
|
||||
// FS mocks
|
||||
createTempDirMock = jest
|
||||
.spyOn(fsHelper, 'createTempDir')
|
||||
.mockImplementation()
|
||||
isDirectoryMock = jest.spyOn(fsHelper, 'isDirectory').mockImplementation()
|
||||
createArchivesMock = jest
|
||||
.spyOn(fsHelper, 'createArchives')
|
||||
.mockImplementation()
|
||||
removeDirMock = jest.spyOn(fsHelper, 'removeDir').mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
publishOCIArtifactMock = jest
|
||||
.spyOn(ghcr, 'publishOCIArtifact')
|
||||
.mockImplementation()
|
||||
})
|
||||
|
||||
it('fails if no repository found', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = ''
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Could not find Repository.')
|
||||
})
|
||||
|
||||
it('fails if event is not a release', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = 'test/test'
|
||||
github.context.eventName = 'push'
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'Please ensure you have the workflow trigger as release.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if release tag is not a valid semantic version', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = 'test/test'
|
||||
github.context.eventName = 'release'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'invalid-tag'
|
||||
}
|
||||
}
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'invalid-tag is not a valid semantic version, and so cannot be uploaded as an Immutable Action.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if path is not a directory', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = 'test/test'
|
||||
github.context.eventName = 'release'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.0.0'
|
||||
}
|
||||
}
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
if (name === 'path') {
|
||||
return 'not-a-directory'
|
||||
} else if (name === 'registry') {
|
||||
return 'https://ghcr.io'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
isDirectoryMock.mockImplementation(() => false)
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(isDirectoryMock).toHaveBeenCalledWith('not-a-directory')
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'The path not-a-directory is not a directory. Please provide a path to a valid directory.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if an error is thrown from dependent code', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = 'test/test'
|
||||
github.context.eventName = 'release'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.0.0'
|
||||
}
|
||||
}
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
if (name === 'path') {
|
||||
return 'directory'
|
||||
} else if (name === 'registry') {
|
||||
return 'https://ghcr.io'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
isDirectoryMock.mockImplementation(() => true)
|
||||
|
||||
createTempDirMock.mockImplementation(() => '/tmp/test')
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(isDirectoryMock).toHaveBeenCalledWith('directory')
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
|
||||
// Expect the files to be cleaned up
|
||||
expect(removeDirMock).toHaveBeenCalledWith('/tmp/test')
|
||||
})
|
||||
|
||||
it('uploads and returns the manifest & package URL if all succeeds', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_REPOSITORY = 'test/test'
|
||||
github.context.eventName = 'release'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.0.0'
|
||||
}
|
||||
}
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
if (name === 'path') {
|
||||
return 'test'
|
||||
} else if (name === 'registry') {
|
||||
return 'https://ghcr.io'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
isDirectoryMock.mockImplementation(() => true)
|
||||
|
||||
createTempDirMock.mockImplementation(() => '/tmp/test')
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return new URL('https://ghcr.io/v2/test/test:1.0.0')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check manifest is in output
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-url',
|
||||
'https://ghcr.io/v2/test/test:1.0.0'
|
||||
)
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest',
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
// Validate the manifest
|
||||
const manifest = JSON.parse(setOutputMock.mock.calls[1][1])
|
||||
expect(manifest.mediaType).toEqual(
|
||||
'application/vnd.oci.image.manifest.v1+json'
|
||||
)
|
||||
expect(manifest.config.mediaType).toEqual(
|
||||
'application/vnd.github.actions.package.config.v1+json'
|
||||
)
|
||||
expect(manifest.layers.length).toEqual(3)
|
||||
expect(manifest.annotations['com.github.package.type']).toEqual(
|
||||
'actions_oci_pkg'
|
||||
)
|
||||
|
||||
// Expect the files to be cleaned up
|
||||
expect(removeDirMock).toHaveBeenCalledWith('/tmp/test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createActionPackageManifest } from '../src/oci-container'
|
||||
import { FileMetadata } from '../src/fs-helper'
|
||||
|
||||
describe('createActionPackageManigest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
let date = new Date()
|
||||
let repo = 'test-repo'
|
||||
let version = '1.0.0'
|
||||
let tarFile: FileMetadata = {
|
||||
path: '/test/test/test',
|
||||
sha256: '1234567890',
|
||||
size: 100
|
||||
}
|
||||
let zipFile: FileMetadata = {
|
||||
path: '/test/test/test',
|
||||
sha256: '1234567890',
|
||||
size: 100
|
||||
}
|
||||
|
||||
let expectedJSON: String = `{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.github.actions.package.config.v1+json",
|
||||
"size": 0,
|
||||
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"annotations": {
|
||||
"org.opencontainers.image.title":"config.json"
|
||||
}
|
||||
},
|
||||
"layers":[
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.config.v1+json",
|
||||
"size":0,
|
||||
"digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"config.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
|
||||
"size":${tarFile.size},
|
||||
"digest":"${tarFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"${repo}-${version}.tar.gz"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.zip",
|
||||
"size":${tarFile.size},
|
||||
"digest":"${tarFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"${repo}-${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"
|
||||
}
|
||||
}`
|
||||
|
||||
let manifest = createActionPackageManifest(
|
||||
{
|
||||
path: 'test.tar.gz',
|
||||
size: 100,
|
||||
sha256: '1234567890'
|
||||
},
|
||||
{
|
||||
path: 'test.zip',
|
||||
size: 100,
|
||||
sha256: '1234567890'
|
||||
},
|
||||
'test-repo',
|
||||
'1.0.0',
|
||||
date
|
||||
)
|
||||
|
||||
let manifestJSON = JSON.stringify(manifest)
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user