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