initial mvp version

This commit is contained in:
Conor Sloan
2023-11-17 20:04:42 +00:00
committed by Edwin Sirko
parent 5d945681fa
commit d057826061
36 changed files with 90248 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
lib/
dist/
node_modules/
coverage/
+1
View File
@@ -0,0 +1 @@
dist/** -diff linguist-generated=true
+4
View File
@@ -0,0 +1,4 @@
# Repository CODEOWNERS
* @actions/actions-runtime
* @ncalteen
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
labels:
- dependabot
- actions
schedule:
interval: daily
- package-ecosystem: npm
directory: /
labels:
- dependabot
- npm
schedule:
interval: daily
+83
View File
@@ -0,0 +1,83 @@
env:
node: true
es6: true
jest: true
globals:
Atomics: readonly
SharedArrayBuffer: readonly
ignorePatterns:
- '!.*'
- '**/node_modules/.*'
- '**/dist/.*'
- '**/coverage/.*'
- '*.json'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 2023
sourceType: module
project:
- './.github/linters/tsconfig.json'
- './tsconfig.json'
plugins:
- jest
- '@typescript-eslint'
extends:
- eslint:recommended
- plugin:@typescript-eslint/eslint-recommended
- plugin:@typescript-eslint/recommended
- plugin:github/recommended
- plugin:jest/recommended
rules:
{
'camelcase': 'off',
'eslint-comments/no-use': 'off',
'eslint-comments/no-unused-disable': 'off',
'i18n-text/no-en': 'off',
'import/no-namespace': 'off',
'no-console': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
'semi': 'off',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/explicit-member-accessibility':
['error', { 'accessibility': 'no-public' }],
'@typescript-eslint/explicit-function-return-type':
['error', { 'allowExpressions': true }],
'@typescript-eslint/func-call-spacing': ['error', 'never'],
'@typescript-eslint/no-array-constructor': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-for-in-array': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'warn',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/semi': ['error', 'never'],
'@typescript-eslint/space-before-function-paren': 'off',
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unbound-method': 'error'
}
+7
View File
@@ -0,0 +1,7 @@
# Unordered list style
MD004:
style: dash
# Ordered list item prefix
MD029:
style: one
+10
View File
@@ -0,0 +1,10 @@
rules:
document-end: disable
document-start:
level: warning
present: false
line-length:
level: warning
max: 80
allow-non-breakable-words: true
allow-non-breakable-inline-mappings: true
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["../../__tests__/**/*", "../../src/**/*"],
"exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"]
}
+60
View File
@@ -0,0 +1,60 @@
# In TypeScript actions, `dist/index.js` is a special file. When you reference
# an action with `uses:`, `dist/index.js` is the code that will be run. For this
# project, the `dist/index.js` file is generated from other source files through
# the build process. We need to make sure that the checked-in `dist/index.js`
# file matches what is expected from the build.
#
# This workflow will fail if the checked-in `dist/index.js` file does not match
# what is expected from the build.
name: Check dist/
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
check-dist:
name: Check dist/
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- name: Install Dependencies
id: install
run: npm ci
- name: Build dist/ Directory
id: build
run: npm run bundle
- name: Compare Expected and Actual Directories
id: diff
run: |
if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then
echo "Detected uncommitted changes after build. See status below:"
git diff --ignore-space-at-eol --text dist/
exit 1
fi
# If index.js was different than expected, upload the expected version as
# a workflow artifact.
- uses: actions/upload-artifact@v3
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
with:
name: dist
path: dist/
+63
View File
@@ -0,0 +1,63 @@
name: Continuous Integration
on:
pull_request:
push:
branches:
- main
- 'releases/*'
permissions:
contents: read
jobs:
test-typescript:
name: TypeScript Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- name: Install Dependencies
id: npm-ci
run: npm ci
- name: Check Format
id: npm-format-check
run: npm run format:check
- name: Lint
id: npm-lint
run: npm run lint
- name: Test
id: npm-ci-test
run: npm run ci-test
test-action:
name: GitHub Actions Test
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Test Local Action
id: test-action
uses: ./
with:
milliseconds: 1000
- name: Print Output
id: output
run: echo "${{ steps.test-action.outputs.time }}"
+48
View File
@@ -0,0 +1,48 @@
name: CodeQL
on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
- cron: '31 7 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language:
- TypeScript
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
id: initialize
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
source-root: src
- name: Autobuild
id: autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
id: analyze
uses: github/codeql-action/analyze@v2
+43
View File
@@ -0,0 +1,43 @@
name: Lint Code Base
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
statuses: write
jobs:
lint:
name: Lint Code Base
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- name: Install Dependencies
id: install
run: npm ci
- name: Lint Code Base
id: super-linter
uses: super-linter/super-linter/slim@v5
env:
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TYPESCRIPT_DEFAULT_STYLE: prettier
VALIDATE_JSCPD: false
+103
View File
@@ -0,0 +1,103 @@
# Dependency directory
node_modules
# Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# OS metadata
.DS_Store
Thumbs.db
# Ignore built ts files
__tests__/runner/*
# IDE files
.idea
.vscode
*.code-workspace
+1
View File
@@ -0,0 +1 @@
20.6.0
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
coverage/
+16
View File
@@ -0,0 +1,16 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "none",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "avoid",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "lf"
}
+197
View File
@@ -0,0 +1,197 @@
## Initial Setup
After you've cloned the repository to your local machine or codespace, you'll
need to perform some initial setup steps before you can develop your action.
> [!NOTE]
>
> You'll need to have a reasonably modern version of
> [Node.js](https://nodejs.org) handy (20.x or later should work!). If you are
> using a version manager like [`nodenv`](https://github.com/nodenv/nodenv) or
> [`nvm`](https://github.com/nvm-sh/nvm), this template has a `.node-version`
> file at the root of the repository that will be used to automatically switch
> to the correct version when you `cd` into the repository. Additionally, this
> `.node-version` file is used by GitHub Actions in any `actions/setup-node`
> actions.
1. :hammer_and_wrench: Install the dependencies
```bash
npm install
```
1. :building_construction: Package the TypeScript for distribution
```bash
npm run bundle
```
1. :white_check_mark: Run the tests
```bash
$ npm test
PASS ./index.test.js
✓ throws invalid number (3ms)
✓ wait 500 ms (504ms)
✓ test runs (95ms)
...
```
## Update the Action Metadata
The [`action.yml`](action.yml) file defines metadata about your action, such as
input(s) and output(s). For details about this file, see
[Metadata syntax for GitHub Actions](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions).
When you copy this repository, update `action.yml` with the name, description,
inputs, and outputs for your action.
## Update the Action Code
The [`src/`](./src/) directory is the heart of your action! This contains the
source code that will be run when your action is invoked. You can replace the
contents of this directory with your own code.
There are a few things to keep in mind when writing your action code:
- Most GitHub Actions toolkit and CI/CD operations are processed asynchronously.
In `main.ts`, you will see that the action is run in an `async` function.
```javascript
import * as core from '@actions/core'
//...
async function run() {
try {
//...
} catch (error) {
core.setFailed(error.message)
}
}
```
For more information about the GitHub Actions toolkit, see the
[documentation](https://github.com/actions/toolkit/blob/master/README.md).
So, what are you waiting for? Go ahead and start customizing your action!
1. Create a new branch
```bash
git checkout -b releases/v1
```
1. Replace the contents of `src/` with your action code
1. Add tests to `__tests__/` for your source code
1. Format, test, and build the action
```bash
npm run all
```
> [!WARNING]
>
> This step is important! It will run [`ncc`](https://github.com/vercel/ncc)
> to build the final JavaScript action code with all dependencies included.
> If you do not run this step, your action will not work correctly when it is
> used in a workflow. This step also includes the `--license` option for
> `ncc`, which will create a license file for all of the production node
> modules used in your project.
1. Commit your changes
```bash
git add .
git commit -m "My first action is ready!"
```
1. Push them to your repository
```bash
git push -u origin releases/v1
```
1. Create a pull request and get feedback on your action
1. Merge the pull request into the `main` branch
Your action is now published! :rocket:
For information about versioning your action, see
[Versioning](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md)
in the GitHub Actions toolkit.
## Validate the Action
You can now validate the action by referencing it in a workflow file. For
example, [`ci.yml`](./.github/workflows/ci.yml) demonstrates how to reference an
action in the same repository.
```yaml
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Test Local Action
id: test-action
uses: ./
with:
milliseconds: 1000
- name: Print Output
id: output
run: echo "${{ steps.test-action.outputs.time }}"
```
For example workflow runs, check out the
[Actions tab](https://github.com/actions/typescript-action/actions)! :rocket:
## Usage
After testing, you can create version tag(s) that developers can use to
reference different stable versions of your action. For more information, see
[Versioning](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md)
in the GitHub Actions toolkit.
To include the action in a workflow in another repository, you can use the
`uses` syntax with the `@` symbol to reference a specific branch, tag, or commit
hash.
```yaml
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Test Local Action
id: test-action
uses: actions/typescript-action@v1 # Commit with the `v1` tag
with:
milliseconds: 1000
- name: Print Output
id: output
run: echo "${{ steps.test-action.outputs.time }}"
```
## Publishing a new release
This project includes a helper script designed to streamline the process of
tagging and pushing new releases for GitHub Actions.
GitHub Actions allows users to select a specific version of the action to use,
based on release tags. Our script simplifies this process by performing the
following steps:
1. **Retrieving the latest release tag:** The script starts by fetching the most
recent release tag by looking at the local data available in your repository.
1. **Prompting for a new release tag:** The user is then prompted to enter a new
release tag. To assist with this, the script displays the latest release tag
and provides a regular expression to validate the format of the new tag.
1. **Tagging the new release:** Once a valid new tag is entered, the script tags
the new release.
1. **Pushing the new tag to the remote:** Finally, the script pushes the new tag
to the remote repository. From here, you will need to create a new release in
GitHub and users can easily reference the new tag in their workflows.
+60
View File
@@ -0,0 +1,60 @@
<p align="center">
<a href="https://github.com/ruchika-org/package-action"></a>
</p>
# Package and Publish
This action packages your action repository as OCI artifacts and publishes it to [GHCR](ghcr.io), so your action can then be consumed as a package to make the actions ecosystem more secure.
The whole action repository is packaged by default. Set `path` input to specify which path you want to package if you want only a few folders (for eg. dist) to be packaged.
Make sure you use the [Starter Workflow] (https://github.com/actions-on-packages/.github) (TODO) to run the action and ensure you have the release trigger in the workflow where you use this action.
# Usage
<!-- start usage -->
```yaml
on:
release:
- uses: immutable-actions/publish-action-package@1.0.1
with:
# Personal access token (PAT) or GITHUB_TOKEN with write:package scope used to upload the package to GHCR. The GITHUB_TOKEN is taken by default.
#
# We recommend using a service account with the least permissions necessary. Also
# when generating a new PAT, select the least scopes necessary.
#
# [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
#
# Default: ${{ github.token }}
token: ''
# 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/'
```
<!-- end usage -->
# License
The scripts and documentation in this project are released under the [MIT License](LICENSE)
# [Internal] Differences from previous implementation
This is a new implementation of an Action which publishes a given release to ghcr.io (GitHub Packages). It will eventually be moved to https://github.com/actions-on-packages/package-action and replace the existing implementation.
The key differences are:
* This Action goes directly to GitHub Packages rather than using an API endpoint to pass a bundle to.
* This Action uses Node.js libraries to create both a `zip` and `tar.gz` of the content as layers.
* This Action creates and publishes the OCI manifest which houses those archives, which was previously done on the backend.
* This Action has the goal of generating provenance attestations for any release that is created.
* This Action parses and validates that the release tag which triggered it is in a valid semver format, either `1.0.3-prerelease` or `v1.0.0-prerelease`.
+172
View File
@@ -0,0 +1,172 @@
import * as fsHelper from '../src/fs-helper'
import * as fs from 'fs'
import * as os from 'os'
import { execSync } from 'child_process'
const fileContent = 'This is the content of the file'
describe('createArchives', () => {
let tmpDir: string
let distDir: string
beforeAll(() => {
distDir = fsHelper.createTempDir()
fs.writeFileSync(`${distDir}/hello.txt`, fileContent)
fs.writeFileSync(`${distDir}/world.txt`, fileContent)
})
beforeEach(() => {
tmpDir = fsHelper.createTempDir()
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true })
})
afterAll(() => {
fs.rmSync(distDir, { recursive: true })
})
it('creates archives', async () => {
const { zipFile, tarFile } = await fsHelper.createArchives(distDir, tmpDir)
expect(zipFile.path).toEqual(`${tmpDir}/archive.zip`)
expect(fs.existsSync(zipFile.path)).toEqual(true)
expect(fs.statSync(zipFile.path).size).toBeGreaterThan(0)
expect(zipFile.sha256.startsWith('sha256:')).toEqual(true)
expect(tarFile.path).toEqual(`${tmpDir}/archive.tar.gz`)
expect(fs.existsSync(tarFile.path)).toEqual(true)
expect(fs.statSync(tarFile.path).size).toBeGreaterThan(0)
expect(tarFile.sha256.startsWith('sha256:')).toEqual(true)
// Validate the hashes by comparing to the output of the system's hashing utility
let zipSHA = zipFile.sha256.substring(7) // remove "sha256:" prefix
let tarSHA = tarFile.sha256.substring(7) // remove "sha256:" prefix
// sha256 hash is 64 characters long
expect(zipSHA).toHaveLength(64)
expect(tarSHA).toHaveLength(64)
let systemZipHash: string
let systemTarHash: string
if (os.platform() === 'win32') {
// Windows
systemZipHash = execSync(`CertUtil -hashfile ${zipFile.path} SHA256`)
.toString()
.split(' ')[1]
.trim()
systemTarHash = execSync(`CertUtil -hashfile ${tarFile.path} SHA256`)
.toString()
.split(' ')[1]
.trim()
} else {
// Unix-based systems
systemZipHash = execSync(`shasum -a 256 ${zipFile.path}`)
.toString()
.split(' ')[0]
systemTarHash = execSync(`shasum -a 256 ${tarFile.path}`)
.toString()
.split(' ')[0]
}
expect(zipSHA).toEqual(systemZipHash)
expect(tarSHA).toEqual(systemTarHash)
})
// TODO: Test the failure cases
})
describe('createTempDir', () => {
let dirs: string[] = []
beforeEach(() => {
dirs = []
})
afterEach(() => {
dirs.forEach(dir => {
fs.rmSync(dir, { recursive: true })
})
})
it('creates a temporary directory in the OS temporary dir', () => {
let tmpDir = fsHelper.createTempDir()
dirs.push(tmpDir)
expect(fs.existsSync(tmpDir)).toEqual(true)
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
expect(tmpDir.startsWith(os.tmpdir())).toEqual(true)
})
it('creates a unique temporary directory', () => {
let dir1 = fsHelper.createTempDir()
dirs.push(dir1)
let dir2 = fsHelper.createTempDir()
dirs.push(dir2)
expect(dir1).not.toEqual(dir2)
})
})
describe('isDirectory', () => {
let dir: string
beforeEach(() => {
dir = fsHelper.createTempDir()
})
afterEach(() => {
fs.rmSync(dir, { recursive: true })
})
it('returns true if the path is a directory', () => {
expect(fsHelper.isDirectory(dir)).toEqual(true)
})
it('returns false if the path is not a directory', () => {
const tempFile = `${dir}/file.txt`
fs.writeFileSync(tempFile, fileContent)
expect(fsHelper.isDirectory(tempFile)).toEqual(false)
})
})
describe('readFileContents', () => {
let dir: string
beforeEach(() => {
dir = fsHelper.createTempDir()
})
afterEach(() => {
fs.rmSync(dir, { recursive: true })
})
it('reads the contents of a file', () => {
const tempFile = `${dir}/file.txt`
fs.writeFileSync(tempFile, fileContent)
expect(fsHelper.readFileContents(tempFile).toString()).toEqual(fileContent)
})
})
describe('removeDir', () => {
let dir: string
beforeEach(() => {
dir = fsHelper.createTempDir()
})
afterEach(() => {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true })
}
})
it('removes a directory', () => {
fsHelper.removeDir(dir)
expect(fs.existsSync(dir)).toEqual(false)
})
})
+480
View File
@@ -0,0 +1,480 @@
import { publishOCIArtifact } from '../src/ghcr-client'
import axios, { AxiosRequestConfig } from 'axios'
import * as fs from 'fs'
import * as fsHelper from '../src/fs-helper'
import * as ociContainer from '../src/oci-container'
// Mocks
let fsReadFileSyncMock: jest.SpyInstance
let axiosPostMock: jest.SpyInstance
let axiosPutMock: jest.SpyInstance
let axiosHeadMock: jest.SpyInstance
const token = '1234567890'
const registry = new URL('https://ghcr.io')
const repository = 'test/test'
const releaseId = '1234567890'
const semver = '1.0.0'
const zipFile: fsHelper.FileMetadata = {
path: 'test-repo-1.0.0.zip',
size: 100,
sha256: '1234567890'
}
const tarFile: fsHelper.FileMetadata = {
path: 'test-repo-1.0.0.tar.gz',
size: 100,
sha256: '1234567890'
}
const testManifest: ociContainer.Manifest = {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
artifactType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.github.actions.package.config.v1+json',
size: 0,
digest:
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
annotations: {
'org.opencontainers.image.title': 'config.json'
}
},
layers: [
{
mediaType: 'application/vnd.github.actions.package.config.v1+json',
size: 0,
digest:
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
annotations: {
'org.opencontainers.image.title': 'config.json'
}
},
{
mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip',
size: 100,
digest: 'sha256:1234567890',
annotations: {
'org.opencontainers.image.title': 'test-repo-1.0.0.tar.gz'
}
},
{
mediaType: 'application/vnd.github.actions.package.layer.v1.zip',
size: 100,
digest: 'sha256:1234567890',
annotations: {
'org.opencontainers.image.title': 'test-repo-1.0.0.zip'
}
}
],
annotations: {
'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z',
'action.tar.gz.digest': '1234567890',
'action.zip.digest': '1234567890',
'com.github.package.type': 'actions_oci_pkg'
}
}
describe('publishOCIArtifact', () => {
beforeEach(() => {
jest.clearAllMocks()
fsReadFileSyncMock = jest
.spyOn(fsHelper, 'readFileContents')
.mockImplementation()
axiosPostMock = jest.spyOn(axios, 'post').mockImplementation()
axiosPutMock = jest.spyOn(axios, 'put').mockImplementation()
axiosHeadMock = jest.spyOn(axios, 'head').mockImplementation()
})
it('publishes layer blobs & then a manifest to the provided registry', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(
async (url: string, config: AxiosRequestConfig) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
}
)
// Simulate successful initiation of uploads for all blobs & return location
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
}
}
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(async path => {
return Buffer.from('test')
})
// Simulate successful upload of all blobs & then the manifest
axiosPutMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(201, url, config)
return {
status: 201
}
})
await publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
expect(axiosHeadMock).toHaveBeenCalledTimes(3)
expect(axiosPostMock).toHaveBeenCalledTimes(3)
expect(axiosPutMock).toHaveBeenCalledTimes(4)
// TODO: Check that the base64 encoded token is sent in the Authorization header
})
it('skips uploading layer blobs that already exist', async () => {
// Simulate all blobs already existing
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(200, url, config)
return {
status: 200
}
})
// Simulate successful initiation of uploads for all blobs & return location
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
}
}
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(async path => {
return Buffer.from('test')
})
// Simulate successful upload of all blobs & then the manifest
axiosPutMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(201, url, config)
return {
status: 201
}
})
await publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
// We should only head all the blobs and then upload the manifest
expect(axiosHeadMock).toHaveBeenCalledTimes(3)
expect(axiosPostMock).toHaveBeenCalledTimes(0)
expect(axiosPutMock).toHaveBeenCalledTimes(1)
})
it('throws an error if checking for existing blobs fails', async () => {
// Simulate failed response code
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(503, url, config)
return {
status: 503
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from blob check for layer/)
})
it('throws an error if initiating layer upload fails', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
})
// Simulate failed initiation of uploads
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(503, url, config)
return {
status: 503
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow('Unexpected response from POST upload 503')
})
it('throws an error if the upload endpoint does not return a location', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
})
// Simulate successful response code but no location header
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {}
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^No location header in response from upload post/)
})
it('throws an error if a layer upload fails', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
})
// Simulate successful initiation of uploads for all blobs & return location
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
}
}
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(async path => {
return Buffer.from('test')
})
// Simulate fails upload of all blobs & manifest
axiosPutMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(500, url, config)
return {
status: 500
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from PUT upload 500/)
})
it('throws an error if a manifest upload fails', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
})
// Simulate successful initiation of uploads for all blobs & return location
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
}
}
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(async path => {
return Buffer.from('test')
})
// Simulate successful upload of all blobs & then the manifest
axiosPutMock.mockImplementation(async (url, data, config) => {
if (url.includes('manifest')) {
validateRequestConfig(500, url, config)
return {
status: 500
}
}
validateRequestConfig(201, url, config)
return {
status: 201
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow(/^Unexpected response from PUT manifest 500/)
})
it('throws an error if reading one of the files fails', async () => {
// Simulate none of the blobs existing currently
axiosHeadMock.mockImplementation(async (url, config) => {
validateRequestConfig(404, url, config)
return {
status: 404
}
})
// Simulate successful initiation of uploads for all blobs & return location
axiosPostMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(202, url, config)
return {
status: 202,
headers: {
location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890'
}
}
})
// Simulate successful reading of all the files
fsReadFileSyncMock.mockImplementation(path => {
throw new Error('failed to read a file: test')
})
// Simulate successful upload of all blobs & then the manifest
axiosPutMock.mockImplementation(async (url, data, config) => {
validateRequestConfig(201, url, config)
return {
status: 201
}
})
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow('failed to read a file: test')
})
it('throws an error if one of the layers has the wrong media type', async () => {
let modifiedTestManifest = testManifest
modifiedTestManifest.layers[0].mediaType = 'application/json'
expect(
publishOCIArtifact(
token,
registry,
repository,
releaseId,
semver,
zipFile,
tarFile,
testManifest
)
).rejects.toThrow('Unknown media type application/json')
})
})
// We expect all axios calls to have auth headers set and to not intercept any status codes so we can handle them.
// This function verifies that given an axios request config.
function validateRequestConfig(
status: number,
url: string,
config: AxiosRequestConfig
) {
// Basic URL checks
expect(url).toBeDefined()
if (!url.startsWith(registry.toString())) {
console.log(url)
}
expect(url.startsWith(registry.toString())).toBe(true)
// Config checks
expect(config).toBeDefined()
expect(config.validateStatus).toBeDefined()
if (config.validateStatus) {
// Check axios will not intercept this status
expect(config.validateStatus(status)).toBe(true)
}
expect(config.headers).toBeDefined()
if (config.headers) {
// Check the auth header is set
expect(config.headers.Authorization).toBeDefined()
// Check the auth header is the base 64 encoded token
expect(config.headers.Authorization).toBe(
`Bearer ${Buffer.from(token).toString('base64')}`
)
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Unit tests for the action's entrypoint, src/index.ts
*/
import * as main from '../src/main'
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
describe('index', () => {
it('calls run when imported', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
expect(runMock).toHaveBeenCalled()
})
})
+250
View File
@@ -0,0 +1,250 @@
/**
* Unit tests for the action's main functionality, src/main.ts
*
* These should be run as if the action was called from a workflow.
* Specifically, the inputs listed in `action.yml` should be set as environment
* variables following the pattern `INPUT_<INPUT_NAME>`.
*/
import * as core from '@actions/core'
import * as main from '../src/main'
import * as github from '@actions/github'
import * as fsHelper from '../src/fs-helper'
import * as ociContainer from '../src/oci-container'
import * as ghcr from '../src/ghcr-client'
// Mock the action's main function
const runMock = jest.spyOn(main, 'run')
// Mock the GitHub Actions core library
let debugMock: jest.SpyInstance
let errorMock: jest.SpyInstance
let getInputMock: jest.SpyInstance
let setFailedMock: jest.SpyInstance
let setOutputMock: jest.SpyInstance
// Mock the filesystem helper
let createTempDirMock: jest.SpyInstance
let isDirectoryMock: jest.SpyInstance
let createArchivesMock: jest.SpyInstance
let removeDirMock: jest.SpyInstance
// Mock the GHCR Client
let publishOCIArtifactMock: jest.SpyInstance
describe('action', () => {
beforeEach(() => {
jest.clearAllMocks()
// Core mocks
debugMock = jest.spyOn(core, 'debug').mockImplementation()
errorMock = jest.spyOn(core, 'error').mockImplementation()
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()
// FS mocks
createTempDirMock = jest
.spyOn(fsHelper, 'createTempDir')
.mockImplementation()
isDirectoryMock = jest.spyOn(fsHelper, 'isDirectory').mockImplementation()
createArchivesMock = jest
.spyOn(fsHelper, 'createArchives')
.mockImplementation()
removeDirMock = jest.spyOn(fsHelper, 'removeDir').mockImplementation()
// GHCR Client mocks
publishOCIArtifactMock = jest
.spyOn(ghcr, 'publishOCIArtifact')
.mockImplementation()
})
it('fails if no repository found', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = ''
// Run the action
await main.run()
// Check the results
expect(setFailedMock).toHaveBeenCalledWith('Could not find Repository.')
})
it('fails if event is not a release', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = 'test/test'
github.context.eventName = 'push'
// Run the action
await main.run()
// Check the results
expect(setFailedMock).toHaveBeenCalledWith(
'Please ensure you have the workflow trigger as release.'
)
})
it('fails if release tag is not a valid semantic version', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = 'test/test'
github.context.eventName = 'release'
github.context.payload = {
release: {
id: '123',
tag_name: 'invalid-tag'
}
}
// Run the action
await main.run()
// Check the results
expect(setFailedMock).toHaveBeenCalledWith(
'invalid-tag is not a valid semantic version, and so cannot be uploaded as an Immutable Action.'
)
})
it('fails if path is not a directory', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = 'test/test'
github.context.eventName = 'release'
github.context.payload = {
release: {
id: '123',
tag_name: 'v1.0.0'
}
}
getInputMock.mockImplementation((name: string) => {
if (name === 'path') {
return 'not-a-directory'
} else if (name === 'registry') {
return 'https://ghcr.io'
}
return ''
})
isDirectoryMock.mockImplementation(() => false)
// Run the action
await main.run()
// Check the results
expect(isDirectoryMock).toHaveBeenCalledWith('not-a-directory')
expect(setFailedMock).toHaveBeenCalledWith(
'The path not-a-directory is not a directory. Please provide a path to a valid directory.'
)
})
it('fails if an error is thrown from dependent code', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = 'test/test'
github.context.eventName = 'release'
github.context.payload = {
release: {
id: '123',
tag_name: 'v1.0.0'
}
}
getInputMock.mockImplementation((name: string) => {
if (name === 'path') {
return 'directory'
} else if (name === 'registry') {
return 'https://ghcr.io'
}
return ''
})
isDirectoryMock.mockImplementation(() => true)
createTempDirMock.mockImplementation(() => '/tmp/test')
createArchivesMock.mockImplementation(() => {
throw new Error('Something went wrong')
})
// Run the action
await main.run()
// Check the results
expect(isDirectoryMock).toHaveBeenCalledWith('directory')
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
// Expect the files to be cleaned up
expect(removeDirMock).toHaveBeenCalledWith('/tmp/test')
})
it('uploads and returns the manifest & package URL if all succeeds', async () => {
// Mock the environment
process.env.GITHUB_REPOSITORY = 'test/test'
github.context.eventName = 'release'
github.context.payload = {
release: {
id: '123',
tag_name: 'v1.0.0'
}
}
getInputMock.mockImplementation((name: string) => {
if (name === 'path') {
return 'test'
} else if (name === 'registry') {
return 'https://ghcr.io'
}
return ''
})
isDirectoryMock.mockImplementation(() => true)
createTempDirMock.mockImplementation(() => '/tmp/test')
createArchivesMock.mockImplementation(() => {
return {
zipFile: {
path: 'test',
size: 5,
sha256: '123'
},
tarFile: {
path: 'test2',
size: 52,
sha256: '1234'
}
}
})
publishOCIArtifactMock.mockImplementation(() => {
return new URL('https://ghcr.io/v2/test/test:1.0.0')
})
// Run the action
await main.run()
expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1)
// Check manifest is in output
expect(setOutputMock).toHaveBeenCalledWith(
'package-url',
'https://ghcr.io/v2/test/test:1.0.0'
)
expect(setOutputMock).toHaveBeenCalledWith(
'package-manifest',
expect.any(String)
)
// Validate the manifest
const manifest = JSON.parse(setOutputMock.mock.calls[1][1])
expect(manifest.mediaType).toEqual(
'application/vnd.oci.image.manifest.v1+json'
)
expect(manifest.config.mediaType).toEqual(
'application/vnd.github.actions.package.config.v1+json'
)
expect(manifest.layers.length).toEqual(3)
expect(manifest.annotations['com.github.package.type']).toEqual(
'actions_oci_pkg'
)
// Expect the files to be cleaned up
expect(removeDirMock).toHaveBeenCalledWith('/tmp/test')
})
})
+85
View File
@@ -0,0 +1,85 @@
import { createActionPackageManifest } from '../src/oci-container'
import { FileMetadata } from '../src/fs-helper'
describe('createActionPackageManigest', () => {
it('creates a manifest containing the provided information', () => {
let date = new Date()
let repo = 'test-repo'
let version = '1.0.0'
let tarFile: FileMetadata = {
path: '/test/test/test',
sha256: '1234567890',
size: 100
}
let zipFile: FileMetadata = {
path: '/test/test/test',
sha256: '1234567890',
size: 100
}
let expectedJSON: String = `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.github.actions.package.config.v1+json",
"size": 0,
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"annotations": {
"org.opencontainers.image.title":"config.json"
}
},
"layers":[
{
"mediaType":"application/vnd.github.actions.package.config.v1+json",
"size":0,
"digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"annotations":{
"org.opencontainers.image.title":"config.json"
}
},
{
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
"size":${tarFile.size},
"digest":"${tarFile.sha256}",
"annotations":{
"org.opencontainers.image.title":"${repo}-${version}.tar.gz"
}
},
{
"mediaType":"application/vnd.github.actions.package.layer.v1.zip",
"size":${tarFile.size},
"digest":"${tarFile.sha256}",
"annotations":{
"org.opencontainers.image.title":"${repo}-${version}.zip"
}
}
],
"annotations":{
"org.opencontainers.image.created":"${date.toISOString()}",
"action.tar.gz.digest":"${tarFile.sha256}",
"action.zip.digest":"${zipFile.sha256}",
"com.github.package.type":"actions_oci_pkg"
}
}`
let manifest = createActionPackageManifest(
{
path: 'test.tar.gz',
size: 100,
sha256: '1234567890'
},
{
path: 'test.zip',
size: 100,
sha256: '1234567890'
},
'test-repo',
'1.0.0',
date
)
let manifestJSON = JSON.stringify(manifest)
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
})
})
+31
View File
@@ -0,0 +1,31 @@
name: 'Package and Publish'
description: 'Publish actions as OCI artifacts to GHCR'
# TODO: Add your action's branding here. This will appear on the GitHub Marketplace.
branding:
icon: 'heart'
color: 'red'
inputs:
token:
required: true
description: >
GITHUB_TOKEN having the write:package scope to publish an action package to GHCR.
default: ${{ github.token }}
path:
required: false
description: The work directory or path to be tar archived and uploaded as OCI Artifact layer.
default: '.'
registry:
required: false
description: The registry to publish the action package to.
default: https://ghcr.io/ # TODO: this should perhaps be fetched from GitHub API.
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'
package-manifest:
description: 'The package manifest of the published package in JSON format'
runs:
using: 'node16'
main: 'dist/index.js'
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</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="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" 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="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Generated Vendored
+77667
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+2303
View File
File diff suppressed because it is too large Load Diff
+7830
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
{
"name": "typescript-action",
"description": "GitHub Actions TypeScript template",
"version": "0.0.0",
"author": "",
"private": true,
"homepage": "https://github.com/actions/typescript-action",
"repository": {
"type": "git",
"url": "git+https://github.com/actions/typescript-action.git"
},
"bugs": {
"url": "https://github.com/actions/typescript-action/issues"
},
"keywords": [
"actions",
"node",
"setup"
],
"exports": {
".": "./dist/index.js"
},
"engines": {
"node": ">=20"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
"ci-test": "jest",
"coverage": "make-coverage-badge --output-path ./badges/coverage.svg",
"format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts",
"lint": "npx eslint . -c ./.github/linters/.eslintrc.yml",
"package": "ncc build src/index.ts --license licenses.txt",
"package:watch": "npm run package -- --watch",
"test": "jest",
"all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package"
},
"license": "MIT",
"jest": {
"preset": "ts-jest",
"verbose": true,
"clearMocks": true,
"testEnvironment": "node",
"moduleFileExtensions": [
"js",
"ts"
],
"testMatch": [
"**/*.test.ts"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"transform": {
"^.+\\.ts$": "ts-jest"
},
"coverageReporters": [
"json-summary",
"text",
"lcov"
],
"collectCoverage": true,
"collectCoverageFrom": [
"./src/**"
]
},
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"archiver": "^6.0.1",
"axios": "^1.6.1",
"axios-debug-log": "^1.0.0",
"tar": "^6.2.0"
},
"devDependencies": {
"@types/archiver": "^6.0.1",
"@types/jest": "^29.5.8",
"@types/node": "^20.9.0",
"@types/tar": "^6.1.8",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vercel/ncc": "^0.38.1",
"eslint": "^8.53.0",
"eslint-plugin-github": "^4.10.1",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-jsonc": "^2.10.0",
"eslint-plugin-prettier": "^5.0.1",
"jest": "^29.7.0",
"js-yaml": "^4.1.0",
"make-coverage-badge": "^1.2.0",
"prettier": "^3.0.3",
"prettier-eslint": "^16.1.2",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
}
}
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# About:
# This is a helper script to tag and push a new release.
# GitHub Actions use release tags to allow users to select a specific version of the action to use.
# This script will do the following:
# 1. Get the latest release tag
# 2. Prompt the user for a new release tag (while displaying the latest release tag, and a regex to validate the new tag)
# 3. Tag the new release
# 4. Push the new tag to the remote
# Usage:
# script/release
# COLORS
OFF='\033[0m'
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
latest_tag=$(git describe --tags "$(git rev-list --tags --max-count=1)")
# if the latest_tag is empty, then there are no tags - let the user know
if [[ -z "$latest_tag" ]]; then
echo -e "No tags found (yet) - continue to create your first tag and push it"
latest_tag="[unknown]"
fi
echo -e "The latest release tag is: ${BLUE}${latest_tag}${OFF}"
read -r -p 'New Release Tag (vX.X.X format): ' new_tag
tag_regex='v[0-9]+\.[0-9]+\.[0-9]+$'
if echo "$new_tag" | grep -q -E "$tag_regex"; then
echo -e "Tag: ${BLUE}$new_tag${OFF} is valid"
else
echo -e "Tag: ${BLUE}$new_tag${OFF} is ${RED}not valid${OFF} (must be in vX.X.X format)"
exit 1
fi
git tag -a "$new_tag" -m "$new_tag Release"
echo -e "${GREEN}OK${OFF} - Tagged: $new_tag"
git push --tags
echo -e "${GREEN}OK${OFF} - Tags pushed to remote!"
echo -e "${GREEN}DONE${OFF}"
+110
View File
@@ -0,0 +1,110 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as path from 'path'
import * as tar from 'tar'
import * as archiver from 'archiver'
import * as crypto from 'crypto'
import * as os from 'os'
import * as zlib from 'zlib'
export function createTempDir() {
const randomDirName = crypto.randomBytes(4).toString('hex')
const tempDir = path.join(os.tmpdir(), randomDirName)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir)
}
return tempDir
}
export function removeDir(dir: string) {
fs.rmSync(dir, { recursive: true })
}
export interface FileMetadata {
path: string
size: number
sha256: string
}
// 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 = createTempDir()
): Promise<{ zipFile: FileMetadata; tarFile: FileMetadata }> {
const zipPath = path.join(archiveTargetPath, `archive.zip`)
const tarPath = path.join(archiveTargetPath, `archive.tar.gz`)
return Promise.all([
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()
}),
new Promise<FileMetadata>((resolve, reject) => {
const tarStream = tar
.c(
{
file: tarPath,
C: distPath, // Change to the source directory for relative paths (TODO)
gzip: true
},
['.']
)
.then(() => {
resolve(fileMetadata(tarPath))
})
.catch((err: Error) => reject(err))
})
]).then(([zipFile, tarFile]) => ({ zipFile, tarFile }))
}
export function isDirectory(path: string): boolean {
return fs.existsSync(path) && fs.lstatSync(path).isDirectory()
}
export function readFileContents(path: string): Buffer {
return fs.readFileSync(path)
}
// Converts a file path to a filemetadata object by querying the fs for relevant metadata.
async function fileMetadata(path: string): Promise<FileMetadata> {
const stats = fs.statSync(path)
const size = stats.size
const hash = crypto.createHash('sha256')
const fileStream = fs.createReadStream(path)
return new Promise((resolve, reject) => {
fileStream.on('data', data => {
hash.update(data)
})
fileStream.on('end', () => {
const sha256 = hash.digest('hex')
resolve({
path: path,
size: size,
sha256: 'sha256:' + sha256
})
})
fileStream.on('error', err => {
reject(err)
})
})
}
+214
View File
@@ -0,0 +1,214 @@
import * as core from '@actions/core'
import { FileMetadata } from './fs-helper'
import * as ociContainer from './oci-container'
import axios from 'axios'
import { fieldEnds } from 'tar'
import * as fs from 'fs'
import { promiseHooks } from 'v8'
import * as fsHelper from './fs-helper'
import axiosDebugLog from 'axios-debug-log'
// Publish the OCI artifact and return the URL where it can be downloaded
export async function publishOCIArtifact(
token: string,
registry: URL,
repository: string,
releaseId: string,
semver: string,
zipFile: FileMetadata,
tarFile: FileMetadata,
manifest: ociContainer.Manifest,
debugRequests: boolean = false
): Promise<URL> {
if (debugRequests) {
configureRequestDebugLogging()
}
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}".`
)
let layerUploads: Promise<void>[] = manifest.layers.map(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)
await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token)
return new URL(`${repository}:${semver}`, registry)
}
async function uploadLayer(
layer: ociContainer.Layer,
file: FileMetadata,
registryURL: URL,
checkBlobEndpoint: string,
uploadBlobEndpoint: string,
b64Token: string
): Promise<void> {
const checkExistsResponse = await axios.head(
checkBlobEndpoint + layer.digest,
{
headers: {
Authorization: `Bearer ${b64Token}`
},
validateStatus: function (status: number) {
return true // Allow non 2xx responses
}
}
)
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 axios.post(uploadBlobEndpoint, layer, {
headers: {
Authorization: `Bearer ${b64Token}`
},
validateStatus: function (status: number) {
return true // Allow non 2xx responses
}
})
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['location']
if (locationResponseHeader == undefined) {
throw new Error(
`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`
)
}
let pathname = (locationResponseHeader as string) + '?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
var data: Buffer
if (file.size === 0) {
data = Buffer.alloc(0)
} else {
data = fsHelper.readFileContents(file.path)
}
const putResponse = await axios.put(uploadBlobUrl, data, {
headers: {
Authorization: `Bearer ${b64Token}`,
'Content-Type': 'application/octet-stream',
'Accept-Encoding': 'gzip', // TODO: What about for the config layer?
'Content-Length': layer.size.toString()
},
validateStatus: function (status: number) {
return true // Allow non 2xx responses
}
})
if (putResponse.status != 201) {
throw new Error(
`Unexpected response from PUT upload ${putResponse.status} for layer ${layer.digest}`
)
}
}
async function uploadManifest(
manifestJSON: string,
manifestEndpoint: string,
b64Token: string
): Promise<void> {
core.info(`Uploading manifest to ${manifestEndpoint}.`)
const putResponse = await axios.put(manifestEndpoint, manifestJSON, {
headers: {
Authorization: `Bearer ${b64Token}`,
'Content-Type': 'application/vnd.oci.image.manifest.v1+json'
},
validateStatus: function (status: number) {
return true // Allow non 2xx responses
}
})
if (putResponse.status != 201) {
throw new Error(
`Unexpected response from PUT manifest ${putResponse.status}`
)
}
}
function configureRequestDebugLogging() {
axiosDebugLog({
request: function (debug, config) {
core.debug(`Request with ${config}`)
},
response: function (debug, response) {
core.debug(`Response with ${response}`)
},
error: function (debug, error) {
core.debug(`Error with ${error}`)
}
})
}
+7
View File
@@ -0,0 +1,7 @@
/**
* The entrypoint for the action.
*/
import { run } from './main'
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run()
+92
View File
@@ -0,0 +1,92 @@
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 semver from 'semver'
import { url } from 'inspector'
/**
* The main function for the action.
* @returns {Promise<void>} Resolves when the action is complete.
*/
export async function run(): Promise<void> {
let tmpDir: string = ''
try {
// Parse and validate Actions execution context, including the repository name, release name and event type
const repository: string = process.env.GITHUB_REPOSITORY || ''
if (repository === '') {
core.setFailed(`Could not find Repository.`)
return
}
if (github.context.eventName !== 'release') {
core.setFailed('Please ensure you have the workflow trigger as release.')
return
}
const releaseId: string = github.context.payload.release.id
const releaseTag: string = github.context.payload.release.tag_name
// Strip any leading 'v' from the tag in case the release format is e.g. 'v1.0.0' as recommended by GitHub docs
// https://docs.github.com/en/actions/creating-actions/releasing-and-maintaining-actions
let targetVersion = semver.parse(releaseTag.replace(/^v/, ''))
if (!targetVersion) {
core.setFailed(
`${releaseTag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.`
)
return
}
// Gather & validate user inputs
const token: string = core.getInput('token')
const path: string = core.getInput('path')
const registryURL: URL = new URL(core.getInput('registry')) // TODO: Should this be dynamic? Maybe an API endpoint to grab the registry for GHES/proxima purposes.
if (!fsHelper.isDirectory(path)) {
core.setFailed(
`The path ${path} is not a directory. Please provide a path to a valid directory.`
)
return
}
// Create a temporary directory to store the archives
tmpDir = fsHelper.createTempDir()
const archives = await fsHelper.createArchives(path)
const manifest = ociContainer.createActionPackageManifest(
archives.tarFile,
archives.zipFile,
repository,
targetVersion.raw,
new Date()
)
let packageURL = await ghcr.publishOCIArtifact(
token,
registryURL,
repository,
releaseId.toString(),
targetVersion.raw,
archives.zipFile,
archives.tarFile,
manifest,
true
)
core.setOutput('package-url', packageURL.toString())
// TODO: We might need to do some attestation stuff here, but unsure how to integrate it yet.
// We might need to return the manifest JSON from the Action and link it to another action,
// or we might be able to make an API call here. It's unclear at this point.
core.setOutput('package-manifest', JSON.stringify(manifest))
} catch (error) {
// Fail the workflow run if an error occurs
if (error instanceof Error) core.setFailed(error.message)
} finally {
// Clean up the temporary directory if it exists
if (tmpDir !== '') {
fsHelper.removeDir(tmpDir)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { Tracing } from 'trace_events'
import { FileMetadata } from './fs-helper'
export interface Manifest {
schemaVersion: number
mediaType: string
artifactType: string
config: Layer
layers: Layer[]
annotations: {}
}
export interface Layer {
mediaType: string
size: number
digest: string
annotations: {}
}
// 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,
version: string,
created: Date
): Manifest {
const configLayer = createConfigLayer()
const tarLayer = createTarLayer(tarFile, repository, version)
const zipLayer = createZipLayer(zipFile, repository, version)
const manifest: Manifest = {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
artifactType: 'application/vnd.oci.image.manifest.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'
}
}
return manifest
}
// TODO: is this ok hardcoded?
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
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"sourceMap": true,
"outDir": "./dist",
"noImplicitAny": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"newLine": "lf"
},
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"]
}