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:
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
getRepositoryMetadata,
|
||||
getContainerRegistryURL
|
||||
} from '../src/api-client'
|
||||
|
||||
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('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('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('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('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()
|
||||
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()).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()).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()).rejects.toThrow(
|
||||
'Failed to fetch repository metadata: unexpected response format'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
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'
|
||||
|
||||
let getContainerRegistryURLMock: jest.SpyInstance
|
||||
let getInputMock: jest.SpyInstance
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
|
||||
describe('config.resolvePublishActionOptions', () => {
|
||||
beforeEach(() => {
|
||||
getContainerRegistryURLMock = jest
|
||||
.spyOn(iaToolkit, 'getContainerRegistryURL')
|
||||
.mockImplementation()
|
||||
|
||||
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
|
||||
|
||||
configureEventContext()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
clearEventContext()
|
||||
})
|
||||
|
||||
it('throws an error when the token is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce(undefined)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_TOKEN.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the event is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.eventName = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find event name.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the ref is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REF = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REF.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the workspaceDir is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_WORKSPACE = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_WORKSPACE.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repository is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REPOSITORY = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find Repository.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the apiBaseUrl is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_API_URL = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_API_URL.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the runnerTempDir is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.RUNNER_TEMP = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find RUNNER_TEMP.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the sha is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_SHA = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_SHA.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the githubServerUrl is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_SERVER_URL = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_SERVER_URL.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repositoryId is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REPOSITORY_ID = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REPOSITORY_ID.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repositoryOwnerId is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REPOSITORY_OWNER_ID.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when getting the container registry URL fails', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockRejectedValue(
|
||||
new Error('Failed to get container registry URL')
|
||||
)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Failed to get container registry URL'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns options when all values are present', async () => {
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
expect(name).toBe('github-token')
|
||||
return 'token'
|
||||
})
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
|
||||
const options = await cfg.resolvePublishActionOptions()
|
||||
|
||||
expect(options).toEqual({
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets enterprise to true when the server URL is not github.com or ghe.com', async () => {
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
expect(name).toBe('github-token')
|
||||
return 'token'
|
||||
})
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
|
||||
process.env.GITHUB_SERVER_URL = 'https://github-enterprise.com'
|
||||
|
||||
const options = await cfg.resolvePublishActionOptions()
|
||||
|
||||
expect(options).toEqual({
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: true,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('config.serializeOptions', () => {
|
||||
it('serializes the options, ignoring internal keys', () => {
|
||||
const options: cfg.PublishActionOptions = {
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token'
|
||||
}
|
||||
|
||||
const serialized = cfg.serializeOptions(options)
|
||||
|
||||
// Parse the JSON
|
||||
const parsed = JSON.parse(serialized)
|
||||
|
||||
expect(parsed.nameWithOwner).toBe('nameWithOwner')
|
||||
expect(parsed.ref).toBe('ref')
|
||||
expect(parsed.workspaceDir).toBe('workspaceDir')
|
||||
expect(parsed.event).toBe('release')
|
||||
expect(parsed.apiBaseUrl).toBe('apiBaseUrl')
|
||||
expect(parsed.sha).toBe('sha')
|
||||
expect(parsed.isEnterprise).toBe(false)
|
||||
expect(parsed.containerRegistryUrl).toBe(ghcrUrl.toString())
|
||||
expect(parsed.token).toBeUndefined()
|
||||
expect(parsed.repositoryId).toBeUndefined()
|
||||
expect(parsed.repositoryOwnerId).toBeUndefined()
|
||||
expect(parsed.runnerTempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function configureEventContext(): void {
|
||||
process.env.GITHUB_REF = 'ref'
|
||||
process.env.GITHUB_WORKSPACE = 'workspaceDir'
|
||||
process.env.GITHUB_REPOSITORY = 'nameWithOwner'
|
||||
process.env.GITHUB_API_URL = 'apiBaseUrl'
|
||||
process.env.RUNNER_TEMP = 'runnerTempDir'
|
||||
process.env.GITHUB_SHA = 'sha'
|
||||
process.env.GITHUB_SERVER_URL = 'github.com'
|
||||
process.env.GITHUB_REPOSITORY_ID = 'repositoryId'
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = 'repositoryOwnerId'
|
||||
github.context.eventName = 'release'
|
||||
}
|
||||
|
||||
function clearEventContext(): void {
|
||||
process.env.GITHUB_REF = ''
|
||||
process.env.GITHUB_WORKSPACE = ''
|
||||
process.env.GITHUB_REPOSITORY = ''
|
||||
process.env.GITHUB_API_URL = ''
|
||||
process.env.RUNNER_TEMP = ''
|
||||
process.env.GITHUB_SHA = ''
|
||||
process.env.GITHUB_SERVER_URL = ''
|
||||
process.env.GITHUB_REPOSITORY_ID = ''
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = ''
|
||||
github.context.eventName = ''
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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('stageActionFiles', () => {
|
||||
let sourceDir: string
|
||||
let stagingDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
sourceDir = fsHelper.createTempDir('source')
|
||||
fs.mkdirSync(`${sourceDir}/src`)
|
||||
fs.writeFileSync(`${sourceDir}/src/main.js`, fileContent)
|
||||
fs.writeFileSync(`${sourceDir}/src/other.js`, fileContent)
|
||||
|
||||
stagingDir = fsHelper.createTempDir('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(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
stageDir = fsHelper.createTempDir('staging')
|
||||
fs.writeFileSync(`${stageDir}/hello.txt`, fileContent)
|
||||
fs.writeFileSync(`${stageDir}/world.txt`, fileContent)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
archiveDir = fsHelper.createTempDir('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', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
const tmpDir = fsHelper.createTempDir('subdir')
|
||||
|
||||
expect(fs.existsSync(tmpDir)).toEqual(true)
|
||||
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
|
||||
})
|
||||
|
||||
it('creates a unique temporary directory', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
const dir1 = fsHelper.createTempDir('dir1')
|
||||
dirs.push(dir1)
|
||||
|
||||
const dir2 = fsHelper.createTempDir('dir2')
|
||||
dirs.push(dir2)
|
||||
|
||||
expect(dir1).not.toEqual(dir2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirectory', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
dir = fsHelper.createTempDir('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(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
dir = fsHelper.createTempDir('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)
|
||||
})
|
||||
})
|
||||
@@ -1,507 +0,0 @@
|
||||
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.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: `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
|
||||
}
|
||||
+212
-228
@@ -7,28 +7,28 @@
|
||||
*/
|
||||
|
||||
import * as core from '@actions/core'
|
||||
import * as attest from '@actions/attest'
|
||||
import * as main from '../src/main'
|
||||
import * as github from '@actions/github'
|
||||
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'
|
||||
import * as api from '../src/api-client'
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
|
||||
// Mock the GitHub Actions core library
|
||||
let setFailedMock: jest.SpyInstance
|
||||
let setOutputMock: jest.SpyInstance
|
||||
|
||||
// Mock the filesystem helper
|
||||
// Mock the IA Toolkit
|
||||
let createTempDirMock: jest.SpyInstance
|
||||
let createArchivesMock: jest.SpyInstance
|
||||
let stageActionFilesMock: jest.SpyInstance
|
||||
|
||||
// Mock the GHCR Client
|
||||
let publishOCIArtifactMock: jest.SpyInstance
|
||||
|
||||
// Mock the API Client
|
||||
let getContainerRegistryURLMock: jest.SpyInstance
|
||||
let getRepositoryMetadataMock: jest.SpyInstance
|
||||
// Mock the config resolution
|
||||
let resolvePublishActionOptionsMock: jest.SpyInstance
|
||||
|
||||
// Mock generating attestation
|
||||
let generateAttestationMock: jest.SpyInstance
|
||||
|
||||
describe('run', () => {
|
||||
beforeEach(() => {
|
||||
@@ -40,176 +40,60 @@ describe('run', () => {
|
||||
|
||||
// FS mocks
|
||||
createTempDirMock = jest
|
||||
.spyOn(fsHelper, 'createTempDir')
|
||||
.spyOn(iaToolkit, 'createTempDir')
|
||||
.mockImplementation()
|
||||
createArchivesMock = jest
|
||||
.spyOn(fsHelper, 'createArchives')
|
||||
.spyOn(iaToolkit, 'createArchives')
|
||||
.mockImplementation()
|
||||
stageActionFilesMock = jest
|
||||
.spyOn(fsHelper, 'stageActionFiles')
|
||||
.spyOn(iaToolkit, 'stageActionFiles')
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
publishOCIArtifactMock = jest
|
||||
.spyOn(ghcr, 'publishOCIArtifact')
|
||||
.spyOn(iaToolkit, 'publishOCIArtifact')
|
||||
.mockImplementation()
|
||||
|
||||
// API Client mocks
|
||||
getContainerRegistryURLMock = jest
|
||||
.spyOn(api, 'getContainerRegistryURL')
|
||||
// Config mocks
|
||||
resolvePublishActionOptionsMock = jest
|
||||
.spyOn(cfg, 'resolvePublishActionOptions')
|
||||
.mockImplementation()
|
||||
|
||||
getRepositoryMetadataMock = jest
|
||||
.spyOn(api, 'getRepositoryMetadata')
|
||||
// Attestation mocks
|
||||
generateAttestationMock = jest
|
||||
.spyOn(attest, 'attestProvenance')
|
||||
.mockImplementation()
|
||||
})
|
||||
|
||||
it('fails if no action workspace found', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = ''
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'Could not find GITHUB_WORKSPACE.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if no repository found', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = ''
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Could not find Repository.')
|
||||
})
|
||||
|
||||
it('fails if no token found', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
process.env.TOKEN = ''
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Could not find GITHUB_TOKEN.')
|
||||
})
|
||||
|
||||
it('fails if no source commit found', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
process.env.TOKEN = 'test'
|
||||
process.env.GITHUB_SHA = ''
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Could not find source commit.')
|
||||
})
|
||||
|
||||
it('fails if trigger is not release or tag push', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
|
||||
// TODO: If we want we can add all of these: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
|
||||
const invalidEvents = ['workflow_dispatch, pull_request, schedule']
|
||||
for (const event of invalidEvents) {
|
||||
github.context.eventName = event
|
||||
await main.run()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'This action can only be triggered by release events or tag push events.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails if the trigger is a push, but not a tag push', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.eventName = 'push'
|
||||
github.context.ref = 'refs/heads/main' // This is a branch, not a tag
|
||||
it('fails if the action ref is not a tag', async () => {
|
||||
const options = baseOptions()
|
||||
options.ref = 'refs/heads/main' // This is a branch, not a tag
|
||||
resolvePublishActionOptionsMock.mockReturnValueOnce(options)
|
||||
|
||||
await main.run()
|
||||
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'This action can only be triggered by release events or tag push events.'
|
||||
'The ref refs/heads/main is not a valid tag reference.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if the value of the tag input is not a valid semver', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.eventName = 'release'
|
||||
|
||||
it('fails if the value of the tag ref is not a valid semver', async () => {
|
||||
const tags = ['test', 'v1.0', 'chicken', '111111']
|
||||
|
||||
for (const tag of tags) {
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: tag
|
||||
}
|
||||
}
|
||||
const options = baseOptions()
|
||||
options.ref = `refs/tags/${tag}`
|
||||
resolvePublishActionOptionsMock.mockReturnValueOnce(options)
|
||||
|
||||
await main.run()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
`${tag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.`
|
||||
`${tag} is not a valid semantic version tag, and so cannot be uploaded to the action package.`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails if staging files fails', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
}
|
||||
}
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating temp directory fails', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
}
|
||||
}
|
||||
it('fails if creating staging temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
@@ -222,21 +106,14 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating archives fails', async () => {
|
||||
// Mock the environment
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
}
|
||||
}
|
||||
it('fails if staging files fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'tmpDir/staging'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
@@ -247,39 +124,35 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if getting container registry URL fails', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
it('fails if creating archives temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation((_, path: string) => {
|
||||
if (path === 'staging') {
|
||||
return 'staging'
|
||||
}
|
||||
}
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating archives fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
getRepositoryMetadataMock.mockImplementation(() => {
|
||||
return { repoId: 'test', ownerId: 'test' }
|
||||
})
|
||||
|
||||
getContainerRegistryURLMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
@@ -291,17 +164,13 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
it('fails if publishing OCI artifact fails', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
}
|
||||
}
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
@@ -318,14 +187,6 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
getRepositoryMetadataMock.mockImplementation(() => {
|
||||
return { repoId: 'test', ownerId: 'test' }
|
||||
})
|
||||
|
||||
getContainerRegistryURLMock.mockImplementation(() => {
|
||||
return new URL('https://ghcr.io')
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
@@ -337,20 +198,14 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and cleans up tmp dirs', async () => {
|
||||
process.env.GITHUB_WORKSPACE = '.'
|
||||
process.env.GITHUB_REPOSITORY = 'test-org/test-repo'
|
||||
github.context.eventName = 'release'
|
||||
process.env.GITHUB_SHA = 'test-sha'
|
||||
process.env.TOKEN = 'token'
|
||||
github.context.payload = {
|
||||
release: {
|
||||
id: '123',
|
||||
tag_name: 'v1.2.3'
|
||||
}
|
||||
}
|
||||
it('fails if creating attestation fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => '/tmp/test/subdir')
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
@@ -367,12 +222,48 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
getRepositoryMetadataMock.mockImplementation(() => {
|
||||
return { repoId: 'test', ownerId: 'test' }
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
getContainerRegistryURLMock.mockImplementation(() => {
|
||||
return new URL('https://ghcr.io')
|
||||
generateAttestationMock.mockImplementation(async () => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
options.isEnterprise = true
|
||||
resolvePublishActionOptionsMock.mockReturnValue(options)
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
@@ -406,4 +297,97 @@ describe('run', () => {
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async () => {
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(4)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-url',
|
||||
'https://ghcr.io/v2/test-org/test-repo:1.2.3'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest',
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'attestation-id',
|
||||
'test-attestation-id'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function baseOptions(): cfg.PublishActionOptions {
|
||||
return {
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token',
|
||||
ref: 'refs/tags/v1.2.3'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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.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":"${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, ''))
|
||||
})
|
||||
})
|
||||
+8
-29
@@ -6,41 +6,20 @@ branding:
|
||||
icon: 'heart'
|
||||
color: 'red'
|
||||
|
||||
inputs:
|
||||
github-token:
|
||||
description: 'The GitHub actions token used to authenticate with GitHub APIs'
|
||||
|
||||
outputs:
|
||||
package-url:
|
||||
description: 'The name of package published to GHCR along with semver. For example, https://ghcr.io/actions/package-action:1.0.1'
|
||||
value: ${{steps.publish.outputs.package-url}}
|
||||
package-manifest:
|
||||
description: 'The package manifest of the published package in JSON format'
|
||||
value: ${{steps.publish.outputs.package-manifest}}
|
||||
package-manifest-sha:
|
||||
description: 'A sha256 hash of the package manifest'
|
||||
value: ${{steps.publish.outputs.package-manifest-sha}}
|
||||
attestation-id:
|
||||
description: 'The attestation id of the generated provenance attestation. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Publish Action Package
|
||||
run: 'npm --prefix "${{github.action_path}}" start'
|
||||
shell: bash
|
||||
id: publish
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITHUB_WORKSPACE: ${{ github.workspace }}
|
||||
- name: Output variables
|
||||
shell: bash
|
||||
run: |
|
||||
echo "package manifest sha: ${{steps.publish.outputs.package-manifest-sha}}"
|
||||
echo "package url: ${{steps.publish.outputs.package-url}}"
|
||||
echo "subject name: ${{github.repository}}_${{github.ref}}"
|
||||
- name: Generate Provenance Attestation
|
||||
uses: github-early-access/generate-build-provenance@main
|
||||
id: build-provenance
|
||||
if: endsWith(github.server_url, 'github.com') || endsWith(github.server_url, 'ghe.com')
|
||||
with:
|
||||
subject-name: ${{github.repository}}_${{github.ref}}
|
||||
subject-digest: ${{steps.publish.outputs.package-manifest-sha}}
|
||||
push-to-registry: false
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
|
||||
+1
-1
@@ -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: 93.96%"><title>Coverage: 93.96%</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">93.96%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">93.96%</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: 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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+91826
-19846
File diff suppressed because one or more lines are too long
+1511
-1112
File diff suppressed because it is too large
Load Diff
Generated
+806
-28
File diff suppressed because it is too large
Load Diff
@@ -67,9 +67,11 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/attest": "^1.0.0",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@immutable-actions/toolkit": "^0.0.3",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"archiver": "^6.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+77
-77
@@ -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.`
|
||||
)
|
||||
}
|
||||
|
||||
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.`
|
||||
)
|
||||
}
|
||||
|
||||
return semanticVersion
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
function removePrefix(str: string, prefix: string): string {
|
||||
if (str.startsWith(prefix)) {
|
||||
return str.slice(prefix.length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -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('/', '-')
|
||||
}
|
||||
Reference in New Issue
Block a user