Compare commits
192 Commits
main-archive
...
node24
| Author | SHA1 | Date | |
|---|---|---|---|
| b02968aaa2 | |||
| 4bc8754ffc | |||
| f4d851fab3 | |||
| fc8ba308da | |||
| 3fc9aa365f | |||
| 2d5c4224f2 | |||
| 4b1aa5c1cd | |||
| 2acc8d8fc9 | |||
| d34ce159aa | |||
| d5420c2c97 | |||
| 23606e825a | |||
| 391942adf2 | |||
| ff725e43e8 | |||
| 65d4d4211f | |||
| 1cbbcdd5ae | |||
| b080e888d4 | |||
| 87530877ea | |||
| 49e905350a | |||
| 3a0ab30032 | |||
| 84bfe2de2e | |||
| 578de6e124 | |||
| e03465965b | |||
| e3f16e22ab | |||
| 1255bb0a54 | |||
| 4aeb3f6341 | |||
| 86a49c7f6a | |||
| 8a96626c28 | |||
| 36e729c5aa | |||
| 432126c06c | |||
| 3555a7ef80 | |||
| 1b9faf628d | |||
| 72b670f356 | |||
| e308348d01 | |||
| e53d6ca2a2 | |||
| da1f4d6352 | |||
| 028b950050 | |||
| bafa38ff94 | |||
| e44432d3e5 | |||
| c11354f432 | |||
| 1f725c56d6 | |||
| f213f0c945 | |||
| 8c9931350a | |||
| 7af620c09c | |||
| 9c79aec798 | |||
| a2e9ffc7b9 | |||
| 2af7b38c8b | |||
| 3cc27d51e4 | |||
| e039e1d6b7 | |||
| 11f5dcdbc3 | |||
| 91044eb688 | |||
| cf53527ffc | |||
| f58dd8f0ed | |||
| a959dfafba | |||
| a61106e002 | |||
| 90d59724e7 | |||
| 1a8d07a497 | |||
| 50c672a353 | |||
| 0fd4266160 | |||
| e58130f44d | |||
| 229ed04906 | |||
| 766a6934c5 | |||
| b40fcfc004 | |||
| 67f4b7749e | |||
| abf929b7e4 | |||
| b8bd8fe389 | |||
| ffcb1087c4 | |||
| f7d49cfdd1 | |||
| bebbbc6eee | |||
| 2bbf08d922 | |||
| 2bc8c192b1 | |||
| c1f237b012 | |||
| 8215ec2f64 | |||
| 5de4baf048 | |||
| 9c2a630347 | |||
| 8e9002fe5a | |||
| e2e9fea210 | |||
| b757396339 | |||
| ef67e6d74f | |||
| cd067bec7f | |||
| 36524bea42 | |||
| d623812b29 | |||
| 1354f92349 | |||
| 23baf08c4c | |||
| 7fee9b1717 | |||
| 6f395ba687 | |||
| e7734cb142 | |||
| d669870b09 | |||
| 81207d4b2c | |||
| 011940d503 | |||
| 5d5043a13c | |||
| 37578f447e | |||
| b7462ded63 | |||
| de2db00ca6 | |||
| 1660fcacaa | |||
| cf36a13357 | |||
| d0e1c8dd23 | |||
| a67b4b908a | |||
| 3a114e3b75 | |||
| 287eff5a0a | |||
| b54ac768df | |||
| 9bd8f2e9c0 | |||
| bc99e92f1f | |||
| 638fb07f1a | |||
| 58addcb0cc | |||
| 5c92b3920b | |||
| d627a3342b | |||
| 3d3a333728 | |||
| 18cf56a126 | |||
| 881fd1c540 | |||
| 17c0582657 | |||
| 507635d01b | |||
| 6dc0f68595 | |||
| 96609b599a | |||
| d835c26532 | |||
| cd600c26cd | |||
| 85d00a6e39 | |||
| f11d125628 | |||
| 113eb50eb5 | |||
| 369a6e7b30 | |||
| 761ae0d82e | |||
| cbce22dbfd | |||
| 2fabbad58f | |||
| a4456b225e | |||
| 05bd356814 | |||
| 9c9b57d4d4 | |||
| 1529e43c68 | |||
| e3a931402a | |||
| bc3ee93941 | |||
| 3c21f58d1c | |||
| 54d9a343c3 | |||
| 2c0bfdf7d3 | |||
| 2218323404 | |||
| 7cc514f31a | |||
| a6a87a7e13 | |||
| b42b69f193 | |||
| 1167b03ce8 | |||
| 4fb632b14a | |||
| 6d082c4eab | |||
| c4d8d934a0 | |||
| e5b7da2730 | |||
| 501681319f | |||
| 38b91834f7 | |||
| caf8cf0ef1 | |||
| 2222ac6d53 | |||
| d4e4f829cb | |||
| f2fb01cf17 | |||
| 1105b75f95 | |||
| 1b0ee34e34 | |||
| 646d55a089 | |||
| a07f7523c0 | |||
| 3f76c4d47c | |||
| b8317831f8 | |||
| c8ca97ca0c | |||
| 9525e839de | |||
| b80af95dd0 | |||
| ebbc8c8d58 | |||
| b337f88666 | |||
| 2bc73b1fa7 | |||
| dcd5d901d2 | |||
| 8a5726de70 | |||
| cb79bd1a60 | |||
| 4e41e8883c | |||
| b79f58714f | |||
| 6233cad2a5 | |||
| 50e278b239 | |||
| 6e5c3af726 | |||
| e40ff00655 | |||
| f64597ec50 | |||
| a9399d2ddb | |||
| 621cb8210d | |||
| dfbae910c5 | |||
| 1f47b19ed3 | |||
| 3c4259bfdd | |||
| 7472b3f822 | |||
| cb62dd8450 | |||
| db688d0eea | |||
| 5f9b214e33 | |||
| 002cf60682 | |||
| 5e2391735e | |||
| c41316d7a8 | |||
| 1fbd6bde21 | |||
| 997bea009b | |||
| abe17aada8 | |||
| b0e29673ab | |||
| 55e5053422 | |||
| aa6729b4a8 | |||
| 648b907f71 | |||
| 57949b89fa | |||
| fd9f0530bf | |||
| 4ac7dfc3cb | |||
| 651b1739d1 | |||
| d057826061 |
@@ -0,0 +1,4 @@
|
||||
lib/
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
||||
@@ -0,0 +1 @@
|
||||
dist/** -diff linguist-generated=true
|
||||
@@ -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,12 @@
|
||||
# Unordered list style
|
||||
MD004:
|
||||
style: dash
|
||||
|
||||
# Increase the max line length limit
|
||||
MD013:
|
||||
line_length: 200
|
||||
|
||||
# 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,61 @@
|
||||
# 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
|
||||
packages: 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@v4
|
||||
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'releases/*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: 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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Disable until this is a public repo since advanced security is not enabled
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - main
|
||||
# schedule:
|
||||
# - cron: '31 7 * * 3'
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
source-root: src
|
||||
|
||||
- name: Autobuild
|
||||
id: autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Lint Code Base
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Code Base
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
# this is necessary based on https://github.com/super-linter/super-linter?tab=readme-ov-file#get-started
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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@v6
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VALIDATE_TYPESCRIPT_STANDARD: false
|
||||
VALIDATE_JSCPD: false
|
||||
FILTER_REGEX_EXCLUDE: .*/licenses\.txt$
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Release new action version
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update_tag:
|
||||
name: Update the major tag to include the ${{ github.event.release.tag_name }} changes
|
||||
environment:
|
||||
name: releaseNewActionVersion
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update the ${{ env.TAG_NAME }} tag
|
||||
id: update-major-tag
|
||||
uses: actions/publish-action@v0.3.0
|
||||
with:
|
||||
source-tag: ${{ env.TAG_NAME }}
|
||||
slack-webhook: ${{ secrets.SLACK_WEBHOOK }}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Package and publish the action when a new release is published
|
||||
# Since this is the publishing action itself, we can use the current checkout as the action
|
||||
name: 'Publish Immutable Action Version'
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
jobs:
|
||||
package-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Immutable Action Version
|
||||
uses: ./
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Dependency directory
|
||||
node_modules
|
||||
|
||||
.npmrc
|
||||
|
||||
# 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 @@
|
||||
24.4.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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Repository CODEOWNERS
|
||||
|
||||
* @actions/actions-sudo
|
||||
@@ -0,0 +1,60 @@
|
||||
# Publish Immutable Action
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This action is **not ready for public use**. It is part of an upcoming public roadmap item (see [GitHub Actions: Immutable actions publishing](https://github.com/github/roadmap/issues/592)).
|
||||
> Attempts to use this action to upload an OCI artifact will not work until this feature has been fully released to the public. Please do not attempt to use it until that time.
|
||||
|
||||
This action packages _your action_ as an [OCI container](https://opencontainers.org/) and publishes it to the [GitHub Container registry](https://ghcr.io).
|
||||
This allows your action to be consumed as an _immutable_ package if a [SemVer](https://semver.org/) is specified in the consumer's workflow file.
|
||||
|
||||
Your workflow can be triggered by any [event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) which has a `GITHUB_REF` that points to a Git tag.
|
||||
Some examples of these events are:
|
||||
|
||||
- [`release`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release) (uses tag associated with release)
|
||||
- [`push`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push) (only applies to pushed tags)
|
||||
- [`workflow_dispatch`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch) (only applies if subject of dispatch is a tag)
|
||||
|
||||
The associated tag must follow [semantic versioning](https://semver.org/) - this tag value will be used to create a package version.
|
||||
|
||||
Consumers of your action will then be able to specify that version to consume your action from the package, e.g.
|
||||
|
||||
- `- uses: your-name/your-action@v1.2.3`
|
||||
- `- uses: your-name/your-action@v1`
|
||||
|
||||
Such packages will come with stronger security guarantees for consumers than existing git-based action resolution, such as:
|
||||
|
||||
- Provenance attestations generated using the [`@actions/attest`](https://github.com/actions/toolkit/tree/main/packages/attest) package
|
||||
- Tag immutability - it will not be possible to overwrite tags once published, ensuring versions of an action can't change once in use
|
||||
- Namespace immutability - it will not be possible to delete and recreate the package with different content; this would undermine tag immutability
|
||||
|
||||
## Usage
|
||||
|
||||
An actions workflow file like the following should be placed in your action repository:
|
||||
|
||||
<!-- start usage -->
|
||||
```yaml
|
||||
name: "Publish Immutable Action Version"
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
id: publish
|
||||
uses: actions/publish-immutable-action@0.0.3
|
||||
```
|
||||
<!-- end usage -->
|
||||
|
||||
## License
|
||||
|
||||
The scripts and documentation in this project are released under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
getRepositoryMetadata,
|
||||
getContainerRegistryURL
|
||||
} from '../src/api-client'
|
||||
|
||||
const url = 'https://registry.example.com'
|
||||
const test_token = 'test_token'
|
||||
|
||||
let fetchMock: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore()
|
||||
})
|
||||
|
||||
describe('getRepositoryMetadata', () => {
|
||||
it('returns repository metadata when the fetch response is ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: '123',
|
||||
owner: { id: '456' },
|
||||
visibility: 'public'
|
||||
})
|
||||
)
|
||||
)
|
||||
const result = await getRepositoryMetadata(url, 'repository', test_token)
|
||||
expect(result).toEqual({
|
||||
repoId: '123',
|
||||
ownerId: '456',
|
||||
visibility: 'public'
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://registry.example.com/repos/repository',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${test_token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the fetch errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('API is down'))
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow('API is down')
|
||||
})
|
||||
|
||||
it('throws an error when the response status is not ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch repository metadata due to bad status code: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the response data is in the wrong format', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ wrong: 'format' }))
|
||||
)
|
||||
await expect(
|
||||
getRepositoryMetadata(url, 'repository', 'token')
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch repository metadata: unexpected response format'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getContainerRegistryURL', () => {
|
||||
it('returns container registry URL when the fetch response is ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ url: 'https://registry.example.com' }))
|
||||
)
|
||||
const result = await getContainerRegistryURL(url, test_token)
|
||||
|
||||
expect(result).toEqual(new URL('https://registry.example.com'))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://registry.example.com/packages/container-registry-url',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${test_token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the fetch errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('API is down'))
|
||||
await expect(getContainerRegistryURL(url, test_token)).rejects.toThrow(
|
||||
'API is down'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the response status is not ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
await expect(getContainerRegistryURL(url, test_token)).rejects.toThrow(
|
||||
'Failed to fetch container registry url due to bad status code: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the response data is in the wrong format', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ wrong: 'format' }))
|
||||
)
|
||||
await expect(getContainerRegistryURL(url, test_token)).rejects.toThrow(
|
||||
'Failed to fetch repository metadata: unexpected response format'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import * as cfg from '../src/config'
|
||||
import * as apiClient from '../src/api-client'
|
||||
|
||||
let getContainerRegistryURLMock: jest.SpyInstance
|
||||
let getRepositoryMetadataMock: jest.SpyInstance
|
||||
let getInputMock: jest.SpyInstance
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
|
||||
describe('config.resolvePublishActionOptions', () => {
|
||||
beforeEach(() => {
|
||||
getContainerRegistryURLMock = jest
|
||||
.spyOn(apiClient, 'getContainerRegistryURL')
|
||||
.mockImplementation()
|
||||
|
||||
getRepositoryMetadataMock = jest
|
||||
.spyOn(apiClient, 'getRepositoryMetadata')
|
||||
.mockImplementation()
|
||||
|
||||
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
|
||||
|
||||
configureEventContext()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
clearEventContext()
|
||||
})
|
||||
|
||||
it('throws an error when the token is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce(undefined)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_TOKEN.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the event is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.eventName = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find event name.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the ref is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.ref = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REF.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the workspaceDir is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_WORKSPACE = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_WORKSPACE.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repository is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.payload.repository = undefined
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find Repository.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the apiBaseUrl is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.apiUrl = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_API_URL.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the runnerTempDir is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.RUNNER_TEMP = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find RUNNER_TEMP.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the sha is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.sha = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_SHA.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the githubServerUrl is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
github.context.serverUrl = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_SERVER_URL.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repositoryId is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REPOSITORY_ID = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REPOSITORY_ID.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when the repositoryOwnerId is not provided', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = ''
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find GITHUB_REPOSITORY_OWNER_ID.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when getting the container registry URL fails', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockRejectedValue(
|
||||
new Error('Failed to get container registry URL')
|
||||
)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Failed to get container registry URL'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when getting the repository metadata fails', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockRejectedValue(
|
||||
new Error('Failed to get repository metadata')
|
||||
)
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Failed to get repository metadata'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository visibility is empty', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: ''
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Could not find repository visibility.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository id does not match env var', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
ownerId: '12345',
|
||||
repoId: '54321'
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Repository ID mismatch.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error when returned repository owner id does not match env var', async () => {
|
||||
getInputMock.mockReturnValueOnce('token')
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
ownerId: '123124',
|
||||
repoId: 'repositoryId'
|
||||
})
|
||||
|
||||
await expect(cfg.resolvePublishActionOptions()).rejects.toThrow(
|
||||
'Repository Owner ID mismatch.'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns options when all values are present', async () => {
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
expect(name).toBe('github-token')
|
||||
return 'token'
|
||||
})
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
repoId: 'repositoryId',
|
||||
ownerId: 'repositoryOwnerId'
|
||||
})
|
||||
|
||||
const options = await cfg.resolvePublishActionOptions()
|
||||
|
||||
expect(options).toEqual({
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryVisibility: 'public',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets enterprise to true when the server URL is not github.com or ghe.com', async () => {
|
||||
getInputMock.mockImplementation((name: string) => {
|
||||
expect(name).toBe('github-token')
|
||||
return 'token'
|
||||
})
|
||||
getContainerRegistryURLMock.mockResolvedValue(ghcrUrl)
|
||||
|
||||
getRepositoryMetadataMock.mockResolvedValue({
|
||||
visibility: 'public',
|
||||
repoId: 'repositoryId',
|
||||
ownerId: 'repositoryOwnerId'
|
||||
})
|
||||
|
||||
github.context.serverUrl = 'https://github-enterprise.com'
|
||||
|
||||
const options = await cfg.resolvePublishActionOptions()
|
||||
|
||||
expect(options).toEqual({
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: true,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token',
|
||||
repositoryVisibility: 'public'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('config.serializeOptions', () => {
|
||||
it('serializes the options, ignoring internal keys', () => {
|
||||
const options: cfg.PublishActionOptions = {
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
ref: 'ref',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token',
|
||||
repositoryVisibility: 'public'
|
||||
}
|
||||
|
||||
const serialized = cfg.serializeOptions(options)
|
||||
|
||||
// Parse the JSON
|
||||
const parsed = JSON.parse(serialized)
|
||||
|
||||
expect(parsed.nameWithOwner).toBe('nameWithOwner')
|
||||
expect(parsed.ref).toBe('ref')
|
||||
expect(parsed.workspaceDir).toBe('workspaceDir')
|
||||
expect(parsed.event).toBe('release')
|
||||
expect(parsed.apiBaseUrl).toBe('apiBaseUrl')
|
||||
expect(parsed.sha).toBe('sha')
|
||||
expect(parsed.isEnterprise).toBe(false)
|
||||
expect(parsed.containerRegistryUrl).toBe(ghcrUrl.toString())
|
||||
expect(parsed.token).toBeUndefined()
|
||||
expect(parsed.repositoryId).toBeUndefined()
|
||||
expect(parsed.repositoryOwnerId).toBeUndefined()
|
||||
expect(parsed.runnerTempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function configureEventContext(): void {
|
||||
github.context.ref = 'ref'
|
||||
github.context.eventName = 'release'
|
||||
github.context.apiUrl = 'apiBaseUrl'
|
||||
github.context.sha = 'sha'
|
||||
github.context.serverUrl = 'https://github.com/'
|
||||
github.context.payload = {
|
||||
repository: {
|
||||
full_name: 'nameWithOwner',
|
||||
name: 'name',
|
||||
owner: {
|
||||
login: 'owner'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.env.RUNNER_TEMP = 'runnerTempDir'
|
||||
process.env.GITHUB_WORKSPACE = 'workspaceDir'
|
||||
process.env.GITHUB_REPOSITORY_ID = 'repositoryId'
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = 'repositoryOwnerId'
|
||||
}
|
||||
|
||||
function clearEventContext(): void {
|
||||
github.context.ref = ''
|
||||
github.context.eventName = ''
|
||||
github.context.apiUrl = ''
|
||||
github.context.sha = ''
|
||||
github.context.serverUrl = ''
|
||||
github.context.payload = {}
|
||||
process.env.RUNNER_TEMP = ''
|
||||
process.env.GITHUB_WORKSPACE = ''
|
||||
process.env.GITHUB_REPOSITORY_ID = ''
|
||||
process.env.GITHUB_REPOSITORY_OWNER_ID = ''
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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'
|
||||
const tmpFileDir = '/tmp'
|
||||
|
||||
describe('stageActionFiles', () => {
|
||||
let sourceDir: string
|
||||
let stagingDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
sourceDir = fsHelper.createTempDir(tmpFileDir, 'source')
|
||||
fs.mkdirSync(`${sourceDir}/src`)
|
||||
fs.writeFileSync(`${sourceDir}/src/main.js`, fileContent)
|
||||
fs.writeFileSync(`${sourceDir}/src/other.js`, fileContent)
|
||||
|
||||
stagingDir = fsHelper.createTempDir(tmpFileDir, 'staging')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
fs.rmSync(stagingDir, { recursive: true })
|
||||
})
|
||||
|
||||
it('copies all files (excluding the .git folder) to the staging directory', () => {
|
||||
fs.writeFileSync(`${sourceDir}/action.yml`, fileContent)
|
||||
|
||||
fs.mkdirSync(`${sourceDir}/.git`)
|
||||
fs.writeFileSync(`${sourceDir}/.git/HEAD`, fileContent)
|
||||
|
||||
fs.mkdirSync(`${sourceDir}/.github/workflows`, { recursive: true })
|
||||
fs.writeFileSync(`${sourceDir}/.github/workflows/workflow.yml`, fileContent)
|
||||
|
||||
fsHelper.stageActionFiles(sourceDir, stagingDir)
|
||||
expect(fs.existsSync(`${stagingDir}/action.yml`)).toBe(true)
|
||||
expect(fs.existsSync(`${stagingDir}/src/main.js`)).toBe(true)
|
||||
expect(fs.existsSync(`${stagingDir}/src/other.js`)).toBe(true)
|
||||
|
||||
// Hidden files are copied
|
||||
expect(fs.existsSync(`${stagingDir}/.github`)).toBe(true)
|
||||
|
||||
// .git folder is not copied
|
||||
expect(fs.existsSync(`${stagingDir}/.git`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createArchives', () => {
|
||||
let stageDir: string
|
||||
let archiveDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
stageDir = fsHelper.createTempDir(tmpFileDir, 'staging')
|
||||
fs.writeFileSync(`${stageDir}/hello.txt`, fileContent)
|
||||
fs.writeFileSync(`${stageDir}/world.txt`, fileContent)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
archiveDir = fsHelper.createTempDir(tmpFileDir, 'archive')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(archiveDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(stageDir, { recursive: true })
|
||||
})
|
||||
|
||||
it('creates archives', async () => {
|
||||
const { zipFile, tarFile } = await fsHelper.createArchives(
|
||||
stageDir,
|
||||
archiveDir
|
||||
)
|
||||
|
||||
expect(zipFile.path).toEqual(`${archiveDir}/archive.zip`)
|
||||
expect(fs.existsSync(zipFile.path)).toEqual(true)
|
||||
expect(fs.statSync(zipFile.path).size).toBeGreaterThan(0)
|
||||
expect(zipFile.sha256.startsWith('sha256:')).toEqual(true)
|
||||
|
||||
expect(tarFile.path).toEqual(`${archiveDir}/archive.tar.gz`)
|
||||
expect(fs.existsSync(tarFile.path)).toEqual(true)
|
||||
expect(fs.statSync(tarFile.path).size).toBeGreaterThan(0)
|
||||
expect(tarFile.sha256.startsWith('sha256:')).toEqual(true)
|
||||
|
||||
// Validate the hashes by comparing to the output of the system's hashing utility
|
||||
const zipSHA = zipFile.sha256.substring(7) // remove "sha256:" prefix
|
||||
const tarSHA = tarFile.sha256.substring(7) // remove "sha256:" prefix
|
||||
|
||||
// sha256 hash is 64 characters long
|
||||
expect(zipSHA).toHaveLength(64)
|
||||
expect(tarSHA).toHaveLength(64)
|
||||
|
||||
let systemZipHash: string
|
||||
let systemTarHash: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
// Windows
|
||||
systemZipHash = execSync(`CertUtil -hashfile ${zipFile.path} SHA256`)
|
||||
.toString()
|
||||
.split(' ')[1]
|
||||
.trim()
|
||||
systemTarHash = execSync(`CertUtil -hashfile ${tarFile.path} SHA256`)
|
||||
.toString()
|
||||
.split(' ')[1]
|
||||
.trim()
|
||||
} else {
|
||||
// Unix-based systems
|
||||
systemZipHash = execSync(`shasum -a 256 ${zipFile.path}`)
|
||||
.toString()
|
||||
.split(' ')[0]
|
||||
systemTarHash = execSync(`shasum -a 256 ${tarFile.path}`)
|
||||
.toString()
|
||||
.split(' ')[0]
|
||||
}
|
||||
|
||||
expect(zipSHA).toEqual(systemZipHash)
|
||||
expect(tarSHA).toEqual(systemTarHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTempDir', () => {
|
||||
let dirs: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
dirs = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a temporary directory', () => {
|
||||
const tmpDir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
|
||||
expect(fs.existsSync(tmpDir)).toEqual(true)
|
||||
expect(fs.statSync(tmpDir).isDirectory()).toEqual(true)
|
||||
})
|
||||
|
||||
it('creates a unique temporary directory', () => {
|
||||
const dir1 = fsHelper.createTempDir(tmpFileDir, 'dir1')
|
||||
dirs.push(dir1)
|
||||
|
||||
const dir2 = fsHelper.createTempDir(tmpFileDir, 'dir2')
|
||||
dirs.push(dir2)
|
||||
|
||||
expect(dir1).not.toEqual(dir2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirectory', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
})
|
||||
|
||||
it('returns true if the path is a directory', () => {
|
||||
expect(fsHelper.isDirectory(dir)).toEqual(true)
|
||||
})
|
||||
|
||||
it('returns false if the path is not a directory', () => {
|
||||
const tempFile = `${dir}/file.txt`
|
||||
fs.writeFileSync(tempFile, fileContent)
|
||||
expect(fsHelper.isDirectory(tempFile)).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readFileContents', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
})
|
||||
|
||||
it('reads the contents of a file', () => {
|
||||
const tempFile = `${dir}/file.txt`
|
||||
fs.writeFileSync(tempFile, fileContent)
|
||||
|
||||
expect(fsHelper.readFileContents(tempFile).toString()).toEqual(fileContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureCorrectShaCheckedOut', () => {
|
||||
let dir: string
|
||||
let commit1: string
|
||||
let commit2: string
|
||||
const tag1 = 'tag1'
|
||||
const tag2 = 'tag2'
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fsHelper.createTempDir(tmpFileDir, 'subdir')
|
||||
|
||||
// Set up a git repository
|
||||
execSync('git init', { cwd: dir })
|
||||
|
||||
// Set user and email in this git repo (not globally)
|
||||
execSync('git config user.email monalisa@github.com', { cwd: dir })
|
||||
execSync('git config user.name Mona', { cwd: dir })
|
||||
|
||||
// Add a file to the repo
|
||||
fs.writeFileSync(`${dir}/file1.txt`, fileContent)
|
||||
execSync('git add .', { cwd: dir })
|
||||
|
||||
// Add two commits
|
||||
execSync('git commit --allow-empty -m "test"', { cwd: dir })
|
||||
execSync('git commit --allow-empty -m "test"', { cwd: dir })
|
||||
|
||||
// Grab the two commits
|
||||
commit1 = execSync('git rev-parse HEAD~1', { cwd: dir }).toString().trim()
|
||||
commit2 = execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()
|
||||
|
||||
// Create a tag for each commit
|
||||
execSync(`git tag ${tag1} ${commit1}`, { cwd: dir })
|
||||
execSync(`git tag ${tag2} ${commit2}`, { cwd: dir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true })
|
||||
})
|
||||
|
||||
it('does not throw an error if the correct SHA is checked out', async () => {
|
||||
await expect(
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag2}`, commit2, dir)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws an error if the correct SHA is not checked out', async () => {
|
||||
await expect(
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag1}`, commit1, dir)
|
||||
).rejects.toThrow(
|
||||
'The expected commit associated with the tag refs/tags/tag1 is not checked out.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if there is an issue getting sha for tag', async () => {
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(
|
||||
`refs/tags/some-unknown-tag`,
|
||||
commit2,
|
||||
dir
|
||||
)
|
||||
).rejects.toThrow('Error retrieving commit associated with tag')
|
||||
})
|
||||
|
||||
it('throws an error if the sha of the tag does not match expected sha', async () => {
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag1}`, commit2, dir)
|
||||
).rejects.toThrow(
|
||||
'The commit associated with the tag refs/tags/tag1 does not match the SHA of the commit provided by the actions context.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if the provided ref is not a tag ref', async () => {
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/heads/main`, commit2, dir)
|
||||
).rejects.toThrow('Tag ref provided is not in expected format.')
|
||||
})
|
||||
|
||||
it('throws if there are untracked files in the working directory', async () => {
|
||||
// Add an untracked file
|
||||
fs.writeFileSync(`${dir}/untracked-file.txt`, fileContent)
|
||||
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag2}`, commit2, dir)
|
||||
).rejects.toThrow(
|
||||
'The working directory has uncommitted changes. Uploading modified code from the checked out repository is not supported by this action.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if there are uncommitted changes in the working directory', async () => {
|
||||
// Add an untracked file
|
||||
fs.writeFileSync(`${dir}/file1.txt`, fileContent + fileContent)
|
||||
execSync('git add .', { cwd: dir })
|
||||
|
||||
await expect(async () =>
|
||||
fsHelper.ensureTagAndRefCheckedOut(`refs/tags/${tag2}`, commit2, dir)
|
||||
).rejects.toThrow(
|
||||
'The working directory has uncommitted changes. Uploading modified code from the checked out repository is not supported by this action.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,643 @@
|
||||
import { Client } from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Mocks
|
||||
let fetchMock: jest.SpyInstance
|
||||
|
||||
let client: Client
|
||||
|
||||
const token = 'test-token'
|
||||
const registry = new URL('https://ghcr.io')
|
||||
const repository = 'test-org/test-repo'
|
||||
const semver = '1.2.3'
|
||||
const genericSha = '1234567890' // We should look at using different shas here to catch bug, but that make location validation harder
|
||||
|
||||
const checkBlobNoExistingBlobs = (): object => {
|
||||
// Simulate none of the blobs existing currently
|
||||
return {
|
||||
text() {
|
||||
return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}'
|
||||
},
|
||||
status: 404,
|
||||
statusText: 'Not Found'
|
||||
}
|
||||
}
|
||||
|
||||
const checkBlobAllExistingBlobs = (): object => {
|
||||
// Simulate all of the blobs existing currently
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0
|
||||
const checkBlobSomeExistingBlobs = (): object => {
|
||||
count++
|
||||
// report one as existing
|
||||
if (count === 1) {
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
}
|
||||
} else {
|
||||
// report all others are missing
|
||||
return {
|
||||
text() {
|
||||
return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}'
|
||||
},
|
||||
status: 404,
|
||||
statusText: 'Not Found'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkBlobFailure = (): object => {
|
||||
return {
|
||||
text() {
|
||||
// In this case we'll simulate a response which does not use the expected error format
|
||||
return '503 Service Unavailable'
|
||||
},
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
const initiateBlobUploadSuccessForAllBlobs = (): object => {
|
||||
// Simulate successful initiation of uploads for all blobs & return location
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'location') {
|
||||
return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initiateBlobUploadFailureForAllBlobs = (): object => {
|
||||
// Simulate failed initiation of uploads
|
||||
return {
|
||||
text() {
|
||||
// In this case we'll simulate a response which does not use the expected error format
|
||||
return '503 Service Unavailable'
|
||||
},
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
const initiateBlobUploadNoLocationHeader = (): object => {
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
get: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const putManifestSuccessful = (
|
||||
digestToReturn: string,
|
||||
expectedVersion: string
|
||||
): ((url: string) => object) => {
|
||||
return (url: string): object => {
|
||||
expect(url.endsWith(`manifests/${expectedVersion}`)).toBeTruthy()
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'docker-content-digest') {
|
||||
return digestToReturn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const putBlobSuccess = (): object => {
|
||||
return {
|
||||
status: 201
|
||||
}
|
||||
}
|
||||
|
||||
const putManifestFailure = (): object => {
|
||||
// Simulate fails upload of all blobs & manifest
|
||||
return {
|
||||
text() {
|
||||
return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}'
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
}
|
||||
}
|
||||
|
||||
const putBlobFailure = (): object => {
|
||||
// Simulate fails upload of all blobs & manifest
|
||||
return {
|
||||
text() {
|
||||
return '{"errors": [{"code": "BAD_REQUEST", "message": "digest issue."}]}'
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
}
|
||||
}
|
||||
|
||||
type MethodHandlers = {
|
||||
checkBlobMock?: (url: string, options: { method: string }) => object
|
||||
initiateBlobUploadMock?: (url: string, options: { method: string }) => object
|
||||
putManifestMock?: (url: string, options: { method: string }) => object
|
||||
putBlobMock?: (url: string, options: { method: string }) => object
|
||||
}
|
||||
|
||||
type ForcedRetries = {
|
||||
checkBlob: number
|
||||
initiateBlobUpload: number
|
||||
putBlob: number
|
||||
putManifest: number
|
||||
}
|
||||
|
||||
function configureFetchMock(
|
||||
fetchMockInstance: jest.SpyInstance,
|
||||
methodHandlers: MethodHandlers,
|
||||
forcedRetries: ForcedRetries = {
|
||||
checkBlob: 0,
|
||||
initiateBlobUpload: 0,
|
||||
putBlob: 0,
|
||||
putManifest: 0
|
||||
}
|
||||
): void {
|
||||
const retriableError = async (retries: number): Promise<object> => {
|
||||
if (retries % 2 === 0) {
|
||||
throw new Error('Network Error')
|
||||
} else {
|
||||
return {
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'retry-after') {
|
||||
return '0.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchMockInstance.mockImplementation(
|
||||
async (url: string, options: { method: string }) => {
|
||||
// Simulate retries for every request until the number of forced retries is exhausted.
|
||||
// We'll simulate both failing status codes and network errors for full coverage.
|
||||
validateRequestConfig(url, options)
|
||||
switch (options.method) {
|
||||
case 'HEAD':
|
||||
if (forcedRetries.checkBlob > 0) {
|
||||
forcedRetries.checkBlob--
|
||||
return retriableError(forcedRetries.checkBlob)
|
||||
}
|
||||
|
||||
return methodHandlers.checkBlobMock?.(url, options)
|
||||
case 'POST':
|
||||
if (forcedRetries.initiateBlobUpload > 0) {
|
||||
forcedRetries.initiateBlobUpload--
|
||||
return retriableError(forcedRetries.initiateBlobUpload)
|
||||
}
|
||||
|
||||
return methodHandlers.initiateBlobUploadMock?.(url, options)
|
||||
case 'PUT':
|
||||
if (url.includes('manifest')) {
|
||||
if (forcedRetries.putManifest > 0) {
|
||||
forcedRetries.putManifest--
|
||||
return retriableError(forcedRetries.putManifest)
|
||||
}
|
||||
|
||||
return methodHandlers.putManifestMock?.(url, options)
|
||||
} else {
|
||||
if (forcedRetries.putBlob > 0) {
|
||||
forcedRetries.putBlob--
|
||||
return retriableError(forcedRetries.putBlob)
|
||||
}
|
||||
|
||||
return methodHandlers.putBlobMock?.(url, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('uploadOCIIndexManifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
||||
|
||||
client = new Client(token, registry, {
|
||||
retries: 5,
|
||||
backoff: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads the tagged manifest with the appropriate tag', async () => {
|
||||
const { manifest, sha } = testIndexManifest()
|
||||
const tag = 'sha-1234'
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
putManifestMock: putManifestSuccessful(sha, tag)
|
||||
})
|
||||
|
||||
await client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws an error if a manifest upload fails', async () => {
|
||||
const { manifest, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestFailure
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
||||
const { manifest, sha } = testIndexManifest()
|
||||
|
||||
const tag = 'sha-1234'
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', tag)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIIndexManifest(repository, manifest, tag)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadOCIImageManifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
||||
})
|
||||
|
||||
it('uploads blobs then untagged manifest to the provided registry', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('uploads blobs then tagged manifest to the provided registry', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, semver)
|
||||
})
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs, semver)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('uploads everything to the provided registry by retrying requests', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(
|
||||
fetchMock,
|
||||
{
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
},
|
||||
{
|
||||
checkBlob: 2,
|
||||
initiateBlobUpload: 2,
|
||||
putBlob: 2,
|
||||
putManifest: 2
|
||||
}
|
||||
) // Fail each request twice before succeeding
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
// 8 Additional requests - 2 for each of the 4 failed request types
|
||||
expect(fetchMock).toHaveBeenCalledTimes(18)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(5)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(5)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('skips blob uploads if all blobs already exist', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips blob uploads if some blobs already exist', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobSomeExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('throws an error if checking for existing blobs fails', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobFailure,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
/^Unexpected 503 Service Unavailable response from check blob/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if a blob file is not provided', async () => {
|
||||
const { manifest, sha } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(
|
||||
repository,
|
||||
manifest,
|
||||
new Map<string, Buffer>()
|
||||
)
|
||||
).rejects.toThrow(/^Blob for layer sha256:[a-zA-Z0-9]+ not found/)
|
||||
})
|
||||
|
||||
it('throws an error if initiating layer upload fails', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadFailureForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the upload endpoint does not return a location', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadNoLocationHeader,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^No location header in response from upload post/)
|
||||
})
|
||||
|
||||
it('throws an error if a layer upload fails', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobNoExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobFailure,
|
||||
putManifestMock: putManifestSuccessful(sha, sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/)
|
||||
})
|
||||
|
||||
it('throws an error if a manifest upload fails', async () => {
|
||||
const { manifest, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestFailure
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
||||
const { manifest, sha, blobs } = testImageManifest()
|
||||
|
||||
configureFetchMock(fetchMock, {
|
||||
checkBlobMock: checkBlobAllExistingBlobs,
|
||||
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
||||
putBlobMock: putBlobSuccess,
|
||||
putManifestMock: putManifestSuccessful('some-garbage-digest', sha)
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.uploadOCIImageManifest(repository, manifest, blobs)
|
||||
).rejects.toThrow(
|
||||
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function testImageManifest(): {
|
||||
manifest: ociContainer.OCIImageManifest
|
||||
sha: string
|
||||
blobs: Map<string, Buffer>
|
||||
} {
|
||||
const blobs = new Map<string, Buffer>()
|
||||
blobs.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
|
||||
const firstFile = Buffer.from('test1')
|
||||
const secondFile = Buffer.from('test2')
|
||||
|
||||
const firstFileDigest = `sha256:${crypto
|
||||
.createHash('sha256')
|
||||
.update(firstFile)
|
||||
.digest('hex')}`
|
||||
|
||||
const secondFileDigest = `sha256:${crypto
|
||||
.createHash('sha256')
|
||||
.update(secondFile)
|
||||
.digest('hex')}`
|
||||
|
||||
blobs.set(firstFileDigest, firstFile)
|
||||
blobs.set(secondFileDigest, secondFile)
|
||||
|
||||
const manifest: ociContainer.OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: ociContainer.imageManifestMediaType,
|
||||
artifactType: ociContainer.imageManifestMediaType,
|
||||
config: ociContainer.createEmptyConfigLayer(),
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/octet-stream',
|
||||
size: firstFile.length,
|
||||
digest: firstFileDigest
|
||||
},
|
||||
{
|
||||
mediaType: 'application/octet-stream',
|
||||
size: secondFile.length,
|
||||
digest: secondFileDigest
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const sha = ociContainer.sha256Digest(manifest)
|
||||
|
||||
return { manifest, sha, blobs }
|
||||
}
|
||||
|
||||
function testIndexManifest(): {
|
||||
manifest: ociContainer.OCIIndexManifest
|
||||
sha: string
|
||||
} {
|
||||
const manifest = ociContainer.createReferrerTagManifest(
|
||||
'attestation-digest',
|
||||
1234,
|
||||
'bundle-media-type',
|
||||
'bundle-predicate-type',
|
||||
new Date(),
|
||||
new Date()
|
||||
)
|
||||
const sha = ociContainer.sha256Digest(manifest)
|
||||
return { manifest, sha }
|
||||
}
|
||||
|
||||
// We expect all fetch calls to have auth headers set
|
||||
// This function verifies that given an request config.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateRequestConfig(url: string, config: any): void {
|
||||
// Basic URL checks
|
||||
expect(url).toBeDefined()
|
||||
if (!url.startsWith(registry.toString())) {
|
||||
console.log(`${url} does not start with ${registry}`)
|
||||
}
|
||||
// if these expect fails, run the test again with `-- --silent=false`
|
||||
// the console.log above should give a clue about which URL is failing
|
||||
expect(url.startsWith(registry.toString())).toBeTruthy()
|
||||
|
||||
// Config checks
|
||||
expect(config).toBeDefined()
|
||||
|
||||
expect(config.headers).toBeDefined()
|
||||
if (config.headers) {
|
||||
// Check the auth header is set
|
||||
expect(config.headers.Authorization).toBeDefined()
|
||||
// Check the auth header is the base 64 encoded token
|
||||
expect(config.headers.Authorization).toBe(
|
||||
`Bearer ${Buffer.from(token).toString('base64')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,714 @@
|
||||
/**
|
||||
* 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 attest from '@actions/attest'
|
||||
import * as main from '../src/main'
|
||||
import * as cfg from '../src/config'
|
||||
import * as fsHelper from '../src/fs-helper'
|
||||
import * as ghcr from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
const predicateType = 'https://slsa.dev/provenance/v1'
|
||||
const bundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json'
|
||||
|
||||
// Mock the GitHub Actions core library
|
||||
let setFailedMock: jest.SpyInstance
|
||||
let setOutputMock: jest.SpyInstance
|
||||
|
||||
// Mock the FS Helper
|
||||
let createTempDirMock: jest.SpyInstance
|
||||
let createArchivesMock: jest.SpyInstance
|
||||
let stageActionFilesMock: jest.SpyInstance
|
||||
let ensureCorrectShaCheckedOutMock: jest.SpyInstance
|
||||
let readFileContentsMock: jest.SpyInstance
|
||||
|
||||
// Mock OCI container lib
|
||||
let calculateManifestDigestMock: jest.SpyInstance
|
||||
|
||||
// Mock GHCR client
|
||||
let client: ghcr.Client
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let createGHCRClient: jest.SpyInstance
|
||||
let uploadOCIImageManifestMock: jest.SpyInstance
|
||||
let uploadOCIIndexManifestMock: jest.SpyInstance
|
||||
|
||||
// Mock the config resolution
|
||||
let resolvePublishActionOptionsMock: jest.SpyInstance
|
||||
|
||||
// Mock generating attestation
|
||||
let generateAttestationMock: jest.SpyInstance
|
||||
|
||||
describe('run', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
client = new ghcr.Client('token', ghcrUrl)
|
||||
|
||||
// Core mocks
|
||||
setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
|
||||
setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()
|
||||
|
||||
// FS mocks
|
||||
createTempDirMock = jest
|
||||
.spyOn(fsHelper, 'createTempDir')
|
||||
.mockImplementation()
|
||||
createArchivesMock = jest
|
||||
.spyOn(fsHelper, 'createArchives')
|
||||
.mockImplementation()
|
||||
stageActionFilesMock = jest
|
||||
.spyOn(fsHelper, 'stageActionFiles')
|
||||
.mockImplementation()
|
||||
ensureCorrectShaCheckedOutMock = jest
|
||||
.spyOn(fsHelper, 'ensureTagAndRefCheckedOut')
|
||||
.mockImplementation()
|
||||
readFileContentsMock = jest
|
||||
.spyOn(fsHelper, 'readFileContents')
|
||||
.mockImplementation()
|
||||
|
||||
// OCI Container mocks
|
||||
calculateManifestDigestMock = jest
|
||||
.spyOn(ociContainer, 'sha256Digest')
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
createGHCRClient = jest
|
||||
.spyOn(ghcr, 'Client')
|
||||
.mockImplementation(() => client)
|
||||
|
||||
uploadOCIImageManifestMock = jest
|
||||
.spyOn(client, 'uploadOCIImageManifest')
|
||||
.mockImplementation()
|
||||
uploadOCIIndexManifestMock = jest
|
||||
.spyOn(client, 'uploadOCIIndexManifest')
|
||||
.mockImplementation()
|
||||
|
||||
// Config mocks
|
||||
resolvePublishActionOptionsMock = jest
|
||||
.spyOn(cfg, 'resolvePublishActionOptions')
|
||||
.mockImplementation()
|
||||
|
||||
// Attestation mocks
|
||||
generateAttestationMock = jest
|
||||
.spyOn(attest, 'attestProvenance')
|
||||
.mockImplementation()
|
||||
})
|
||||
|
||||
it('fails if the action ref is not a tag', async () => {
|
||||
const options = baseOptions()
|
||||
options.ref = 'refs/heads/main' // This is a branch, not a tag
|
||||
resolvePublishActionOptionsMock.mockReturnValueOnce(options)
|
||||
|
||||
await main.run()
|
||||
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'The ref refs/heads/main is not a valid tag reference.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if the value of the tag ref is not a valid semver', async () => {
|
||||
const tags = ['test', 'v1.0', 'chicken', '111111']
|
||||
|
||||
for (const tag of tags) {
|
||||
const options = baseOptions()
|
||||
options.ref = `refs/tags/${tag}`
|
||||
resolvePublishActionOptionsMock.mockReturnValueOnce(options)
|
||||
|
||||
await main.run()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
`${tag} is not a valid semantic version tag, and so cannot be uploaded to the action package.`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails if ensuring the correct SHA is checked out errors', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating staging temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if staging files fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'tmpDir/staging'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating archives temp directory fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation((_, path: string) => {
|
||||
if (path === 'staging') {
|
||||
return 'staging'
|
||||
}
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating archives fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating attestation fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async () => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if uploading attestation to GHCR fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if uploading referrer index manifest to GHCR fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(() => {
|
||||
return 'attestation-digest'
|
||||
})
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if publishing action package version fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
readFileContentsMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repo, manifest, blobs, tag) => {
|
||||
if (tag === undefined) {
|
||||
return 'attestation-digest'
|
||||
} else {
|
||||
throw new Error('Something went wrong')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(() => {
|
||||
return 'referrer-index-digest'
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
options.isEnterprise = true
|
||||
resolvePublishActionOptionsMock.mockReturnValue(options)
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'zip',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'tar',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
readFileContentsMock.mockImplementation(filepath => {
|
||||
return Buffer.from(`${filepath}`)
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repository, manifest, blobs, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('1.2.3')
|
||||
expect(blobs.size).toBe(3)
|
||||
expect(blobs.has(ociContainer.emptyConfigSha)).toBeTruthy()
|
||||
expect(blobs.has('123')).toBeTruthy()
|
||||
expect(blobs.has('1234')).toBeTruthy()
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType)
|
||||
expect(manifest.layers.length).toBe(2)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
ociContainer.actionPackageAnnotationValue
|
||||
)
|
||||
|
||||
return 'sha256:my-test-digest'
|
||||
}
|
||||
)
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and creates an attestation in non-enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
resolvePublishActionOptionsMock.mockReturnValue(options)
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
readFileContentsMock.mockImplementation(() => {
|
||||
return Buffer.from('test')
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async opts => {
|
||||
expect(opts).toHaveProperty('skipWrite', true)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: bundleMediaType,
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payload: btoa(`{"predicateType": "${predicateType}"}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadOCIIndexManifestMock.mockImplementation(
|
||||
async (repository, manifest, tag) => {
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(tag).toBe('sha256-my-test-digest')
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageIndexMediaType)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
ociContainer.actionPackageReferrerTagAnnotationValue
|
||||
)
|
||||
expect(manifest.manifests.length).toBe(1)
|
||||
expect(manifest.manifests[0].mediaType).toBe(
|
||||
ociContainer.imageManifestMediaType
|
||||
)
|
||||
expect(manifest.manifests[0].artifactType).toBe(bundleMediaType)
|
||||
expect(
|
||||
manifest.manifests[0].annotations['dev.sigstore.bundle.predicateType']
|
||||
).toBe(predicateType)
|
||||
expect(
|
||||
manifest.manifests[0].annotations['com.github.package.type']
|
||||
).toBe(ociContainer.actionPackageAttestationAnnotationValue)
|
||||
|
||||
return 'sha256:referrer-index-digest'
|
||||
}
|
||||
)
|
||||
|
||||
uploadOCIImageManifestMock.mockImplementation(
|
||||
(repository, manifest, blobs, tag) => {
|
||||
let expectedBlobKeys: string[] = []
|
||||
let expectedAnnotationValue = ''
|
||||
let expectedTagValue: string | undefined = undefined
|
||||
let returnValue = ''
|
||||
let expectedPredicateTypeValue: string | undefined = undefined
|
||||
|
||||
let expectedSubjectMediaType: string | undefined = undefined
|
||||
|
||||
if (tag === undefined) {
|
||||
expectedAnnotationValue =
|
||||
ociContainer.actionPackageAttestationAnnotationValue
|
||||
const sigStoreLayer = manifest.layers.find(
|
||||
(layer: ociContainer.Descriptor) =>
|
||||
layer.mediaType === bundleMediaType
|
||||
)
|
||||
expectedPredicateTypeValue = predicateType
|
||||
|
||||
expectedBlobKeys = [sigStoreLayer.digest, ociContainer.emptyConfigSha]
|
||||
|
||||
expectedSubjectMediaType = ociContainer.imageManifestMediaType
|
||||
|
||||
returnValue = 'sha256:attestation-digest'
|
||||
} else {
|
||||
expectedAnnotationValue = ociContainer.actionPackageAnnotationValue
|
||||
expectedTagValue = '1.2.3'
|
||||
expectedBlobKeys = ['123', '1234', ociContainer.emptyConfigSha]
|
||||
returnValue = 'sha256:my-test-digest'
|
||||
}
|
||||
|
||||
expect(repository).toBe(options.nameWithOwner)
|
||||
expect(manifest.mediaType).toBe(ociContainer.imageManifestMediaType)
|
||||
expect(manifest.annotations['com.github.package.type']).toBe(
|
||||
expectedAnnotationValue
|
||||
)
|
||||
expect(manifest.annotations['dev.sigstore.bundle.predicateType']).toBe(
|
||||
expectedPredicateTypeValue
|
||||
)
|
||||
expect(tag).toBe(expectedTagValue)
|
||||
expect(manifest.subject?.mediaType).toBe(expectedSubjectMediaType)
|
||||
|
||||
expect(manifest.layers.length).toBe(expectedBlobKeys.length - 1) // Minus config layer
|
||||
expect(blobs.size).toBe(expectedBlobKeys.length)
|
||||
for (const expectedBlobKey of expectedBlobKeys) {
|
||||
expect(blobs.has(expectedBlobKey)).toBeTruthy()
|
||||
}
|
||||
|
||||
return returnValue
|
||||
}
|
||||
)
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(uploadOCIImageManifestMock).toHaveBeenCalledTimes(2)
|
||||
expect(uploadOCIIndexManifestMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Check outputs
|
||||
expect(setOutputMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'attestation-manifest-sha',
|
||||
'sha256:attestation-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'referrer-index-manifest-sha',
|
||||
'sha256:referrer-index-digest'
|
||||
)
|
||||
|
||||
expect(setOutputMock).toHaveBeenCalledWith(
|
||||
'package-manifest-sha',
|
||||
'sha256:my-test-digest'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function baseOptions(): cfg.PublishActionOptions {
|
||||
return {
|
||||
nameWithOwner: 'nameWithOwner',
|
||||
workspaceDir: 'workspaceDir',
|
||||
event: 'release',
|
||||
apiBaseUrl: 'apiBaseUrl',
|
||||
runnerTempDir: 'runnerTempDir',
|
||||
sha: 'sha',
|
||||
repositoryId: 'repositoryId',
|
||||
repositoryOwnerId: 'repositoryOwnerId',
|
||||
isEnterprise: false,
|
||||
containerRegistryUrl: ghcrUrl,
|
||||
token: 'token',
|
||||
ref: 'refs/tags/v1.2.3',
|
||||
repositoryVisibility: 'public'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
createActionPackageManifest,
|
||||
sha256Digest,
|
||||
sizeInBytes,
|
||||
OCIImageManifest,
|
||||
createSigstoreAttestationManifest,
|
||||
OCIIndexManifest,
|
||||
createReferrerTagManifest
|
||||
} from '../src/oci-container'
|
||||
import { FileMetadata } from '../src/fs-helper'
|
||||
|
||||
const createdTimestamp = '2021-01-01T00:00:00.000Z'
|
||||
|
||||
describe('sha256Digest', () => {
|
||||
it('calculates the SHA256 digest of the provided manifest', () => {
|
||||
const { manifest } = testActionPackageManifest()
|
||||
const digest = sha256Digest(manifest)
|
||||
const expectedDigest =
|
||||
'sha256:1af9bf993bf068a51fbb54822471ab7507b07c553bcac09a7c91328740d8ed69'
|
||||
|
||||
expect(digest).toEqual(expectedDigest)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size', () => {
|
||||
it('returns the total size of the provided manifest', () => {
|
||||
const { manifest } = testActionPackageManifest()
|
||||
const size = sizeInBytes(manifest)
|
||||
expect(size).toBe(991)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createActionPackageManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const { manifest, zipFile, tarFile } = testActionPackageManifest()
|
||||
|
||||
const expectedJSON = `{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.github.actions.package.v1+json",
|
||||
"config": {
|
||||
"mediaType":"application/vnd.oci.empty.v1+json",
|
||||
"size":2,
|
||||
"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
"layers":[
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.tar+gzip",
|
||||
"size":${tarFile.size},
|
||||
"digest":"${tarFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"test-org-test-repo_1.2.3.tar.gz"
|
||||
}
|
||||
},
|
||||
{
|
||||
"mediaType":"application/vnd.github.actions.package.layer.v1.zip",
|
||||
"size":${zipFile.size},
|
||||
"digest":"${zipFile.sha256}",
|
||||
"annotations":{
|
||||
"org.opencontainers.image.title":"test-org-test-repo_1.2.3.zip"
|
||||
}
|
||||
}
|
||||
],
|
||||
"annotations":{
|
||||
"org.opencontainers.image.created":"${createdTimestamp}",
|
||||
"action.tar.gz.digest":"${tarFile.sha256}",
|
||||
"action.zip.digest":"${zipFile.sha256}",
|
||||
"com.github.package.type":"actions_oci_pkg",
|
||||
"com.github.package.version":"1.2.3",
|
||||
"com.github.source.repo.id":"123",
|
||||
"com.github.source.repo.owner.id":"456",
|
||||
"com.github.source.commit":"abc"
|
||||
}
|
||||
}`
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const { manifest } = testActionPackageManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSigstoreAttestationManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const manifest = testAttestationManifest()
|
||||
|
||||
const expectedJSON = `{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.empty.v1+json",
|
||||
"size": 2,
|
||||
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"size": 10,
|
||||
"digest": "bundleDigest"
|
||||
}
|
||||
],
|
||||
"subject": {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"size": 100,
|
||||
"digest": "subjectDigest"
|
||||
},
|
||||
"annotations": {
|
||||
"dev.sigstore.bundle.content": "dsse-envelope",
|
||||
"dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1",
|
||||
"com.github.package.type": "actions_oci_pkg_attestation",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const manifest = testAttestationManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createReferrerIndexManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const manifest = testReferrerIndexManifest()
|
||||
|
||||
const expectedJSON = `
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"size": 100,
|
||||
"digest": "attDigest",
|
||||
"annotations": {
|
||||
"com.github.package.type": "actions_oci_pkg_attestation",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z",
|
||||
"dev.sigstore.bundle.content": "dsse-envelope",
|
||||
"dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"com.github.package.type": "actions_oci_pkg_referrer_index",
|
||||
"org.opencontainers.image.created": "2021-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const manifestJSON = JSON.stringify(manifest)
|
||||
|
||||
expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, ''))
|
||||
})
|
||||
|
||||
it('uses the current time if no created date is provided', () => {
|
||||
const manifest = testReferrerIndexManifest(false)
|
||||
expect(
|
||||
manifest.annotations['org.opencontainers.image.created']
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
function testActionPackageManifest(setCreated = true): {
|
||||
manifest: OCIImageManifest
|
||||
tarFile: FileMetadata
|
||||
zipFile: FileMetadata
|
||||
} {
|
||||
const date = new Date('2021-01-01T00:00:00Z')
|
||||
const repo = 'test-org/test-repo'
|
||||
const version = '1.2.3'
|
||||
const repoId = '123'
|
||||
const ownerId = '456'
|
||||
const sourceCommit = 'abc'
|
||||
const tarFile: FileMetadata = {
|
||||
path: '/test/test/test.tar.gz',
|
||||
sha256: 'tarSha',
|
||||
size: 123
|
||||
}
|
||||
const zipFile: FileMetadata = {
|
||||
path: '/test/test/test.zip',
|
||||
sha256: 'zipSha',
|
||||
size: 456
|
||||
}
|
||||
|
||||
const manifest = createActionPackageManifest(
|
||||
tarFile,
|
||||
zipFile,
|
||||
repo,
|
||||
repoId,
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
|
||||
return {
|
||||
manifest,
|
||||
tarFile,
|
||||
zipFile
|
||||
}
|
||||
}
|
||||
|
||||
function testAttestationManifest(setCreated = true): OCIImageManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createSigstoreAttestationManifest(
|
||||
10,
|
||||
'bundleDigest',
|
||||
'application/vnd.dev.sigstore.bundle.v0.3+json',
|
||||
'https://slsa.dev/provenance/v1',
|
||||
100,
|
||||
'subjectDigest',
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function testReferrerIndexManifest(setCreated = true): OCIIndexManifest {
|
||||
const date = new Date(createdTimestamp)
|
||||
return createReferrerTagManifest(
|
||||
'attDigest',
|
||||
100,
|
||||
'application/vnd.dev.sigstore.bundle.v0.3+json',
|
||||
'https://slsa.dev/provenance/v1',
|
||||
date,
|
||||
setCreated ? date : undefined
|
||||
)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
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:
|
||||
github-token:
|
||||
description: 'The GitHub actions token used to authenticate with GitHub APIs'
|
||||
default: ${{ github.token }}
|
||||
|
||||
outputs:
|
||||
package-manifest-sha:
|
||||
description: 'A sha256 hash of the package manifest'
|
||||
attestation-manifest-sha:
|
||||
description: 'The sha256 of the provenance attestation uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
referrer-index-manifest-sha:
|
||||
description: 'The sha256 of the referrer index uploaded to GHCR. This is not present if the package is not attested, e.g. in enterprise environments.'
|
||||
|
||||
runs:
|
||||
using: node24
|
||||
main: dist/index.js
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 97.06%"><title>Coverage: 97.06%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">97.06%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">97.06%</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+128766
File diff suppressed because one or more lines are too long
+3921
File diff suppressed because it is too large
Load Diff
Generated
+8677
File diff suppressed because it is too large
Load Diff
+104
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"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": ">=24"
|
||||
},
|
||||
"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": "rm -rf dist && ncc build src/index.ts --license licenses.txt",
|
||||
"package:watch": "npm run package -- --watch",
|
||||
"test": "jest",
|
||||
"start": "node dist/index.js",
|
||||
"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/attest": "^1.4.0",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@sigstore/oci": "^0.3.7",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"archiver": "^7.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"simple-git": "^3.22.0",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-github": "^4.10.1",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-jsonc": "^2.13.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"make-coverage-badge": "^1.2.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-eslint": "^16.3.0",
|
||||
"ts-jest": "^29.2.3",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
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,65 @@
|
||||
export async function getRepositoryMetadata(
|
||||
githubAPIURL: string,
|
||||
repository: string,
|
||||
token: string
|
||||
): Promise<{ repoId: string; ownerId: string; visibility: string }> {
|
||||
const response = await fetch(`${githubAPIURL}/repos/${repository}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch repository metadata due to bad status code: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Check that the response contains the expected data
|
||||
if (!data.id || !data.owner.id) {
|
||||
throw new Error(
|
||||
`Failed to fetch repository metadata: unexpected response format`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
repoId: String(data.id),
|
||||
ownerId: String(data.owner.id),
|
||||
visibility: String(data.visibility)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerRegistryURL(
|
||||
githubAPIURL: string,
|
||||
token: string
|
||||
): Promise<URL> {
|
||||
const response = await fetch(
|
||||
`${githubAPIURL}/packages/container-registry-url`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch container registry url due to bad status code: ${response.status}`
|
||||
)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.url) {
|
||||
throw new Error(
|
||||
`Failed to fetch repository metadata: unexpected response format`
|
||||
)
|
||||
}
|
||||
|
||||
const registryURL: URL = new URL(data.url)
|
||||
return registryURL
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import * as apiClient from './api-client'
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
|
||||
// All the environment options required to run the action
|
||||
export interface PublishActionOptions {
|
||||
// The name of the repository in the format owner/repo
|
||||
nameWithOwner: string
|
||||
// The GitHub token to use for API requests
|
||||
token: string
|
||||
// The base URL for the GitHub API
|
||||
apiBaseUrl: string
|
||||
// The base URL for the GitHub Container Registry
|
||||
containerRegistryUrl: URL
|
||||
// The directory where the action is running, used for git operations
|
||||
workspaceDir: string
|
||||
// The directory set up to be used for temporary files by the runner
|
||||
runnerTempDir: string
|
||||
// Whether this action is running in enterprise, determined from the github URL
|
||||
isEnterprise: boolean
|
||||
// The visibility of the action repository ("public", "internal" or "private")
|
||||
repositoryVisibility: string
|
||||
// The repository ID of the action repository
|
||||
repositoryId: string
|
||||
// The owner ID of the action repository
|
||||
repositoryOwnerId: string
|
||||
// The event that triggered the action
|
||||
event: string
|
||||
// The ref that triggered the action, associated with the event
|
||||
ref: string
|
||||
// The commit SHA associated with the ref that triggered the action
|
||||
sha: string
|
||||
}
|
||||
|
||||
export async function resolvePublishActionOptions(): Promise<PublishActionOptions> {
|
||||
// Action Inputs
|
||||
const token: string = core.getInput('github-token') || ''
|
||||
if (token === '') {
|
||||
throw new Error(`Could not find GITHUB_TOKEN.`)
|
||||
}
|
||||
|
||||
// Context Inputs
|
||||
const event: string = github.context.eventName
|
||||
if (event === '') {
|
||||
throw new Error(`Could not find event name.`)
|
||||
}
|
||||
|
||||
const ref: string = github.context.ref || ''
|
||||
if (ref === '') {
|
||||
throw new Error(`Could not find GITHUB_REF.`)
|
||||
}
|
||||
|
||||
const nameWithOwner: string =
|
||||
github.context.payload.repository?.full_name || ''
|
||||
if (nameWithOwner === '') {
|
||||
throw new Error(`Could not find Repository.`)
|
||||
}
|
||||
|
||||
const sha: string = github.context.sha || ''
|
||||
if (sha === '') {
|
||||
throw new Error(`Could not find GITHUB_SHA.`)
|
||||
}
|
||||
|
||||
const apiBaseUrl: string = github.context.apiUrl || ''
|
||||
if (apiBaseUrl === '') {
|
||||
throw new Error(`Could not find GITHUB_API_URL.`)
|
||||
}
|
||||
|
||||
const githubServerUrl = github.context.serverUrl || ''
|
||||
if (githubServerUrl === '') {
|
||||
throw new Error(`Could not find GITHUB_SERVER_URL.`)
|
||||
}
|
||||
|
||||
// Environment Variables
|
||||
const workspaceDir: string = process.env.GITHUB_WORKSPACE || ''
|
||||
if (workspaceDir === '') {
|
||||
throw new Error(`Could not find GITHUB_WORKSPACE.`)
|
||||
}
|
||||
|
||||
const runnerTempDir: string = process.env.RUNNER_TEMP || ''
|
||||
if (runnerTempDir === '') {
|
||||
throw new Error(`Could not find RUNNER_TEMP.`)
|
||||
}
|
||||
|
||||
const repositoryId = process.env.GITHUB_REPOSITORY_ID || ''
|
||||
if (repositoryId === '') {
|
||||
throw new Error(`Could not find GITHUB_REPOSITORY_ID.`)
|
||||
}
|
||||
|
||||
const repositoryOwnerId = process.env.GITHUB_REPOSITORY_OWNER_ID || ''
|
||||
if (repositoryOwnerId === '') {
|
||||
throw new Error(`Could not find GITHUB_REPOSITORY_OWNER_ID.`)
|
||||
}
|
||||
|
||||
// Required Values fetched from the GitHub API
|
||||
const containerRegistryUrl: URL = await apiClient.getContainerRegistryURL(
|
||||
apiBaseUrl,
|
||||
token
|
||||
)
|
||||
|
||||
const isEnterprise =
|
||||
!githubServerUrl.includes('https://github.com') &&
|
||||
!githubServerUrl.endsWith('.ghe.com')
|
||||
|
||||
const repoMetadata = await apiClient.getRepositoryMetadata(
|
||||
apiBaseUrl,
|
||||
nameWithOwner,
|
||||
token
|
||||
)
|
||||
|
||||
if (repoMetadata.visibility === '') {
|
||||
throw new Error(`Could not find repository visibility.`)
|
||||
}
|
||||
|
||||
if (repoMetadata.repoId !== repositoryId) {
|
||||
throw new Error(`Repository ID mismatch.`)
|
||||
}
|
||||
|
||||
if (repoMetadata.ownerId !== repositoryOwnerId) {
|
||||
throw new Error(`Repository Owner ID mismatch.`)
|
||||
}
|
||||
|
||||
const repositoryVisibility = repoMetadata.visibility
|
||||
|
||||
return {
|
||||
event,
|
||||
ref,
|
||||
workspaceDir,
|
||||
nameWithOwner,
|
||||
token,
|
||||
apiBaseUrl,
|
||||
runnerTempDir,
|
||||
sha,
|
||||
containerRegistryUrl,
|
||||
isEnterprise,
|
||||
repositoryVisibility,
|
||||
repositoryId,
|
||||
repositoryOwnerId
|
||||
}
|
||||
}
|
||||
|
||||
// When printing this object, we want to hide some of them from being displayed
|
||||
const internalKeys = new Set<string>([
|
||||
'token',
|
||||
'runnerTempDir',
|
||||
'repositoryId',
|
||||
'repositoryOwnerId'
|
||||
])
|
||||
|
||||
export function serializeOptions(options: PublishActionOptions): string {
|
||||
return JSON.stringify(
|
||||
options,
|
||||
(key: string, value: unknown) =>
|
||||
internalKeys.has(key) ? undefined : value,
|
||||
2 // 2 spaces for pretty-printing
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as fs from 'fs'
|
||||
import fsExtra from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import * as tar from 'tar'
|
||||
import * as archiver from 'archiver'
|
||||
import * as crypto from 'crypto'
|
||||
import * as simpleGit from 'simple-git'
|
||||
|
||||
export interface FileMetadata {
|
||||
path: string
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
// Simple convenience around creating subdirectories in the same base temporary directory
|
||||
export function createTempDir(tmpDirPath: string, subDirName: string): string {
|
||||
const tempDir = path.join(tmpDirPath, subDirName)
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
return tempDir
|
||||
}
|
||||
|
||||
// Creates both a tar.gz and zip archive of the given directory and returns the paths to both archives (stored in the provided target directory)
|
||||
// as well as the size/sha256 hash of each file.
|
||||
export async function createArchives(
|
||||
distPath: string,
|
||||
archiveTargetPath: string
|
||||
): Promise<{ zipFile: FileMetadata; tarFile: FileMetadata }> {
|
||||
const zipPath = path.join(archiveTargetPath, `archive.zip`)
|
||||
const tarPath = path.join(archiveTargetPath, `archive.tar.gz`)
|
||||
|
||||
const createZipPromise = new Promise<FileMetadata>((resolve, reject) => {
|
||||
const output = fs.createWriteStream(zipPath)
|
||||
const archive = archiver.create('zip')
|
||||
|
||||
output.on('error', (err: Error) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
archive.on('error', (err: Error) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
output.on('close', () => {
|
||||
resolve(fileMetadata(zipPath))
|
||||
})
|
||||
|
||||
archive.pipe(output)
|
||||
archive.directory(distPath, 'action')
|
||||
archive.finalize()
|
||||
})
|
||||
|
||||
const createTarPromise = new Promise<FileMetadata>((resolve, reject) => {
|
||||
tar
|
||||
.c(
|
||||
{
|
||||
file: tarPath,
|
||||
C: distPath,
|
||||
gzip: true,
|
||||
prefix: 'action'
|
||||
},
|
||||
['.']
|
||||
)
|
||||
// eslint-disable-next-line github/no-then
|
||||
.catch(err => {
|
||||
reject(err)
|
||||
})
|
||||
// eslint-disable-next-line github/no-then
|
||||
.then(() => {
|
||||
resolve(fileMetadata(tarPath))
|
||||
})
|
||||
})
|
||||
|
||||
const [zipFile, tarFile] = await Promise.all([
|
||||
createZipPromise,
|
||||
createTarPromise
|
||||
])
|
||||
|
||||
return { zipFile, tarFile }
|
||||
}
|
||||
|
||||
export function isDirectory(dirPath: string): boolean {
|
||||
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory()
|
||||
}
|
||||
|
||||
export function readFileContents(filePath: string): Buffer {
|
||||
return fs.readFileSync(filePath)
|
||||
}
|
||||
|
||||
// Copy actions files from sourceDir to targetDir, excluding the .git folder.
|
||||
export function stageActionFiles(actionDir: string, targetDir: string): void {
|
||||
fsExtra.copySync(actionDir, targetDir, {
|
||||
filter: (src: string) => {
|
||||
const basename = path.basename(src)
|
||||
|
||||
// Filter out the .git folder.
|
||||
if (basename === '.git') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure the correct SHA is checked out for the tag by inspecting the git metadata in the workspace
|
||||
// and comparing it to the information actions provided us.
|
||||
// Provided ref should be in format refs/tags/<tagname>.
|
||||
export async function ensureTagAndRefCheckedOut(
|
||||
tagRef: string,
|
||||
expectedSha: string,
|
||||
gitDir: string
|
||||
): Promise<void> {
|
||||
if (!tagRef.startsWith('refs/tags/')) {
|
||||
throw new Error(`Tag ref provided is not in expected format.`)
|
||||
}
|
||||
|
||||
const git: simpleGit.SimpleGit = simpleGit.simpleGit(gitDir)
|
||||
|
||||
let tagCommitSha: string
|
||||
|
||||
try {
|
||||
tagCommitSha = await git.raw(['rev-parse', '--verify', tagRef])
|
||||
} catch (err) {
|
||||
throw new Error(`Error retrieving commit associated with tag: ${err}`)
|
||||
}
|
||||
if (tagCommitSha.trim() !== expectedSha) {
|
||||
throw new Error(
|
||||
`The commit associated with the tag ${tagRef} does not match the SHA of the commit provided by the actions context.`
|
||||
)
|
||||
}
|
||||
|
||||
let currentlyCheckedOutSha: string
|
||||
try {
|
||||
currentlyCheckedOutSha = await git.revparse(['HEAD'])
|
||||
} catch (err) {
|
||||
throw new Error(`Error validating checked out tag and ref: ${err}`)
|
||||
}
|
||||
if (currentlyCheckedOutSha.trim() !== expectedSha) {
|
||||
throw new Error(
|
||||
`The expected commit associated with the tag ${tagRef} is not checked out.`
|
||||
)
|
||||
}
|
||||
|
||||
// Call git status to check for any changes in the working directory
|
||||
// This version of this action only supports uploading actions packages
|
||||
// which contain the same content as the repository at the appropriate source commit.
|
||||
let status: simpleGit.StatusResult
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch (err) {
|
||||
throw new Error(`Error checking git status: ${err}`)
|
||||
}
|
||||
if (!status.isClean()) {
|
||||
throw new Error(
|
||||
`The working directory has uncommitted changes. Uploading modified code from the checked out repository is not supported by this action.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Converts a file path to a filemetadata object by querying the fs for relevant metadata.
|
||||
async function fileMetadata(filePath: string): Promise<FileMetadata> {
|
||||
const stats = fs.statSync(filePath)
|
||||
const size = stats.size
|
||||
const hash = crypto.createHash('sha256')
|
||||
const fileStream = fs.createReadStream(filePath)
|
||||
return new Promise((resolve, reject) => {
|
||||
fileStream.on('data', data => {
|
||||
hash.update(data)
|
||||
})
|
||||
fileStream.on('end', () => {
|
||||
const sha256 = hash.digest('hex')
|
||||
resolve({
|
||||
path: filePath,
|
||||
size,
|
||||
sha256: `sha256:${sha256}`
|
||||
})
|
||||
})
|
||||
fileStream.on('error', err => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as ociContainer from './oci-container'
|
||||
|
||||
const defaultRetries = 5
|
||||
const defaultBackoff = 1000
|
||||
const retryableStatusCodes = [408, 429, 500, 502, 503, 504]
|
||||
|
||||
export interface RetryOptions {
|
||||
retries: number
|
||||
backoff: number
|
||||
}
|
||||
|
||||
export class Client {
|
||||
private _b64Token: string
|
||||
private _registry: URL
|
||||
private _retryOptions: RetryOptions
|
||||
|
||||
constructor(
|
||||
token: string,
|
||||
registry: URL,
|
||||
retryOptions: RetryOptions = {
|
||||
retries: defaultRetries,
|
||||
backoff: defaultBackoff
|
||||
}
|
||||
) {
|
||||
this._b64Token = Buffer.from(token).toString('base64')
|
||||
this._registry = registry
|
||||
this._retryOptions = retryOptions
|
||||
}
|
||||
|
||||
async uploadOCIImageManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIImageManifest,
|
||||
blobs: Map<string, Buffer>,
|
||||
tag?: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
if (tag) {
|
||||
core.info(
|
||||
`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
} else {
|
||||
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`)
|
||||
}
|
||||
|
||||
// We must also upload the config layer
|
||||
const layersToUpload = manifest.layers.concat(manifest.config)
|
||||
|
||||
const layerUploads: Promise<void>[] = layersToUpload.map(async layer => {
|
||||
const blob = blobs.get(layer.digest)
|
||||
if (!blob) {
|
||||
throw new Error(`Blob for layer ${layer.digest} not found`)
|
||||
}
|
||||
return this.uploadLayer(layer, blob, repository)
|
||||
})
|
||||
|
||||
await Promise.all(layerUploads)
|
||||
|
||||
const publishedDigest = await this.uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
repository,
|
||||
tag || manifestSHA
|
||||
)
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
)
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
|
||||
async uploadOCIIndexManifest(
|
||||
repository: string,
|
||||
manifest: ociContainer.OCIIndexManifest,
|
||||
tag: string
|
||||
): Promise<string> {
|
||||
const manifestSHA = ociContainer.sha256Digest(manifest)
|
||||
|
||||
core.info(
|
||||
`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`
|
||||
)
|
||||
|
||||
const publishedDigest = await this.uploadManifest(
|
||||
JSON.stringify(manifest),
|
||||
manifest.mediaType,
|
||||
repository,
|
||||
tag
|
||||
)
|
||||
|
||||
if (publishedDigest !== manifestSHA) {
|
||||
throw new Error(
|
||||
`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`
|
||||
)
|
||||
}
|
||||
|
||||
return manifestSHA
|
||||
}
|
||||
|
||||
private async uploadLayer(
|
||||
layer: ociContainer.Descriptor,
|
||||
data: Buffer,
|
||||
repository: string
|
||||
): Promise<void> {
|
||||
const checkExistsResponse = await this.fetchWithRetries(
|
||||
this.checkBlobEndpoint(repository, layer.digest),
|
||||
{
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
checkExistsResponse.status === 200 ||
|
||||
checkExistsResponse.status === 202
|
||||
) {
|
||||
core.info(`Layer ${layer.digest} already exists. Skipping upload.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (checkExistsResponse.status !== 404) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`check blob (${layer.digest}) exists`,
|
||||
checkExistsResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
core.info(`Uploading layer ${layer.digest}.`)
|
||||
|
||||
const initiateUploadBlobURL = this.uploadBlobEndpoint(repository)
|
||||
|
||||
const initiateUploadResponse = await this.fetchWithRetries(
|
||||
initiateUploadBlobURL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`
|
||||
},
|
||||
body: JSON.stringify(layer)
|
||||
}
|
||||
)
|
||||
|
||||
if (initiateUploadResponse.status !== 202) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`initiate layer upload`,
|
||||
initiateUploadResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const locationResponseHeader =
|
||||
initiateUploadResponse.headers.get('location')
|
||||
if (locationResponseHeader === undefined) {
|
||||
throw new Error(
|
||||
`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`
|
||||
)
|
||||
}
|
||||
|
||||
const pathname = `${locationResponseHeader}?digest=${layer.digest}`
|
||||
const uploadBlobUrl = new URL(pathname, this._registry).toString()
|
||||
|
||||
const putResponse = await this.fetchWithRetries(uploadBlobUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Content-Length': layer.size.toString()
|
||||
},
|
||||
body: data
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(
|
||||
`layer (${layer.digest}) upload`,
|
||||
putResponse
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the manifest and returns the digest returned by GHCR
|
||||
private async uploadManifest(
|
||||
manifestJSON: string,
|
||||
manifestMediaType: string,
|
||||
repository: string,
|
||||
version: string
|
||||
): Promise<string> {
|
||||
const manifestUrl = this.manifestEndpoint(repository, version)
|
||||
|
||||
core.info(`Uploading manifest to ${manifestUrl}.`)
|
||||
|
||||
const putResponse = await this.fetchWithRetries(manifestUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._b64Token}`,
|
||||
'Content-Type': manifestMediaType
|
||||
},
|
||||
body: manifestJSON
|
||||
})
|
||||
|
||||
if (putResponse.status !== 201) {
|
||||
throw new Error(
|
||||
await errorMessageForFailedRequest(`manifest upload`, putResponse)
|
||||
)
|
||||
}
|
||||
|
||||
const digestResponseHeader =
|
||||
putResponse.headers.get('docker-content-digest') || ''
|
||||
|
||||
return digestResponseHeader
|
||||
}
|
||||
|
||||
private checkBlobEndpoint(repository: string, digest: string): string {
|
||||
return new URL(
|
||||
`v2/${repository}/blobs/${digest}`,
|
||||
this._registry
|
||||
).toString()
|
||||
}
|
||||
|
||||
private uploadBlobEndpoint(repository: string): string {
|
||||
return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString()
|
||||
}
|
||||
|
||||
private manifestEndpoint(repository: string, version: string): string {
|
||||
return new URL(
|
||||
`v2/${repository}/manifests/${version}`,
|
||||
this._registry
|
||||
).toString()
|
||||
}
|
||||
|
||||
private async fetchWithDebug(
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`)
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
core.debug(`Response with ${JSON.stringify(response)}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
core.debug(`Error with ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithRetries(
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const allowedAttempts = this._retryOptions.retries + 1 // Initial attempt + retries
|
||||
|
||||
for (
|
||||
let attemptNumber = 1;
|
||||
attemptNumber <= allowedAttempts;
|
||||
attemptNumber++
|
||||
) {
|
||||
let backoff = this._retryOptions.backoff
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithDebug(url, config)
|
||||
|
||||
// If this is the last attempt, just return it
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
return response
|
||||
}
|
||||
|
||||
// If the response is retryable, backoff and retry
|
||||
if (retryableStatusCodes.includes(response.status)) {
|
||||
const retryAfter = response.headers.get('retry-after')
|
||||
if (retryAfter) {
|
||||
backoff = parseInt(retryAfter) * 1000 // convert to ms
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Received ${response.status} response. Retrying after ${backoff}ms...`
|
||||
)
|
||||
await new Promise(resolve => setTimeout(resolve, backoff))
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, just return the response
|
||||
return response
|
||||
} catch (error) {
|
||||
// If this is the last attempt, throw the error
|
||||
if (attemptNumber === allowedAttempts) {
|
||||
throw error
|
||||
}
|
||||
|
||||
core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`)
|
||||
await new Promise(resolve => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
|
||||
// Should be unreachable
|
||||
throw new Error('Exhausted retries without a successful response')
|
||||
}
|
||||
}
|
||||
|
||||
interface ghcrError {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// Generate an error message for a failed HTTP request
|
||||
async function errorMessageForFailedRequest(
|
||||
requestDescription: string,
|
||||
response: Response
|
||||
): Promise<string> {
|
||||
const bodyText = await response.text()
|
||||
|
||||
// Try to parse the body as JSON and extract the expected fields returned from GHCR
|
||||
// Expected format: { "errors": [{"code": "BAD_REQUEST", "message": "Something went wrong."}] }
|
||||
// If the body does not match the expected format, just return the whole response body
|
||||
let errorString = `Response Body: ${bodyText}.`
|
||||
|
||||
try {
|
||||
const body = JSON.parse(bodyText)
|
||||
const errors = body.errors
|
||||
|
||||
if (
|
||||
Array.isArray(errors) &&
|
||||
errors.length > 0 &&
|
||||
errors.every(isGHCRError)
|
||||
) {
|
||||
const errorMessages = errors.map((error: ghcrError) => {
|
||||
return `${error.code} - ${error.message}`
|
||||
})
|
||||
errorString = `Errors: ${errorMessages.join(', ')}`
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
return `Unexpected ${response.status} ${response.statusText} response from ${requestDescription}. ${errorString}`
|
||||
}
|
||||
|
||||
// Runtime checks that parsed JSON object is in the expected format
|
||||
// {"code": "BAD_REQUEST", "message": "Something went wrong."}
|
||||
function isGHCRError(obj: unknown): boolean {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
'code' in obj &&
|
||||
typeof (obj as { code: unknown }).code === 'string' &&
|
||||
'message' in obj &&
|
||||
typeof (obj as { message: unknown }).message === 'string'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The entrypoint for the action.
|
||||
*/
|
||||
import { run } from './main'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
run()
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
import * as core from '@actions/core'
|
||||
import semver from 'semver'
|
||||
import * as fsHelper from './fs-helper'
|
||||
import * as ociContainer from './oci-container'
|
||||
import * as ghcr from './ghcr-client'
|
||||
import * as attest from '@actions/attest'
|
||||
import * as cfg from './config'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* The main function for the action.
|
||||
* @returns {Promise<void>} Resolves when the action is complete.
|
||||
*/
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
const options: cfg.PublishActionOptions =
|
||||
await cfg.resolvePublishActionOptions()
|
||||
|
||||
core.info(`Publishing action package version with options:`)
|
||||
core.info(cfg.serializeOptions(options))
|
||||
|
||||
const semverTag: semver.SemVer = parseSemverTagFromRef(options)
|
||||
|
||||
// Ensure the correct SHA is checked out for the tag we're parsing, otherwise the bundled content will be incorrect.
|
||||
await fsHelper.ensureTagAndRefCheckedOut(
|
||||
options.ref,
|
||||
options.sha,
|
||||
options.workspaceDir
|
||||
)
|
||||
|
||||
const stagedActionFilesDir = fsHelper.createTempDir(
|
||||
options.runnerTempDir,
|
||||
'staging'
|
||||
)
|
||||
fsHelper.stageActionFiles(options.workspaceDir, stagedActionFilesDir)
|
||||
|
||||
const archiveDir = fsHelper.createTempDir(options.runnerTempDir, 'archives')
|
||||
const archives = await fsHelper.createArchives(
|
||||
stagedActionFilesDir,
|
||||
archiveDir
|
||||
)
|
||||
|
||||
const manifest = ociContainer.createActionPackageManifest(
|
||||
archives.tarFile,
|
||||
archives.zipFile,
|
||||
options.nameWithOwner,
|
||||
options.repositoryId,
|
||||
options.repositoryOwnerId,
|
||||
options.sha,
|
||||
semverTag.raw,
|
||||
new Date()
|
||||
)
|
||||
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest)
|
||||
|
||||
const ghcrClient = new ghcr.Client(
|
||||
options.token,
|
||||
options.containerRegistryUrl
|
||||
)
|
||||
|
||||
// Attestations are not supported in GHES.
|
||||
if (!options.isEnterprise) {
|
||||
const { bundle, bundleDigest, bundleMediaType, bundlePredicateType } =
|
||||
await generateAttestation(manifestDigest, semverTag.raw, options)
|
||||
|
||||
const attestationCreated = new Date()
|
||||
const attestationManifest =
|
||||
ociContainer.createSigstoreAttestationManifest(
|
||||
bundle.length,
|
||||
bundleDigest,
|
||||
bundleMediaType,
|
||||
bundlePredicateType,
|
||||
ociContainer.sizeInBytes(manifest),
|
||||
manifestDigest,
|
||||
attestationCreated
|
||||
)
|
||||
|
||||
const referrerIndexManifest = ociContainer.createReferrerTagManifest(
|
||||
ociContainer.sha256Digest(attestationManifest),
|
||||
ociContainer.sizeInBytes(attestationManifest),
|
||||
bundleMediaType,
|
||||
bundlePredicateType,
|
||||
attestationCreated
|
||||
)
|
||||
|
||||
const { attestationSHA, referrerIndexSHA } = await publishAttestation(
|
||||
ghcrClient,
|
||||
options.nameWithOwner,
|
||||
bundle,
|
||||
bundleDigest,
|
||||
manifest,
|
||||
attestationManifest,
|
||||
referrerIndexManifest
|
||||
)
|
||||
|
||||
if (attestationSHA !== undefined) {
|
||||
core.info(`Uploaded attestation ${attestationSHA}`)
|
||||
core.setOutput('attestation-manifest-sha', attestationSHA)
|
||||
}
|
||||
if (referrerIndexSHA !== undefined) {
|
||||
core.info(`Uploaded referrer index ${referrerIndexSHA}`)
|
||||
core.setOutput('referrer-index-manifest-sha', referrerIndexSHA)
|
||||
}
|
||||
}
|
||||
|
||||
const publishedDigest = await publishImmutableActionVersion(
|
||||
ghcrClient,
|
||||
options.nameWithOwner,
|
||||
semverTag.raw,
|
||||
archives.zipFile,
|
||||
archives.tarFile,
|
||||
manifest
|
||||
)
|
||||
|
||||
core.setOutput('package-manifest-sha', publishedDigest)
|
||||
} catch (error) {
|
||||
// Fail the workflow run if an error occurs
|
||||
if (error instanceof Error) core.setFailed(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// This action can be triggered by any workflow that specifies a tag as its GITHUB_REF.
|
||||
// This includes releases, creating or pushing tags, or workflow_dispatch.
|
||||
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#about-events-that-trigger-workflows.
|
||||
function parseSemverTagFromRef(opts: cfg.PublishActionOptions): semver.SemVer {
|
||||
const ref = opts.ref
|
||||
|
||||
if (!ref.startsWith('refs/tags/')) {
|
||||
throw new Error(`The ref ${ref} is not a valid tag reference.`)
|
||||
}
|
||||
|
||||
const rawTag = ref.replace(/^refs\/tags\//, '')
|
||||
const semverTag = semver.parse(rawTag.replace(/^v/, ''))
|
||||
if (!semverTag) {
|
||||
throw new Error(
|
||||
`${rawTag} is not a valid semantic version tag, and so cannot be uploaded to the action package.`
|
||||
)
|
||||
}
|
||||
|
||||
return semverTag
|
||||
}
|
||||
|
||||
async function publishImmutableActionVersion(
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
semverTag: string,
|
||||
zipFile: fsHelper.FileMetadata,
|
||||
tarFile: fsHelper.FileMetadata,
|
||||
manifest: ociContainer.OCIImageManifest
|
||||
): Promise<string> {
|
||||
const manifestDigest = ociContainer.sha256Digest(manifest)
|
||||
|
||||
core.info(
|
||||
`Creating GHCR package ${manifestDigest} for release with semver: ${semverTag}.`
|
||||
)
|
||||
|
||||
const files = new Map<string, Buffer>()
|
||||
files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path))
|
||||
files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path))
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
|
||||
return await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
manifest,
|
||||
files,
|
||||
semverTag
|
||||
)
|
||||
}
|
||||
|
||||
async function publishAttestation(
|
||||
client: ghcr.Client,
|
||||
nameWithOwner: string,
|
||||
bundle: Buffer,
|
||||
bundleDigest: string,
|
||||
subjectManifest: ociContainer.OCIImageManifest,
|
||||
attestationManifest: ociContainer.OCIImageManifest,
|
||||
referrerIndexManifest: ociContainer.OCIIndexManifest
|
||||
): Promise<{
|
||||
attestationSHA: string
|
||||
referrerIndexSHA: string
|
||||
}> {
|
||||
const attestationManifestDigest =
|
||||
ociContainer.sha256Digest(attestationManifest)
|
||||
const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest)
|
||||
const referrerIndexManifestDigest = ociContainer.sha256Digest(
|
||||
referrerIndexManifest
|
||||
)
|
||||
|
||||
core.info(
|
||||
`Publishing attestation ${attestationManifestDigest} for subject ${subjectManifestDigest}.`
|
||||
)
|
||||
|
||||
const files = new Map<string, Buffer>()
|
||||
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
||||
files.set(bundleDigest, bundle)
|
||||
|
||||
const attestationSHA = await client.uploadOCIImageManifest(
|
||||
nameWithOwner,
|
||||
attestationManifest,
|
||||
files
|
||||
)
|
||||
|
||||
// The referrer index is tagged with the subject's digest in format sha256-<digest>
|
||||
const referrerTag = subjectManifestDigest.replace(':', '-')
|
||||
|
||||
core.info(
|
||||
`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`
|
||||
)
|
||||
|
||||
const referrerIndexSHA = await client.uploadOCIIndexManifest(
|
||||
nameWithOwner,
|
||||
referrerIndexManifest,
|
||||
referrerTag
|
||||
)
|
||||
|
||||
return { attestationSHA, referrerIndexSHA }
|
||||
}
|
||||
|
||||
async function generateAttestation(
|
||||
manifestDigest: string,
|
||||
semverTag: string,
|
||||
options: cfg.PublishActionOptions
|
||||
): Promise<{
|
||||
bundle: Buffer
|
||||
bundleDigest: string
|
||||
bundleMediaType: string
|
||||
bundlePredicateType: string
|
||||
}> {
|
||||
const subjectName = `${options.nameWithOwner}@${semverTag}`
|
||||
const subjectDigest = removePrefix(manifestDigest, 'sha256:')
|
||||
|
||||
core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`)
|
||||
|
||||
const attestation = await attest.attestProvenance({
|
||||
subjectName,
|
||||
subjectDigest: { sha256: subjectDigest },
|
||||
token: options.token,
|
||||
sigstore: 'github',
|
||||
skipWrite: true // We will upload attestations to GHCR
|
||||
})
|
||||
|
||||
const bundleArtifact = Buffer.from(JSON.stringify(attestation.bundle))
|
||||
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(bundleArtifact)
|
||||
const bundleSHA = hash.digest('hex')
|
||||
|
||||
// We must base64 decode the dsse envelope to grab the predicate type
|
||||
const dsseEnvelopeArtifact = attestation.bundle.dsseEnvelope
|
||||
if (dsseEnvelopeArtifact === undefined) {
|
||||
throw new Error('Attestation bundle is missing dsseEnvelope artifact')
|
||||
}
|
||||
|
||||
const dsseEnvelope = JSON.parse(
|
||||
Buffer.from(dsseEnvelopeArtifact.payload, 'base64').toString('utf-8')
|
||||
)
|
||||
const predicateType = dsseEnvelope.predicateType
|
||||
if (predicateType === undefined) {
|
||||
throw new Error('Attestation bundle is missing predicateType')
|
||||
}
|
||||
|
||||
return {
|
||||
bundle: bundleArtifact,
|
||||
bundleDigest: `sha256:${bundleSHA}`,
|
||||
bundleMediaType: attestation.bundle.mediaType,
|
||||
bundlePredicateType: predicateType
|
||||
}
|
||||
}
|
||||
|
||||
function removePrefix(str: string, prefix: string): string {
|
||||
if (str.startsWith(prefix)) {
|
||||
return str.slice(prefix.length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { FileMetadata } from './fs-helper'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
export const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json'
|
||||
export const imageManifestMediaType =
|
||||
'application/vnd.oci.image.manifest.v1+json'
|
||||
export const actionsPackageMediaType =
|
||||
'application/vnd.github.actions.package.v1+json'
|
||||
export const actionsPackageTarLayerMediaType =
|
||||
'application/vnd.github.actions.package.layer.v1.tar+gzip'
|
||||
export const actionsPackageZipLayerMediaType =
|
||||
'application/vnd.github.actions.package.layer.v1.zip'
|
||||
|
||||
export const actionPackageAnnotationValue = 'actions_oci_pkg'
|
||||
export const actionPackageAttestationAnnotationValue =
|
||||
'actions_oci_pkg_attestation'
|
||||
export const actionPackageReferrerTagAnnotationValue =
|
||||
'actions_oci_pkg_referrer_index'
|
||||
|
||||
export const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json'
|
||||
export const emptyConfigSize = 2
|
||||
export const emptyConfigSha =
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'
|
||||
|
||||
export interface OCIImageManifest {
|
||||
schemaVersion: number
|
||||
mediaType: string
|
||||
artifactType: string
|
||||
config: Descriptor
|
||||
layers: Descriptor[]
|
||||
subject?: Descriptor
|
||||
annotations: { [key: string]: string }
|
||||
}
|
||||
|
||||
export interface OCIIndexManifest {
|
||||
schemaVersion: number
|
||||
mediaType: string
|
||||
manifests: Descriptor[]
|
||||
annotations: { [key: string]: string }
|
||||
}
|
||||
|
||||
export interface Descriptor {
|
||||
mediaType: string
|
||||
size: number
|
||||
digest: string
|
||||
artifactType?: string
|
||||
annotations?: { [key: string]: string }
|
||||
}
|
||||
|
||||
// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package.
|
||||
export function createActionPackageManifest(
|
||||
tarFile: FileMetadata,
|
||||
zipFile: FileMetadata,
|
||||
repository: string,
|
||||
repoId: string,
|
||||
ownerId: string,
|
||||
sourceCommit: string,
|
||||
version: string,
|
||||
created: Date = new Date()
|
||||
): OCIImageManifest {
|
||||
const configLayer = createEmptyConfigLayer()
|
||||
const sanitizedRepo = sanitizeRepository(repository)
|
||||
const tarLayer = createTarLayer(tarFile, sanitizedRepo, version)
|
||||
const zipLayer = createZipLayer(zipFile, sanitizedRepo, version)
|
||||
|
||||
const manifest: OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: actionsPackageMediaType,
|
||||
config: configLayer,
|
||||
layers: [tarLayer, zipLayer],
|
||||
annotations: {
|
||||
'org.opencontainers.image.created': created.toISOString(),
|
||||
'action.tar.gz.digest': tarFile.sha256,
|
||||
'action.zip.digest': zipFile.sha256,
|
||||
'com.github.package.type': actionPackageAnnotationValue,
|
||||
'com.github.package.version': version,
|
||||
'com.github.source.repo.id': repoId,
|
||||
'com.github.source.repo.owner.id': ownerId,
|
||||
'com.github.source.commit': sourceCommit
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function createSigstoreAttestationManifest(
|
||||
bundleSize: number,
|
||||
bundleDigest: string,
|
||||
bundleMediaType: string,
|
||||
bundlePredicateType: string,
|
||||
subjectSize: number,
|
||||
subjectDigest: string,
|
||||
created: Date = new Date()
|
||||
): OCIImageManifest {
|
||||
const configLayer = createEmptyConfigLayer()
|
||||
|
||||
const sigstoreAttestationLayer: Descriptor = {
|
||||
mediaType: bundleMediaType,
|
||||
size: bundleSize,
|
||||
digest: bundleDigest
|
||||
}
|
||||
|
||||
const subject: Descriptor = {
|
||||
mediaType: imageManifestMediaType,
|
||||
size: subjectSize,
|
||||
digest: subjectDigest
|
||||
}
|
||||
|
||||
const manifest: OCIImageManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
config: configLayer,
|
||||
layers: [sigstoreAttestationLayer],
|
||||
subject,
|
||||
|
||||
annotations: {
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType,
|
||||
'com.github.package.type': actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function createReferrerTagManifest(
|
||||
attestationDigest: string,
|
||||
attestationSize: number,
|
||||
bundleMediaType: string,
|
||||
bundlePredicateType: string,
|
||||
attestationCreated: Date,
|
||||
created: Date = new Date()
|
||||
): OCIIndexManifest {
|
||||
const manifest: OCIIndexManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: imageIndexMediaType,
|
||||
manifests: [
|
||||
{
|
||||
mediaType: imageManifestMediaType,
|
||||
artifactType: bundleMediaType,
|
||||
size: attestationSize,
|
||||
digest: attestationDigest,
|
||||
annotations: {
|
||||
'com.github.package.type': actionPackageAttestationAnnotationValue,
|
||||
'org.opencontainers.image.created': attestationCreated.toISOString(),
|
||||
'dev.sigstore.bundle.content': 'dsse-envelope',
|
||||
'dev.sigstore.bundle.predicateType': bundlePredicateType
|
||||
}
|
||||
}
|
||||
],
|
||||
annotations: {
|
||||
'com.github.package.type': actionPackageReferrerTagAnnotationValue,
|
||||
'org.opencontainers.image.created': created.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
// Calculate the SHA256 digest of a given manifest.
|
||||
// This should match the digest which the GitHub container registry calculates for this manifest.
|
||||
export function sha256Digest(
|
||||
manifest: OCIImageManifest | OCIIndexManifest
|
||||
): string {
|
||||
const data = JSON.stringify(manifest)
|
||||
const buffer = Buffer.from(data, 'utf8')
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(buffer)
|
||||
const hexHash = hash.digest('hex')
|
||||
return `sha256:${hexHash}`
|
||||
}
|
||||
|
||||
export function sizeInBytes(
|
||||
manifest: OCIImageManifest | OCIIndexManifest
|
||||
): number {
|
||||
const data = JSON.stringify(manifest)
|
||||
return Buffer.byteLength(data, 'utf8')
|
||||
}
|
||||
|
||||
export function createEmptyConfigLayer(): Descriptor {
|
||||
const configLayer: Descriptor = {
|
||||
mediaType: ociEmptyMediaType,
|
||||
size: emptyConfigSize,
|
||||
digest: emptyConfigSha
|
||||
}
|
||||
|
||||
return configLayer
|
||||
}
|
||||
|
||||
function createZipLayer(
|
||||
zipFile: FileMetadata,
|
||||
repository: string,
|
||||
version: string
|
||||
): Descriptor {
|
||||
const zipLayer: Descriptor = {
|
||||
mediaType: actionsPackageZipLayerMediaType,
|
||||
size: zipFile.size,
|
||||
digest: zipFile.sha256,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': `${repository}_${version}.zip`
|
||||
}
|
||||
}
|
||||
|
||||
return zipLayer
|
||||
}
|
||||
|
||||
function createTarLayer(
|
||||
tarFile: FileMetadata,
|
||||
repository: string,
|
||||
version: string
|
||||
): Descriptor {
|
||||
const tarLayer: Descriptor = {
|
||||
mediaType: actionsPackageTarLayerMediaType,
|
||||
size: tarFile.size,
|
||||
digest: tarFile.sha256,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': `${repository}_${version}.tar.gz`
|
||||
}
|
||||
}
|
||||
|
||||
return tarLayer
|
||||
}
|
||||
|
||||
// Remove slashes so we can use the repository in a filename
|
||||
// repository usually includes the namespace too, e.g. my-org/my-repo
|
||||
function sanitizeRepository(repository: string): string {
|
||||
return repository.replace('/', '-')
|
||||
}
|
||||
@@ -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