Compare commits

...

191 Commits

Author SHA1 Message Date
Konrad Pabjan 4bc8754ffc Merge pull request #208 from actions/konradpabjan/add-new-release-workflow
Check dist/ / Check dist/ (push) Has been cancelled
Continuous Integration / TypeScript Tests (push) Has been cancelled
Lint Code Base / Lint Code Base (push) Has been cancelled
Add release action
2024-10-22 10:09:29 -04:00
Konrad Pabjan f4d851fab3 Simplify release workflow configuration 2024-10-22 09:28:52 -04:00
Konrad Pabjan fc8ba308da Create release-new-action-version.yml 2024-10-22 09:08:09 -04:00
Joel Ambass 3fc9aa365f Merge pull request #200 from actions/jcambass/2024-09-16/update-instructions
Use Release Published
2024-09-16 17:15:28 +02:00
Joel Ambass 2d5c4224f2 Use Release Published 2024-09-16 17:05:18 +02:00
Joel Ambass 4b1aa5c1cd Merge pull request #199 from actions/jcambass/2024-09-16/do-not-check-for-action-yml
Do not assume action.yml exists
2024-09-16 16:29:19 +02:00
Joel Ambass 2acc8d8fc9 Do not assume action.yml exists 2024-09-16 16:23:56 +02:00
Joel Ambass d34ce159aa Merge pull request #198 from actions/Jcambass-patch-1
Use newest Release in README
2024-09-16 15:49:38 +02:00
Joel Ambass d5420c2c97 Use newest Release in README 2024-09-16 15:46:12 +02:00
Joel Ambass 23606e825a Merge pull request #197 from actions/jcambass/2024-09-16/default-to-actions-token
Default to GITHUB_TOKEN
2024-09-16 15:43:51 +02:00
Joel Ambass 391942adf2 Default to GITHUB_TOKEN 2024-09-16 15:16:26 +02:00
Joel Ambass ff725e43e8 Merge pull request #190 from actions/fix_logging
Fix Logging
2024-09-03 17:38:42 +02:00
Joel Ambass 65d4d4211f remove old build artifacts 2024-09-03 17:22:12 +02:00
Joel Ambass 1cbbcdd5ae Fix Logging 2024-09-03 16:51:21 +02:00
Joel Ambass b080e888d4 Merge pull request #189 from actions/jcambass/2024-09-03/only-exclude-git-folder
Only Exclude .git Folder
2024-09-03 16:42:27 +02:00
Joel Ambass 87530877ea We only need to exclude the .git folder 2024-09-03 15:37:40 +02:00
Conor Sloan 49e905350a Merge pull request #185 from actions/conorsloan/remove-unnecessary-token-permission
Remove attestation write permissions from release workflow
2024-08-29 14:56:01 +01:00
Conor Sloan 3a0ab30032 Update README.md
Remove reference to unnecessary permission
2024-08-29 13:45:22 +01:00
Conor Sloan 84bfe2de2e Remove attestation write permissions from release workflow
This isn't needed now that we're storing attestations in GHCR.
2024-08-29 13:43:50 +01:00
Conor Sloan 578de6e124 Merge pull request #183 from actions/conorsloan/update-version-in-readme
Update README.md
2024-08-28 14:05:18 +01:00
Conor Sloan e03465965b Update README.md 2024-08-28 14:02:38 +01:00
Conor Sloan e3f16e22ab Merge pull request #182 from actions/conorsloan/check-for-uncommitted-changes
Fail if local changes made to the checked out action content
2024-08-28 13:56:41 +01:00
Conor Sloan 1255bb0a54 error if local changes made to the checked out action content 2024-08-28 13:22:37 +01:00
Conor Sloan 4aeb3f6341 Merge pull request #181 from actions/conorsloan/fixup-runtime-security
Secure actions execution context
2024-08-28 12:39:22 +01:00
Conor Sloan 86a49c7f6a secure actions execution context 2024-08-28 12:10:13 +01:00
Conor Sloan 8a96626c28 Merge pull request #179 from actions/conorsloan/upload-attestations-to-ghcr
Upload attestations to GHCR instead of Attestations API
2024-08-27 21:27:41 +01:00
Conor Sloan 36e729c5aa grab attestation media type and predicate type from attestation bundle 2024-08-27 20:52:44 +01:00
Conor Sloan 432126c06c change value of package type for referrer index 2024-08-23 13:42:27 +01:00
Conor Sloan 3555a7ef80 update dist 2024-08-23 13:33:13 +01:00
Conor Sloan 1b9faf628d add retries and fix up tests 2024-08-23 13:17:07 +01:00
Conor Sloan 72b670f356 add tests for index upload 2024-08-23 11:06:03 +01:00
Conor Sloan e308348d01 fix up ghcr client tests and remove config from action package layers 2024-08-23 10:56:04 +01:00
Conor Sloan e53d6ca2a2 reinstate main tests 2024-08-23 10:00:06 +01:00
Conor Sloan da1f4d6352 reverse the upload order 2024-08-22 20:30:50 +01:00
Conor Sloan 028b950050 experimental: manually generate and upload all manifests 2024-08-22 20:00:30 +01:00
Conor Sloan bafa38ff94 refactor ghcr client for reusable upload functions 2024-08-22 18:40:02 +01:00
Conor Sloan e44432d3e5 add new OCI manifests for attestations 2024-08-22 18:13:15 +01:00
Conor Sloan c11354f432 upload attestation and referrer index before artifact
This avoids race conditions when the artifact is read but its attestation doesn't exist
2024-08-22 16:10:12 +01:00
Conor Sloan 1f725c56d6 upload attestation to GHCR instead of attestations API 2024-08-22 14:10:50 +01:00
Conor Sloan f213f0c945 Merge pull request #175 from actions/conorsloan/update-attest-package
Update @actions/attest dependency and send Attestations API header
2024-08-21 16:03:33 +01:00
Conor Sloan 8c9931350a update attest dep and send IA header 2024-08-21 11:11:46 +01:00
Conor Sloan 7af620c09c Merge pull request #164 from actions/conorsloan/fix-readme-example-permissions
Update README.md
2024-08-12 18:06:57 +01:00
Beth Brennan 9c79aec798 Merge branch 'main' into conorsloan/fix-readme-example-permissions 2024-08-12 12:53:26 -04:00
Beth Brennan a2e9ffc7b9 Merge pull request #165 from actions/elbrenn/codeowners
Remove unused CODEOWNERS
2024-08-12 12:51:55 -04:00
Beth Brennan 2af7b38c8b Remove unused codeowners 2024-08-12 12:48:32 -04:00
Conor Sloan 3cc27d51e4 Update README.md
Fix permissions in example workflow
2024-08-12 14:12:39 +01:00
Conor Sloan e039e1d6b7 Merge pull request #163 from actions/conorsloan/create-codeowners
Create CODEOWNERS
2024-08-12 11:40:40 +01:00
Conor Sloan 11f5dcdbc3 Create CODEOWNERS 2024-08-12 10:15:17 +01:00
Conor Sloan 91044eb688 Merge pull request #162 from actions/conorsloan/update-deps
Update dependency versions
2024-08-12 10:03:13 +01:00
Conor Sloan cf53527ffc update dep versions 2024-08-12 09:56:19 +01:00
Conor Sloan f58dd8f0ed Merge pull request #161 from actions/conorsloan/fix-self-publishing-workflow-permissions
Update permissions on self-publishing workflow
2024-08-12 09:56:01 +01:00
Conor Sloan a959dfafba replace contents: write with attestations: write in release 2024-08-12 09:53:52 +01:00
Conor Sloan a61106e002 Merge pull request #159 from actions/conorsloan/never-skip-attestation-write
always set skipWrite to false when generating attestations
2024-08-12 09:53:41 +01:00
Conor Sloan 90d59724e7 always set skipWrite to false when generating attestations 2024-08-12 09:51:04 +01:00
Conor Sloan 1a8d07a497 Merge pull request #94 from actions/dependabot/github_actions/super-linter/super-linter-6
Bump super-linter/super-linter from 5 to 6
2024-08-12 09:50:39 +01:00
Conor Sloan 50c672a353 dont ignore tests in eslint 2024-08-10 11:24:48 +01:00
Conor Sloan 0fd4266160 replace default style with validate env var
See https://github.com/super-linter/super-linter/blob/main/docs/upgrade-guide.md#javascript_default_style-and-typescript_default_style
2024-08-10 11:24:48 +01:00
Conor Sloan e58130f44d move permissions to top level 2024-08-10 11:24:48 +01:00
Conor Sloan 229ed04906 ignore tests in eslinter 2024-08-10 11:24:48 +01:00
Conor Sloan 766a6934c5 fix codeql permissions 2024-08-10 11:24:48 +01:00
Conor Sloan b40fcfc004 attempt 1 to fix linter issues 2024-08-10 11:24:48 +01:00
Sneha Kripanandan 67f4b7749e Set fetch-depth for checkout when using super-linter 2024-08-10 11:24:48 +01:00
dependabot[bot] abf929b7e4 Bump super-linter/super-linter from 5 to 6
Bumps [super-linter/super-linter](https://github.com/super-linter/super-linter) from 5 to 6.
- [Release notes](https://github.com/super-linter/super-linter/releases)
- [Changelog](https://github.com/super-linter/super-linter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/super-linter/super-linter/compare/v5...v6)

---
updated-dependencies:
- dependency-name: super-linter/super-linter
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-10 11:24:48 +01:00
Conor Sloan b8bd8fe389 Merge pull request #158 from actions/conorsloan/send-auth-to-ghcr-lookup-endpoint
Send auth token to fetch container registry URL API endpoint
2024-08-09 15:45:58 +01:00
Conor Sloan ffcb1087c4 send auth token to get container registry url endpoint 2024-08-09 14:49:07 +01:00
Conor Sloan f7d49cfdd1 Merge pull request #157 from actions/conorsloan/dump-response-body-on-unexpected-ghcr-response
Include information from GHCR response bodies in error reporting
2024-08-08 16:48:54 +01:00
Conor Sloan bebbbc6eee parse GHCR error format for errors 2024-08-08 14:07:54 +01:00
Conor Sloan 2bbf08d922 print response body when an http request to ghcr returns unexpected status 2024-08-08 11:45:25 +01:00
Conor Sloan 2bc8c192b1 Merge pull request #156 from actions/conorsloan/attest-before-publish
Generate provenance attestation before performing upload to ghcr
2024-08-07 21:48:38 +01:00
Conor Sloan c1f237b012 Generate provenance attestation before performing upload to ghcr
This allows us to check in the backend that a valid attestation exists for a package version before we allow the upload to succeed.
In doing this, we can perform an integrity check that the attestation is valid and all action packages have valid attestations.
2024-08-07 17:13:39 +01:00
Sneha Kripanandan 8215ec2f64 Merge pull request #154 from actions/dependabot/npm_and_yarn/types/jest-29.5.12
Bump @types/jest from 29.5.11 to 29.5.12
2024-08-06 09:39:03 -04:00
dependabot[bot] 5de4baf048 Bump @types/jest from 29.5.11 to 29.5.12
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.11 to 29.5.12.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-05 22:25:35 +00:00
Sneha Kripanandan 9c2a630347 Merge pull request #152 from actions/dependabot/npm_and_yarn/eslint-plugin-jest-28.6.0
Bump eslint-plugin-jest from 27.6.3 to 28.6.0
2024-08-05 15:16:15 -04:00
dependabot[bot] 8e9002fe5a Bump eslint-plugin-jest from 27.6.3 to 28.6.0
Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 27.6.3 to 28.6.0.
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v27.6.3...v28.6.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-jest
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-01 22:17:15 +00:00
Sneha Kripanandan e2e9fea210 Merge pull request #150 from actions/dependabot/npm_and_yarn/eslint-plugin-prettier-5.2.1
Bump eslint-plugin-prettier from 5.1.3 to 5.2.1
2024-08-01 09:07:01 -04:00
dependabot[bot] b757396339 Bump eslint-plugin-prettier from 5.1.3 to 5.2.1
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.1.3 to 5.2.1.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.1.3...v5.2.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-01 12:59:46 +00:00
Sneha Kripanandan ef67e6d74f Merge pull request #149 from actions/dependabot/npm_and_yarn/prettier-3.3.3
Bump prettier from 3.2.4 to 3.3.3
2024-08-01 08:58:29 -04:00
dependabot[bot] cd067bec7f Bump prettier from 3.2.4 to 3.3.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.4 to 3.3.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.4...3.3.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 22:23:15 +00:00
Sneha Kripanandan 36524bea42 Merge pull request #87 from actions/dependabot/npm_and_yarn/typescript-eslint/parser-6.21.0
Bump @typescript-eslint/parser from 6.19.0 to 6.21.0
2024-07-31 13:55:44 -04:00
Sneha Kripanandan d623812b29 Merge branch 'main' into dependabot/npm_and_yarn/typescript-eslint/parser-6.21.0 2024-07-31 13:50:29 -04:00
Sneha Kripanandan 1354f92349 Merge pull request #147 from actions/dependabot/npm_and_yarn/ts-jest-29.2.3
Bump ts-jest from 29.1.1 to 29.2.3
2024-07-31 13:45:56 -04:00
dependabot[bot] 23baf08c4c Bump ts-jest from 29.1.1 to 29.2.3
Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.1 to 29.2.3.
- [Release notes](https://github.com/kulshekhar/ts-jest/releases)
- [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.1...v29.2.3)

---
updated-dependencies:
- dependency-name: ts-jest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 17:09:35 +00:00
Sneha Kripanandan 7fee9b1717 Merge pull request #146 from actions/dependabot/npm_and_yarn/types/node-22.0.0
Bump @types/node from 20.11.13 to 22.0.0
2024-07-31 13:08:10 -04:00
dependabot[bot] 6f395ba687 Bump @types/node from 20.11.13 to 22.0.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.13 to 22.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 17:02:46 +00:00
Sneha Kripanandan e7734cb142 Merge pull request #145 from actions/dependabot/npm_and_yarn/braces-3.0.3
Bump braces from 3.0.2 to 3.0.3
2024-07-31 13:01:30 -04:00
dependabot[bot] d669870b09 Bump braces from 3.0.2 to 3.0.3
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 16:50:48 +00:00
Sneha Kripanandan 81207d4b2c Merge pull request #117 from actions/dependabot/npm_and_yarn/follow-redirects-1.15.6
Bump follow-redirects from 1.15.3 to 1.15.6
2024-07-31 12:49:22 -04:00
dependabot[bot] 011940d503 Bump follow-redirects from 1.15.3 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 16:29:22 +00:00
Sneha Kripanandan 5d5043a13c Merge pull request #97 from actions/dependabot/npm_and_yarn/eslint-8.57.0
Bump eslint from 8.54.0 to 8.57.0
2024-07-31 12:26:52 -04:00
dependabot[bot] 37578f447e Bump eslint from 8.54.0 to 8.57.0
Bumps [eslint](https://github.com/eslint/eslint) from 8.54.0 to 8.57.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.54.0...v8.57.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 16:12:09 +00:00
Sneha Kripanandan b7462ded63 Merge branch 'main' into dependabot/npm_and_yarn/typescript-eslint/parser-6.21.0 2024-07-31 11:59:15 -04:00
Sneha Kripanandan de2db00ca6 Merge pull request #92 from actions/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-6.21.0
Bump @typescript-eslint/eslint-plugin from 6.20.0 to 6.21.0
2024-07-31 11:57:51 -04:00
dependabot[bot] 1660fcacaa Bump @typescript-eslint/eslint-plugin from 6.20.0 to 6.21.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.20.0 to 6.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.21.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 16:50:25 +01:00
Conor Sloan cf36a13357 Merge pull request #144 from actions/conorsloan/minor-readme-update
Update README.md
2024-07-30 17:56:27 +01:00
Conor Sloan d0e1c8dd23 Update README.md 2024-07-30 17:48:02 +01:00
Conor Sloan a67b4b908a Merge pull request #143 from actions/conorsloan/add-readme-disclaimer
add disclaimer to readme
2024-07-30 17:39:05 +01:00
Conor Sloan 3a114e3b75 Update README.md 2024-07-30 17:25:31 +01:00
Conor Sloan 287eff5a0a Update README.md 2024-07-30 17:20:03 +01:00
Conor Sloan b54ac768df Update README.md 2024-07-30 17:12:06 +01:00
Conor Sloan 9bd8f2e9c0 Update README.md 2024-07-30 17:03:18 +01:00
Conor Sloan bc99e92f1f Update README.md 2024-07-30 16:56:30 +01:00
Conor Sloan 638fb07f1a Update README.md 2024-07-30 16:48:33 +01:00
Conor Sloan 58addcb0cc Update README.md 2024-07-30 16:36:28 +01:00
Conor Sloan 5c92b3920b add disclaimer 2024-07-30 15:33:26 +01:00
Conor Sloan d627a3342b Merge pull request #115 from immutable-actions/conorsloan/attestation-finishing-touches
Attestation finishing touches for staffship
2024-04-15 16:55:51 +01:00
Conor Sloan 3d3a333728 remove async in parser 2024-04-15 16:11:50 +01:00
Conor Sloan 18cf56a126 move checking of git checkout out of parse logic 2024-04-15 15:43:26 +01:00
Conor Sloan 881fd1c540 fix fs helper git test 2024-04-15 14:03:10 +01:00
Conor Sloan 17c0582657 check github_ref tag and sha are checked out on parse 2024-04-15 13:45:54 +01:00
Conor Sloan 507635d01b only write attestation for non-private repos 2024-04-15 12:26:26 +01:00
Conor Sloan 6dc0f68595 get visibility when grabbing repo information 2024-04-15 12:03:02 +01:00
Conor Sloan 96609b599a Merge pull request #110 from immutable-actions/add-with-to-self-publish
Update release.yml
2024-04-09 21:32:29 +01:00
Conor Sloan d835c26532 Update release.yml 2024-04-09 21:25:35 +01:00
Conor Sloan cd600c26cd Merge pull request #112 from immutable-actions/ddivad195/update-archive-paths
add subdirectories in archives
2024-04-09 19:52:55 +01:00
ddivad195 85d00a6e39 add subdirectories in archives 2024-04-09 17:05:31 +01:00
David Daly f11d125628 Merge pull request #108 from immutable-actions/ddivad195/re-integrate-toolkit
re-integrate toolkit code to main action
2024-03-26 10:55:52 +00:00
ddivad195 113eb50eb5 re-integrate toolkit code to main action 2024-03-25 17:44:45 +00:00
boxofyellow 369a6e7b30 Merge pull request #102 from immutable-actions/users/boxofyellow/2024_03/bump-toolkit-0.0.7_0
Bump toolkit to 0.0.7
2024-03-08 08:53:06 -05:00
boxofyellow 761ae0d82e Bump toolkit to 0.0.7 2024-03-08 05:45:40 -08:00
David Daly cbce22dbfd Merge pull request #100 from immutable-actions/ddivad195/fix-semver-parsing
fix semver parsing by removing `v` from version
2024-03-05 17:24:03 +00:00
ddivad195 2fabbad58f fix semver parsing by removing 2024-03-05 17:09:16 +00:00
David Daly a4456b225e Merge pull request #101 from immutable-actions/ddivad195/fix-url-parsing
fix isEnterprise check
2024-03-05 17:08:55 +00:00
ddivad195 05bd356814 fix test 2024-03-05 16:51:22 +00:00
ddivad195 9c9b57d4d4 update dist 2024-03-05 16:35:10 +00:00
ddivad195 1529e43c68 fix isEnterprise check 2024-03-05 16:31:13 +00:00
Conor Sloan e3a931402a Merge pull request #98 from immutable-actions/conorsloan/remove-composite-action
Move from composite to regular node action.
2024-03-04 14:58:34 +00:00
Conor Sloan bc3ee93941 fix actions workflows 2: electric boogaloo 2024-03-01 17:07:20 +00:00
Conor Sloan 3c21f58d1c fix actions workflows 2024-03-01 17:04:43 +00:00
Conor Sloan 54d9a343c3 Move from composite to regular node action.
This involves generating the attestation in the code using the new attest library in the actions toolkit.
2024-03-01 16:45:32 +00:00
David Daly 2c0bfdf7d3 Merge pull request #91 from immutable-actions/ddivad195/update-readme
remove path from readme
2024-02-07 17:15:02 +00:00
David Daly 2218323404 remove path from readme
Remove the `path` input from the readme example as its no longer in use.
2024-02-07 15:42:55 +00:00
dependabot[bot] 7cc514f31a Bump @typescript-eslint/parser from 6.19.0 to 6.21.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.19.0 to 6.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.21.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-06 18:37:13 +00:00
David Daly a6a87a7e13 Merge pull request #86 from immutable-actions/ddivad195/replace-axios2
remove axios and replace with fetch
2024-02-06 18:36:27 +00:00
ddivad195 b42b69f193 cleanup debug logging 2024-02-06 18:27:57 +00:00
ddivad195 1167b03ce8 refactor debug logging 2024-02-06 18:27:55 +00:00
ddivad195 4fb632b14a fix lint 2024-02-06 18:27:04 +00:00
ddivad195 6d082c4eab cleanup tests 2024-02-06 18:27:04 +00:00
Edwin Sirko c4d8d934a0 npm bundled 2024-02-06 18:27:01 +00:00
ddivad195 e5b7da2730 update tests to remove axios mocks and mock fetch instead 2024-02-06 18:25:25 +00:00
ddivad195 501681319f replace axios with fetch 2024-02-06 18:25:25 +00:00
ddivad195 38b91834f7 replace axios with fetch 2024-02-06 18:25:25 +00:00
Edwin Sirko caf8cf0ef1 fix event_type (#90) 2024-02-06 13:20:16 -05:00
Edwin Sirko 2222ac6d53 choose your own platform (#89) 2024-02-06 12:56:38 -05:00
Conor Sloan d4e4f829cb Merge pull request #88 from immutable-actions/conorsloan/use-npm-to-run
kick off action using custom npm script and prefix arg
2024-02-06 16:48:41 +00:00
Conor Sloan f2fb01cf17 run bundle 2024-02-06 16:42:15 +00:00
Conor Sloan 1105b75f95 update coverage 2024-02-06 16:39:53 +00:00
Conor Sloan 1b0ee34e34 remove manifest from action output printing 2024-02-06 16:34:26 +00:00
Conor Sloan 646d55a089 properly quote the output variables 2024-02-06 16:34:26 +00:00
Conor Sloan a07f7523c0 quote the path when calling npm to avoid backslash issues 2024-02-06 16:34:26 +00:00
Conor Sloan 3f76c4d47c use GITHUB_WORKSPACE as target dir to package up 2024-02-06 16:34:26 +00:00
Conor Sloan b8317831f8 kick off action using custom npm script and prefix arg
this accepts a prefix path which should be platform-agnostic.
2024-02-06 16:34:26 +00:00
Edwin Sirko c8ca97ca0c elongated e2e test timeout to 90s (#85) 2024-02-06 11:10:25 -05:00
Edwin Sirko 9525e839de handle todos (#82)
* handle todos

* dist/index.js
2024-02-02 14:59:25 -05:00
Edwin Sirko b80af95dd0 use runner's RUNNER_TEMP for temp directory (#75)
* use runner tempdir

* fix tests etc

* feedback

* ran npm install before generating dist
2024-02-02 13:05:08 -05:00
ddivad195 ebbc8c8d58 rebuild dist after dependabot updates 2024-02-02 13:04:37 -05:00
dependabot[bot] b337f88666 Bump @types/node from 20.11.4 to 20.11.13
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.4 to 20.11.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:37 -05:00
dependabot[bot] 2bc73b1fa7 Bump fs-extra from 11.1.1 to 11.2.0
Bumps [fs-extra](https://github.com/jprichardson/node-fs-extra) from 11.1.1 to 11.2.0.
- [Changelog](https://github.com/jprichardson/node-fs-extra/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jprichardson/node-fs-extra/compare/11.1.1...11.2.0)

---
updated-dependencies:
- dependency-name: fs-extra
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:37 -05:00
dependabot[bot] dcd5d901d2 Bump eslint-plugin-jest from 27.6.0 to 27.6.3
Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 27.6.0 to 27.6.3.
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v27.6.0...v27.6.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-jest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:37 -05:00
David Daly 8a5726de70 fix lint 2024-02-02 13:04:37 -05:00
dependabot[bot] cb79bd1a60 Bump eslint-plugin-prettier from 5.0.1 to 5.1.3
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.0.1 to 5.1.3.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.0.1...v5.1.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:36 -05:00
dependabot[bot] 4e41e8883c Bump @types/tar from 6.1.10 to 6.1.11
Bumps [@types/tar](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/tar) from 6.1.10 to 6.1.11.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/tar)

---
updated-dependencies:
- dependency-name: "@types/tar"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:36 -05:00
Edwin Sirko b79f58714f e2e test (#71) 2024-02-02 13:04:36 -05:00
ddivad195 6233cad2a5 fix failing lint and test errors 2024-02-02 13:04:36 -05:00
dependabot[bot] 50e278b239 Bump @typescript-eslint/eslint-plugin from 6.12.0 to 6.20.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.12.0 to 6.20.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.20.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:36 -05:00
dependabot[bot] 6e5c3af726 Bump eslint-plugin-jsonc from 2.10.0 to 2.13.0
Bumps [eslint-plugin-jsonc](https://github.com/ota-meshi/eslint-plugin-jsonc) from 2.10.0 to 2.13.0.
- [Release notes](https://github.com/ota-meshi/eslint-plugin-jsonc/releases)
- [Changelog](https://github.com/ota-meshi/eslint-plugin-jsonc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ota-meshi/eslint-plugin-jsonc/compare/v2.10.0...v2.13.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-jsonc
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:36 -05:00
dependabot[bot] e40ff00655 Bump prettier-eslint from 16.1.2 to 16.3.0
Bumps [prettier-eslint](https://github.com/prettier/prettier-eslint) from 16.1.2 to 16.3.0.
- [Release notes](https://github.com/prettier/prettier-eslint/releases)
- [Changelog](https://github.com/prettier/prettier-eslint/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier-eslint/compare/v16.1.2...v16.3.0)

---
updated-dependencies:
- dependency-name: prettier-eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:35 -05:00
dependabot[bot] f64597ec50 Bump prettier from 3.1.0 to 3.2.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.0 to 3.2.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.0...3.2.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:35 -05:00
dependabot[bot] a9399d2ddb Bump typescript from 5.3.2 to 5.3.3
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.2 to 5.3.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.2...v5.3.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 13:04:35 -05:00
David Daly 621cb8210d only run if environment is not ghes 2024-02-02 13:04:35 -05:00
Edwin Sirko dfbae910c5 fixed bug with fsExtra.copySync (#55) 2024-02-02 13:04:35 -05:00
Conor Sloan 1f47b19ed3 Tying up loose ends (#54)
* various qol updates to publish action

* review comments and run bundle
2024-02-02 13:02:14 -05:00
David Daly 3c4259bfdd re-enable generate-build-provenance step 2024-02-02 13:02:14 -05:00
Edwin Sirko 7472b3f822 refactored path calculation 2024-02-02 13:02:14 -05:00
ddivad195 cb62dd8450 remove console log of path 2024-02-02 13:00:34 -05:00
boxofyellow db688d0eea make sure to populate outputs of the composite action, Disable attestations 2024-02-02 13:00:34 -05:00
Edwin Sirko 5f9b214e33 properly getting CR URL 2024-02-02 12:59:49 -05:00
David Daly 002cf60682 cleanup action steps 2024-02-02 12:58:40 -05:00
boxofyellow 5e2391735e tests 2024-02-02 12:58:40 -05:00
Edwin Sirko c41316d7a8 v0.0.52: fetch CR URL 2024-02-02 12:58:36 -05:00
David Daly 1fbd6bde21 Refactor into a composite action and integrate with generate-build-provenance action
- Refactor the action from a 'node' action to a 'composite' action
- Create and output a sha256 hash of the image manifest
- Update action inputs & outputs
- Integrate with the 'generate-build-provenance' action as part of the composite action
2024-02-02 12:55:36 -05:00
dependabot[bot] 997bea009b Bump @typescript-eslint/parser from 6.12.0 to 6.19.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.12.0 to 6.19.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.19.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] abe17aada8 Bump @types/node from 20.9.4 to 20.11.4
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.9.4 to 20.11.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] b0e29673ab Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] 55e5053422 Bump github/codeql-action from 2 to 3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] aa6729b4a8 Bump @types/jest from 29.5.8 to 29.5.11
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.8 to 29.5.11.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] 648b907f71 Bump @types/archiver from 6.0.1 to 6.0.2
Bumps [@types/archiver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/archiver) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/archiver)

---
updated-dependencies:
- dependency-name: "@types/archiver"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] 57949b89fa Bump @types/tar from 6.1.9 to 6.1.10
Bumps [@types/tar](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/tar) from 6.1.9 to 6.1.10.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/tar)

---
updated-dependencies:
- dependency-name: "@types/tar"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
dependabot[bot] fd9f0530bf Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:55:36 -05:00
Conor Sloan 4ac7dfc3cb update deps, linting, test cases, etc. 2024-02-02 12:55:29 -05:00
dependabot[bot] 651b1739d1 Bump @types/tar from 6.1.8 to 6.1.9
Bumps [@types/tar](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/tar) from 6.1.8 to 6.1.9.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/tar)

---
updated-dependencies:
- dependency-name: "@types/tar"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 12:52:31 -05:00
Conor Sloan d057826061 initial mvp version 2024-02-02 12:52:31 -05:00
41 changed files with 145763 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
lib/
dist/
node_modules/
coverage/
+1
View File
@@ -0,0 +1 @@
dist/** -diff linguist-generated=true
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
labels:
- dependabot
- actions
schedule:
interval: daily
- package-ecosystem: npm
directory: /
labels:
- dependabot
- npm
schedule:
interval: daily
+83
View File
@@ -0,0 +1,83 @@
env:
node: true
es6: true
jest: true
globals:
Atomics: readonly
SharedArrayBuffer: readonly
ignorePatterns:
- '!.*'
- '**/node_modules/.*'
- '**/dist/.*'
- '**/coverage/.*'
- '*.json'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 2023
sourceType: module
project:
- './.github/linters/tsconfig.json'
- './tsconfig.json'
plugins:
- jest
- '@typescript-eslint'
extends:
- eslint:recommended
- plugin:@typescript-eslint/eslint-recommended
- plugin:@typescript-eslint/recommended
- plugin:github/recommended
- plugin:jest/recommended
rules:
{
'camelcase': 'off',
'eslint-comments/no-use': 'off',
'eslint-comments/no-unused-disable': 'off',
'i18n-text/no-en': 'off',
'import/no-namespace': 'off',
'no-console': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
'semi': 'off',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/explicit-member-accessibility':
['error', { 'accessibility': 'no-public' }],
'@typescript-eslint/explicit-function-return-type':
['error', { 'allowExpressions': true }],
'@typescript-eslint/func-call-spacing': ['error', 'never'],
'@typescript-eslint/no-array-constructor': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-for-in-array': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'warn',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/semi': ['error', 'never'],
'@typescript-eslint/space-before-function-paren': 'off',
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unbound-method': 'error'
}
+12
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
rules:
document-end: disable
document-start:
level: warning
present: false
line-length:
level: warning
max: 80
allow-non-breakable-words: true
allow-non-breakable-inline-mappings: true
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["../../__tests__/**/*", "../../src/**/*"],
"exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"]
}
+61
View File
@@ -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/
+46
View File
@@ -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
+49
View File
@@ -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
+48
View File
@@ -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 }}
+18
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
20.6.0
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
coverage/
+16
View File
@@ -0,0 +1,16 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "none",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "avoid",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "lf"
}
+3
View File
@@ -0,0 +1,3 @@
# Repository CODEOWNERS
* @actions/actions-sudo
+60
View File
@@ -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).
+120
View File
@@ -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'
)
})
})
+331
View File
@@ -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 = ''
}
+293
View File
@@ -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.'
)
})
})
+643
View File
@@ -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')}`
)
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Unit tests for the action's entrypoint, src/index.ts
*/
import * as main from '../src/main'
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
describe('index', () => {
it('calls run when imported', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
expect(runMock).toHaveBeenCalled()
})
})
+714
View File
@@ -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'
}
}
+240
View File
@@ -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
View File
@@ -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: node20
main: dist/index.js
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="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

