diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b2cc4b..6eb6ecb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,5 @@ jobs: steps: - name: Checking out! uses: actions/checkout@v4 - - name: Publish action package uses: ./ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4088f4f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,197 +0,0 @@ -# Initial Setup - -After you've cloned the repository to your local machine or codespace, you'll -need to perform some initial setup steps before you can develop your action. - -> [!NOTE] -> -> You'll need to have a reasonably modern version of -> [Node.js](https://nodejs.org) handy (20.x or later should work!). If you are -> using a version manager like [`nodenv`](https://github.com/nodenv/nodenv) or -> [`nvm`](https://github.com/nvm-sh/nvm), this template has a `.node-version` -> file at the root of the repository that will be used to automatically switch -> to the correct version when you `cd` into the repository. Additionally, this -> `.node-version` file is used by GitHub Actions in any `actions/setup-node` -> actions. - -1. :hammer_and_wrench: Install the dependencies - - ```bash - npm install - ``` - -1. :building_construction: Package the TypeScript for distribution - - ```bash - npm run bundle - ``` - -1. :white_check_mark: Run the tests - - ```bash - $ npm test - - PASS ./index.test.js - ✓ throws invalid number (3ms) - ✓ wait 500 ms (504ms) - ✓ test runs (95ms) - - ... - ``` - -## Update the Action Metadata - -The [`action.yml`](action.yml) file defines metadata about your action, such as -input(s) and output(s). For details about this file, see -[Metadata syntax for GitHub Actions](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions). - -When you copy this repository, update `action.yml` with the name, description, -inputs, and outputs for your action. - -## Update the Action Code - -The [`src/`](./src/) directory is the heart of your action! This contains the -source code that will be run when your action is invoked. You can replace the -contents of this directory with your own code. - -There are a few things to keep in mind when writing your action code: - -- Most GitHub Actions toolkit and CI/CD operations are processed asynchronously. - In `main.ts`, you will see that the action is run in an `async` function. - - ```javascript - import * as core from '@actions/core' - //... - - async function run() { - try { - //... - } catch (error) { - core.setFailed(error.message) - } - } - ``` - - For more information about the GitHub Actions toolkit, see the - [documentation](https://github.com/actions/toolkit/blob/master/README.md). - -So, what are you waiting for? Go ahead and start customizing your action! - -1. Create a new branch - - ```bash - git checkout -b releases/v1 - ``` - -1. Replace the contents of `src/` with your action code -1. Add tests to `__tests__/` for your source code -1. Format, test, and build the action - - ```bash - npm run all - ``` - - > [!WARNING] - > - > This step is important! It will run [`ncc`](https://github.com/vercel/ncc) - > to build the final JavaScript action code with all dependencies included. - > If you do not run this step, your action will not work correctly when it is - > used in a workflow. This step also includes the `--license` option for - > `ncc`, which will create a license file for all of the production node - > modules used in your project. - -1. Commit your changes - - ```bash - git add . - git commit -m "My first action is ready!" - ``` - -1. Push them to your repository - - ```bash - git push -u origin releases/v1 - ``` - -1. Create a pull request and get feedback on your action -1. Merge the pull request into the `main` branch - -Your action is now published! :rocket: - -For information about versioning your action, see -[Versioning](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md) -in the GitHub Actions toolkit. - -## Validate the Action - -You can now validate the action by referencing it in a workflow file. For -example, [`ci.yml`](./.github/workflows/ci.yml) demonstrates how to reference an -action in the same repository. - -```yaml -steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Test Local Action - id: test-action - uses: ./ - with: - milliseconds: 1000 - - - name: Print Output - id: output - run: echo "${{ steps.test-action.outputs.time }}" -``` - -For example workflow runs, check out the -[Actions tab](https://github.com/actions/typescript-action/actions)! :rocket: - -## Usage - -After testing, you can create version tag(s) that developers can use to -reference different stable versions of your action. For more information, see -[Versioning](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md) -in the GitHub Actions toolkit. - -To include the action in a workflow in another repository, you can use the -`uses` syntax with the `@` symbol to reference a specific branch, tag, or commit -hash. - -```yaml -steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Test Local Action - id: test-action - uses: actions/typescript-action@v1 # Commit with the `v1` tag - with: - milliseconds: 1000 - - - name: Print Output - id: output - run: echo "${{ steps.test-action.outputs.time }}" -``` - -## Publishing a new release - -This project includes a helper script designed to streamline the process of -tagging and pushing new releases for GitHub Actions. - -GitHub Actions allows users to select a specific version of the action to use, -based on release tags. Our script simplifies this process by performing the -following steps: - -1. **Retrieving the latest release tag:** The script starts by fetching the most - recent release tag by looking at the local data available in your repository. -1. **Prompting for a new release tag:** The user is then prompted to enter a new - release tag. To assist with this, the script displays the latest release tag - and provides a regular expression to validate the format of the new tag. -1. **Tagging the new release:** Once a valid new tag is entered, the script tags - the new release. -1. **Pushing the new tag to the remote:** Finally, the script pushes the new tag - to the remote repository. From here, you will need to create a new release in - GitHub and users can easily reference the new tag in their workflows. diff --git a/README.md b/README.md index ed43b04..b8108f7 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,10 @@ # Publish Action Package -This action packages your action repository as OCI artifacts and publishes it to [GHCR](ghcr.io). +_This action_ packages _your action_ as OCI artifacts and publishes it to the [GitHub Container registry](ghcr.io). -This allows your action to be consumed as an immutable package to make the actions ecosystem more secure. +This allows your action to be consumed as an _immutable_ package even if a [semver](https://semver.org/) is specified in the consumer's workflow file. -The whole action repository is packaged by default. Set `path` input to specify which path you want to package. - -Make sure you use the [Starter Workflow](https://github.com/actions-on-packages/.github) (TODO) to run the action. -Please also ensure you have the release trigger in the workflow where you use this action. +Your action workflow must be triggered on `release` as in the following example. The release's title must follow [semantic versioning](https://semver.org/). Then consumers of your action will then be able to specify the version, e.g., `- uses: your-name/your-action@v1.2.3` or even `- uses: your-name/your-action@v1`. ## Usage @@ -16,19 +13,8 @@ Please also ensure you have the release trigger in the workflow where you use th on: release: -- uses: immutable-actions/publish-action-package@1.0.1 +- uses: immutable-actions/publish-action-package@v1 with: - - # Personal access token (PAT) or GITHUB_TOKEN with write:package scope used to upload the package to GHCR. The GITHUB_TOKEN is taken by default. - # - # We recommend using a service account with the least permissions necessary. Also - # when generating a new PAT, select the least scopes necessary. - # - # [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) - # - # Default: ${{ github.token }} - token: '' - # Relative path of the working directory of the repository to be tar archived # and uploaded as OCI Artifact layer. You can mention multiple files/folders # by mentioning relative paths as space separated values. @@ -36,38 +22,9 @@ on: # This defaults to the entire action repository contents if not explicitly defined. # Default: '.' path: 'src/ action.yml dist/' - - ``` ## License -The scripts and documentation in this project are released under the [MIT License](LICENSE) - -## [Internal] Differences from previous implementation - -This is a new implementation of an Action which publishes a given release to ghcr.io (GitHub Packages). - -It will eventually be moved to our `actions`` org. - -The key differences are: - -- This Action goes directly to GitHub Packages rather than using an API endpoint to pass a bundle to. -- This Action uses Node.js libraries to create both a `zip` and `tar.gz` of the content as layers. -- This Action creates and publishes the OCI manifest which houses those archives, which was previously done on the backend. -- This Action has the goal of generating provenance attestations for any release that is created. -- This Action parses and validates that the release tag which triggered it is in a valid SemVer format, either `1.0.3-prerelease` or `v1.0.0-prerelease`. - -## [Internal] Features to consider adding to this Action - -These are some features which should likely be part of this implementation. - -- [ ] Parsing SemVer more strictly, based on what formats we decide to support (e.g. we might not support prerelease tags) -- [ ] Checking that there is a valid `action.yml` file in the repository running the action, failing if it is not an Action repository - - Could be as simple as just checking for the file, or could be validating it is yml with expected actions components -- [ ] Sanitising and validating relative paths passed into the `path` arg - - For example, checking that they're inside the repository of the action and not other places on the filsystem -- [ ] Integration with Attestations API (speak to `phillmv` about this) -- [ ] A way to dynamically work out the Container Registry's URL rather than relying on the user to provide it - - Options seem to be an API endpoint or an environment variable in the runner, but this might be difficult for backwards compatibility +The scripts and documentation in this project are released under the [MIT License](LICENSE). diff --git a/__tests__/fs-helper.test.ts b/__tests__/fs-helper.test.ts index b0f6796..d9da52c 100644 --- a/__tests__/fs-helper.test.ts +++ b/__tests__/fs-helper.test.ts @@ -1,10 +1,122 @@ import * as fsHelper from '../src/fs-helper' import * as fs from 'fs' +import * as path from 'path' import * as os from 'os' import { execSync } from 'child_process' const fileContent = 'This is the content of the file' +describe('getConsolidatedDirectory', () => { + let sourceDir: string + + beforeAll(() => { + sourceDir = `.` // fsHelper.createTempDir() + fs.mkdirSync(`${sourceDir}/folder1`) + fs.mkdirSync(`${sourceDir}/folder2`) + fs.mkdirSync(`${sourceDir}/folder2/folder3`) + fs.writeFileSync(`${sourceDir}/file0.txt`, fileContent) + fs.writeFileSync(`${sourceDir}/folder1/file1.txt`, fileContent) + fs.writeFileSync(`${sourceDir}/folder2/file2.txt`, fileContent) + fs.writeFileSync(`${sourceDir}/folder2/folder3/file3.txt`, fileContent) + }) + + beforeEach(() => {}) + + afterEach(() => {}) + + afterAll(() => { + fs.rmSync(`file0.txt`) + fs.rmSync(`folder1`, { recursive: true }) + fs.rmSync(`folder2`, { recursive: true }) + }) + + it('returns the directory itself if it is a single directory, and instructed to not clean it up', () => { + // TODO: In these tests, we're not really distinguishing between the `publish-action-package` directory and the consumer repo directory, i.e., they share the same space. + // In real life, when the consumer workflow runs, its own javascript is in ., but + // the publish-action-package's code is in ${{github.action_path}}. + // So.... I guess to emulate this, we should create a temp directory (representing the consumer repo) + // and cd there before the test starts? + const { consolidatedPath, needToCleanUpDir } = + fsHelper.getConsolidatedDirectory('.') + + expect(needToCleanUpDir).toBe(false) + expect(consolidatedPath).toBe('.') + expect(fsHelper.readFileContents(`file0.txt`).toString()).toEqual( + fileContent + ) + expect(fsHelper.readFileContents(`folder1/file1.txt`).toString()).toEqual( + fileContent + ) + expect(fsHelper.readFileContents(`folder2/file2.txt`).toString()).toEqual( + fileContent + ) + expect( + fsHelper.readFileContents(`folder2/folder3/file3.txt`).toString() + ).toEqual(fileContent) + }) + it('returns a new directory containing copies of the multiple paths if they are legally specified, and instruct to clean it up', () => { + const { consolidatedPath, needToCleanUpDir } = + fsHelper.getConsolidatedDirectory('file0.txt folder1') + + expect(needToCleanUpDir).toBe(true) + expect(consolidatedPath).not.toBe('.') + expect( + fsHelper + .readFileContents(path.join(consolidatedPath, `file0.txt`)) + .toString() + ).toEqual(fileContent) + expect( + fsHelper + .readFileContents(path.join(consolidatedPath, `folder1/file1.txt`)) + .toString() + ).toEqual(fileContent) + expect( + fs.existsSync(path.join(consolidatedPath, `folder2/file2.txt`)) + ).toEqual(false) + expect( + fs.existsSync(path.join(consolidatedPath, `folder2/folder3/file3.txt`)) + ).toEqual(false) + }) + + it('what happens here?', () => { + const { consolidatedPath, needToCleanUpDir } = + fsHelper.getConsolidatedDirectory('folder1 folder2/folder3') + + expect(needToCleanUpDir).toBe(true) + expect(consolidatedPath).not.toBe('.') + expect(fs.existsSync(path.join(consolidatedPath, `file0.txt`))).toEqual( + false + ) + expect( + fsHelper + .readFileContents(path.join(consolidatedPath, `folder1/file1.txt`)) + .toString() + ).toEqual(fileContent) + expect( + fs.existsSync(path.join(consolidatedPath, `folder2/file2.txt`)) + ).toEqual(false) + expect( + fsHelper + .readFileContents(path.join(consolidatedPath, `folder3/file3.txt`)) + .toString() + ).toEqual(fileContent) // <--- TODO: This is what I'm unsure of + }) + + it('throws an error for illegal path spec - single', () => { + expect(() => { + fsHelper.getConsolidatedDirectory('folder4') + }).toThrow('filePath folder4 does not exist') + }) + + it('throws an error for illegal path spec - multiple', () => { + expect(() => { + fsHelper.getConsolidatedDirectory('folder1 folder4') + }).toThrow('filePath folder4 does not exist') + }) + + // TODO: consider doing the thing Michael suggested where we exclude directories starting with . +}) + describe('createArchives', () => { let tmpDir: string let distDir: string @@ -74,8 +186,6 @@ describe('createArchives', () => { expect(zipSHA).toEqual(systemZipHash) expect(tarSHA).toEqual(systemTarHash) }) - - // TODO: Test the failure cases }) describe('createTempDir', () => { @@ -133,6 +243,33 @@ describe('isDirectory', () => { }) }) +describe('isActionRepo', () => { + let stagingDir: string + + beforeEach(() => { + stagingDir = fsHelper.createTempDir() + }) + + afterEach(() => { + fs.rmSync(stagingDir, { recursive: true }) + }) + + it('returns true if action.yml exists at the root', () => { + fs.writeFileSync(path.join(stagingDir, `action.yml`), fileContent) + expect(fsHelper.isActionRepo(stagingDir)).toEqual(true) + }) + + it('returns true if action.yaml exists at the root', () => { + fs.writeFileSync(path.join(stagingDir, `action.yaml`), fileContent) + expect(fsHelper.isActionRepo(stagingDir)).toEqual(true) + }) + + it("returns false if action.y(a)ml doesn't exist at the root", () => { + fs.writeFileSync(path.join(stagingDir, `action.yaaml`), fileContent) + expect(fsHelper.isActionRepo(stagingDir)).toEqual(false) + }) +}) + describe('readFileContents', () => { let dir: string @@ -170,47 +307,3 @@ describe('removeDir', () => { expect(fs.existsSync(dir)).toEqual(false) }) }) - -describe('bundleFilesintoDirectory', () => { - let sourceDir: string - let targetDir: string - - beforeEach(() => { - sourceDir = fsHelper.createTempDir() - targetDir = fsHelper.createTempDir() - }) - - afterEach(() => { - fs.rmSync(sourceDir, { recursive: true }) - fs.rmSync(targetDir, { recursive: true }) - }) - - it('bundles files and folders into a directory', () => { - // Create some test files and folders in the sourceDir - const file1 = `${sourceDir}/file1.txt` - const folder1 = `${sourceDir}/folder1` - const file2 = `${folder1}/file3.txt` - - fs.mkdirSync(folder1) - fs.writeFileSync(file1, fileContent) - fs.writeFileSync(file2, fileContent) - - // Bundle the files and folders into the targetDir - fsHelper.bundleFilesintoDirectory([file1, folder1], targetDir) - - // Check that the files and folders were copied - expect(fs.existsSync(file1)).toEqual(true) - expect(fsHelper.readFileContents(file1).toString()).toEqual(fileContent) - - expect(fs.existsSync(`${targetDir}/folder1`)).toEqual(true) - - expect(fs.existsSync(file2)).toEqual(true) - expect(fsHelper.readFileContents(file2).toString()).toEqual(fileContent) - }) - - it('throws an error if a file or directory does not exist', () => { - expect(() => { - fsHelper.bundleFilesintoDirectory(['/does/not/exist'], targetDir) - }).toThrow('File /does/not/exist does not exist') - }) -}) diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index 4a0e431..d275675 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -9,20 +9,21 @@ let axiosPostMock: jest.SpyInstance let axiosPutMock: jest.SpyInstance let axiosHeadMock: jest.SpyInstance -const token = '1234567890' +const token = 'test-token' const registry = new URL('https://ghcr.io') -const repository = 'test/test' -const releaseId = '1234567890' -const semver = '1.0.0' +const repository = 'test-org/test-repo' +const releaseId = 'test-release-id' +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 zipFile: fsHelper.FileMetadata = { - path: 'test-repo-1.0.0.zip', - size: 100, - sha256: '1234567890' + path: `test-repo-${semver}.zip`, + size: 123, + sha256: genericSha } const tarFile: fsHelper.FileMetadata = { - path: 'test-repo-1.0.0.tar.gz', - size: 100, - sha256: '1234567890' + path: `test-repo-${semver}.tar.gz`, + size: 456, + sha256: genericSha } const testManifest: ociContainer.Manifest = { @@ -50,25 +51,25 @@ const testManifest: ociContainer.Manifest = { }, { mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', - size: 100, - digest: 'sha256:1234567890', + size: tarFile.size, + digest: `sha256:${tarFile.sha256}`, annotations: { - 'org.opencontainers.image.title': 'test-repo-1.0.0.tar.gz' + 'org.opencontainers.image.title': tarFile.path } }, { mediaType: 'application/vnd.github.actions.package.layer.v1.zip', - size: 100, - digest: 'sha256:1234567890', + size: zipFile.size, + digest: `sha256:${zipFile.sha256}`, annotations: { - 'org.opencontainers.image.title': 'test-repo-1.0.0.zip' + 'org.opencontainers.image.title': zipFile.path } } ], annotations: { 'org.opencontainers.image.created': '2021-01-01T00:00:00.000Z', - 'action.tar.gz.digest': '1234567890', - 'action.zip.digest': '1234567890', + 'action.tar.gz.digest': tarFile.sha256, + 'action.zip.digest': zipFile.sha256, 'com.github.package.type': 'actions_oci_pkg' } } @@ -101,7 +102,7 @@ describe('publishOCIArtifact', () => { return { status: 202, headers: { - location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890' + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` } } }) @@ -133,11 +134,9 @@ describe('publishOCIArtifact', () => { expect(axiosHeadMock).toHaveBeenCalledTimes(3) expect(axiosPostMock).toHaveBeenCalledTimes(3) expect(axiosPutMock).toHaveBeenCalledTimes(4) - - // TODO: Check that the base64 encoded token is sent in the Authorization header }) - it('skips uploading layer blobs that already exist', async () => { + it('skips uploading all layer blobs when they all already exist', async () => { // Simulate all blobs already existing axiosHeadMock.mockImplementation(async (url, config) => { validateRequestConfig(200, url, config) @@ -152,7 +151,7 @@ describe('publishOCIArtifact', () => { return { status: 202, headers: { - location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890' + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` } } }) @@ -187,6 +186,68 @@ describe('publishOCIArtifact', () => { expect(axiosPutMock).toHaveBeenCalledTimes(1) }) + it('skips uploading layer blobs that already exist', async () => { + // Simulate some blobs already existing + + let count = 0 + axiosHeadMock.mockImplementation(async (url, config) => { + count++ + if (count === 1) { + // report the first blob as being there + validateRequestConfig(200, url, config) + return { + status: 200 + } + } else { + // report all others are missing + validateRequestConfig(404, url, config) + return { + status: 404 + } + } + }) + + // Simulate successful initiation of uploads for all blobs & return location + axiosPostMock.mockImplementation(async (url, data, config) => { + validateRequestConfig(202, url, config) + return { + status: 202, + headers: { + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + }) + + // Simulate successful reading of all the files + fsReadFileSyncMock.mockImplementation(() => { + return Buffer.from('test') + }) + + // Simulate successful upload of all blobs & then the manifest + axiosPutMock.mockImplementation(async (url, data, config) => { + validateRequestConfig(201, url, config) + return { + status: 201 + } + }) + + await publishOCIArtifact( + token, + registry, + repository, + releaseId, + semver, + zipFile, + tarFile, + testManifest + ) + + // We should only head all the blobs and then upload the missing blobs and manifest + expect(axiosHeadMock).toHaveBeenCalledTimes(3) + expect(axiosPostMock).toHaveBeenCalledTimes(2) + expect(axiosPutMock).toHaveBeenCalledTimes(3) + }) + it('throws an error if checking for existing blobs fails', async () => { // Simulate failed response code axiosHeadMock.mockImplementation(async (url, config) => { @@ -288,7 +349,7 @@ describe('publishOCIArtifact', () => { return { status: 202, headers: { - location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890' + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` } } }) @@ -335,7 +396,7 @@ describe('publishOCIArtifact', () => { return { status: 202, headers: { - location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890' + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` } } }) @@ -389,7 +450,7 @@ describe('publishOCIArtifact', () => { return { status: 202, headers: { - location: 'https://ghcr.io/v2/test/test/blobs/uploads/1234567890' + location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` } } }) @@ -422,9 +483,15 @@ describe('publishOCIArtifact', () => { }) it('throws an error if one of the layers has the wrong media type', async () => { - const modifiedTestManifest = testManifest + const modifiedTestManifest = { ...testManifest } // This is _NOT_ a deep clone + modifiedTestManifest.layers = cloneLayers(modifiedTestManifest.layers) modifiedTestManifest.layers[0].mediaType = 'application/json' + // just checking to make sure we are not changing the shared object + expect(modifiedTestManifest.layers[0].mediaType).not.toEqual( + testManifest.layers[0].mediaType + ) + await expect( publishOCIArtifact( token, @@ -434,7 +501,7 @@ describe('publishOCIArtifact', () => { semver, zipFile, tarFile, - testManifest + modifiedTestManifest ) ).rejects.toThrow('Unknown media type application/json') }) @@ -446,12 +513,12 @@ describe('publishOCIArtifact', () => { function validateRequestConfig(status: number, url: string, config: any): void { // Basic URL checks expect(url).toBeDefined() - if (!url.startsWith(registry.toString())) { - console.log(url) + console.log(`${url} does not start with ${registry}`) } - - expect(url.startsWith(registry.toString())).toBe(true) + // 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() @@ -472,3 +539,11 @@ function validateRequestConfig(status: number, url: string, config: any): void { ) } } + +function cloneLayers(layers: ociContainer.Layer[]): ociContainer.Layer[] { + const result: ociContainer.Layer[] = [] + for (const layer of layers) { + result.push({ ...layer }) // this is _NOT_ a deep clone + } + return result +} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 13d9d3a..d550439 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -20,10 +20,10 @@ let setOutputMock: jest.SpyInstance // Mock the filesystem helper let createTempDirMock: jest.SpyInstance -let isDirectoryMock: jest.SpyInstance let createArchivesMock: jest.SpyInstance let removeDirMock: jest.SpyInstance -let bundleFilesintoDirectoryMock: jest.SpyInstance +let getConsolidatedDirectoryMock: jest.SpyInstance +let isActionRepoMock: jest.SpyInstance // Mock the GHCR Client let publishOCIArtifactMock: jest.SpyInstance @@ -41,14 +41,14 @@ describe('action', () => { createTempDirMock = jest .spyOn(fsHelper, 'createTempDir') .mockImplementation() - isDirectoryMock = jest.spyOn(fsHelper, 'isDirectory').mockImplementation() createArchivesMock = jest .spyOn(fsHelper, 'createArchives') .mockImplementation() removeDirMock = jest.spyOn(fsHelper, 'removeDir').mockImplementation() - bundleFilesintoDirectoryMock = jest - .spyOn(fsHelper, 'bundleFilesintoDirectory') + getConsolidatedDirectoryMock = jest + .spyOn(fsHelper, 'getConsolidatedDirectory') .mockImplementation() + isActionRepoMock = jest.spyOn(fsHelper, 'isActionRepo').mockImplementation() // GHCR Client mocks publishOCIArtifactMock = jest @@ -61,7 +61,7 @@ describe('action', () => { process.env.GITHUB_REPOSITORY = '' // Run the action - await main.run() + await main.run('directory1 directory2') // Check the results expect(setFailedMock).toHaveBeenCalledWith('Could not find Repository.') @@ -69,11 +69,11 @@ describe('action', () => { it('fails if event is not a release', async () => { // Mock the environment - process.env.GITHUB_REPOSITORY = 'test/test' + process.env.GITHUB_REPOSITORY = 'test-org/test-repo' github.context.eventName = 'push' // Run the action - await main.run() + await main.run('directory1 directory2') // Check the results expect(setFailedMock).toHaveBeenCalledWith( @@ -83,7 +83,7 @@ describe('action', () => { it('fails if release tag is not a valid semantic version', async () => { // Mock the environment - process.env.GITHUB_REPOSITORY = 'test/test' + process.env.GITHUB_REPOSITORY = 'test-org/test-repo' github.context.eventName = 'release' github.context.payload = { release: { @@ -93,7 +93,7 @@ describe('action', () => { } // Run the action - await main.run() + await main.run('directory1 directory2') // Check the results expect(setFailedMock).toHaveBeenCalledWith( @@ -103,12 +103,12 @@ describe('action', () => { it('fails if multiple paths are provided and staging files fails', async () => { // Mock the environment - process.env.GITHUB_REPOSITORY = 'test/test' + process.env.GITHUB_REPOSITORY = 'test-org/test-repo' github.context.eventName = 'release' github.context.payload = { release: { id: '123', - tag_name: 'v1.0.0' + tag_name: 'v1.2.3' } } getInputMock.mockImplementation((name: string) => { @@ -120,14 +120,12 @@ describe('action', () => { return '' }) - isDirectoryMock.mockImplementation(() => true) - - bundleFilesintoDirectoryMock.mockImplementation(() => { + getConsolidatedDirectoryMock.mockImplementation(() => { throw new Error('Something went wrong') }) // Run the action - await main.run() + await main.run('directory1 directory2') // Check the results expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') @@ -135,12 +133,12 @@ describe('action', () => { it('fails if an error is thrown from dependent code', async () => { // Mock the environment - process.env.GITHUB_REPOSITORY = 'test/test' + process.env.GITHUB_REPOSITORY = 'test-org/test-repo' github.context.eventName = 'release' github.context.payload = { release: { id: '123', - tag_name: 'v1.0.0' + tag_name: 'v1.2.3' } } getInputMock.mockImplementation((name: string) => { @@ -152,7 +150,10 @@ describe('action', () => { return '' }) - isDirectoryMock.mockImplementation(() => true) + getConsolidatedDirectoryMock.mockImplementation(() => { + return { consolidatedDirectory: '/tmp/test', needToCleanUpDir: false } + }) + isActionRepoMock.mockImplementation(() => true) createTempDirMock.mockImplementation(() => '/tmp/test') @@ -161,10 +162,10 @@ describe('action', () => { }) // Run the action - await main.run() + await main.run('directory') // Check the results - expect(isDirectoryMock).toHaveBeenCalledWith('directory') + expect(getConsolidatedDirectoryMock).toHaveBeenCalledTimes(1) expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') // Expect the files to be cleaned up @@ -172,22 +173,22 @@ describe('action', () => { }) it('successfully uploads if the release tag is a semver without v prefix', async () => { - await testHappyPath('1.0.0', 'test') + await testHappyPath('1.2.3', 'test') }) it('successfully uploads if the release tag is a semver with v prefix', async () => { - await testHappyPath('v1.0.0', 'test') + await testHappyPath('v1.2.3', 'test') }) it('successfully uploads if multiple paths are provided', async () => { - await testHappyPath('v1.0.0', 'test test2') + await testHappyPath('v1.2.3', 'test test2') }) }) // Test that main successfully uploads and returns the manifest & package URL async function testHappyPath(version: string, path: string): Promise { // Mock the environment - process.env.GITHUB_REPOSITORY = 'test/test' + process.env.GITHUB_REPOSITORY = 'test-org/test-repo' github.context.eventName = 'release' github.context.payload = { release: { @@ -204,10 +205,10 @@ async function testHappyPath(version: string, path: string): Promise { return '' }) - isDirectoryMock.mockImplementation(() => true) + isActionRepoMock.mockImplementation(() => true) - bundleFilesintoDirectoryMock.mockImplementation(() => { - return '/tmp/test' + getConsolidatedDirectoryMock.mockImplementation(() => { + return { consolidatedDirectory: '/tmp/test', needToCleanUpDir: false } // TODO: I don't understand why I have to name the variables here but not in the implementation code }) createTempDirMock.mockImplementation(() => '/tmp/test') @@ -228,18 +229,18 @@ async function testHappyPath(version: string, path: string): Promise { }) publishOCIArtifactMock.mockImplementation(() => { - return new URL('https://ghcr.io/v2/test/test:1.0.0') + return new URL('https://ghcr.io/v2/test-org/test-repo:1.2.3') }) // Run the action - await main.run() + await main.run(path) expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1) // Check manifest is in output expect(setOutputMock).toHaveBeenCalledWith( 'package-url', - 'https://ghcr.io/v2/test/test:1.0.0' + 'https://ghcr.io/v2/test-org/test-repo:1.2.3' ) expect(setOutputMock).toHaveBeenCalledWith( 'package-manifest', diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index ffb4b4d..b52bf67 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -1,21 +1,21 @@ import { createActionPackageManifest } from '../src/oci-container' import { FileMetadata } from '../src/fs-helper' -describe('createActionPackageManigest', () => { +describe('createActionPackageManifest', () => { it('creates a manifest containing the provided information', () => { const date = new Date() const repo = 'test-org/test-repo' const sanitizedRepo = 'test-org-test-repo' - const version = '1.0.0' + const version = '1.2.3' const tarFile: FileMetadata = { - path: '/test/test/test', - sha256: '1234567890', - size: 100 + path: '/test/test/test.tar.gz', + sha256: 'tarSha', + size: 123 } const zipFile: FileMetadata = { - path: '/test/test/test', - sha256: '1234567890', - size: 100 + path: '/test/test/test.zip', + sha256: 'zipSha', + size: 456 } const expectedJSON = `{ @@ -49,8 +49,8 @@ describe('createActionPackageManigest', () => { }, { "mediaType":"application/vnd.github.actions.package.layer.v1.zip", - "size":${tarFile.size}, - "digest":"${tarFile.sha256}", + "size":${zipFile.size}, + "digest":"${zipFile.sha256}", "annotations":{ "org.opencontainers.image.title":"${sanitizedRepo}_${version}.zip" } @@ -67,13 +67,13 @@ describe('createActionPackageManigest', () => { const manifest = createActionPackageManifest( { path: 'test.tar.gz', - size: 100, - sha256: '1234567890' + size: tarFile.size, + sha256: tarFile.sha256 }, { path: 'test.zip', - size: 100, - sha256: '1234567890' + size: zipFile.size, + sha256: zipFile.sha256 }, repo, version, diff --git a/action.yml b/action.yml index 36aa449..f204310 100644 --- a/action.yml +++ b/action.yml @@ -7,25 +7,42 @@ branding: color: 'red' inputs: - token: - required: true - description: > - GITHUB_TOKEN having the write:package scope to publish an action package to GHCR. - default: ${{ github.token }} path: required: false description: The work directory or path to be tar archived and uploaded as OCI Artifact layer. default: '.' - registry: - required: false - description: The registry to publish the action package to. - default: https://ghcr.io/ # TODO: this should perhaps be fetched from GitHub API. outputs: package-url: description: 'The name of package published to GHCR along with semver. For example, https://ghcr.io/actions/package-action:1.0.1' + value: ${{steps.publish.outputs.package-url}} package-manifest: description: 'The package manifest of the published package in JSON format' + value: ${{steps.publish.outputs.package-manifest}} + package-manifest-sha: + description: 'A sha256 hash of the package manifest' + value: ${{steps.publish.outputs.package-manifest-sha}} runs: - using: 'node16' - main: 'dist/index.js' + using: 'composite' + steps: + - name: Publish Action Package + run: 'node ${{github.action_path}}/dist/index.js --path ${{ inputs.path }}' + shell: bash + id: publish + env: + TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + - name: Output variables + shell: bash + run: | + echo "package manifest": ${{steps.publish.outputs.package-manifest}} + echo "package manifest sha": ${{steps.publish.outputs.package-manifest-sha}} + echo "package url": ${{steps.publish.outputs.package-url}} + echo "subject name": ${{github.repository}}_${{github.ref}} + # - name: Generate Provenance Attestation + # uses: github-early-access/generate-build-provenance@main + # id: build-provenance + # with: + # subject-name: ${{github.repository}}_${{github.ref}} + # subject-digest: ${{steps.publish.outputs.package-manifest-sha}} + # push-to-registry: false diff --git a/badges/coverage.svg b/badges/coverage.svg index 5bb55be..abd228b 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 100%Coverage100% \ No newline at end of file +Coverage: 93.81%Coverage93.81% \ No newline at end of file diff --git a/dist/fs-helper.js b/dist/fs-helper.js new file mode 100644 index 0000000..453e979 --- /dev/null +++ b/dist/fs-helper.js @@ -0,0 +1,144 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.bundleFilesintoDirectory = exports.readFileContents = exports.isDirectory = exports.createArchives = exports.removeDir = exports.createTempDir = void 0; +const fs = __importStar(require("fs")); +const fs_extra_1 = __importDefault(require("fs-extra")); +const path = __importStar(require("path")); +const tar = __importStar(require("tar")); +const archiver = __importStar(require("archiver")); +const crypto = __importStar(require("crypto")); +const os = __importStar(require("os")); +function createTempDir() { + const randomDirName = crypto.randomBytes(4).toString('hex'); + const tempDir = path.join(os.tmpdir(), randomDirName); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + return tempDir; +} +exports.createTempDir = createTempDir; +function removeDir(dir) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true }); + } +} +exports.removeDir = removeDir; +// 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. +async function createArchives(distPath, archiveTargetPath = createTempDir()) { + const zipPath = path.join(archiveTargetPath, `archive.zip`); + const tarPath = path.join(archiveTargetPath, `archive.tar.gz`); + const createZipPromise = new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipPath); + const archive = archiver.create('zip'); + output.on('error', (err) => { + reject(err); + }); + archive.on('error', (err) => { + reject(err); + }); + output.on('close', () => { + resolve(fileMetadata(zipPath)); + }); + archive.pipe(output); + archive.directory(distPath, false); + archive.finalize(); + }); + const createTarPromise = new Promise((resolve, reject) => { + tar + .c({ + file: tarPath, + C: distPath, // Change to the source directory for relative paths (TODO) + gzip: true + }, ['.']) + // 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 }; +} +exports.createArchives = createArchives; +function isDirectory(dirPath) { + return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory(); +} +exports.isDirectory = isDirectory; +function readFileContents(filePath) { + return fs.readFileSync(filePath); +} +exports.readFileContents = readFileContents; +function bundleFilesintoDirectory(files, targetDir = createTempDir()) { + for (const file of files) { + if (!fs.existsSync(file)) { + throw new Error(`File ${file} does not exist`); + } + if (isDirectory(file)) { + const targetFolder = path.join(targetDir, path.basename(file)); + fs_extra_1.default.copySync(file, targetFolder); + } + else { + const targetFile = path.join(targetDir, path.basename(file)); + fs.copyFileSync(file, targetFile); + } + } + return targetDir; +} +exports.bundleFilesintoDirectory = bundleFilesintoDirectory; +// Converts a file path to a filemetadata object by querying the fs for relevant metadata. +async function fileMetadata(filePath) { + 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); + }); + }); +} +//# sourceMappingURL=fs-helper.js.map \ No newline at end of file diff --git a/dist/fs-helper.js.map b/dist/fs-helper.js.map new file mode 100644 index 0000000..0f52ef1 --- /dev/null +++ b/dist/fs-helper.js.map @@ -0,0 +1 @@ +{"version":3,"file":"fs-helper.js","sourceRoot":"","sources":["../src/fs-helper.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,uCAAwB;AACxB,wDAA8B;AAC9B,2CAA4B;AAC5B,yCAA0B;AAC1B,mDAAoC;AACpC,+CAAgC;AAChC,uCAAwB;AAExB,SAAgB,aAAa;IAC3B,MAAM,aAAa,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;IAC3D,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,aAAa,CAAC,CAAA;IAErD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvB,CAAC;IAED,OAAO,OAAO,CAAA;AAChB,CAAC;AATD,sCASC;AAED,SAAgB,SAAS,CAAC,GAAW;IACnC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACvB,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACrC,CAAC;AACH,CAAC;AAJD,8BAIC;AAQD,gJAAgJ;AAChJ,gDAAgD;AACzC,KAAK,UAAU,cAAc,CAClC,QAAgB,EAChB,oBAA4B,aAAa,EAAE;IAE3C,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,aAAa,CAAC,CAAA;IAC3D,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAA;IAE9D,MAAM,gBAAgB,GAAG,IAAI,OAAO,CAAe,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrE,MAAM,MAAM,GAAG,EAAE,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAA;QAC5C,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QAEtC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAU,EAAE,EAAE;YAChC,MAAM,CAAC,GAAG,CAAC,CAAA;QACb,CAAC,CAAC,CAAA;QAEF,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAU,EAAE,EAAE;YACjC,MAAM,CAAC,GAAG,CAAC,CAAA;QACb,CAAC,CAAC,CAAA;QAEF,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YACtB,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAA;QAChC,CAAC,CAAC,CAAA;QAEF,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACpB,OAAO,CAAC,SAAS,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;QAClC,OAAO,CAAC,QAAQ,EAAE,CAAA;IACpB,CAAC,CAAC,CAAA;IAEF,MAAM,gBAAgB,GAAG,IAAI,OAAO,CAAe,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrE,GAAG;aACA,CAAC,CACA;YACE,IAAI,EAAE,OAAO;YACb,CAAC,EAAE,QAAQ,EAAE,2DAA2D;YACxE,IAAI,EAAE,IAAI;SACX,EACD,CAAC,GAAG,CAAC,CACN;YACD,0CAA0C;aACzC,KAAK,CAAC,GAAG,CAAC,EAAE;YACX,MAAM,CAAC,GAAG,CAAC,CAAA;QACb,CAAC,CAAC;YACF,0CAA0C;aACzC,IAAI,CAAC,GAAG,EAAE;YACT,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAA;QAChC,CAAC,CAAC,CAAA;IACN,CAAC,CAAC,CAAA;IAEF,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC3C,gBAAgB;QAChB,gBAAgB;KACjB,CAAC,CAAA;IAEF,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC7B,CAAC;AAtDD,wCAsDC;AAED,SAAgB,WAAW,CAAC,OAAe;IACzC,OAAO,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,CAAA;AACtE,CAAC;AAFD,kCAEC;AAED,SAAgB,gBAAgB,CAAC,QAAgB;IAC/C,OAAO,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAA;AAClC,CAAC;AAFD,4CAEC;AAED,SAAgB,wBAAwB,CACtC,KAAe,EACf,YAAoB,aAAa,EAAE;IAEnC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,QAAQ,IAAI,iBAAiB,CAAC,CAAA;QAChD,CAAC;QAED,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;YACtB,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAA;YAC9D,kBAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;QACtC,CAAC;aAAM,CAAC;YACN,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAA;YAC5D,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;QACnC,CAAC;IACH,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC;AAnBD,4DAmBC;AAED,0FAA0F;AAC1F,KAAK,UAAU,YAAY,CAAC,QAAgB;IAC1C,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;IACnC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACvB,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAA;IACxC,MAAM,UAAU,GAAG,EAAE,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAA;IAChD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE;YAC3B,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QACnB,CAAC,CAAC,CAAA;QACF,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;YACxB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACjC,OAAO,CAAC;gBACN,IAAI,EAAE,QAAQ;gBACd,IAAI;gBACJ,MAAM,EAAE,UAAU,MAAM,EAAE;aAC3B,CAAC,CAAA;QACJ,CAAC,CAAC,CAAA;QACF,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,CAAC,EAAE;YAC3B,MAAM,CAAC,GAAG,CAAC,CAAA;QACb,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC"} \ No newline at end of file diff --git a/dist/ghcr-client.js b/dist/ghcr-client.js new file mode 100644 index 0000000..158378a --- /dev/null +++ b/dist/ghcr-client.js @@ -0,0 +1,148 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.publishOCIArtifact = void 0; +const core = __importStar(require("@actions/core")); +const axios_1 = __importDefault(require("axios")); +const fsHelper = __importStar(require("./fs-helper")); +const axios_debug_log_1 = __importDefault(require("axios-debug-log")); +// Publish the OCI artifact and return the URL where it can be downloaded +async function publishOCIArtifact(token, registry, repository, releaseId, semver, zipFile, tarFile, manifest, debugRequests = false) { + if (debugRequests) { + configureRequestDebugLogging(); + } + const b64Token = Buffer.from(token).toString('base64'); + const checkBlobEndpoint = new URL(`v2/${repository}/blobs/`, registry).toString(); + const uploadBlobEndpoint = new URL(`v2/${repository}/blobs/uploads/`, registry).toString(); + const manifestEndpoint = new URL(`v2/${repository}/manifests/${semver}`, registry).toString(); + core.info(`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`); + const layerUploads = manifest.layers.map(async (layer) => { + switch (layer.mediaType) { + case 'application/vnd.github.actions.package.layer.v1.tar+gzip': + return uploadLayer(layer, tarFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + case 'application/vnd.github.actions.package.layer.v1.zip': + return uploadLayer(layer, zipFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + case 'application/vnd.github.actions.package.config.v1+json': + return uploadLayer(layer, { path: '', size: 0, sha256: layer.digest }, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + default: + throw new Error(`Unknown media type ${layer.mediaType}`); + } + }); + await Promise.all(layerUploads); + await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token); + return new URL(`${repository}:${semver}`, registry); +} +exports.publishOCIArtifact = publishOCIArtifact; +async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBlobEndpoint, b64Token) { + const checkExistsResponse = await axios_1.default.head(checkBlobEndpoint + layer.digest, { + headers: { + Authorization: `Bearer ${b64Token}` + }, + validateStatus: () => { + return true; // Allow non 2xx responses + } + }); + if (checkExistsResponse.status === 200 || + checkExistsResponse.status === 202) { + core.info(`Layer ${layer.digest} already exists. Skipping upload.`); + return; + } + if (checkExistsResponse.status !== 404) { + throw new Error(`Unexpected response from blob check for layer ${layer.digest}: ${checkExistsResponse.status} ${checkExistsResponse.statusText}`); + } + core.info(`Uploading layer ${layer.digest}.`); + const initiateUploadResponse = await axios_1.default.post(uploadBlobEndpoint, layer, { + headers: { + Authorization: `Bearer ${b64Token}` + }, + validateStatus: () => { + return true; // Allow non 2xx responses + } + }); + if (initiateUploadResponse.status !== 202) { + core.error(`Unexpected response from upload post ${uploadBlobEndpoint}: ${initiateUploadResponse.status}`); + throw new Error(`Unexpected response from POST upload ${initiateUploadResponse.status}`); + } + const locationResponseHeader = initiateUploadResponse.headers['location']; + if (locationResponseHeader === undefined) { + throw new Error(`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`); + } + const pathname = `${locationResponseHeader}?digest=${layer.digest}`; + const uploadBlobUrl = new URL(pathname, registryURL).toString(); + // TODO: must we handle the empty config layer? Maybe we can just skip calling this at all + let data; + if (file.size === 0) { + data = Buffer.alloc(0); + } + else { + data = fsHelper.readFileContents(file.path); + } + const putResponse = await axios_1.default.put(uploadBlobUrl, data, { + headers: { + Authorization: `Bearer ${b64Token}`, + 'Content-Type': 'application/octet-stream', + 'Accept-Encoding': 'gzip', // TODO: What about for the config layer? + 'Content-Length': layer.size.toString() + }, + validateStatus: () => { + return true; // Allow non 2xx responses + } + }); + if (putResponse.status !== 201) { + throw new Error(`Unexpected response from PUT upload ${putResponse.status} for layer ${layer.digest}`); + } +} +async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { + core.info(`Uploading manifest to ${manifestEndpoint}.`); + const putResponse = await axios_1.default.put(manifestEndpoint, manifestJSON, { + headers: { + Authorization: `Bearer ${b64Token}`, + 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' + }, + validateStatus: () => { + return true; // Allow non 2xx responses + } + }); + if (putResponse.status !== 201) { + throw new Error(`Unexpected response from PUT manifest ${putResponse.status}`); + } +} +function configureRequestDebugLogging() { + (0, axios_debug_log_1.default)({ + request: (debug, config) => { + core.debug(`Request with ${config}`); + }, + response: (debug, response) => { + core.debug(`Response with ${response}`); + }, + error: (debug, error) => { + core.debug(`Error with ${error}`); + } + }); +} +//# sourceMappingURL=ghcr-client.js.map \ No newline at end of file diff --git a/dist/ghcr-client.js.map b/dist/ghcr-client.js.map new file mode 100644 index 0000000..530c641 --- /dev/null +++ b/dist/ghcr-client.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ghcr-client.js","sourceRoot":"","sources":["../src/ghcr-client.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index ce106e3..4978efb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -33737,6 +33737,277 @@ function populateMaps (extensions, types) { } +/***/ }), + +/***/ 35871: +/***/ ((module) => { + +"use strict"; + + +function hasKey(obj, keys) { + var o = obj; + keys.slice(0, -1).forEach(function (key) { + o = o[key] || {}; + }); + + var key = keys[keys.length - 1]; + return key in o; +} + +function isNumber(x) { + if (typeof x === 'number') { return true; } + if ((/^0x[0-9a-f]+$/i).test(x)) { return true; } + return (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$/).test(x); +} + +function isConstructorOrProto(obj, key) { + return (key === 'constructor' && typeof obj[key] === 'function') || key === '__proto__'; +} + +module.exports = function (args, opts) { + if (!opts) { opts = {}; } + + var flags = { + bools: {}, + strings: {}, + unknownFn: null, + }; + + if (typeof opts.unknown === 'function') { + flags.unknownFn = opts.unknown; + } + + if (typeof opts.boolean === 'boolean' && opts.boolean) { + flags.allBools = true; + } else { + [].concat(opts.boolean).filter(Boolean).forEach(function (key) { + flags.bools[key] = true; + }); + } + + var aliases = {}; + + function aliasIsBoolean(key) { + return aliases[key].some(function (x) { + return flags.bools[x]; + }); + } + + Object.keys(opts.alias || {}).forEach(function (key) { + aliases[key] = [].concat(opts.alias[key]); + aliases[key].forEach(function (x) { + aliases[x] = [key].concat(aliases[key].filter(function (y) { + return x !== y; + })); + }); + }); + + [].concat(opts.string).filter(Boolean).forEach(function (key) { + flags.strings[key] = true; + if (aliases[key]) { + [].concat(aliases[key]).forEach(function (k) { + flags.strings[k] = true; + }); + } + }); + + var defaults = opts.default || {}; + + var argv = { _: [] }; + + function argDefined(key, arg) { + return (flags.allBools && (/^--[^=]+$/).test(arg)) + || flags.strings[key] + || flags.bools[key] + || aliases[key]; + } + + function setKey(obj, keys, value) { + var o = obj; + for (var i = 0; i < keys.length - 1; i++) { + var key = keys[i]; + if (isConstructorOrProto(o, key)) { return; } + if (o[key] === undefined) { o[key] = {}; } + if ( + o[key] === Object.prototype + || o[key] === Number.prototype + || o[key] === String.prototype + ) { + o[key] = {}; + } + if (o[key] === Array.prototype) { o[key] = []; } + o = o[key]; + } + + var lastKey = keys[keys.length - 1]; + if (isConstructorOrProto(o, lastKey)) { return; } + if ( + o === Object.prototype + || o === Number.prototype + || o === String.prototype + ) { + o = {}; + } + if (o === Array.prototype) { o = []; } + if (o[lastKey] === undefined || flags.bools[lastKey] || typeof o[lastKey] === 'boolean') { + o[lastKey] = value; + } else if (Array.isArray(o[lastKey])) { + o[lastKey].push(value); + } else { + o[lastKey] = [o[lastKey], value]; + } + } + + function setArg(key, val, arg) { + if (arg && flags.unknownFn && !argDefined(key, arg)) { + if (flags.unknownFn(arg) === false) { return; } + } + + var value = !flags.strings[key] && isNumber(val) + ? Number(val) + : val; + setKey(argv, key.split('.'), value); + + (aliases[key] || []).forEach(function (x) { + setKey(argv, x.split('.'), value); + }); + } + + Object.keys(flags.bools).forEach(function (key) { + setArg(key, defaults[key] === undefined ? false : defaults[key]); + }); + + var notFlags = []; + + if (args.indexOf('--') !== -1) { + notFlags = args.slice(args.indexOf('--') + 1); + args = args.slice(0, args.indexOf('--')); + } + + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + var key; + var next; + + if ((/^--.+=/).test(arg)) { + // Using [\s\S] instead of . because js doesn't support the + // 'dotall' regex modifier. See: + // http://stackoverflow.com/a/1068308/13216 + var m = arg.match(/^--([^=]+)=([\s\S]*)$/); + key = m[1]; + var value = m[2]; + if (flags.bools[key]) { + value = value !== 'false'; + } + setArg(key, value, arg); + } else if ((/^--no-.+/).test(arg)) { + key = arg.match(/^--no-(.+)/)[1]; + setArg(key, false, arg); + } else if ((/^--.+/).test(arg)) { + key = arg.match(/^--(.+)/)[1]; + next = args[i + 1]; + if ( + next !== undefined + && !(/^(-|--)[^-]/).test(next) + && !flags.bools[key] + && !flags.allBools + && (aliases[key] ? !aliasIsBoolean(key) : true) + ) { + setArg(key, next, arg); + i += 1; + } else if ((/^(true|false)$/).test(next)) { + setArg(key, next === 'true', arg); + i += 1; + } else { + setArg(key, flags.strings[key] ? '' : true, arg); + } + } else if ((/^-[^-]+/).test(arg)) { + var letters = arg.slice(1, -1).split(''); + + var broken = false; + for (var j = 0; j < letters.length; j++) { + next = arg.slice(j + 2); + + if (next === '-') { + setArg(letters[j], next, arg); + continue; + } + + if ((/[A-Za-z]/).test(letters[j]) && next[0] === '=') { + setArg(letters[j], next.slice(1), arg); + broken = true; + break; + } + + if ( + (/[A-Za-z]/).test(letters[j]) + && (/-?\d+(\.\d*)?(e-?\d+)?$/).test(next) + ) { + setArg(letters[j], next, arg); + broken = true; + break; + } + + if (letters[j + 1] && letters[j + 1].match(/\W/)) { + setArg(letters[j], arg.slice(j + 2), arg); + broken = true; + break; + } else { + setArg(letters[j], flags.strings[letters[j]] ? '' : true, arg); + } + } + + key = arg.slice(-1)[0]; + if (!broken && key !== '-') { + if ( + args[i + 1] + && !(/^(-|--)[^-]/).test(args[i + 1]) + && !flags.bools[key] + && (aliases[key] ? !aliasIsBoolean(key) : true) + ) { + setArg(key, args[i + 1], arg); + i += 1; + } else if (args[i + 1] && (/^(true|false)$/).test(args[i + 1])) { + setArg(key, args[i + 1] === 'true', arg); + i += 1; + } else { + setArg(key, flags.strings[key] ? '' : true, arg); + } + } + } else { + if (!flags.unknownFn || flags.unknownFn(arg) !== false) { + argv._.push(flags.strings._ || !isNumber(arg) ? arg : Number(arg)); + } + if (opts.stopEarly) { + argv._.push.apply(argv._, args.slice(i + 1)); + break; + } + } + } + + Object.keys(defaults).forEach(function (k) { + if (!hasKey(argv, k.split('.'))) { + setKey(argv, k.split('.'), defaults[k]); + + (aliases[k] || []).forEach(function (x) { + setKey(argv, x.split('.'), defaults[k]); + }); + } + }); + + if (opts['--']) { + argv['--'] = notFlags.slice(); + } else { + notFlags.forEach(function (k) { + argv._.push(k); + }); + } + + return argv; +}; + + /***/ }), /***/ 41077: @@ -74422,7 +74693,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.bundleFilesintoDirectory = exports.readFileContents = exports.isDirectory = exports.createArchives = exports.removeDir = exports.createTempDir = void 0; +exports.readFileContents = exports.isActionRepo = exports.isDirectory = exports.createArchives = exports.getConsolidatedDirectory = exports.removeDir = exports.createTempDir = void 0; const fs = __importStar(__nccwpck_require__(57147)); const fs_extra_1 = __importDefault(__nccwpck_require__(5630)); const path = __importStar(__nccwpck_require__(71017)); @@ -74445,6 +74716,24 @@ function removeDir(dir) { } } exports.removeDir = removeDir; +// TODO: rename this function, it is not state-preserving, so it shouldn't just be called "get'" +function getConsolidatedDirectory(filePathSpec) { + const paths = filePathSpec.split(' '); // TODO: handle files with spaces + // TODO: do check on paths to make sure they're valid and not reaching outside the space + let consolidatedPath = ''; + let needToCleanUpDir = false; + if (paths.length === 1 && isDirectory(paths[0])) { + // If the path is a single directory, we can skip the bundling step + consolidatedPath = paths[0]; + } + else { + // Otherwise, we need to bundle the files & folders into a temporary directory + consolidatedPath = bundleFilesintoDirectory(paths); + needToCleanUpDir = true; + } + return { consolidatedPath, needToCleanUpDir }; +} +exports.getConsolidatedDirectory = getConsolidatedDirectory; // 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. async function createArchives(distPath, archiveTargetPath = createTempDir()) { @@ -74463,7 +74752,7 @@ async function createArchives(distPath, archiveTargetPath = createTempDir()) { resolve(fileMetadata(zipPath)); }); archive.pipe(output); - archive.directory(distPath, false); + archive.directory(distPath, false); // TODO: make sure this doesn't include dirs that start with ., same with below archive.finalize(); }); const createTarPromise = new Promise((resolve, reject) => { @@ -74493,27 +74782,32 @@ function isDirectory(dirPath) { return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory(); } exports.isDirectory = isDirectory; +function isActionRepo(stagingDir) { + return (fs.existsSync(path.join(stagingDir, 'action.yml')) || + fs.existsSync(path.join(stagingDir, 'action.yaml'))); +} +exports.isActionRepo = isActionRepo; function readFileContents(filePath) { return fs.readFileSync(filePath); } exports.readFileContents = readFileContents; -function bundleFilesintoDirectory(files, targetDir = createTempDir()) { - for (const file of files) { - if (!fs.existsSync(file)) { - throw new Error(`File ${file} does not exist`); +function bundleFilesintoDirectory(filePaths) { + const targetDir = createTempDir(); + for (const filePath of filePaths) { + if (!fs.existsSync(filePath)) { + throw new Error(`filePath ${filePath} does not exist`); } - if (isDirectory(file)) { - const targetFolder = path.join(targetDir, path.basename(file)); - fs_extra_1.default.copySync(file, targetFolder); + if (isDirectory(filePath)) { + const targetFolder = path.join(targetDir, path.basename(filePath)); // TODO: basename is probably not what we actually want here. Or is it? Maybe conflicts between dir1/dir2 and dir1/dir3/dir2 are just user error or ?? + fs_extra_1.default.copySync(filePath, targetFolder); // TODO: ignore files preceded by . } else { - const targetFile = path.join(targetDir, path.basename(file)); - fs.copyFileSync(file, targetFile); + const targetFile = path.join(targetDir, path.basename(filePath)); + fs.copyFileSync(filePath, targetFile); } } return targetDir; } -exports.bundleFilesintoDirectory = bundleFilesintoDirectory; // Converts a file path to a filemetadata object by querying the fs for relevant metadata. async function fileMetadata(filePath) { const stats = fs.statSync(filePath); @@ -74694,6 +74988,27 @@ function configureRequestDebugLogging() { } +/***/ }), + +/***/ 6144: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +/** + * The entrypoint for the action. + */ +const main_1 = __nccwpck_require__(70399); +const minimist_1 = __importDefault(__nccwpck_require__(35871)); +const path = (0, minimist_1.default)(process.argv.slice(2)).path || '.'; +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(0, main_1.run)(path); + + /***/ }), /***/ 70399: @@ -74735,11 +75050,12 @@ const fsHelper = __importStar(__nccwpck_require__(76642)); const ociContainer = __importStar(__nccwpck_require__(33207)); const ghcr = __importStar(__nccwpck_require__(62894)); const semver_1 = __importDefault(__nccwpck_require__(11383)); +const crypto_1 = __importDefault(__nccwpck_require__(6113)); /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. */ -async function run() { +async function run(pathInput) { const tmpDirs = []; try { // Parse and validate Actions execution context, including the repository name, release name and event type @@ -74758,37 +75074,39 @@ async function run() { // https://docs.github.com/en/actions/creating-actions/releasing-and-maintaining-actions const targetVersion = semver_1.default.parse(releaseTag.replace(/^v/, '')); if (!targetVersion) { - // TODO: We may want to limit semvers to only x.x.x, without the pre-release tags, but for now we'll allow them. core.setFailed(`${releaseTag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.`); return; } - // Gather & validate user inputs - const token = core.getInput('token'); - const registryURL = new URL(core.getInput('registry')); // TODO: Should this be dynamic? Maybe an API endpoint to grab the registry for GHES/proxima purposes. - // Paths to be included in the OCI image - const paths = core.getInput('path').split(' '); - let path = ''; - if (paths.length === 1 && fsHelper.isDirectory(paths[0])) { - // If the path is a single directory, we can skip the bundling step - path = paths[0]; + const token = process.env.TOKEN; + const { consolidatedPath, needToCleanUpDir } = fsHelper.getConsolidatedDirectory(pathInput); + if (needToCleanUpDir) { + tmpDirs.push(consolidatedPath); } - else { - // Otherwise, we need to bundle the files & folders into a temporary directory - const bundleDir = fsHelper.createTempDir(); - tmpDirs.push(bundleDir); - path = fsHelper.bundleFilesintoDirectory(paths, bundleDir); + if (!fsHelper.isActionRepo(consolidatedPath)) { + core.setFailed('action.y(a)ml not found. Action packages can be created only for action repositories.'); + return; } // Create a temporary directory to store the archives const archiveDir = fsHelper.createTempDir(); tmpDirs.push(archiveDir); - const archives = await fsHelper.createArchives(path, archiveDir); + const archives = await fsHelper.createArchives(consolidatedPath, archiveDir); const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, repository, targetVersion.raw, new Date()); + // Generate SHA-256 hash of the manifest + const manifestSHA = crypto_1.default.createHash('sha256'); + const manifestHash = manifestSHA + .update(JSON.stringify(manifest)) + .digest('hex'); + const response = await fetch(`${process.env.GITHUB_API_URL}/packages/container-registry-url`); + if (!response.ok) { + throw new Error(`Failed to fetch status page: ${response.statusText}`); + } + const data = await response.json(); + const registryURL = new URL(data.url); + console.log(`Container registry URL: ${registryURL}`); const packageURL = await ghcr.publishOCIArtifact(token, registryURL, repository, releaseId.toString(), targetVersion.raw, archives.zipFile, archives.tarFile, manifest, true); core.setOutput('package-url', packageURL.toString()); - // TODO: We might need to do some attestation stuff here, but unsure how to integrate it yet. - // We might need to return the manifest JSON from the Action and link it to another action, - // or we might be able to make an API call here. It's unclear at this point. core.setOutput('package-manifest', JSON.stringify(manifest)); + core.setOutput('package-manifest-sha', `sha256:${manifestHash}`); } catch (error) { // Fail the workflow run if an error occurs @@ -79522,22 +79840,12 @@ module.exports = JSON.parse('{"application/1d-interleaved-parityfec":{"source":" /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; /******/ /************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be in strict mode. -(() => { -"use strict"; -var exports = __webpack_exports__; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -/** - * The entrypoint for the action. - */ -const main_1 = __nccwpck_require__(70399); -// eslint-disable-next-line @typescript-eslint/no-floating-promises -(0, main_1.run)(); - -})(); - -module.exports = __webpack_exports__; +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __nccwpck_require__(6144); +/******/ module.exports = __webpack_exports__; +/******/ /******/ })() ; \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map new file mode 100644 index 0000000..787c483 --- /dev/null +++ b/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAAA;;GAEG;AACH,iCAA4B;AAE5B,mEAAmE;AACnE,IAAA,UAAG,GAAE,CAAA"} \ No newline at end of file diff --git a/dist/licenses.txt b/dist/licenses.txt index 57fbb14..b3fc083 100644 --- a/dist/licenses.txt +++ b/dist/licenses.txt @@ -1528,6 +1528,28 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +minimist +MIT +This software is released under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + minipass ISC The ISC License diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..81f4627 --- /dev/null +++ b/dist/main.js @@ -0,0 +1,108 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.run = void 0; +const core = __importStar(require("@actions/core")); +const github = __importStar(require("@actions/github")); +const fsHelper = __importStar(require("./fs-helper")); +const ociContainer = __importStar(require("./oci-container")); +const ghcr = __importStar(require("./ghcr-client")); +const semver_1 = __importDefault(require("semver")); +/** + * The main function for the action. + * @returns {Promise} Resolves when the action is complete. + */ +async function run() { + const tmpDirs = []; + try { + // Parse and validate Actions execution context, including the repository name, release name and event type + const repository = process.env.GITHUB_REPOSITORY || ''; + if (repository === '') { + core.setFailed(`Could not find Repository.`); + return; + } + if (github.context.eventName !== 'release') { + core.setFailed('Please ensure you have the workflow trigger as release.'); + return; + } + const releaseId = github.context.payload.release.id; + const releaseTag = github.context.payload.release.tag_name; + // Strip any leading 'v' from the tag in case the release format is e.g. 'v1.0.0' as recommended by GitHub docs + // https://docs.github.com/en/actions/creating-actions/releasing-and-maintaining-actions + const targetVersion = semver_1.default.parse(releaseTag.replace(/^v/, '')); + if (!targetVersion) { + // TODO: We may want to limit semvers to only x.x.x, without the pre-release tags, but for now we'll allow them. + core.setFailed(`${releaseTag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.`); + return; + } + // Gather & validate user inputs + const token = core.getInput('token'); + const registryURL = new URL('https://ghcr.io/'); // TODO: Should this be dynamic? Maybe an API endpoint to grab the registry for GHES/proxima purposes. + console.log(core.getInput('registry')); + console.log(`registryURL: ${registryURL}`); + // Paths to be included in the OCI image + const paths = core.getInput('path').split(' '); + let path = ''; + if (paths.length === 1 && fsHelper.isDirectory(paths[0])) { + // If the path is a single directory, we can skip the bundling step + path = paths[0]; + } + else { + // Otherwise, we need to bundle the files & folders into a temporary directory + const bundleDir = fsHelper.createTempDir(); + tmpDirs.push(bundleDir); + path = fsHelper.bundleFilesintoDirectory(paths, bundleDir); + } + // Create a temporary directory to store the archives + const archiveDir = fsHelper.createTempDir(); + tmpDirs.push(archiveDir); + const archives = await fsHelper.createArchives(path, archiveDir); + const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, repository, targetVersion.raw, new Date()); + const packageURL = await ghcr.publishOCIArtifact(token, registryURL, repository, releaseId.toString(), targetVersion.raw, archives.zipFile, archives.tarFile, manifest, true); + core.setOutput('package-url', packageURL.toString()); + // TODO: We might need to do some attestation stuff here, but unsure how to integrate it yet. + // We might need to return the manifest JSON from the Action and link it to another action, + // or we might be able to make an API call here. It's unclear at this point. + core.setOutput('package-manifest', JSON.stringify(manifest)); + } + catch (error) { + // Fail the workflow run if an error occurs + if (error instanceof Error) + core.setFailed(error.message); + } + finally { + // Clean up any temporary directories that exist + for (const tmpDir of tmpDirs) { + if (tmpDir !== '') { + fsHelper.removeDir(tmpDir); + } + } + } +} +exports.run = run; +//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/dist/main.js.map b/dist/main.js.map new file mode 100644 index 0000000..a7f69eb --- /dev/null +++ b/dist/main.js.map @@ -0,0 +1 @@ +{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/oci-container.js b/dist/oci-container.js new file mode 100644 index 0000000..8e57f85 --- /dev/null +++ b/dist/oci-container.js @@ -0,0 +1,65 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createActionPackageManifest = void 0; +// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package. +function createActionPackageManifest(tarFile, zipFile, repository, version, created) { + const configLayer = createConfigLayer(); + const sanitizedRepo = sanitizeRepository(repository); + const tarLayer = createTarLayer(tarFile, sanitizedRepo, version); + const zipLayer = createZipLayer(zipFile, sanitizedRepo, version); + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + artifactType: 'application/vnd.oci.image.manifest.v1+json', + config: configLayer, + layers: [configLayer, tarLayer, zipLayer], + annotations: { + 'org.opencontainers.image.created': created.toISOString(), + 'action.tar.gz.digest': tarFile.sha256, + 'action.zip.digest': zipFile.sha256, + 'com.github.package.type': 'actions_oci_pkg' + } + }; + return manifest; +} +exports.createActionPackageManifest = createActionPackageManifest; +// TODO: is this ok hardcoded? +function createConfigLayer() { + const configLayer = { + mediaType: 'application/vnd.github.actions.package.config.v1+json', + size: 0, + digest: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + annotations: { + 'org.opencontainers.image.title': 'config.json' + } + }; + return configLayer; +} +function createZipLayer(zipFile, repository, version) { + const zipLayer = { + mediaType: 'application/vnd.github.actions.package.layer.v1.zip', + size: zipFile.size, + digest: zipFile.sha256, + annotations: { + 'org.opencontainers.image.title': `${repository}_${version}.zip` + } + }; + return zipLayer; +} +function createTarLayer(tarFile, repository, version) { + const tarLayer = { + mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', + size: tarFile.size, + digest: tarFile.sha256, + annotations: { + 'org.opencontainers.image.title': `${repository}_${version}.tar.gz` + } + }; + return tarLayer; +} +// 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) { + return repository.replace('/', '-'); +} +//# sourceMappingURL=oci-container.js.map \ No newline at end of file diff --git a/dist/oci-container.js.map b/dist/oci-container.js.map new file mode 100644 index 0000000..e011b3b --- /dev/null +++ b/dist/oci-container.js.map @@ -0,0 +1 @@ +{"version":3,"file":"oci-container.js","sourceRoot":"","sources":["../src/oci-container.ts"],"names":[],"mappings":";;;AAkBA,+GAA+G;AAC/G,SAAgB,2BAA2B,CACzC,OAAqB,EACrB,OAAqB,EACrB,UAAkB,EAClB,OAAe,EACf,OAAa;IAEb,MAAM,WAAW,GAAG,iBAAiB,EAAE,CAAA;IACvC,MAAM,aAAa,GAAG,kBAAkB,CAAC,UAAU,CAAC,CAAA;IACpD,MAAM,QAAQ,GAAG,cAAc,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,CAAA;IAChE,MAAM,QAAQ,GAAG,cAAc,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,CAAA;IAEhE,MAAM,QAAQ,GAAa;QACzB,aAAa,EAAE,CAAC;QAChB,SAAS,EAAE,4CAA4C;QACvD,YAAY,EAAE,4CAA4C;QAC1D,MAAM,EAAE,WAAW;QACnB,MAAM,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,QAAQ,CAAC;QACzC,WAAW,EAAE;YACX,kCAAkC,EAAE,OAAO,CAAC,WAAW,EAAE;YACzD,sBAAsB,EAAE,OAAO,CAAC,MAAM;YACtC,mBAAmB,EAAE,OAAO,CAAC,MAAM;YACnC,yBAAyB,EAAE,iBAAiB;SAC7C;KACF,CAAA;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AA3BD,kEA2BC;AAED,8BAA8B;AAC9B,SAAS,iBAAiB;IACxB,MAAM,WAAW,GAAU;QACzB,SAAS,EAAE,uDAAuD;QAClE,IAAI,EAAE,CAAC;QACP,MAAM,EACJ,yEAAyE;QAC3E,WAAW,EAAE;YACX,gCAAgC,EAAE,aAAa;SAChD;KACF,CAAA;IAED,OAAO,WAAW,CAAA;AACpB,CAAC;AAED,SAAS,cAAc,CACrB,OAAqB,EACrB,UAAkB,EAClB,OAAe;IAEf,MAAM,QAAQ,GAAU;QACtB,SAAS,EAAE,qDAAqD;QAChE,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW,EAAE;YACX,gCAAgC,EAAE,GAAG,UAAU,IAAI,OAAO,MAAM;SACjE;KACF,CAAA;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,cAAc,CACrB,OAAqB,EACrB,UAAkB,EAClB,OAAe;IAEf,MAAM,QAAQ,GAAU;QACtB,SAAS,EAAE,0DAA0D;QACrE,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW,EAAE;YACX,gCAAgC,EAAE,GAAG,UAAU,IAAI,OAAO,SAAS;SACpE;KACF,CAAA;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,4DAA4D;AAC5D,qEAAqE;AACrE,SAAS,kBAAkB,CAAC,UAAkB;IAC5C,OAAO,UAAU,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;AACrC,CAAC"} \ No newline at end of file diff --git a/generate-new-version.sh b/generate-new-version.sh new file mode 100755 index 0000000..fbe0e37 --- /dev/null +++ b/generate-new-version.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +VERSION=$1 +MESSAGE=$2 + +if [ -z "$VERSION" ] +then + echo "No version supplied" + exit 1 +fi + +if [ -z "$MESSAGE" ] +then + echo "No message supplied" + exit 1 +fi + +echo "Generating new version $VERSION with message $MESSAGE" + +#sed -i '' -E 's/ddivad195\/publish-action-package\/package-and-publish.*$/ddivad195\/publish-action-package\/package-and-publish@'$VERSION'/g' action.yml +npm run bundle +git add . +git commit -m "$VERSION: $MESSAGE" +git push +git tag $VERSION +git push origin $VERSION +gh release create --repo ddivad195/publish-action-package --title $VERSION --notes $VERSION $VERSION \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 22a40d8..f0b3706 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "axios": "^1.6.2", "axios-debug-log": "^1.0.0", "fs-extra": "^11.1.1", + "minimist": "^1.2.8", "tar": "^6.2.0" }, "devDependencies": { "@types/archiver": "^6.0.2", "@types/axios": "^0.14.0", "@types/jest": "^29.5.11", + "@types/minimist": "^1.2.5", "@types/node": "^20.11.4", "@types/tar": "^6.1.10", "@typescript-eslint/eslint-plugin": "^6.12.0", @@ -1672,6 +1674,12 @@ "@types/node": "*" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -6157,7 +6165,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } diff --git a/package.json b/package.json index 835e482..beb5ae3 100644 --- a/package.json +++ b/package.json @@ -74,12 +74,14 @@ "axios": "^1.6.2", "axios-debug-log": "^1.0.0", "fs-extra": "^11.1.1", + "minimist": "^1.2.8", "tar": "^6.2.0" }, "devDependencies": { "@types/archiver": "^6.0.2", "@types/axios": "^0.14.0", "@types/jest": "^29.5.11", + "@types/minimist": "^1.2.5", "@types/node": "^20.11.4", "@types/tar": "^6.1.10", "@typescript-eslint/eslint-plugin": "^6.12.0", diff --git a/src/fs-helper.ts b/src/fs-helper.ts index 2245132..4f6599f 100644 --- a/src/fs-helper.ts +++ b/src/fs-helper.ts @@ -29,6 +29,27 @@ export interface FileMetadata { sha256: string } +// TODO: rename this function, it is not state-preserving, so it shouldn't just be called "get'" +export function getConsolidatedDirectory(filePathSpec: string): { + consolidatedPath: string + needToCleanUpDir: boolean +} { + const paths: string[] = filePathSpec.split(' ') // TODO: handle files with spaces + // TODO: do check on paths to make sure they're valid and not reaching outside the space + let consolidatedPath = '' + let needToCleanUpDir = false + if (paths.length === 1 && isDirectory(paths[0])) { + // If the path is a single directory, we can skip the bundling step + consolidatedPath = paths[0] + } else { + // Otherwise, we need to bundle the files & folders into a temporary directory + consolidatedPath = bundleFilesintoDirectory(paths) + needToCleanUpDir = true + } + + return { consolidatedPath, needToCleanUpDir } +} + // 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( @@ -55,7 +76,7 @@ export async function createArchives( }) archive.pipe(output) - archive.directory(distPath, false) + archive.directory(distPath, false) // TODO: make sure this doesn't include dirs that start with ., same with below archive.finalize() }) @@ -91,25 +112,30 @@ export function isDirectory(dirPath: string): boolean { return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory() } +export function isActionRepo(stagingDir: string): boolean { + return ( + fs.existsSync(path.join(stagingDir, 'action.yml')) || + fs.existsSync(path.join(stagingDir, 'action.yaml')) + ) +} + export function readFileContents(filePath: string): Buffer { return fs.readFileSync(filePath) } -export function bundleFilesintoDirectory( - files: string[], - targetDir: string = createTempDir() -): string { - for (const file of files) { - if (!fs.existsSync(file)) { - throw new Error(`File ${file} does not exist`) +function bundleFilesintoDirectory(filePaths: string[]): string { + const targetDir: string = createTempDir() + for (const filePath of filePaths) { + if (!fs.existsSync(filePath)) { + throw new Error(`filePath ${filePath} does not exist`) } - if (isDirectory(file)) { - const targetFolder = path.join(targetDir, path.basename(file)) - fsExtra.copySync(file, targetFolder) + if (isDirectory(filePath)) { + const targetFolder = path.join(targetDir, path.basename(filePath)) // TODO: basename is probably not what we actually want here. Or is it? Maybe conflicts between dir1/dir2 and dir1/dir3/dir2 are just user error or ?? + fsExtra.copySync(filePath, targetFolder) // TODO: ignore files preceded by . } else { - const targetFile = path.join(targetDir, path.basename(file)) - fs.copyFileSync(file, targetFile) + const targetFile = path.join(targetDir, path.basename(filePath)) + fs.copyFileSync(filePath, targetFile) } } diff --git a/src/index.ts b/src/index.ts index b08f970..02d4ca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ * The entrypoint for the action. */ import { run } from './main' +import minimist from 'minimist' +const path = minimist(process.argv.slice(2)).path || '.' // eslint-disable-next-line @typescript-eslint/no-floating-promises -run() +run(path) diff --git a/src/main.ts b/src/main.ts index 9215c2e..c717690 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,62 +4,61 @@ import * as fsHelper from './fs-helper' import * as ociContainer from './oci-container' import * as ghcr from './ghcr-client' import semver from 'semver' +import crypto from 'crypto' /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. */ -export async function run(): Promise { +export async function run(pathInput: string): Promise { const tmpDirs: string[] = [] try { // Parse and validate Actions execution context, including the repository name, release name and event type const repository: string = process.env.GITHUB_REPOSITORY || '' + if (repository === '') { core.setFailed(`Could not find Repository.`) return } + if (github.context.eventName !== 'release') { core.setFailed('Please ensure you have the workflow trigger as release.') return } + const releaseId: string = github.context.payload.release.id const releaseTag: string = github.context.payload.release.tag_name - // Strip any leading 'v' from the tag in case the release format is e.g. 'v1.0.0' as recommended by GitHub docs // https://docs.github.com/en/actions/creating-actions/releasing-and-maintaining-actions const targetVersion = semver.parse(releaseTag.replace(/^v/, '')) if (!targetVersion) { - // TODO: We may want to limit semvers to only x.x.x, without the pre-release tags, but for now we'll allow them. core.setFailed( `${releaseTag} is not a valid semantic version, and so cannot be uploaded as an Immutable Action.` ) return } - // Gather & validate user inputs - const token: string = core.getInput('token') - const registryURL: URL = new URL(core.getInput('registry')) // TODO: Should this be dynamic? Maybe an API endpoint to grab the registry for GHES/proxima purposes. + const token: string = process.env.TOKEN! - // Paths to be included in the OCI image - const paths: string[] = core.getInput('path').split(' ') - let path = '' + const { consolidatedPath, needToCleanUpDir } = + fsHelper.getConsolidatedDirectory(pathInput) + if (needToCleanUpDir) { + tmpDirs.push(consolidatedPath) + } - if (paths.length === 1 && fsHelper.isDirectory(paths[0])) { - // If the path is a single directory, we can skip the bundling step - path = paths[0] - } else { - // Otherwise, we need to bundle the files & folders into a temporary directory - const bundleDir = fsHelper.createTempDir() - tmpDirs.push(bundleDir) - path = fsHelper.bundleFilesintoDirectory(paths, bundleDir) + if (!fsHelper.isActionRepo(consolidatedPath)) { + core.setFailed( + 'action.y(a)ml not found. Action packages can be created only for action repositories.' + ) + return } // Create a temporary directory to store the archives const archiveDir = fsHelper.createTempDir() tmpDirs.push(archiveDir) - const archives = await fsHelper.createArchives(path, archiveDir) + const archives = await fsHelper.createArchives(consolidatedPath, archiveDir) const manifest = ociContainer.createActionPackageManifest( archives.tarFile, @@ -69,6 +68,22 @@ export async function run(): Promise { new Date() ) + // Generate SHA-256 hash of the manifest + const manifestSHA = crypto.createHash('sha256') + const manifestHash = manifestSHA + .update(JSON.stringify(manifest)) + .digest('hex') + + const response = await fetch( + `${process.env.GITHUB_API_URL}/packages/container-registry-url` + ) + if (!response.ok) { + throw new Error(`Failed to fetch status page: ${response.statusText}`) + } + const data = await response.json() + const registryURL: URL = new URL(data.url) + console.log(`Container registry URL: ${registryURL}`) + const packageURL = await ghcr.publishOCIArtifact( token, registryURL, @@ -82,11 +97,8 @@ export async function run(): Promise { ) core.setOutput('package-url', packageURL.toString()) - - // TODO: We might need to do some attestation stuff here, but unsure how to integrate it yet. - // We might need to return the manifest JSON from the Action and link it to another action, - // or we might be able to make an API call here. It's unclear at this point. core.setOutput('package-manifest', JSON.stringify(manifest)) + core.setOutput('package-manifest-sha', `sha256:${manifestHash}`) } catch (error) { // Fail the workflow run if an error occurs if (error instanceof Error) core.setFailed(error.message)