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