Generated Vendored
+128766
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+3921
View File
File diff suppressed because it is too large Load Diff
+8675
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -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": ">=20"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
"ci-test": "jest",
"coverage": "make-coverage-badge --output-path ./badges/coverage.svg",
"format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts",
"lint": "npx eslint . -c ./.github/linters/.eslintrc.yml",
"package": "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": "^22.0.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
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# About:
# This is a helper script to tag and push a new release.
# GitHub Actions use release tags to allow users to select a specific version of the action to use.
# This script will do the following:
# 1. Get the latest release tag
# 2. Prompt the user for a new release tag (while displaying the latest release tag, and a regex to validate the new tag)
# 3. Tag the new release
# 4. Push the new tag to the remote
# Usage:
# script/release
# COLORS
OFF='\033[0m'
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
latest_tag=$(git describe --tags "$(git rev-list --tags --max-count=1)")
# if the latest_tag is empty, then there are no tags - let the user know
if [[ -z "$latest_tag" ]]; then
echo -e "No tags found (yet) - continue to create your first tag and push it"
latest_tag="[unknown]"
fi
echo -e "The latest release tag is: ${BLUE}${latest_tag}${OFF}"
read -r -p 'New Release Tag (vX.X.X format): ' new_tag
tag_regex='v[0-9]+\.[0-9]+\.[0-9]+$'
if echo "$new_tag" | grep -q -E "$tag_regex"; then
echo -e "Tag: ${BLUE}$new_tag${OFF} is valid"
else
echo -e "Tag: ${BLUE}$new_tag${OFF} is ${RED}not valid${OFF} (must be in vX.X.X format)"
exit 1
fi
git tag -a "$new_tag" -m "$new_tag Release"
echo -e "${GREEN}OK${OFF} - Tagged: $new_tag"
git push --tags
echo -e "${GREEN}OK${OFF} - Tags pushed to remote!"
echo -e "${GREEN}DONE${OFF}"
+65
View File
@@ -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
View File
@@ -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
)
}
+186
View File
@@ -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)
})
})
}
+358
View File
@@ -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'
)
}
+7
View File
@@ -0,0 +1,7 @@
/**
* The entrypoint for the action.
*/
import { run } from './main'
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run()
+275
View File
@@ -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
}
+231
View File
@@ -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('/', '-')
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"sourceMap": true,
"outDir": "./dist",
"noImplicitAny": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"newLine": "lf"
},
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"]
}