Merge branch 'main' into dependabot/npm_and_yarn/typescript-eslint/parser-6.21.0
This commit is contained in:
@@ -17,6 +17,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
check-dist:
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
test-typescript:
|
||||
@@ -42,4 +43,4 @@ jobs:
|
||||
- name: Test
|
||||
id: npm-ci-test
|
||||
run: npm run ci-test
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
checks: write
|
||||
contents: read
|
||||
security-events: write
|
||||
packages: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
@@ -14,3 +14,5 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish action package
|
||||
uses: ./
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Dependency directory
|
||||
node_modules
|
||||
|
||||
.npmrc
|
||||
|
||||
# Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
# Publish Action Package
|
||||
# Publish Immutable Action
|
||||
|
||||
_This action_ packages _your action_ as OCI artifacts and publishes it to the [GitHub Container registry](ghcr.io).
|
||||
> [!IMPORTANT]
|
||||
> This action is **not ready for public use**. It is part of an upcoming public roadmap item (see [GitHub Actions: Immutable actions publishing](https://github.com/github/roadmap/issues/592)).
|
||||
> Attempts to use this action to upload an OCI artifact will not work until this feature has been fully released to the public. Please do not attempt to use it until that time.
|
||||
|
||||
This allows your action to be consumed as an _immutable_ package even if a [SemVer](https://semver.org/) is specified in the consumer's workflow file.
|
||||
This action packages _your action_ as an [OCI container](https://opencontainers.org/) and publishes it to the [GitHub Container registry](https://ghcr.io).
|
||||
This allows your action to be consumed as an _immutable_ package if a [SemVer](https://semver.org/) is specified in the consumer's workflow file.
|
||||
|
||||
Your action workflow must be triggered on `release` as in the following example. The release's title must follow [semantic versioning](https://semver.org/).
|
||||
Then consumers of your action will then be able to specify the version, e.g., `- uses: your-name/your-action@v1.2.3` or even `- uses: your-name/your-action@v1`.
|
||||
Your workflow can be triggered by any [event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) which has a `GITHUB_REF` that points to a git tag.
|
||||
Some examples of these events are:
|
||||
|
||||
- [`release`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release) (uses tag associated with release)
|
||||
- [`push`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push) (only applies to pushed tags)
|
||||
- [`workflow_dispatch`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch) (only applies if subject of dispatch is a tag)
|
||||
|
||||
The associated tag must follow [semantic versioning](https://semver.org/) - this tag value will be used to create a package version.
|
||||
|
||||
Consumers of your action will then be able to specify that version to consume your action from the package, e.g.
|
||||
|
||||
- `- uses: your-name/your-action@v1.2.3`
|
||||
- `- uses: your-name/your-action@v1`
|
||||
|
||||
Such packages will come with stronger security guarantees for consumers than existing git-based action resolution, such as:
|
||||
|
||||
- Provenance attestations generated using the [`@actions/attest`](https://github.com/actions/toolkit/tree/main/packages/attest) package
|
||||
- Tag immutability - it will not be possible to overwrite tags once published, ensuring versions of an action can't change once in use
|
||||
- Namespace immutability - it will not be possible to delete and recreate the package with different content; this would undermine tag immutability
|
||||
|
||||
## Usage
|
||||
|
||||
An actions workflow file like the following should be placed in your action repository:
|
||||
|
||||
<!-- start usage -->
|
||||
```yaml
|
||||
name: "Publish Immutable Action Version"
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
- uses: immutable-actions/publish-action-package@v1
|
||||
with:
|
||||
# Relative path of the working directory of the repository to be tar archived
|
||||
# and uploaded as OCI Artifact layer. You can mention multiple files/folders
|
||||
# by mentioning relative paths as space separated values.
|
||||
#
|
||||
# This defaults to the entire action repository contents if not explicitly defined.
|
||||
# Default: '.'
|
||||
path: 'src/ action.yml dist/'
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
packages: write
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
id: publish
|
||||
uses: actions/publish-immutable-action@v1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
<!-- end usage -->
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
getContainerRegistryURL
|
||||
} from '../src/api-client'
|
||||
|
||||
const url = 'https://registry.example.com'
|
||||
|
||||
let fetchMock: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -16,22 +18,34 @@ afterEach(() => {
|
||||
describe('getRepositoryMetadata', () => {
|
||||
it('returns repository metadata when the fetch response is ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: '123', owner: { id: '456' } }))
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: '123',
|
||||
owner: { id: '456' },
|
||||
visibility: 'public'
|
||||
})
|
||||
)
|
||||
)
|
||||
const result = await getRepositoryMetadata('repository', 'token')
|
||||
expect(result).toEqual({ repoId: '123', ownerId: '456' })
|
||||
const result = await getRepositoryMetadata(url, 'repository', 'token')
|
||||
expect(result).toEqual({
|
||||
repoId: '123',
|
||||
ownerId: '456',
|
||||
visibility: 'public'
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
)
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow('API is down')
|
||||
})
|
||||
|
||||
it('throws an error when the response status is not ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
await expect(getRepositoryMetadata('repository', 'token')).rejects.toThrow(
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch repository metadata due to bad status code: 500'
|
||||
)
|
||||
})
|
||||
@@ -40,7 +54,9 @@ describe('getRepositoryMetadata', () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ wrong: 'format' }))
|
||||
)
|
||||
await expect(getRepositoryMetadata('repository', 'token')).rejects.toThrow(
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch repository metadata: unexpected response format'
|
||||
)
|
||||
})
|
||||
@@ -51,18 +67,18 @@ describe('getContainerRegistryURL', () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ url: 'https://registry.example.com' }))
|
||||
)
|
||||
const result = await getContainerRegistryURL()
|
||||
const result = await getContainerRegistryURL(url)
|
||||
expect(result).toEqual(new URL('https://registry.example.com'))
|
||||
})
|
||||
|
||||
it('throws an error when the fetch errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('API is down'))
|
||||
await expect(getContainerRegistryURL()).rejects.toThrow('API is down')
|
||||
await expect(getContainerRegistryURL(url)).rejects.toThrow('API is down')
|
||||
})
|
||||
|
||||
it('throws an error when the response status is not ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
await expect(getContainerRegistryURL()).rejects.toThrow(
|
||||
await expect(getContainerRegistryURL(url)).rejects.toThrow(
|
||||
'Failed to fetch container registry url due to bad status code: 500'
|
||||
)
|
||||
})
|
||||
@@ -71,7 +87,7 @@ describe('getContainerRegistryURL', () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ wrong: 'format' }))
|
||||
)
|
||||
await expect(getContainerRegistryURL()).rejects.toThrow(
|
||||
await expect(getContainerRegistryURL(url)).rejects.toThrow(
|
||||
'Failed to fetch repository metadata: unexpected response format'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import * as cfg from '../src/config'
|
||||
import * as apiClient from '../src/api-client'
|
||||
|
||||
let getContainerRegistryURLMock: jest.SpyInstance
|
||||
let getRepositoryMetadataMock: jest.SpyInstance
|
||||
let getInputMock: jest.SpyInstance
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
|
||||
describe('config.resolvePublishActionOptions', () => {
|
||||
beforeEach(() => {
|
||||
getContainerRegistryURLMock = jest
|
||||
.spyOn(apiClient, 'getContainerRegistryURL')
|
||||
.mockImplementation()
|
||||
|
||||
getRepositoryMetadataMock = jest
|
||||
.spyOn(apiClient, 'getRepositoryMetadata')
|
||||
.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('throws an error when getting the repository metadata fails', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockRejectedValue(
|
||||
new Error('Failed to get repository metadata')
|
||||
)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Failed to get repository metadata'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository visibility is empty', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: ''
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find repository visibility.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository id does not match env var', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
ownerId: '12345',
|
||||
repoId: '54321'
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Repository ID mismatch.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository owner id does not match env var', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
ownerId: '123124',
|
||||
repoId: 'repositoryId'
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Repository Owner ID mismatch.'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns options when all values are present', async () => {
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
expect(name).toBe('github-token')
|
||||
return 'token'
|
||||
})
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
repoId: 'repositoryId',
|
||||
ownerId: 'repositoryOwnerId'
|
||||
})
|
||||
|
||||
const options = await cfg.resolvePublishActionOptions()
|
||||
|
||||
expect(options).toEqual({
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryVisibility: 'public',
|
||||
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)
|
||||
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
repoId: 'repositoryId',
|
||||
ownerId: 'repositoryOwnerId'
|
||||
})
|
||||
|
||||
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',
|
||||
repositoryVisibility: 'public'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
repositoryVisibility: 'public'
|
||||
}
|
||||
|
||||
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 = 'https://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 = ''
|
||||
}
|
||||
+69
-15
@@ -4,19 +4,19 @@ import * as os from 'os'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
const fileContent = 'This is the content of the file'
|
||||
const tmpFileDir = '/tmp'
|
||||
|
||||
describe('stageActionFiles', () => {
|
||||
let sourceDir: string
|
||||
let stagingDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
sourceDir = fsHelper.createTempDir('source')
|
||||
sourceDir = fsHelper.createTempDir(tmpFileDir, 'source')
|
||||
fs.mkdirSync(`${sourceDir}/src`)
|
||||
fs.writeFileSync(`${sourceDir}/src/main.js`, fileContent)
|
||||
fs.writeFileSync(`${sourceDir}/src/other.js`, fileContent)
|
||||
|
||||
stagingDir = fsHelper.createTempDir('staging')
|
||||
stagingDir = fsHelper.createTempDir(tmpFileDir, 'staging')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -72,14 +72,13 @@ describe('createArchives', () => {
|
||||
let archiveDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
stageDir = fsHelper.createTempDir('staging')
|
||||
stageDir = fsHelper.createTempDir(tmpFileDir, 'staging')
|
||||
fs.writeFileSync(`${stageDir}/hello.txt`, fileContent)
|
||||
fs.writeFileSync(`${stageDir}/world.txt`, fileContent)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
archiveDir = fsHelper.createTempDir('archive')
|
||||
archiveDir = fsHelper.createTempDir(tmpFileDir, 'archive')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -156,19 +155,17 @@ describe('createTempDir', () => {
|
||||
})
|
||||
|
||||
it('creates a temporary directory', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
const tmpDir = fsHelper.createTempDir('subdir')
|
||||
const tmpDir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
|
||||
expect(fs.existsSync(tmpDir)).toEqual(true)
|
||||
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
|
||||
})
|
||||
|
||||
it('creates a unique temporary directory', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
const dir1 = fsHelper.createTempDir('dir1')
|
||||
const dir1 = fsHelper.createTempDir(tmpFileDir, 'dir1')
|
||||
dirs.push(dir1)
|
||||
|
||||
const dir2 = fsHelper.createTempDir('dir2')
|
||||
const dir2 = fsHelper.createTempDir(tmpFileDir, 'dir2')
|
||||
dirs.push(dir2)
|
||||
|
||||
expect(dir1).not.toEqual(dir2)
|
||||
@@ -179,8 +176,7 @@ describe('isDirectory', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
dir = fsHelper.createTempDir('subdir')
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -202,8 +198,7 @@ describe('readFileContents', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RUNNER_TEMP = '/tmp'
|
||||
dir = fsHelper.createTempDir('subdir')
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -217,3 +212,62 @@ describe('readFileContents', () => {
|
||||
expect(fsHelper.readFileContents(tempFile).toString()).toEqual(fileContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureCorrectShaCheckedOut', () => {
|
||||
let dir: string
|
||||
let commit1: string
|
||||
let commit2: string
|
||||
const tag1 = 'tag1'
|
||||
const tag2 = 'tag2'
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
|
||||
// Set up a git repository
|
||||
execSync('git init', { cwd: dir })
|
||||
|
||||
// Set user and email in this git repo (not globally)
|
||||
execSync('git config user.email monalisa@github.com', { cwd: dir })
|
||||
execSync('git config user.name Mona', { cwd: dir })
|
||||
|
||||
// Add two commits
|
||||
execSync('git commit --allow-empty -m "test"', { cwd: dir })
|
||||
execSync('git commit --allow-empty -m "test"', { cwd: dir })
|
||||
|
||||
// Grab the two commits
|
||||
commit1 = execSync('git rev-parse HEAD~1', { cwd: dir }).toString().trim()
|
||||
commit2 = execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()
|
||||
|
||||
// Create a tag for each commit
|
||||
execSync(`git tag ${tag1} ${commit1}`, { cwd: dir })
|
||||
execSync(`git tag ${tag2} ${commit2}`, { cwd: dir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
})
|
||||
|
||||
it('does not throw an error if the correct SHA is checked out', async () => {
|
||||
await expect(
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag2}`, commit2, dir)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws an error if the correct SHA is not checked out', async () => {
|
||||
await expect(
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag1}`, commit1, dir)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws an error if the sha of the tag does not match expected sha', async () => {
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag1}`, commit2, dir)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws if the provided ref is not a tag ref', async () => {
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/heads/main`, commit2, dir)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,23 +159,17 @@ const testManifest: ociContainer.Manifest = {
|
||||
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,
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'config.json'
|
||||
}
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.config.v1+json',
|
||||
size: 0,
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'config.json'
|
||||
}
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
},
|
||||
{
|
||||
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
|
||||
|
||||
+326
-222
@@ -7,28 +7,30 @@
|
||||
*/
|
||||
|
||||
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 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 ensureCorrectShaCheckedOutMock: jest.SpyInstance
|
||||
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(() => {
|
||||
@@ -48,143 +50,90 @@ describe('run', () => {
|
||||
stageActionFilesMock = jest
|
||||
.spyOn(fsHelper, 'stageActionFiles')
|
||||
.mockImplementation()
|
||||
ensureCorrectShaCheckedOutMock = jest
|
||||
.spyOn(fsHelper, 'ensureTagAndRefCheckedOut')
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
publishOCIArtifactMock = jest
|
||||
.spyOn(ghcr, '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 ensuring the correct SHA is checked out errors', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.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 staging temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
createTempDirMock.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 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'
|
||||
}
|
||||
}
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'tmpDir/staging'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
@@ -197,24 +146,20 @@ describe('run', () => {
|
||||
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 archives temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation((_, path: string) => {
|
||||
if (path === 'staging') {
|
||||
return 'staging'
|
||||
}
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
@@ -223,18 +168,15 @@ describe('run', () => {
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
@@ -247,61 +189,16 @@ 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'
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
@@ -318,14 +215,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 +226,16 @@ 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')
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
@@ -367,12 +252,50 @@ 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 skips writing attestation in enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
options.isEnterprise = true
|
||||
resolvePublishActionOptionsMock.mockReturnValue(options)
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
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 +329,185 @@ describe('run', () => {
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise for public repo', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
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 options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation but skips storing it in non-enterprise for private repo', async () => {
|
||||
const opts = baseOptions()
|
||||
opts.repositoryVisibility = 'private'
|
||||
|
||||
resolvePublishActionOptionsMock.mockReturnValue(opts)
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
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 options => {
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
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',
|
||||
repositoryVisibility: 'public'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,21 +26,15 @@ describe('createActionPackageManifest', () => {
|
||||
"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"
|
||||
}
|
||||
"mediaType":"application/vnd.oci.empty.v1+json",
|
||||
"size":2,
|
||||
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
"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.oci.empty.v1+json",
|
||||
"size":2,
|
||||
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
|
||||
|
||||
+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: 96.81%"><title>Coverage: 96.81%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">96.81%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">96.81%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+50278
-1633
File diff suppressed because one or more lines are too long
+1663
File diff suppressed because it is too large
Load Diff
Generated
+851
-64
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -67,13 +67,15 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/attest": "^1.0.0",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"archiver": "^6.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"tar": "^6.2.0"
|
||||
"tar": "^6.2.0",
|
||||
"simple-git": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.2",
|
||||
@@ -82,7 +84,7 @@
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^20.11.13",
|
||||
"@types/tar": "^6.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"eslint": "^8.54.0",
|
||||
|
||||
+17
-13
@@ -1,17 +1,15 @@
|
||||
export async function getRepositoryMetadata(
|
||||
githubAPIURL: string,
|
||||
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'
|
||||
}
|
||||
): Promise<{ repoId: string; ownerId: string; visibility: string }> {
|
||||
const response = await fetch(`${githubAPIURL}/repos/${repository}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -28,12 +26,18 @@ export async function getRepositoryMetadata(
|
||||
)
|
||||
}
|
||||
|
||||
return { repoId: String(data.id), ownerId: String(data.owner.id) }
|
||||
return {
|
||||
repoId: String(data.id),
|
||||
ownerId: String(data.owner.id),
|
||||
visibility: String(data.visibility)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerRegistryURL(): Promise<URL> {
|
||||
export async function getContainerRegistryURL(
|
||||
githubAPIURL: string
|
||||
): Promise<URL> {
|
||||
const response = await fetch(
|
||||
`${process.env.GITHUB_API_URL}/packages/container-registry-url`
|
||||
`${githubAPIURL}/packages/container-registry-url`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import * as apiClient from './api-client'
|
||||
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 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 visibility of the action repository ("public", "internal" or "private")
|
||||
repositoryVisibility: string
|
||||
// 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
|
||||
// The commit SHA associated with the ref that triggered the action
|
||||
sha: 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 apiClient.getContainerRegistryURL(apiBaseUrl)
|
||||
|
||||
const isEnterprise =
|
||||
!githubServerUrl.includes('https://github.com') &&
|
||||
!githubServerUrl.endsWith('.ghe.com')
|
||||
|
||||
const repoMetadata = await apiClient.getRepositoryMetadata(
|
||||
apiBaseUrl,
|
||||
nameWithOwner,
|
||||
token
|
||||
)
|
||||
|
||||
if (repoMetadata.visibility === '') {
|
||||
throw new Error(`Could not find repository visibility.`)
|
||||
}
|
||||
|
||||
if (repoMetadata.repoId !== repositoryId) {
|
||||
throw new Error(`Repository ID mismatch.`)
|
||||
}
|
||||
|
||||
if (repoMetadata.ownerId !== repositoryOwnerId) {
|
||||
throw new Error(`Repository Owner ID mismatch.`)
|
||||
}
|
||||
|
||||
const repositoryVisibility = repoMetadata.visibility
|
||||
|
||||
return {
|
||||
event,
|
||||
ref,
|
||||
workspaceDir,
|
||||
nameWithOwner,
|
||||
token,
|
||||
apiBaseUrl,
|
||||
runnerTempDir,
|
||||
sha,
|
||||
containerRegistryUrl,
|
||||
isEnterprise,
|
||||
repositoryVisibility,
|
||||
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
|
||||
)
|
||||
}
|
||||
+37
-6
@@ -4,6 +4,7 @@ import * as path from 'path'
|
||||
import * as tar from 'tar'
|
||||
import * as archiver from 'archiver'
|
||||
import * as crypto from 'crypto'
|
||||
import * as simpleGit from 'simple-git'
|
||||
|
||||
export interface FileMetadata {
|
||||
path: string
|
||||
@@ -11,12 +12,12 @@ export interface FileMetadata {
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export function createTempDir(subDirName: string): string {
|
||||
const runnerTempDir: string = process.env.RUNNER_TEMP || ''
|
||||
const tempDir = path.join(runnerTempDir, subDirName)
|
||||
// Simple convenience around creating subdirectories in the same base temporary directory
|
||||
export function createTempDir(tmpDirPath: string, subDirName: string): string {
|
||||
const tempDir = path.join(tmpDirPath, subDirName)
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir)
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
return tempDir
|
||||
@@ -48,7 +49,7 @@ export async function createArchives(
|
||||
})
|
||||
|
||||
archive.pipe(output)
|
||||
archive.directory(distPath, false)
|
||||
archive.directory(distPath, 'action')
|
||||
archive.finalize()
|
||||
})
|
||||
|
||||
@@ -58,7 +59,8 @@ export async function createArchives(
|
||||
{
|
||||
file: tarPath,
|
||||
C: distPath,
|
||||
gzip: true
|
||||
gzip: true,
|
||||
prefix: 'action'
|
||||
},
|
||||
['.']
|
||||
)
|
||||
@@ -113,6 +115,35 @@ export function stageActionFiles(actionDir: string, targetDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the correct SHA is checked out for the tag by inspecting the git metadata in the workspace
|
||||
// and comparing it to the information actions provided us.
|
||||
// Provided ref should be in format refs/tags/<tagname>.
|
||||
export async function ensureTagAndRefCheckedOut(
|
||||
tagRef: string,
|
||||
expectedSha: string,
|
||||
gitDir: string
|
||||
): Promise<void> {
|
||||
if (!tagRef.startsWith('refs/tags/')) {
|
||||
throw new Error(`Tag ref provided is not in expected format.`)
|
||||
}
|
||||
|
||||
const git: simpleGit.SimpleGit = simpleGit.simpleGit(gitDir)
|
||||
|
||||
const tagCommitSha = await git.raw(['rev-parse', '--verify', tagRef])
|
||||
if (tagCommitSha.trim() !== expectedSha) {
|
||||
throw new Error(
|
||||
`The commit associated with the tag ${tagRef} does not match the SHA of the commit provided by the actions context.`
|
||||
)
|
||||
}
|
||||
|
||||
const currentlyCheckedOutSha = await git.revparse(['HEAD'])
|
||||
if (currentlyCheckedOutSha.trim() !== expectedSha) {
|
||||
throw new Error(
|
||||
`The expected commit associated with the tag ${tagRef} is not checked out.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
+4
-4
@@ -52,10 +52,10 @@ export async function publishOCIArtifact(
|
||||
uploadBlobEndpoint,
|
||||
b64Token
|
||||
)
|
||||
case 'application/vnd.github.actions.package.config.v1+json':
|
||||
case 'application/vnd.oci.empty.v1+json':
|
||||
return uploadLayer(
|
||||
layer,
|
||||
{ path: '', size: 0, sha256: layer.digest },
|
||||
{ path: '', size: 2, sha256: layer.digest },
|
||||
registry,
|
||||
checkBlobEndpoint,
|
||||
uploadBlobEndpoint,
|
||||
@@ -143,8 +143,8 @@ async function uploadLayer(
|
||||
|
||||
// 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)
|
||||
if (layer.mediaType === 'application/vnd.oci.empty.v1+json') {
|
||||
data = Buffer.from('{}')
|
||||
} else {
|
||||
data = fsHelper.readFileContents(file.path)
|
||||
}
|
||||
|
||||
+85
-71
@@ -1,10 +1,10 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import semver from 'semver'
|
||||
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 attest from '@actions/attest'
|
||||
import * as cfg from './config'
|
||||
|
||||
/**
|
||||
* The main function for the action.
|
||||
@@ -12,66 +12,49 @@ 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)
|
||||
|
||||
const semanticVersion = parseSourceSemanticVersion()
|
||||
// Ensure the correct SHA is checked out for the tag we're parsing, otherwise the bundled content will be incorrect.
|
||||
await fsHelper.ensureTagAndRefCheckedOut(
|
||||
options.ref,
|
||||
options.sha,
|
||||
options.workspaceDir
|
||||
)
|
||||
|
||||
// Create a temporary directory to stage files for packaging in archives
|
||||
const stagedActionFilesDir = fsHelper.createTempDir('staging')
|
||||
fsHelper.stageActionFiles(workspace, stagedActionFilesDir)
|
||||
const stagedActionFilesDir = fsHelper.createTempDir(
|
||||
options.runnerTempDir,
|
||||
'staging'
|
||||
)
|
||||
fsHelper.stageActionFiles(options.workspaceDir, stagedActionFilesDir)
|
||||
|
||||
// Create a temporary directory to store the archives
|
||||
const archiveDir = fsHelper.createTempDir('archive')
|
||||
const archiveDir = fsHelper.createTempDir(options.runnerTempDir, 'archives')
|
||||
const archives = await fsHelper.createArchives(
|
||||
stagedActionFilesDir,
|
||||
archiveDir
|
||||
)
|
||||
|
||||
const { repoId, ownerId } = await api.getRepositoryMetadata(
|
||||
repository,
|
||||
token
|
||||
)
|
||||
|
||||
const manifest = ociContainer.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,
|
||||
options.token,
|
||||
options.containerRegistryUrl,
|
||||
options.nameWithOwner,
|
||||
semverTag.raw,
|
||||
archives.zipFile,
|
||||
archives.tarFile,
|
||||
manifest
|
||||
@@ -80,40 +63,71 @@ 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)
|
||||
|
||||
// Attestations are not currently supported in GHES.
|
||||
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(opts: cfg.PublishActionOptions): semver.SemVer {
|
||||
const ref = opts.ref
|
||||
|
||||
// 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 {
|
||||
if (!ref.startsWith('refs/tags/')) {
|
||||
throw new Error(`The ref ${ref} is not a valid tag reference.`)
|
||||
}
|
||||
|
||||
const rawTag = ref.replace(/^refs\/tags\//, '')
|
||||
const semverTag = semver.parse(rawTag.replace(/^v/, ''))
|
||||
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,
|
||||
sigstore: 'github',
|
||||
// Attestation storage is only supported for public repositories or repositories which belong to a GitHub Enterprise Cloud account.
|
||||
// See: https://github.com/actions/toolkit/tree/main/packages/attest#storage
|
||||
// Since internal repos can only be owned by Enterprises, we'll use this visibility as a proxy for "owned by a GitHub Enterprise Cloud account."
|
||||
// See: https://docs.github.com/en/enterprise-cloud@latest/repositories/creating-and-managing-repositories/about-repositories#about-internal-repositories
|
||||
skipWrite: options.repositoryVisibility === 'private'
|
||||
})
|
||||
}
|
||||
|
||||
function removePrefix(str: string, prefix: string): string {
|
||||
if (str.startsWith(prefix)) {
|
||||
return str.slice(prefix.length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface Layer {
|
||||
mediaType: string
|
||||
size: number
|
||||
digest: string
|
||||
annotations: { [key: string]: string }
|
||||
annotations?: { [key: string]: string }
|
||||
}
|
||||
|
||||
// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package.
|
||||
@@ -55,13 +55,10 @@ export function createActionPackageManifest(
|
||||
|
||||
function createConfigLayer(): Layer {
|
||||
const configLayer: Layer = {
|
||||
mediaType: 'application/vnd.github.actions.package.config.v1+json',
|
||||
size: 0,
|
||||
mediaType: 'application/vnd.oci.empty.v1+json',
|
||||
size: 2,
|
||||
digest:
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': 'config.json'
|
||||
}
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
}
|
||||
|
||||
return configLayer
|
||||
|
||||
Reference in New Issue
Block a user