From 7b617c260dff86f8d044d5ab0425444b29fa0d18 Mon Sep 17 00:00:00 2001 From: Konrad Pabjan Date: Thu, 17 Aug 2023 14:40:33 -0400 Subject: [PATCH] [Artifacts] @actions/artifact list artifact functionality + download interface setup (#1495) * actions/artifact preparation for download-artifact v4 * Test matrix strategy * Fix needs dependency * Improve list artifact test * Fix typo * Fix variables * Cleanup download-all interfaces * Fix tsc error * Simplify to just name instead of artifactName * Simplify to id instead of ArtifactId * PR cleanup --- .github/workflows/artifact-tests.yml | 86 +++++-- packages/artifact/package-lock.json | 171 ++++++++++++++ packages/artifact/package.json | 4 + packages/artifact/src/artifact.ts | 3 +- packages/artifact/src/internal/client.ts | 215 ++++++++++++++++-- .../internal/download/download-artifact.ts | 14 ++ .../src/internal/find/get-artifact.ts | 11 + .../src/internal/find/list-artifacts.ts | 115 ++++++++++ .../src/internal/find/retry-options.ts | 44 ++++ .../src/internal/shared/interfaces.ts | 123 ++++++++++ .../src/internal/shared/user-agent.ts | 8 + .../src/internal/upload/blob-upload.ts | 11 +- .../src/internal/upload/upload-artifact.ts | 3 +- .../src/internal/upload/upload-options.ts | 18 -- .../src/internal/upload/upload-response.ts | 17 -- 15 files changed, 772 insertions(+), 71 deletions(-) create mode 100644 packages/artifact/src/internal/download/download-artifact.ts create mode 100644 packages/artifact/src/internal/find/get-artifact.ts create mode 100644 packages/artifact/src/internal/find/list-artifacts.ts create mode 100644 packages/artifact/src/internal/find/retry-options.ts create mode 100644 packages/artifact/src/internal/shared/interfaces.ts create mode 100644 packages/artifact/src/internal/shared/user-agent.ts delete mode 100644 packages/artifact/src/internal/upload/upload-options.ts delete mode 100644 packages/artifact/src/internal/upload/upload-response.ts diff --git a/.github/workflows/artifact-tests.yml b/.github/workflows/artifact-tests.yml index d3541984..2b150fcb 100644 --- a/.github/workflows/artifact-tests.yml +++ b/.github/workflows/artifact-tests.yml @@ -54,26 +54,82 @@ jobs: echo '${{ env.file1 }}' > artifact-path/first.txt echo '${{ env.file2 }}' > artifact-path/second.txt - - uses: actions/github-script@v6 + - name: Upload Artifacts using actions/github-script@v6 + uses: actions/github-script@v6 with: script: | - const artifact = require('./packages/artifact/lib/artifact') + const artifact = require('./packages/artifact/lib/artifact') - const artifactName = 'my-artifact-${{ matrix.runs-on }}' - console.log('artifactName: ' + artifactName) + const artifactName = 'my-artifact-${{ matrix.runs-on }}' + console.log('artifactName: ' + artifactName) - const fileContents = ['artifact-path/first.txt','artifact-path/second.txt'] + const fileContents = ['artifact-path/first.txt','artifact-path/second.txt'] - const uploadResult = await artifact.create().uploadArtifact(artifactName, fileContents, './') - console.log(uploadResult) + const uploadResult = await artifact.create().uploadArtifact(artifactName, fileContents, './') + console.log(uploadResult) - const success = uploadResult.success - const size = uploadResult.size - const id = uploadResult.id + const success = uploadResult.success + const size = uploadResult.size + const id = uploadResult.id - if (!success) { - throw new Error('Failed to upload artifact') - } else { - console.log(`Successfully uploaded artifact ${id}`) - } + if (!success) { + throw new Error('Failed to upload artifact') + } else { + console.log(`Successfully uploaded artifact ${id}`) + } + verify: + runs-on: ubuntu-latest + needs: [build] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + + # Need root node_modules because certain npm packages like jest are configured for the entire repository and it won't be possible + # without these to just compile the artifacts package + - name: Install root npm packages + run: npm ci + + - name: Compile artifact package + run: | + npm ci + npm run tsc + working-directory: packages/artifact + + - name: List artifacts using actions/github-script@v6 + uses: actions/github-script@v6 + with: + script: | + const artifact = require('./packages/artifact/lib/artifact') + + const workflowRunId = process.env.GITHUB_RUN_ID + const repository = process.env.GITHUB_REPOSITORY + const repositoryOwner = repository.split('/')[0] + const repositoryName = repository.split('/')[1] + + const listResult = await artifact.create().listArtifacts(workflowRunId, repositoryOwner, repositoryName, '${{ secrets.GITHUB_TOKEN }}') + console.log(listResult) + + const artifacts = listResult.artifacts + + if (artifacts.length !== 3) { + throw new Error('Expected 3 artifacts but only found ' + artifacts.length + ' artifacts') + } + + const artifactNames = artifacts.map(artifact => artifact.name) + if (!artifactNames.includes('my-artifact-ubuntu-latest')){ + throw new Error("Expected artifact list to contain an artifact named my-artifact-ubuntu-latest but it's missing") + } + if (!artifactNames.includes('my-artifact-windows-latest')){ + throw new Error("Expected artifact list to contain an artifact named my-artifact-windows-latest but it's missing") + } + if (!artifactNames.includes('my-artifact-macos-latest')){ + throw new Error("Expected artifact list to contain an artifact named my-artifact-macos-latest but it's missing") + } + + console.log('Successfully listed artifacts that were uploaded') diff --git a/packages/artifact/package-lock.json b/packages/artifact/package-lock.json index 05b66fdf..90c4bd6f 100644 --- a/packages/artifact/package-lock.json +++ b/packages/artifact/package-lock.json @@ -10,8 +10,12 @@ "license": "MIT", "dependencies": { "@actions/core": "^1.10.0", + "@actions/github": "^5.1.1", "@actions/http-client": "^2.1.0", "@azure/storage-blob": "^12.15.0", + "@octokit/core": "^3.5.1", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-retry": "^3.0.9", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^5.3.1", "crypto": "^1.0.1", @@ -32,6 +36,17 @@ "uuid": "^8.3.2" } }, + "node_modules/@actions/github": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", + "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", + "dependencies": { + "@actions/http-client": "^2.0.1", + "@octokit/core": "^3.6.0", + "@octokit/plugin-paginate-rest": "^2.17.0", + "@octokit/plugin-rest-endpoint-methods": "^5.13.0" + } + }, "node_modules/@actions/http-client": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", @@ -166,6 +181,134 @@ "node": ">=14.0.0" } }, + "node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@octokit/core": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", + "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", + "dependencies": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.3", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", + "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", + "dependencies": { + "@octokit/types": "^6.40.0" + }, + "peerDependencies": { + "@octokit/core": ">=2" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz", + "integrity": "sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==", + "dependencies": { + "@octokit/types": "^6.39.0", + "deprecation": "^2.3.1" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-3.0.9.tgz", + "integrity": "sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ==", + "dependencies": { + "@octokit/types": "^6.0.3", + "bottleneck": "^2.15.3" + } + }, + "node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", @@ -395,6 +538,11 @@ } ] }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -405,6 +553,11 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -534,6 +687,11 @@ "node": ">=0.4.0" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "node_modules/dot-object": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-2.1.4.tgz", @@ -642,6 +800,14 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1006,6 +1172,11 @@ "node": ">=4.2.0" } }, + "node_modules/universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/packages/artifact/package.json b/packages/artifact/package.json index 9845862e..b9e86639 100644 --- a/packages/artifact/package.json +++ b/packages/artifact/package.json @@ -40,8 +40,12 @@ }, "dependencies": { "@actions/core": "^1.10.0", + "@actions/github": "^5.1.1", "@actions/http-client": "^2.1.0", "@azure/storage-blob": "^12.15.0", + "@octokit/core": "^3.5.1", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-retry": "^3.0.9", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^5.3.1", "crypto": "^1.0.1", diff --git a/packages/artifact/src/artifact.ts b/packages/artifact/src/artifact.ts index b53733f3..b566df8e 100644 --- a/packages/artifact/src/artifact.ts +++ b/packages/artifact/src/artifact.ts @@ -1,6 +1,5 @@ import {ArtifactClient, Client} from './internal/client' -import {UploadOptions} from './internal/upload/upload-options' -import {UploadResponse} from './internal/upload/upload-response' +import {UploadOptions, UploadResponse} from './internal/shared/interfaces' /** * Exported functionality that we want to expose for any users of @actions/artifact diff --git a/packages/artifact/src/internal/client.ts b/packages/artifact/src/internal/client.ts index f827d882..6ffc632c 100644 --- a/packages/artifact/src/internal/client.ts +++ b/packages/artifact/src/internal/client.ts @@ -1,18 +1,27 @@ -import {UploadOptions} from './upload/upload-options' -import {UploadResponse} from './upload/upload-response' -import {uploadArtifact} from './upload/upload-artifact' import {warning} from '@actions/core' import {isGhes} from './shared/config' +import { + UploadOptions, + UploadResponse, + DownloadArtifactOptions, + GetArtifactResponse, + ListArtifactsResponse, + DownloadArtifactResponse +} from './shared/interfaces' +import {uploadArtifact} from './upload/upload-artifact' +import {downloadArtifact} from './download/download-artifact' +import {getArtifact} from './find/get-artifact' +import {listArtifacts} from './find/list-artifacts' export interface ArtifactClient { /** * Uploads an artifact * - * @param name the name of the artifact, required - * @param files a list of absolute or relative paths that denote what files should be uploaded - * @param rootDirectory an absolute or relative file path that denotes the root parent directory of the files being uploaded - * @param options extra options for customizing the upload behavior - * @returns single UploadInfo object + * @param name The name of the artifact, required + * @param files A list of absolute or relative paths that denote what files should be uploaded + * @param rootDirectory An absolute or relative file path that denotes the root parent directory of the files being uploaded + * @param options Extra options for customizing the upload behavior + * @returns single UploadResponse object */ uploadArtifact( name: string, @@ -21,7 +30,64 @@ export interface ArtifactClient { options?: UploadOptions ): Promise - // TODO Download functionality + /** + * Lists all artifacts that are part of a workflow run. + * + * This calls the public List-Artifacts API https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-workflow-run-artifacts + * Due to paginated responses from the public API. This function will return at most 1000 artifacts per workflow run (100 per page * maximum 10 calls) + * + * @param workflowRunId The workflow run id that the artifact belongs to + * @param repositoryOwner The owner of the repository that the artifact belongs to + * @param repositoryName The name of the repository that the artifact belongs to + * @param token A token with the appropriate permission to the repository to list artifacts + * @returns ListArtifactResponse object + */ + listArtifacts( + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string + ): Promise + + /** + * Finds an artifact by name given a repository and workflow run id. + * + * This calls the public List-Artifacts API with a name filter https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-workflow-run-artifacts + * @actions/artifact > 2.0.0 does not allow for creating multiple artifacts with the same name in the same workflow run. + * It is possible to have multiple artifacts with the same name in the same workflow run by using old versions of upload-artifact (v1,v2 and v3) or @actions/artifact < v2.0.0 + * If there are multiple artifacts with the same name in the same workflow run this function will return the first artifact that matches the name. + * + * @param artifactName The name of the artifact to find + * @param workflowRunId The workflow run id that the artifact belongs to + * @param repositoryOwner The owner of the repository that the artifact belongs to + * @param repositoryName The name of the repository that the artifact belongs to + * @param token A token with the appropriate permission to the repository to find the artifact + */ + getArtifact( + artifactName: string, + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string + ): Promise + + /** + * Downloads an artifact and unzips the content + * + * @param artifactId The name of the artifact to download + * @param repositoryOwner The owner of the repository that the artifact belongs to + * @param repositoryName The name of the repository that the artifact belongs to + * @param token A token with the appropriate permission to the repository to download the artifact + * @param options Extra options that allow for the customization of the download behavior + * @returns single DownloadArtifactResponse object + */ + downloadArtifact( + artifactId: number, + repositoryOwner: string, + repositoryName: string, + token: string, + options?: DownloadArtifactOptions + ): Promise } export class Client implements ArtifactClient { @@ -33,7 +99,7 @@ export class Client implements ArtifactClient { } /** - * Uploads an artifact + * Upload Artifact */ async uploadArtifact( name: string, @@ -43,7 +109,7 @@ export class Client implements ArtifactClient { ): Promise { if (isGhes()) { warning( - `@actions/artifact v2 and upload-artifact v4 are not currently supported on GHES.` + `@actions/artifact v2.0.0+ and upload-artifact@v4+ are not currently supported on GHES.` ) return { success: false @@ -56,9 +122,132 @@ export class Client implements ArtifactClient { warning( `Artifact upload failed with error: ${error}. -Errors can be temporary, so please try again and optionally run the action with debug enabled for more information. +Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information. -If the error persists, please check whether Actions is running normally at [https://githubstatus.com](https://www.githubstatus.com).` +If the error persists, please check whether Actions is operating normally at [https://githubstatus.com](https://www.githubstatus.com).` + ) + return { + success: false + } + } + } + + /** + * Download Artifact + */ + async downloadArtifact( + artifactId: number, + repositoryOwner: string, + repositoryName: string, + token: string, + options?: DownloadArtifactOptions + ): Promise { + if (isGhes()) { + warning( + `@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.` + ) + return { + success: false + } + } + + try { + return downloadArtifact( + artifactId, + repositoryOwner, + repositoryName, + token, + options + ) + } catch (error) { + warning( + `Artifact download failed with error: ${error}. + +Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information. + +If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).` + ) + + return { + success: false + } + } + } + + /** + * List Artifacts + */ + async listArtifacts( + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string + ): Promise { + if (isGhes()) { + warning( + `@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.` + ) + return { + artifacts: [] + } + } + + try { + return listArtifacts( + workflowRunId, + repositoryOwner, + repositoryName, + token + ) + } catch (error: unknown) { + warning( + `Listing Artifacts failed with error: ${error}. + +Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information. + +If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).` + ) + + return { + artifacts: [] + } + } + } + + /** + * Get Artifact + */ + async getArtifact( + artifactName: string, + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string + ): Promise { + if (isGhes()) { + warning( + `@actions/artifact v2.0.0+ and download-artifact@v4+ are not currently supported on GHES.` + ) + return { + success: false + } + } + + try { + return getArtifact( + artifactName, + workflowRunId, + repositoryOwner, + repositoryName, + token + ) + } catch (error: unknown) { + warning( + `Fetching Artifact failed with error: ${error}. + +Errors can be temporary, so please try again and optionally run the action with debug mode enabled for more information. + +If the error persists, please check whether Actions and API requests are operating normally at [https://githubstatus.com](https://www.githubstatus.com).` ) return { success: false diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts new file mode 100644 index 00000000..7e9dfbfd --- /dev/null +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -0,0 +1,14 @@ +import { + DownloadArtifactOptions, + DownloadArtifactResponse +} from '../shared/interfaces' + +export async function downloadArtifact( + artifactId: number, + repositoryOwner: string, + repositoryName: string, + token: string, + options?: DownloadArtifactOptions +): Promise { + throw new Error('Not implemented') +} diff --git a/packages/artifact/src/internal/find/get-artifact.ts b/packages/artifact/src/internal/find/get-artifact.ts new file mode 100644 index 00000000..3b28ff91 --- /dev/null +++ b/packages/artifact/src/internal/find/get-artifact.ts @@ -0,0 +1,11 @@ +import {GetArtifactResponse} from '../shared/interfaces' + +export async function getArtifact( + artifactName: string, + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string +): Promise { + throw new Error('Not implemented') +} diff --git a/packages/artifact/src/internal/find/list-artifacts.ts b/packages/artifact/src/internal/find/list-artifacts.ts new file mode 100644 index 00000000..7fd96838 --- /dev/null +++ b/packages/artifact/src/internal/find/list-artifacts.ts @@ -0,0 +1,115 @@ +import {info, warning, debug} from '@actions/core' +import {getOctokit} from '@actions/github' +import {ListArtifactsResponse, Artifact} from '../shared/interfaces' +import {getUserAgentString} from '../shared/user-agent' +import {RetryOptions, getRetryOptions} from './retry-options' +import {defaults as defaultGitHubOptions} from '@actions/github/lib/utils' +import {requestLog} from '@octokit/plugin-request-log' +import {retry} from '@octokit/plugin-retry' +import {RequestRequestOptions} from '@octokit/types' + +type Options = { + log?: Console + userAgent?: string + previews?: string[] + retry?: RetryOptions + request?: RequestRequestOptions +} + +// Limiting to 1000 for perf reasons +const maximumArtifactCount = 1000 +const paginationCount = 100 +const maxNumberOfPages = maximumArtifactCount / paginationCount +const maxRetryNumber = 5 +const exemptStatusCodes = [400, 401, 403, 404, 422] // https://github.com/octokit/plugin-retry.js/blob/9a2443746c350b3beedec35cf26e197ea318a261/src/index.ts#L14 + +export async function listArtifacts( + workflowRunId: number, + repositoryOwner: string, + repositoryName: string, + token: string +): Promise { + info(`Fetching artifact list for workflow run ${workflowRunId} in repository ${repositoryOwner}/${repositoryName}`) + + const artifacts: Artifact[] = [] + const [retryOpts, requestOpts] = getRetryOptions( + maxRetryNumber, + exemptStatusCodes, + defaultGitHubOptions + ) + + const opts: Options = { + log: undefined, + userAgent: getUserAgentString(), + previews: undefined, + retry: retryOpts, + request: requestOpts + } + + const github = getOctokit(token, opts, retry, requestLog) + + let currentPageNumber = 1 + const {data: listArtifactResponse} = + await github.rest.actions.listWorkflowRunArtifacts({ + owner: repositoryOwner, + repo: repositoryName, + run_id: workflowRunId, + per_page: paginationCount, + page: currentPageNumber + }) + + let numberOfPages = Math.ceil( + listArtifactResponse.total_count / paginationCount + ) + const totalArtifactCount = listArtifactResponse.total_count + if (totalArtifactCount > maximumArtifactCount) { + warning( + `Workflow run ${workflowRunId} has more than 1000 artifacts. Results will be incomplete as only the first ${maximumArtifactCount} artifacts will be returned` + ) + numberOfPages = maxNumberOfPages + } + + // Iterate over the first page + listArtifactResponse.artifacts.forEach(artifact => { + artifacts.push({ + name: artifact.name, + id: artifact.id, + url: artifact.url, + size: artifact.size_in_bytes + }) + }) + + // Iterate over any remaining pages + for ( + currentPageNumber; + currentPageNumber < numberOfPages; + currentPageNumber++ + ) { + currentPageNumber++ + debug(`Fetching page ${currentPageNumber} of artifact list`) + + const {data: listArtifactResponse} = + await github.rest.actions.listWorkflowRunArtifacts({ + owner: repositoryOwner, + repo: repositoryName, + run_id: workflowRunId, + per_page: paginationCount, + page: currentPageNumber + }) + + listArtifactResponse.artifacts.forEach(artifact => { + artifacts.push({ + name: artifact.name, + id: artifact.id, + url: artifact.url, + size: artifact.size_in_bytes + }) + }) + } + + info(`Finished fetching artifact list`) + + return { + artifacts: artifacts + } +} diff --git a/packages/artifact/src/internal/find/retry-options.ts b/packages/artifact/src/internal/find/retry-options.ts new file mode 100644 index 00000000..3fe51b5a --- /dev/null +++ b/packages/artifact/src/internal/find/retry-options.ts @@ -0,0 +1,44 @@ +import * as core from '@actions/core' +import {OctokitOptions} from '@octokit/core/dist-types/types' +import {RequestRequestOptions} from '@octokit/types' + +export type RetryOptions = { + doNotRetry?: number[] + enabled?: boolean +} + +export function getRetryOptions( + retries: number, + exemptStatusCodes: number[], + defaultOptions: OctokitOptions +): [RetryOptions, RequestRequestOptions | undefined] { + if (retries <= 0) { + return [{enabled: false}, defaultOptions.request] + } + + const retryOptions: RetryOptions = { + enabled: true + } + + if (exemptStatusCodes.length > 0) { + retryOptions.doNotRetry = exemptStatusCodes + } + + // The GitHub type has some defaults for `options.request` + // see: https://github.com/actions/toolkit/blob/4fbc5c941a57249b19562015edbd72add14be93d/packages/github/src/utils.ts#L15 + // We pass these in here so they are not overridden. + const requestOptions: RequestRequestOptions = { + ...defaultOptions.request, + retries + } + + core.debug( + `GitHub client configured with: (retries: ${ + requestOptions.retries + }, retry-exempt-status-code: ${ + retryOptions.doNotRetry ?? 'octokit default: [400, 401, 403, 404, 422]' + })` + ) + + return [retryOptions, requestOptions] +} diff --git a/packages/artifact/src/internal/shared/interfaces.ts b/packages/artifact/src/internal/shared/interfaces.ts new file mode 100644 index 00000000..d1f7a7aa --- /dev/null +++ b/packages/artifact/src/internal/shared/interfaces.ts @@ -0,0 +1,123 @@ +/***************************************************************************** + * * + * UploadArtifact * + * * + *****************************************************************************/ +export interface UploadResponse { + /** + * Denotes if an artifact was successfully uploaded + */ + success: boolean + + /** + * Total size of the artifact in bytes. Not provided if no artifact was uploaded + */ + size?: number + + /** + * The id of the artifact that was created. Not provided if no artifact was uploaded + * This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts + */ + id?: number +} + +export interface UploadOptions { + /** + * Duration after which artifact will expire in days. + * + * By default artifact expires after 90 days: + * https://docs.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts#downloading-and-deleting-artifacts-after-a-workflow-run-is-complete + * + * Use this option to override the default expiry. + * + * Min value: 1 + * Max value: 90 unless changed by repository setting + * + * If this is set to a greater value than the retention settings allowed, the retention on artifacts + * will be reduced to match the max value allowed on server, and the upload process will continue. An + * input of 0 assumes default retention setting. + */ + retentionDays?: number +} + +/***************************************************************************** + * * + * GetArtifact * + * * + *****************************************************************************/ + +export interface GetArtifactResponse { + /** + * If an artifact was found + */ + success: boolean + + /** + * Metadata about the artifact that was found + */ + artifact?: Artifact +} + +/***************************************************************************** + * * + * ListArtifact * + * * + *****************************************************************************/ +export interface ListArtifactsResponse { + /** + * A list of artifacts that were found + */ + artifacts: Artifact[] +} + +/***************************************************************************** + * * + * DownloadArtifact * + * * + *****************************************************************************/ +export interface DownloadArtifactResponse { + /** + * If the artifact download was successful + */ + success: boolean +} + +export interface DownloadArtifactOptions { + /** + * Denotes where the artifact will be downloaded to. If not specified then the artifact is download to GITHUB_WORKSPACE + */ + path?: string + + /** + * Specifies if a root folder with the artifact name is created for the artifact that is downloaded + * Zip contents are expanded into this folder. Defaults to false if not specified + * */ + createArtifactFolder?: boolean +} + +/***************************************************************************** + * * + * Shared * + * * + *****************************************************************************/ +export interface Artifact { + /** + * The name of the artifact + */ + name: string + + /** + * The ID of the artifact + */ + id: number + + /** + * The URL of the artifact + */ + url: string + + /** + * The size of the artifact in bytes + */ + size: number +} diff --git a/packages/artifact/src/internal/shared/user-agent.ts b/packages/artifact/src/internal/shared/user-agent.ts new file mode 100644 index 00000000..eee01446 --- /dev/null +++ b/packages/artifact/src/internal/shared/user-agent.ts @@ -0,0 +1,8 @@ +var packageJson = require('../../../package.json') + +/** + * Ensure that this User Agent String is used in all HTTP calls so that we can monitor telemetry between different versions of this package + */ +export function getUserAgentString(): string { + return `@actions/artifact-${packageJson.version}` +} diff --git a/packages/artifact/src/internal/upload/blob-upload.ts b/packages/artifact/src/internal/upload/blob-upload.ts index ede3f00a..82af468a 100644 --- a/packages/artifact/src/internal/upload/blob-upload.ts +++ b/packages/artifact/src/internal/upload/blob-upload.ts @@ -70,18 +70,21 @@ export async function uploadZipToBlobStorage( hashStream.end() md5Hash = hashStream.read() as string core.info(`MD5 hash of uploaded artifact zip is ${md5Hash}`) - } catch (error) { - core.warning(`Failed to upload artifact zip to blob storage, error: ${error}`) + core.warning( + `Failed to upload artifact zip to blob storage, error: ${error}` + ) return { isSuccess: false } } if (uploadByteCount === 0) { - core.warning(`No data was uploaded to blob storage. Reported upload byte count is 0`) + core.warning( + `No data was uploaded to blob storage. Reported upload byte count is 0` + ) return { - isSuccess: false, + isSuccess: false } } diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index 87e687fc..35cd9ac3 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -1,6 +1,5 @@ import * as core from '@actions/core' -import {UploadOptions} from './upload-options' -import {UploadResponse} from './upload-response' +import {UploadOptions, UploadResponse} from '../shared/interfaces' import {getExpiration} from './retention' import {validateArtifactName} from './path-and-artifact-name-validation' import {createArtifactTwirpClient} from '../shared/artifact-twirp-client' diff --git a/packages/artifact/src/internal/upload/upload-options.ts b/packages/artifact/src/internal/upload/upload-options.ts deleted file mode 100644 index 66df123a..00000000 --- a/packages/artifact/src/internal/upload/upload-options.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface UploadOptions { - /** - * Duration after which artifact will expire in days. - * - * By default artifact expires after 90 days: - * https://docs.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts#downloading-and-deleting-artifacts-after-a-workflow-run-is-complete - * - * Use this option to override the default expiry. - * - * Min value: 1 - * Max value: 90 unless changed by repository setting - * - * If this is set to a greater value than the retention settings allowed, the retention on artifacts - * will be reduced to match the max value allowed on server, and the upload process will continue. An - * input of 0 assumes default retention setting. - */ - retentionDays?: number -} diff --git a/packages/artifact/src/internal/upload/upload-response.ts b/packages/artifact/src/internal/upload/upload-response.ts deleted file mode 100644 index 0c968cfb..00000000 --- a/packages/artifact/src/internal/upload/upload-response.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface UploadResponse { - /** - * Denotes if an artifact was successfully uploaded - */ - success: boolean - - /** - * Total size of the artifact in bytes. Not provided if no artifact was uploaded - */ - size?: number - - /** - * The id of the artifact that was created. Not provided if no artifact was uploaded - * This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts - */ - id?: number -}