Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Ghadimi 1959adefa3 Add additional optional param to core.error 2025-03-27 17:03:39 +00:00
194 changed files with 10893 additions and 13460 deletions
-5
View File
@@ -57,8 +57,3 @@ This will ask you some questions about the new package. Start with `0.0.0` as th
```
3. Start developing 😄.
## Releasing Packages
For information on how packages are published to npm, including workflow inputs, dist-tags, and safety guards, see the [release documentation](../docs/release.md).
-27
View File
@@ -1,27 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm"
directory: "/packages/artifact"
schedule:
interval: "daily"
groups:
# Group minor and patch updates together but keep major separate
artifact-minor-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "npm"
directory: "/packages/cache"
schedule:
interval: "daily"
groups:
# Group minor and patch updates together but keep major separate
cache-minor-patch:
update-types:
- "minor"
- "patch"
+14 -220
View File
@@ -22,12 +22,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set Node.js 24.x
uses: actions/setup-node@v5
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.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
@@ -47,7 +47,7 @@ jobs:
echo -n 'hello from file 2' > artifact-path/second.txt
- name: Upload Artifacts
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const {default: artifact} = require('./packages/artifact/lib/artifact')
@@ -71,151 +71,18 @@ jobs:
} catch (err) {
console.log('Successfully blocked second artifact upload')
}
upload-single-file:
name: Upload Single File (no zip)
strategy:
matrix:
runs-on: [ubuntu-latest, windows-latest, macos-latest]
fail-fast: false
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
- name: Install root npm packages
run: npm ci
- name: Compile artifact package
run: |
npm ci
npm run tsc
working-directory: packages/artifact
- name: Create file that will be uploaded
run: |
echo -n 'hello from single file upload' > single-file-${{ matrix.runs-on }}.txt
- name: Upload Single File Artifact (skipArchive)
uses: actions/github-script@v8
with:
script: |
const {default: artifact} = require('./packages/artifact/lib/artifact')
const artifactName = 'my-single-file-${{ matrix.runs-on }}'
console.log('artifactName: ' + artifactName)
const uploadResult = await artifact.uploadArtifact(
artifactName,
['single-file-${{ matrix.runs-on }}.txt'],
'./',
{skipArchive: true}
)
console.log(uploadResult)
const size = uploadResult.size
const id = uploadResult.id
if (!id) {
throw new Error('Artifact ID is missing from upload result')
}
console.log(`Successfully uploaded single file artifact ${id}`)
upload-html-file:
name: Upload HTML File (no zip)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
- name: Install root npm packages
run: npm ci
- name: Compile artifact package
run: |
npm ci
npm run tsc
working-directory: packages/artifact
- name: Create HTML file
run: |
cat > report.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artifact Upload Test Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #24292f; }
h1 { border-bottom: 1px solid #d0d7de; padding-bottom: 8px; }
.success { color: #1a7f37; }
.info { background: #ddf4ff; border: 1px solid #54aeff; border-radius: 6px; padding: 12px 16px; margin: 16px 0; }
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid #d0d7de; padding: 8px 12px; text-align: left; }
th { background: #f6f8fa; }
</style>
</head>
<body>
<h1>Artifact Upload Test Report</h1>
<div class="info">
<strong>This HTML file was uploaded as a single un-zipped artifact.</strong>
If you can see this in the browser, the feature is working correctly!
</div>
<table>
<tr><th>Property</th><th>Value</th></tr>
<tr><td>Upload method</td><td><code>skipArchive: true</code></td></tr>
<tr><td>Content-Type</td><td><code>text/html</code></td></tr>
<tr><td>File</td><td><code>report.html</code></td></tr>
</table>
<p class="success">&#10004; Single file upload is working!</p>
</body>
</html>
EOF
- name: Upload HTML Artifact (skipArchive)
uses: actions/github-script@v8
with:
script: |
const {default: artifact} = require('./packages/artifact/lib/artifact')
const uploadResult = await artifact.uploadArtifact(
'test-report',
['report.html'],
'./',
{skipArchive: true}
)
console.log(uploadResult)
console.log(`Successfully uploaded HTML artifact ${uploadResult.id}`)
console.log('This artifact is intentionally kept for manual browser verification')
verify:
name: Verify and Delete
runs-on: ubuntu-latest
needs: [upload, upload-single-file, upload-html-file]
needs: [upload]
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set Node.js 24.x
uses: actions/setup-node@v5
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.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
@@ -229,7 +96,7 @@ jobs:
working-directory: packages/artifact
- name: List and Download Artifacts
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const {default: artifactClient} = require('./packages/artifact/lib/artifact')
@@ -297,73 +164,8 @@ jobs:
}
}
}
- name: Download and Verify Single File Artifacts
uses: actions/github-script@v8
with:
script: |
const {default: artifactClient} = require('./packages/artifact/lib/artifact')
const {readFile} = require('fs/promises')
const path = require('path')
const findBy = {
repositoryOwner: process.env.GITHUB_REPOSITORY.split('/')[0],
repositoryName: process.env.GITHUB_REPOSITORY.split('/')[1],
token: '${{ secrets.GITHUB_TOKEN }}',
workflowRunId: process.env.GITHUB_RUN_ID
}
const listResult = await artifactClient.listArtifacts({latest: true, findBy})
const expectedSingleFiles = [
'single-file-ubuntu-latest.txt',
'single-file-windows-latest.txt',
'single-file-macos-latest.txt'
]
// Single file artifacts are named after the file basename
const singleFileArtifacts = listResult.artifacts.filter(a =>
expectedSingleFiles.includes(a.name)
)
console.log('Found single file artifacts:', singleFileArtifacts.length)
if (singleFileArtifacts.length !== 3) {
console.log('Unexpected single file artifacts:', singleFileArtifacts)
throw new Error(
`Expected 3 single-file artifacts but found ${singleFileArtifacts.length}`
)
}
for (const artifact of singleFileArtifacts) {
const downloadDir = `single-file-download-${artifact.id}`
const {downloadPath} = await artifactClient.downloadArtifact(artifact.id, {
path: downloadDir,
findBy
})
console.log('Downloaded single file artifact to:', downloadPath)
const filePath = path.join(
process.env.GITHUB_WORKSPACE,
downloadPath,
artifact.name
)
console.log('Checking file:', filePath)
const content = await readFile(filePath, 'utf8')
if (content.trim() !== 'hello from single file upload') {
throw new Error(
`Expected single file to contain 'hello from single file upload' but found '${content}'`
)
}
console.log(`Successfully verified single file artifact ${artifact.id}`)
}
- name: Delete Artifacts
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const {default: artifactClient} = require('./packages/artifact/lib/artifact')
@@ -371,19 +173,11 @@ jobs:
const artifactsToDelete = [
'my-artifact-ubuntu-latest',
'my-artifact-windows-latest',
'my-artifact-macos-latest',
'single-file-ubuntu-latest.txt',
'single-file-windows-latest.txt',
'single-file-macos-latest.txt'
'my-artifact-macos-latest'
]
for (const artifactName of artifactsToDelete) {
try {
const {id} = await artifactClient.deleteArtifact(artifactName)
console.log(`Deleted artifact '${artifactName}' (ID: ${id})`)
} catch (err) {
console.log(`Could not delete artifact '${artifactName}': ${err.message}`)
}
const {id} = await artifactClient.deleteArtifact(artifactName)
}
const {artifacts} = await artifactClient.listArtifacts({latest: true})
+4 -4
View File
@@ -18,12 +18,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set Node.js 24.x
uses: actions/setup-node@v5
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- name: npm install
run: npm install
+12 -10
View File
@@ -22,12 +22,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set Node.js 24.x
uses: actions/setup-node@v5
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
# In order to save & restore cache from a shell script, certain env variables need to be set that are only available in the
# node context. This runs a local action that gets and sets the necessary env variables that are needed
@@ -39,11 +39,9 @@ jobs:
- name: Install root npm packages
run: npm ci
# We need to install only runtime dependencies (omit dev dependencies) to verify that what we're shipping is all
# that is needed
- name: Compile cache package
run: |
npm ci --omit=dev
npm ci
npm run tsc
working-directory: packages/cache
@@ -55,8 +53,10 @@ jobs:
shell: bash
run: packages/cache/__tests__/create-cache-files.sh ${{ runner.os }} ~/test-cache
# We're using node -e to call the functions directly available in the @actions/cache package
- name: Save cache using saveCache()
run: node packages/cache/__tests__/save-cache.mjs ${{ runner.os }} ${{ github.run_id }}
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').saveCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
- name: Delete cache folders before restoring
shell: bash
@@ -65,7 +65,8 @@ jobs:
rm -rf ~/test-cache
- name: Restore cache using restoreCache() with http-client
run: node packages/cache/__tests__/restore-cache.mjs ${{ runner.os }} ${{ github.run_id }} false
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}',[],{useAzureSdk: false}))"
- name: Verify cache restored with http-client
shell: bash
@@ -80,7 +81,8 @@ jobs:
rm -rf ~/test-cache
- name: Restore cache using restoreCache() with Azure SDK
run: node packages/cache/__tests__/restore-cache.mjs ${{ runner.os }} ${{ github.run_id }} true
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
- name: Verify cache restored with Azure SDK
shell: bash
+11 -7
View File
@@ -17,16 +17,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
- shell: bash
run: |
rm "C:\Program Files\Git\usr\bin\tar.exe"
- name: Set Node.js 24.x
uses: actions/setup-node@v5
- name: Set Node.js 20.x
uses: actions/setup-node@v1
with:
node-version: 24.x
node-version: 20.x
# In order to save & restore cache from a shell script, certain env variables need to be set that are only available in the
# node context. This runs a local action that gets and sets the necessary env variables that are needed
@@ -52,8 +52,10 @@ jobs:
shell: bash
run: packages/cache/__tests__/create-cache-files.sh ${{ runner.os }} ~/test-cache
# We're using node -e to call the functions directly available in the @actions/cache package
- name: Save cache using saveCache()
run: node packages/cache/__tests__/save-cache.mjs ${{ runner.os }} ${{ github.run_id }}
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').saveCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
- name: Delete cache folders before restoring
shell: bash
@@ -62,7 +64,8 @@ jobs:
rm -rf ~/test-cache
- name: Restore cache using restoreCache() with http-client
run: node packages/cache/__tests__/restore-cache.mjs ${{ runner.os }} ${{ github.run_id }} false
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}',[],{useAzureSdk: false}))"
- name: Verify cache restored with http-client
shell: bash
@@ -77,7 +80,8 @@ jobs:
rm -rf ~/test-cache
- name: Restore cache using restoreCache() with Azure SDK
run: node packages/cache/__tests__/restore-cache.mjs ${{ runner.os }} ${{ github.run_id }} true
run: |
node -e "Promise.resolve(require('./packages/cache/lib/cache').restoreCache(['test-cache','~/test-cache'],'test-${{ runner.os }}-${{ github.run_id }}'))"
- name: Verify cache restored with Azure SDK
shell: bash
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+19 -46
View File
@@ -1,6 +1,6 @@
name: Publish NPM
run-name: Publish NPM - ${{ inputs.package }} from ${{ inputs.branch }}
run-name: Publish NPM - ${{ github.event.inputs.package }}
on:
workflow_dispatch:
@@ -20,39 +20,23 @@ on:
- http-client
- io
- tool-cache
branch:
type: string
required: false
default: 'main'
description: 'Branch to release from'
npm-tag:
type: string
required: false
default: 'latest'
description: 'npm dist-tag for the release. Use "latest" for main branch releases. For non-main branches, use a non-semver tag like "v1-longlived". Semver values (e.g. "5.0.0") are not valid dist-tags and will be rejected by npm.'
test-all:
type: boolean
required: false
default: false
description: 'Run tests for all packages instead of only the package being published'
jobs:
test:
runs-on: ubuntu-latest
runs-on: macos-latest-large
steps:
- name: setup repo
uses: actions/checkout@v6
with:
ref: ${{ inputs.branch }}
uses: actions/checkout@v4
- name: verify package exists
run: ls packages/${{ github.event.inputs.package }}
- name: Set Node.js 24.x
uses: actions/setup-node@v6
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- name: npm install
run: npm install
@@ -64,25 +48,20 @@ jobs:
run: npm run build
- name: test
run: |
if [ "${{ inputs.test-all }}" = "true" ]; then
npm run test
else
npm run test -- --testPathPattern="packages/${{ inputs.package }}"
fi
run: npm run test
- name: pack
run: npm pack
working-directory: packages/${{ github.event.inputs.package }}
- name: upload artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ github.event.inputs.package }}
path: packages/${{ github.event.inputs.package }}/*.tgz
publish:
runs-on: ubuntu-slim
runs-on: macos-latest-large
needs: test
environment: npm-publish
permissions:
@@ -90,36 +69,30 @@ jobs:
id-token: write
steps:
- name: Set Node.js 24.x
uses: actions/setup-node@v6
with:
node-version: 24.x
- name: download artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: ${{ inputs.package }}
name: ${{ github.event.inputs.package }}
- name: guard against publishing latest from non-main branch
if: inputs.branch != 'main' && inputs.npm-tag == 'latest'
run: |
echo "::error::Publishing with the 'latest' dist-tag from a non-main branch ('${{ inputs.branch }}') is not allowed. Use the package major version as the tag (e.g. v5)."
exit 1
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{ secrets.TOKEN }}
- name: publish
run: npm publish --provenance --tag "${{ inputs.npm-tag }}" *.tgz
run: npm publish --provenance *.tgz
- name: notify slack on failure
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' --data '{"text":":pb__failed: Failed to publish a new version of ${{ inputs.package }} from ${{ inputs.branch }} (tag: ${{ inputs.npm-tag }})"}' $SLACK_WEBHOOK
curl -X POST -H 'Content-type: application/json' --data '{"text":":pb__failed: Failed to publish a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
env:
SLACK_WEBHOOK: ${{ secrets.SLACK }}
- name: notify slack on success
if: success()
run: |
curl -X POST -H 'Content-type: application/json' --data '{"text":":dance: Successfully published a new version of ${{ inputs.package }} from ${{ inputs.branch }} (tag: ${{ inputs.npm-tag }})"}' $SLACK_WEBHOOK
curl -X POST -H 'Content-type: application/json' --data '{"text":":dance: Successfully published a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
env:
SLACK_WEBHOOK: ${{ secrets.SLACK }}
+5 -5
View File
@@ -18,19 +18,19 @@ jobs:
matrix:
runs-on: [ubuntu-latest, macos-latest-large, windows-latest]
# Node 20 is the currently supported stable Node version for actions - https://docs.github.com/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#runsusing-for-javascript-actions
# Node 24 is the new version being added with support in actions runners
node-version: [20.x, 24.x]
# Node 18 is the current default Node version in hosted runners, so users may still use the toolkit with it when running tests (see https://github.com/actions/toolkit/issues/1841)
# Node 20 is the currently support Node version for actions - https://docs.github.com/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#runsusing-for-javascript-actions
node-version: [18.x, 20.x]
fail-fast: false
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
if: ${{ github.repository_owner == 'actions' }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Update Octokit
working-directory: packages/github
run: |
+12 -26
View File
@@ -24,7 +24,7 @@ The GitHub Actions ToolKit provides a set of packages to make creating actions e
Provides functions for inputs, outputs, results, logging, secrets and variables. Read more [here](packages/core)
```bash
npm install @actions/core
$ npm install @actions/core
```
<br/>
@@ -33,7 +33,7 @@ npm install @actions/core
Provides functions to exec cli tools and process output. Read more [here](packages/exec)
```bash
npm install @actions/exec
$ npm install @actions/exec
```
<br/>
@@ -42,7 +42,7 @@ npm install @actions/exec
Provides functions to search for files matching glob patterns. Read more [here](packages/glob)
```bash
npm install @actions/glob
$ npm install @actions/glob
```
<br/>
@@ -51,7 +51,7 @@ npm install @actions/glob
A lightweight HTTP client optimized for building actions. Read more [here](packages/http-client)
```bash
npm install @actions/http-client
$ npm install @actions/http-client
```
<br/>
@@ -60,7 +60,7 @@ npm install @actions/http-client
Provides disk i/o functions like cp, mv, rmRF, which etc. Read more [here](packages/io)
```bash
npm install @actions/io
$ npm install @actions/io
```
<br/>
@@ -71,7 +71,7 @@ Provides functions for downloading and caching tools. e.g. setup-* actions. Rea
See @actions/cache for caching workflow dependencies.
```bash
npm install @actions/tool-cache
$ npm install @actions/tool-cache
```
<br/>
@@ -80,7 +80,7 @@ npm install @actions/tool-cache
Provides an Octokit client hydrated with the context that the current action is being run in. Read more [here](packages/github)
```bash
npm install @actions/github
$ npm install @actions/github
```
<br/>
@@ -89,7 +89,7 @@ npm install @actions/github
Provides functions to interact with actions artifacts. Read more [here](packages/artifact)
```bash
npm install @actions/artifact
$ npm install @actions/artifact
```
<br/>
@@ -98,7 +98,7 @@ npm install @actions/artifact
Provides functions to cache dependencies and build outputs to improve workflow execution time. Read more [here](packages/cache)
```bash
npm install @actions/cache
$ npm install @actions/cache
```
<br/>
@@ -107,7 +107,7 @@ npm install @actions/cache
Provides functions to write attestations for workflow artifacts. Read more [here](packages/attest)
```bash
npm install @actions/attest
$ npm install @actions/attest
```
<br/>
@@ -227,23 +227,9 @@ console.log(`We can even get context data, like the repo: ${context.repo.repo}`)
```
<br/>
## Note
## Contributing
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features were working on and what stage theyre in.
We are taking the following steps to better direct requests related to GitHub Actions, including:
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
3. Security Issues should be handled as per our [security.md](SECURITY.md).
We will still provide security updates for this project and fix major breaking changes during this time.
You are welcome to still raise bugs in this repo.
We welcome contributions. See [how to contribute](.github/CONTRIBUTING.md).
## Code of Conduct
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
os: [ubuntu-16.04, windows-2019]
runs-on: ${{matrix.os}}
actions:
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
version: ${{matrix.node}}
- run: |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

+1 -1
View File
@@ -18,7 +18,7 @@ e.g. To use https://github.com/actions/setup-node, users will author:
```yaml
steps:
using: actions/setup-node@v5
using: actions/setup-node@v4
```
# Define Metadata
-82
View File
@@ -1,82 +0,0 @@
# Releasing Packages
Packages are published to npm via the **Publish NPM** workflow ([`.github/workflows/releases.yml`](../.github/workflows/releases.yml)). The workflow is triggered manually through `workflow_dispatch` from the GitHub Actions UI.
## How it works
The workflow has two jobs:
1. **test** — Checks out the specified branch, installs dependencies, bootstraps the monorepo, builds all packages, runs tests, then packs the target package into a `.tgz` archive and uploads it as a workflow artifact.
2. **publish** — Downloads the packed artifact and publishes it to npm with `--provenance` (OIDC-based). Sends a Slack notification on success or failure. Requires the `npm-publish` environment.
## Inputs
| Input | Type | Required | Default | Description |
|---|---|---|---|---|
| `package` | choice | **yes** | — | Which package to release. One of: `artifact`, `attest`, `cache`, `core`, `exec`, `github`, `glob`, `http-client`, `io`, `tool-cache`. |
| `branch` | string | no | `main` | The branch to check out and release from. |
| `npm-tag` | string | no | `latest` | The npm dist-tag to publish under. See [Dist-tags](#dist-tags) below. |
| `test-all` | boolean | no | `false` | When `false`, only tests for the selected package are run. Set to `true` to run the full test suite across all packages. |
## Dist-tags
npm dist-tags control which version users get when they `npm install @actions/<package>` (or `@<tag>`).
> **Important:** npm dist-tags **cannot** be valid semver strings. Values like `5.0.0` or `1.2.3` will be rejected by npm. Use a descriptive name instead.
- **`latest`** — The default tag. This is what users get with a plain `npm install`. Should only be used for releases from `main`.
- **Custom tags** (e.g. `v1-longlived`) — Used for releases from long-lived or experimental branches.
Examples of **valid** dist-tags: `latest`, `next`, `beta`, `v1-longlived`
Examples of **invalid** dist-tags: `5.0.0`, `1.2.3`, `6.0.0-rc.1` (these are semver and will be rejected)
| ![Screenshot showcasing the npm distribution tags](assets/npm-dist-tags.png) |
|---|
| npm distribution tags |
### Safety guard
The workflow **blocks** publishing with the `latest` dist-tag from any branch other than `main`. This prevents accidentally overwriting `latest` with a version from an older or experimental branch. If you're releasing from a non-main branch, use the package's major version as the tag (e.g. `v5`).
## Examples
### Standard release from main
Use default inputs — just pick the package:
| Input | Value |
|---|---|
| `package` | `core` |
| `branch` | `main` (default) |
| `npm-tag` | `latest` (default) |
| `test-all` | `false` (default) |
This publishes the version in `packages/core/package.json` on `main` as `@actions/core@latest`.
### Patch release from a long-lived branch
| Input | Value |
|---|---|
| `package` | `artifact` |
| `branch` | `releases/v5` |
| `npm-tag` | `v5` |
| `test-all` | `false` (default) |
This publishes the version in `packages/artifact/package.json` on the `releases/v5` branch under the `v5` dist-tag. The `latest` tag remains untouched.
### Release with full test suite
| Input | Value |
|---|---|
| `package` | `cache` |
| `branch` | `main` (default) |
| `npm-tag` | `latest` (default) |
| `test-all` | `true` |
Same as a standard release, but runs tests for all packages before publishing.
## Prerequisites
- You must have permission to trigger workflows on this repository.
- The `npm-publish` environment must be configured with npm credentials and OIDC.
- The `SLACK` secret must be set for Slack notifications to work.
+1 -28
View File
@@ -4,35 +4,8 @@ module.exports = {
roots: ['<rootDir>/packages'],
testEnvironment: 'node',
testMatch: ['**/__tests__/*.test.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@actions/core$': '<rootDir>/packages/core/lib/core.js',
'^@actions/exec$': '<rootDir>/packages/exec/lib/exec.js',
'^@actions/io$': '<rootDir>/packages/io/lib/io.js',
'^@actions/io/lib/io-util$': '<rootDir>/packages/io/lib/io-util.js',
'^@actions/http-client$': '<rootDir>/packages/http-client/lib/index.js',
'^@actions/http-client/lib/auth$': '<rootDir>/packages/http-client/lib/auth.js',
'^@actions/http-client/lib/interfaces$': '<rootDir>/packages/http-client/lib/interfaces.js',
'^@actions/github$': '<rootDir>/packages/github/lib/github.js',
'^@actions/github/lib/utils$': '<rootDir>/packages/github/lib/utils.js',
'^@actions/glob$': '<rootDir>/packages/glob/lib/glob.js',
'^@actions/tool-cache$': '<rootDir>/packages/tool-cache/lib/tool-cache.js',
'^@actions/cache$': '<rootDir>/packages/cache/lib/cache.js',
'^@actions/attest$': '<rootDir>/packages/attest/lib/index.js'
},
transform: {
'^.+\\.(ts|js)$': ['ts-jest', {
diagnostics: {warnOnly: true},
tsconfig: {
allowJs: true,
esModuleInterop: true,
module: 'commonjs',
moduleResolution: 'node'
}
}]
'^.+\\.ts$': 'ts-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(@octokit|@actions/github|@actions/http-client|@actions/io|@actions/exec|@actions/core|@actions/glob|@actions/tool-cache|@actions/cache|@actions/attest|universal-user-agent|before-after-hook)/)'
],
verbose: true
}
+5308 -6846
View File
File diff suppressed because it is too large Load Diff
+6 -16
View File
@@ -1,6 +1,6 @@
{
"name": "root",
"private": true,
"private": true,
"scripts": {
"audit-all": "lerna run audit-moderate",
"bootstrap": "lerna exec -- npm install",
@@ -17,7 +17,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.4",
"@types/node": "^24.1.0",
"@types/node": "^20.5.7",
"@types/signale": "^1.4.1",
"concurrently": "^6.1.0",
"eslint": "^8.0.1",
@@ -33,18 +33,8 @@
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"overrides": {
"semver": "^7.6.0",
"tar": "^6.2.1",
"@octokit/plugin-paginate-rest": "^14.0.0",
"@octokit/request": "^10.0.7",
"@octokit/request-error": "^7.1.0",
"@octokit/core": "^7.0.6",
"tmp": "^0.2.4",
"@types/node": "^24.1.0",
"brace-expansion": "^2.0.2",
"form-data": "^4.0.4",
"uri-js": "npm:uri-js-replace@^1.0.1",
"node-fetch": "^3.3.2"
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.0"
}
}
}
+1
View File
@@ -4,6 +4,7 @@ Interact programmatically with [Actions Artifacts](https://docs.github.com/en/ac
This is the core library that powers the [`@actions/upload-artifact`](https://github.com/actions/upload-artifact) and [`@actions/download-artifact`](https://github.com/actions/download-artifact) actions.
- [`@actions/artifact`](#actionsartifact)
- [v2 - What's New](#v2---whats-new)
- [Improvements](#improvements)
+44 -87
View File
@@ -1,161 +1,118 @@
# @actions/artifact Releases
## 6.2.1
- Support the RFC 5987 `filename*` field in the `content-disposition` header. This allows us to correctly download files and artifacts with Chinese/Japanese/Korean (among other) characters in their name.
## 6.2.0
- Support uploading single un-archived files (not zipped). Direct uploads are only supported for artifacts version 7+ (based on the major version of `actions/upload-artifact`). Callers must pass the `skipArchive` option to `uploadArtifact`. Only single files can be uploaded at a time right now. Default behavior should remain unchanged if `skipArchive = false`. When `skipArchive = true`, the name of the file is used as the name of the artifact for consistency with the downloads: you upload `artifact.txt`, you download `artifact.txt`.
## 6.1.0
- Support downloading non-zip artifacts. Zipped artifacts will be decompressed automatically (with an optional override). Un-zipped artifacts will be downloaded as-is.
## 6.0.0
- **Breaking change**: Package is now ESM-only
- CommonJS consumers must use dynamic `import()` instead of `require()`
## 5.0.3
- Bump `@actions/http-client` to `3.0.2`
## 5.0.1
- Fix Node.js 24 punycode deprecation warning by updating `@azure/storage-blob` from `^12.15.0` to `^12.29.1` [#2211](https://github.com/actions/toolkit/pull/2211)
- Removed direct `@azure/core-http` dependency (now uses `@azure/core-rest-pipeline` via storage-blob)
## 5.0.0
- Dependency updates for Node.js 24 runtime support
- Update `@actions/core` to v2
- Update `@actions/http-client` to v3
## 4.0.0
- Add support for Node 24 [#2110](https://github.com/actions/toolkit/pull/2110)
- Fix: artifact pagination bugs and configurable artifact count limits [#2165](https://github.com/actions/toolkit/pull/2165)
- Fix: reject the promise on timeout [#2124](https://github.com/actions/toolkit/pull/2124)
- Update dependency versions
## 2.3.3
- Dependency updates [#2049](https://github.com/actions/toolkit/pull/2049)
## 2.3.2
### 2.3.2
- Added masking for Shared Access Signature (SAS) artifact URLs [#1982](https://github.com/actions/toolkit/pull/1982)
- Change hash to digest for consistent terminology across runner logs [#1991](https://github.com/actions/toolkit/pull/1991)
- Change hash to digest for consistent terminology across runner logs [#1991](https://github.com/actions/toolkit/pull/1991)
## 2.3.1
### 2.3.1
- Fix comment typo on expectedHash. [#1986](https://github.com/actions/toolkit/pull/1986)
## 2.3.0
### 2.3.0
- Allow ArtifactClient to perform digest comparisons, if supplied. [#1975](https://github.com/actions/toolkit/pull/1975)
## 2.2.2
### 2.2.2
- Default concurrency to 5 for uploading artifacts [#1962](https://github.com/actions/toolkit/pull/1962)
- Default concurrency to 5 for uploading artifacts [#1962](https://github.com/actions/toolkit/pull/1962
## 2.2.1
### 2.2.1
- Add `ACTIONS_ARTIFACT_UPLOAD_CONCURRENCY` and `ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS` environment variables [#1928](https://github.com/actions/toolkit/pull/1928)
## 2.2.0
### 2.2.0
- Return artifact digest on upload [#1896](https://github.com/actions/toolkit/pull/1896)
## 2.1.11
### 2.1.11
- Fixed a bug with relative symlinks resolution [#1844](https://github.com/actions/toolkit/pull/1844)
- Use native `crypto` [#1815](https://github.com/actions/toolkit/pull/1815)
## 2.1.10
### 2.1.10
- Fixed a regression with symlinks not being automatically resolved [#1830](https://github.com/actions/toolkit/pull/1830)
- Fixed a regression with chunk timeout [#1786](https://github.com/actions/toolkit/pull/1786)
## 2.1.9
### 2.1.9
- Fixed artifact upload chunk timeout logic [#1774](https://github.com/actions/toolkit/pull/1774)
- Use lazy stream to prevent issues with open file limits [#1771](https://github.com/actions/toolkit/pull/1771)
## 2.1.8
### 2.1.8
- Allows `*.localhost` domains for hostname checks for local development.
## 2.1.7
### 2.1.7
- Update unzip-stream dependency and reverted to using `unzip.Extract()`
## 2.1.6
### 2.1.6
- Will retry on invalid request responses.
## 2.1.5
### 2.1.5
- Bumped `archiver` dependency to 7.0.1
## 2.1.4
### 2.1.4
- Adds info-level logging for zip extraction
## 2.1.3
### 2.1.3
- Fixes a bug in the extract logic updated in 2.1.2
## 2.1.2
### 2.1.2
- Updated the stream extract functionality to use `unzip.Parse()` instead of `unzip.Extract()` for greater control of unzipping artifacts
## 2.1.1
### 2.1.1
- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts
## 2.1.0
### 2.1.0
- Added `ArtifactClient#deleteArtifact` to delete artifacts by name [#1626](https://github.com/actions/toolkit/pull/1626)
- Update error messaging to be more useful [#1628](https://github.com/actions/toolkit/pull/1628)
## 2.0.1
### 2.0.1
- Patch to fix transient request timeouts <https://github.com/actions/download-artifact/issues/249>
- Patch to fix transient request timeouts https://github.com/actions/download-artifact/issues/249
## 2.0.0
### 2.0.0
- Major release. Supports new Artifact backend for improved speed, reliability and behavior.
- Numerous API changes, [some breaking](./README.md#breaking-changes).
- [Blog post with more info](https://github.blog/2024-02-12-get-started-with-v4-of-github-actions-artifacts/)
## 1.1.1
### 1.1.1
- Fixed a bug in Node16 where if an HTTP download finished too quickly (<1ms, e.g. when it's mocked) we attempt to delete a temp file that has not been created yet [#1278](https://github.com/actions/toolkit/pull/1278/commits/b9de68a590daf37c6747e38d3cb4f1dd2cfb791c)
## 1.1.0
### 1.1.0
- Add `x-actions-results-crc64` and `x-actions-results-md5` checksum headers on upload [#1063](https://github.com/actions/toolkit/pull/1063)
## 1.0.2
### 1.0.2
- Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
## 1.0.1
### 1.0.1
- Update to v2.0.0 of `@actions/http-client`
## 1.0.0
### 1.0.0
- Update `lockfileVersion` to `v2` in `package-lock.json` [#1009](https://github.com/actions/toolkit/pull/1009)
## 0.6.1
### 0.6.1
- Fix for failing 0 byte file uploads on Windows [#962](https://github.com/actions/toolkit/pull/962)
## 0.6.0
### 0.6.0
- Support upload from named pipes [#748](https://github.com/actions/toolkit/pull/748)
- Fixes to percentage values being greater than 100% when downloading all artifacts [#889](https://github.com/actions/toolkit/pull/889)
@@ -164,49 +121,49 @@
- Faster upload speeds for certain types of large files by exempting gzip compression [#956](https://github.com/actions/toolkit/pull/956)
- More detailed logging when dealing with chunked uploads [#957](https://github.com/actions/toolkit/pull/957)
## 0.5.2
### 0.5.2
- Add HTTP 500 as a retryable status code for artifact upload and download.
## 0.5.1
### 0.5.1
- Bump @actions/http-client to version 1.0.11 to fix proxy related issues during artifact upload and download
## 0.5.0
### 0.5.0
- Improved retry-ability for all http calls during artifact upload and download if an error is encountered
## 0.4.2
### 0.4.2
- Improved retry-ability when a partial artifact download is encountered
## 0.4.1
### 0.4.1
- Update to latest @actions/core version
## 0.4.0
### 0.4.0
- Add option to specify custom retentions on artifacts
## 0.3.5
-
### 0.3.5
- Retry in the event of a 413 response
## 0.3.3
### 0.3.3
- Increase chunk size during upload from 4MB to 8MB
- Improve user-agent strings during API calls to help internally diagnose issues
## 0.3.2
### 0.3.2
- Fix to ensure readstreams get correctly reset in the event of a retry
## 0.3.1
### 0.3.1
- Fix to ensure temporary gzip files get correctly deleted during artifact upload
- Remove spaces as a forbidden character during upload
## 0.3.0
### 0.3.0
- Fixes to gzip decompression when downloading artifacts
- Support handling 429 response codes
@@ -215,13 +172,13 @@
- Clearer error message if storage quota has been reached
- Improved logging and output during artifact download
## 0.2.0
### 0.2.0
- Fixes to TCP connections not closing
- GZip file compression to speed up downloads
- Improved logging and output
- Extra documentation
## 0.1.0
### 0.1.0
- Initial release
@@ -1,10 +1,10 @@
import * as http from 'http'
import * as net from 'net'
import {HttpClient} from '@actions/http-client'
import * as config from '../src/internal/shared/config.js'
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client.js'
import {noopLogs} from './common.js'
import {NetworkError, UsageError} from '../src/internal/shared/errors.js'
import * as config from '../src/internal/shared/config'
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
import {noopLogs} from './common'
import {NetworkError, UsageError} from '../src/internal/shared/errors'
jest.mock('@actions/http-client')
+5 -51
View File
@@ -1,14 +1,10 @@
import * as config from '../src/internal/shared/config.js'
import * as config from '../src/internal/shared/config'
import os from 'os'
// Mock the `cpus()` function in the `os` module
jest.mock('os', () => {
const osActual = jest.requireActual('os')
return {
...osActual,
cpus: jest.fn()
}
})
// Mock the 'os' module
jest.mock('os', () => ({
cpus: jest.fn()
}))
beforeEach(() => {
jest.resetModules()
@@ -105,45 +101,3 @@ describe('uploadConcurrencyEnv', () => {
}).toThrow()
})
})
describe('getMaxArtifactListCount', () => {
beforeEach(() => {
delete process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT
})
it('should return default 1000 when no env set', () => {
expect(config.getMaxArtifactListCount()).toBe(1000)
})
it('should return value set in ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT', () => {
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = '2000'
expect(config.getMaxArtifactListCount()).toBe(2000)
})
it('should throw if value set in ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT is invalid', () => {
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = 'abc'
expect(() => {
config.getMaxArtifactListCount()
}).toThrow(
'Invalid value set for ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT env variable'
)
})
it('should throw if ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT is < 1', () => {
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = '0'
expect(() => {
config.getMaxArtifactListCount()
}).toThrow(
'Invalid value set for ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT env variable'
)
})
it('should throw if ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT is negative', () => {
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = '-100'
expect(() => {
config.getMaxArtifactListCount()
}).toThrow(
'Invalid value set for ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT env variable'
)
})
})
@@ -4,11 +4,11 @@ import type {RequestInterface} from '@octokit/types'
import {
deleteArtifactInternal,
deleteArtifactPublic
} from '../src/internal/delete/delete-artifact.js'
import * as config from '../src/internal/shared/config.js'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated/index.js'
import * as util from '../src/internal/shared/util.js'
import {noopLogs} from './common.js'
} from '../src/internal/delete/delete-artifact'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated'
import * as util from '../src/internal/shared/util'
import {noopLogs} from './common'
type MockedRequest = jest.MockedFunction<RequestInterface<object>>
@@ -11,12 +11,12 @@ import {
downloadArtifactInternal,
downloadArtifactPublic,
streamExtractExternal
} from '../src/internal/download/download-artifact.js'
import {getUserAgentString} from '../src/internal/shared/user-agent.js'
import {noopLogs} from './common.js'
import * as config from '../src/internal/shared/config.js'
import {ArtifactServiceClientJSON} from '../src/generated/index.js'
import * as util from '../src/internal/shared/util.js'
} from '../src/internal/download/download-artifact'
import {getUserAgentString} from '../src/internal/shared/user-agent'
import {noopLogs} from './common'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON} from '../src/generated'
import * as util from '../src/internal/shared/util'
type MockedDownloadArtifact = jest.MockedFunction<
RestEndpointMethods['actions']['downloadArtifact']
@@ -104,7 +104,6 @@ const cleanup = async (): Promise<void> => {
const mockGetArtifactSuccess = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
@@ -112,17 +111,6 @@ const mockGetArtifactSuccess = jest.fn(() => {
}
})
const mockGetArtifactHung = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
// Don't push any data or call push(null) to end the stream
// This creates a stream that hangs and never completes
return {
message
}
})
const mockGetArtifactFailure = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 500
@@ -136,7 +124,6 @@ const mockGetArtifactFailure = jest.fn(() => {
const mockGetArtifactMalicious = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
message.push(null)
return {
@@ -622,480 +609,6 @@ describe('download-artifact', () => {
...fixtures.backendIds,
name: fixtures.artifactName
})
}, 38000)
})
describe('streamExtractExternal', () => {
beforeEach(async () => {
await setup()
// Create workspace directory for streamExtractExternal tests
await fs.promises.mkdir(fixtures.workspaceDir, {recursive: true})
})
afterEach(cleanup)
it('should fail if the timeout is exceeded', async () => {
const mockSlowGetArtifact = jest.fn(mockGetArtifactHung)
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockSlowGetArtifact
}
}
)
try {
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir,
{timeout: 2}
)
expect(true).toBe(false) // should not be called
} catch (error: unknown) {
const e = error as Error
expect(e).toBeInstanceOf(Error)
expect(e.message).toContain('did not respond in 2ms')
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1)
}
})
it('should extract zip file when content-type is application/zip', async () => {
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactSuccess
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted (not saved as a single file)
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should save raw file without extracting when content-type is not a zip', async () => {
const rawFileContent = 'This is a raw text file, not a zip'
const rawFileName = 'my-artifact.txt'
const mockGetRawFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
message.headers['content-disposition'] =
`attachment; filename="${rawFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetRawFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved as-is, not extracted
const savedFilePath = path.join(fixtures.workspaceDir, rawFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
})
it('should save raw file with default name when content-disposition is missing', async () => {
const rawFileContent = 'Binary content here'
const mockGetRawFileNoDisposition = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/octet-stream'
// No content-disposition header
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetRawFileNoDisposition
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved with default name 'artifact'
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
})
it('should not attempt to unzip when content-type is image/png', async () => {
const pngFileName = 'screenshot.png'
// Simple PNG header bytes for testing
const pngContent = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
const mockGetPngFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'image/png'
message.headers['content-disposition'] =
`attachment; filename="${pngFileName}"`
message.push(pngContent)
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetPngFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify PNG was saved as-is
const savedFilePath = path.join(fixtures.workspaceDir, pngFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath)).toEqual(pngContent)
})
it('should extract when content-type is application/x-zip-compressed', async () => {
const mockGetZipCompressed = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/x-zip-compressed'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetZipCompressed
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should extract zip when URL ends with .zip even if content-type is not application/zip', async () => {
const blobUrlWithZipExtension =
'https://blob-storage.local/artifact.zip?sig=abc123'
const mockGetZipByUrl = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
// Azure Blob Storage may return a generic content-type
message.headers['content-type'] = 'application/octet-stream'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetZipByUrl
}
}
)
await streamExtractExternal(
blobUrlWithZipExtension,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify files were extracted based on URL .zip extension
await expectExtractedArchive(fixtures.workspaceDir)
})
it('should skip decompression when skipDecompress option is true even for zip content-type', async () => {
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactSuccess
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir,
{skipDecompress: true}
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify zip was saved as-is, not extracted
// When skipDecompress is true, the file should be saved with default name 'artifact'
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
expect(fs.existsSync(savedFilePath)).toBe(true)
// The saved file should be the raw zip content
const savedContent = fs.readFileSync(savedFilePath)
const originalZipContent = fs.readFileSync(fixtures.exampleArtifact.path)
expect(savedContent).toEqual(originalZipContent)
})
it('should sanitize path traversal attempts in Content-Disposition filename', async () => {
const rawFileContent = 'malicious content'
const maliciousFileName = '../../../etc/passwd'
const mockGetMaliciousFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
message.headers['content-disposition'] =
`attachment; filename="${maliciousFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetMaliciousFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Verify file was saved with sanitized name (just 'passwd', not the full path)
const sanitizedFileName = 'passwd'
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
// Verify the file was NOT written outside the workspace directory
const maliciousPath = path.resolve(
fixtures.workspaceDir,
maliciousFileName
)
expect(fs.existsSync(maliciousPath)).toBe(false)
})
it('should handle encoded path traversal attempts in Content-Disposition filename', async () => {
const rawFileContent = 'encoded malicious content'
// URL encoded version of ../../../etc/passwd
const encodedMaliciousFileName = '..%2F..%2F..%2Fetc%2Fpasswd'
const mockGetEncodedMaliciousFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/octet-stream'
message.headers['content-disposition'] =
`attachment; filename="${encodedMaliciousFileName}"`
message.push(Buffer.from(rawFileContent))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetEncodedMaliciousFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// After decoding and sanitizing, should just be 'passwd'
const sanitizedFileName = 'passwd'
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
// Verify the file was NOT written outside the workspace directory
const maliciousPathEncoded = path.resolve(
fixtures.workspaceDir,
encodedMaliciousFileName
)
expect(fs.existsSync(maliciousPathEncoded)).toBe(false)
const maliciousPath = path.resolve(
fixtures.workspaceDir,
'../../../etc/passwd'
)
expect(fs.existsSync(maliciousPath)).toBe(false)
})
it('should correctly handle Content-Disposition with filename* parameter (RFC 5987)', async () => {
const rawFileContent = 'content with rfc5987 encoding'
const expectedFileName = '报告-土-x.txt'
const asciiFileName = '__-_-x.txt'
const mockGetRfc5987File = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
// Server sends both: filename with _ fallbacks, filename* with UTF-8 encoding
message.headers['content-disposition'] =
`attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(expectedFileName)}`
message.push(Buffer.from(rawFileContent, 'utf8'))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetRfc5987File
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
const savedFilePath = path.join(fixtures.workspaceDir, expectedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
})
it('should handle zip artifacts with Chinese characters in the artifact name', async () => {
// Simulate Azure Blob Storage URL with rscd containing Chinese filename
const chineseArtifactName = 'probe-土-x'
const asciiArtifactName = 'probe-_-x'
const blobUrlWithChineseName = `https://blob-storage.local/artifact.zip?rscd=${encodeURIComponent(`attachment; filename="${asciiArtifactName}.zip"; filename*=UTF-8''${encodeURIComponent(`${chineseArtifactName}.zip`)}`)}&rsct=application%2Fzip&sig=abc123`
const mockGetZip = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/zip'
message.headers['content-disposition'] =
`attachment; filename="${asciiArtifactName}.zip"; filename*=UTF-8''${encodeURIComponent(`${chineseArtifactName}.zip`)}`
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetZip
}
}
)
await streamExtractExternal(blobUrlWithChineseName, fixtures.workspaceDir)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
// Zip should be extracted normally regardless of Chinese artifact name
await expectExtractedArchive(fixtures.workspaceDir)
})
it.each([
['土', '_'], // U+571F - known to cause 400 errors
['日', '_'], // U+65E5 - reported to work fine
['中文测试', '____'], // multiple Chinese characters
['文件-2026年', '__-2026_'], // mixed Chinese and numbers
['データ', '___'], // Japanese katakana
['테스트', '___'] // Korean characters
])(
'should prefer filename* over filename for non-ASCII character %s (%s)',
async (chars, asciiReplacement) => {
const rawFileContent = `content for ${chars}`
const expectedFileName = `artifact-${chars}.txt`
const asciiFileName = `artifact-${asciiReplacement}.txt`
const mockGetFile = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'text/plain'
// Server sends filename with _ replacing non-ASCII, filename* with proper encoding
message.headers['content-disposition'] =
`attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(expectedFileName)}`
message.push(Buffer.from(rawFileContent, 'utf8'))
message.push(null)
return {
message
}
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetFile
}
}
)
await streamExtractExternal(
fixtures.blobStorageUrl,
fixtures.workspaceDir
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
const savedFilePath = path.join(fixtures.workspaceDir, expectedFileName)
expect(fs.existsSync(savedFilePath)).toBe(true)
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
}
)
})
})
@@ -3,15 +3,15 @@ import type {RequestInterface} from '@octokit/types'
import {
getArtifactInternal,
getArtifactPublic
} from '../src/internal/find/get-artifact.js'
import * as config from '../src/internal/shared/config.js'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated/index.js'
import * as util from '../src/internal/shared/util.js'
import {noopLogs} from './common.js'
} from '../src/internal/find/get-artifact'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated'
import * as util from '../src/internal/shared/util'
import {noopLogs} from './common'
import {
ArtifactNotFoundError,
InvalidResponseError
} from '../src/internal/shared/errors.js'
} from '../src/internal/shared/errors'
type MockedRequest = jest.MockedFunction<RequestInterface<object>>
@@ -3,12 +3,12 @@ import type {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-method
import {
listArtifactsInternal,
listArtifactsPublic
} from '../src/internal/find/list-artifacts.js'
import * as config from '../src/internal/shared/config.js'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated/index.js'
import * as util from '../src/internal/shared/util.js'
import {noopLogs} from './common.js'
import {Artifact} from '../src/internal/shared/interfaces.js'
} from '../src/internal/find/list-artifacts'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON, Timestamp} from '../src/generated'
import * as util from '../src/internal/shared/util'
import {noopLogs} from './common'
import {Artifact} from '../src/internal/shared/interfaces'
import {RequestInterface} from '@octokit/types'
type MockedRequest = jest.MockedFunction<RequestInterface<object>>
@@ -170,126 +170,6 @@ describe('list-artifact', () => {
)
).rejects.toThrow('boom')
})
it('should handle pagination correctly when fetching multiple pages', async () => {
const mockRequest = github.getOctokit(fixtures.token)
.request as MockedRequest
const manyArtifacts = Array.from({length: 150}, (_, i) => ({
id: i + 1,
name: `artifact-${i + 1}`,
size: 100,
createdAt: new Date('2023-12-01')
}))
mockRequest
.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
...artifactsToListResponse(manyArtifacts.slice(0, 100)),
total_count: 150
}
})
.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
...artifactsToListResponse(manyArtifacts.slice(100, 150)),
total_count: 150
}
})
const response = await listArtifactsPublic(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
false
)
// Verify that both API calls were made
expect(mockRequest).toHaveBeenCalledTimes(2)
// Should return all 150 artifacts across both pages
expect(response.artifacts).toHaveLength(150)
// Verify we got artifacts from both pages
expect(response.artifacts[0].name).toBe('artifact-1')
expect(response.artifacts[99].name).toBe('artifact-100')
expect(response.artifacts[100].name).toBe('artifact-101')
expect(response.artifacts[149].name).toBe('artifact-150')
})
it('should respect ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT environment variable', async () => {
const originalEnv = process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = '150'
jest.resetModules()
try {
const {listArtifactsPublic: listArtifactsPublicReloaded} = await import(
'../src/internal/find/list-artifacts'
)
const githubReloaded = await import('@actions/github')
const mockRequest = (githubReloaded.getOctokit as jest.Mock)(
fixtures.token
).request as MockedRequest
const manyArtifacts = Array.from({length: 200}, (_, i) => ({
id: i + 1,
name: `artifact-${i + 1}`,
size: 100,
createdAt: new Date('2023-12-01')
}))
mockRequest
.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
...artifactsToListResponse(manyArtifacts.slice(0, 100)),
total_count: 200
}
})
.mockResolvedValueOnce({
status: 200,
headers: {},
url: '',
data: {
...artifactsToListResponse(manyArtifacts.slice(100, 150)),
total_count: 200
}
})
const response = await listArtifactsPublicReloaded(
fixtures.runId,
fixtures.owner,
fixtures.repo,
fixtures.token,
false
)
// Should only return 150 artifacts due to the limit
expect(response.artifacts).toHaveLength(150)
expect(response.artifacts[0].name).toBe('artifact-1')
expect(response.artifacts[149].name).toBe('artifact-150')
} finally {
// Restore original environment variable
if (originalEnv !== undefined) {
process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT = originalEnv
} else {
delete process.env.ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT
}
// Reset modules again to restore original state
jest.resetModules()
}
})
})
describe('internal', () => {
@@ -1,9 +1,9 @@
import {
validateArtifactName,
validateFilePath
} from '../src/internal/upload/path-and-artifact-name-validation.js'
} from '../src/internal/upload/path-and-artifact-name-validation'
import {noopLogs} from './common.js'
import {noopLogs} from './common'
describe('Path and artifact name validation', () => {
beforeAll(() => {
@@ -1,5 +1,5 @@
import {Timestamp} from '../src/generated/index.js'
import * as retention from '../src/internal/upload/retention.js'
import {Timestamp} from '../src/generated'
import * as retention from '../src/internal/upload/retention'
describe('retention', () => {
beforeEach(() => {
@@ -1,59 +0,0 @@
import * as fs from 'fs'
import * as path from 'path'
import {createRawFileUploadStream} from '../src/internal/upload/stream.js'
import {noopLogs} from './common.js'
const fixtures = {
testDirectory: path.join(__dirname, '_temp', 'stream-test'),
testFile: path.join(__dirname, '_temp', 'stream-test', 'test-file.txt'),
testContent: 'hello stream test'
}
describe('createRawFileUploadStream', () => {
beforeAll(() => {
fs.mkdirSync(fixtures.testDirectory, {recursive: true})
fs.writeFileSync(fixtures.testFile, fixtures.testContent)
})
beforeEach(() => {
noopLogs()
})
afterEach(() => {
jest.restoreAllMocks()
})
it('should stream file contents through the upload stream', async () => {
const uploadStream = await createRawFileUploadStream(fixtures.testFile)
const chunks: Buffer[] = []
const result = await new Promise<string>((resolve, reject) => {
uploadStream.on('data', chunk => chunks.push(Buffer.from(chunk)))
uploadStream.on('end', () =>
resolve(Buffer.concat(chunks).toString('utf-8'))
)
uploadStream.on('error', reject)
})
expect(result).toBe(fixtures.testContent)
})
it('should propagate file read errors through the upload stream', async () => {
// Use a directory path — createReadStream on a directory fails cross-platform
// Mock lstat to return a non-symlink result so we reach createReadStream
const dirPath = fixtures.testDirectory
jest
.spyOn(fs.promises, 'lstat')
.mockResolvedValue({isSymbolicLink: () => false} as fs.Stats)
const uploadStream = await createRawFileUploadStream(dirPath)
await expect(
new Promise((resolve, reject) => {
uploadStream.on('data', resolve)
uploadStream.on('end', resolve)
uploadStream.on('error', reject)
})
).rejects.toThrow('An error has occurred during file read for the artifact')
})
})
@@ -1,13 +1,12 @@
import * as uploadZipSpecification from '../src/internal/upload/upload-zip-specification.js'
import * as zip from '../src/internal/upload/zip.js'
import * as util from '../src/internal/shared/util.js'
import * as config from '../src/internal/shared/config.js'
import {ArtifactServiceClientJSON} from '../src/generated/index.js'
import * as blobUpload from '../src/internal/upload/blob-upload.js'
import {uploadArtifact} from '../src/internal/upload/upload-artifact.js'
import {noopLogs} from './common.js'
import {FilesNotFoundError} from '../src/internal/shared/errors.js'
import * as stream from '../src/internal/upload/stream.js'
import * as uploadZipSpecification from '../src/internal/upload/upload-zip-specification'
import * as zip from '../src/internal/upload/zip'
import * as util from '../src/internal/shared/util'
import * as config from '../src/internal/shared/config'
import {ArtifactServiceClientJSON} from '../src/generated'
import * as blobUpload from '../src/internal/upload/blob-upload'
import {uploadArtifact} from '../src/internal/upload/upload-artifact'
import {noopLogs} from './common'
import {FilesNotFoundError} from '../src/internal/shared/errors'
import {BlockBlobUploadStreamOptions} from '@azure/storage-blob'
import * as fs from 'fs'
import * as path from 'path'
@@ -109,7 +108,7 @@ describe('upload-artifact', () => {
fixtures.files.map(file => ({
sourcePath: path.join(fixtures.uploadDirectory, file.name),
destinationPath: file.name,
stats: fs.statSync(path.join(fixtures.uploadDirectory, file.name))
stats: new fs.Stats()
}))
)
jest.spyOn(config, 'getRuntimeToken').mockReturnValue(fixtures.runtimeToken)
@@ -151,7 +150,7 @@ describe('upload-artifact', () => {
it('should return false if the creation request fails', async () => {
jest
.spyOn(zip, 'createZipUploadStream')
.mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1)))
.mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1)))
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(Promise.resolve({ok: false, signedUploadUrl: ''}))
@@ -168,7 +167,7 @@ describe('upload-artifact', () => {
it('should return false if blob storage upload is unsuccessful', async () => {
jest
.spyOn(zip, 'createZipUploadStream')
.mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1)))
.mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1)))
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
@@ -178,7 +177,7 @@ describe('upload-artifact', () => {
})
)
jest
.spyOn(blobUpload, 'uploadToBlobStorage')
.spyOn(blobUpload, 'uploadZipToBlobStorage')
.mockReturnValue(Promise.reject(new Error('boom')))
const uploadResp = uploadArtifact(
@@ -193,7 +192,7 @@ describe('upload-artifact', () => {
it('should reject if finalize artifact fails', async () => {
jest
.spyOn(zip, 'createZipUploadStream')
.mockReturnValue(Promise.resolve(new stream.WaterMarkedUploadStream(1)))
.mockReturnValue(Promise.resolve(new zip.ZipUploadStream(1)))
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
@@ -202,7 +201,7 @@ describe('upload-artifact', () => {
signedUploadUrl: 'https://signed-upload-url.com'
})
)
jest.spyOn(blobUpload, 'uploadToBlobStorage').mockReturnValue(
jest.spyOn(blobUpload, 'uploadZipToBlobStorage').mockReturnValue(
Promise.resolve({
uploadSize: 1234,
sha256Hash: 'test-sha256-hash'
@@ -371,284 +370,4 @@ describe('upload-artifact', () => {
await expect(uploadResp).rejects.toThrow('Upload progress stalled.')
})
describe('skipArchive option', () => {
it('should throw an error if skipArchive is true and files array is empty', async () => {
const uploadResp = uploadArtifact(
fixtures.inputs.artifactName,
[],
fixtures.inputs.rootDirectory,
{skipArchive: true}
)
await expect(uploadResp).rejects.toThrow(FilesNotFoundError)
})
it('should throw an error if skipArchive is true and multiple files are provided', async () => {
const uploadResp = uploadArtifact(
fixtures.inputs.artifactName,
fixtures.inputs.files,
fixtures.inputs.rootDirectory,
{skipArchive: true}
)
await expect(uploadResp).rejects.toThrow(
'skipArchive option is only supported when uploading a single file'
)
})
it('should upload a single file without archiving when skipArchive is true', async () => {
jest
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockRestore()
const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt')
const expectedContent = 'test 1 file content'
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
signedUploadUrl: 'https://signed-upload-url.local'
})
)
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
artifactId: '1'
})
)
let uploadedContent = ''
let loadedBytes = 0
uploadStreamMock.mockImplementation(
async (
stream: NodeJS.ReadableStream,
bufferSize?: number,
maxConcurrency?: number,
options?: BlockBlobUploadStreamOptions
) => {
const {onProgress} = options || {}
onProgress?.({loadedBytes: 0})
return new Promise((resolve, reject) => {
stream.on('data', chunk => {
loadedBytes += chunk.length
uploadedContent += chunk.toString()
onProgress?.({loadedBytes})
})
stream.on('end', () => {
onProgress?.({loadedBytes})
resolve({})
})
stream.on('error', err => {
reject(err)
})
})
}
)
const {id, size, digest} = await uploadArtifact(
fixtures.inputs.artifactName,
[singleFile],
fixtures.uploadDirectory,
{skipArchive: true}
)
expect(id).toBe(1)
expect(size).toBe(loadedBytes)
expect(digest).toBeDefined()
expect(digest).toHaveLength(64)
// Verify the uploaded content is the raw file, not a zip
expect(uploadedContent).toBe(expectedContent)
})
it('should use the correct MIME type when skipArchive is true', async () => {
jest
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockRestore()
const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt')
const createArtifactSpy = jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
signedUploadUrl: 'https://signed-upload-url.local'
})
)
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
artifactId: '1'
})
)
uploadStreamMock.mockImplementation(
async (
stream: NodeJS.ReadableStream,
bufferSize?: number,
maxConcurrency?: number,
options?: BlockBlobUploadStreamOptions
) => {
const {onProgress} = options || {}
onProgress?.({loadedBytes: 0})
return new Promise((resolve, reject) => {
stream.on('data', chunk => {
onProgress?.({loadedBytes: chunk.length})
})
stream.on('end', () => {
resolve({})
})
stream.on('error', err => {
reject(err)
})
})
}
)
await uploadArtifact(
fixtures.inputs.artifactName,
[singleFile],
fixtures.uploadDirectory,
{skipArchive: true}
)
// Verify CreateArtifact was called with the correct MIME type for .txt file
expect(createArtifactSpy).toHaveBeenCalledWith(
expect.objectContaining({
mimeType: expect.objectContaining({value: 'text/plain'})
})
)
})
it('should use application/zip MIME type when skipArchive is false', async () => {
jest
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockRestore()
const createArtifactSpy = jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
signedUploadUrl: 'https://signed-upload-url.local'
})
)
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
artifactId: '1'
})
)
uploadStreamMock.mockImplementation(
async (
stream: NodeJS.ReadableStream,
bufferSize?: number,
maxConcurrency?: number,
options?: BlockBlobUploadStreamOptions
) => {
const {onProgress} = options || {}
onProgress?.({loadedBytes: 0})
return new Promise((resolve, reject) => {
stream.on('data', chunk => {
onProgress?.({loadedBytes: chunk.length})
})
stream.on('end', () => {
resolve({})
})
stream.on('error', err => {
reject(err)
})
})
}
)
await uploadArtifact(
fixtures.inputs.artifactName,
fixtures.files.map(file =>
path.join(fixtures.uploadDirectory, file.name)
),
fixtures.uploadDirectory
)
// Verify CreateArtifact was called with application/zip MIME type
expect(createArtifactSpy).toHaveBeenCalledWith(
expect.objectContaining({
mimeType: expect.objectContaining({value: 'application/zip'})
})
)
})
it('should use the file basename as artifact name when skipArchive is true', async () => {
jest
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockRestore()
const singleFile = path.join(fixtures.uploadDirectory, 'file1.txt')
const createArtifactSpy = jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
signedUploadUrl: 'https://signed-upload-url.local'
})
)
jest
.spyOn(ArtifactServiceClientJSON.prototype, 'FinalizeArtifact')
.mockReturnValue(
Promise.resolve({
ok: true,
artifactId: '1'
})
)
uploadStreamMock.mockImplementation(
async (
stream: NodeJS.ReadableStream,
bufferSize?: number,
maxConcurrency?: number,
options?: BlockBlobUploadStreamOptions
) => {
const {onProgress} = options || {}
onProgress?.({loadedBytes: 0})
return new Promise((resolve, reject) => {
stream.on('data', chunk => {
onProgress?.({loadedBytes: chunk.length})
})
stream.on('end', () => {
resolve({})
})
stream.on('error', err => {
reject(err)
})
})
}
)
await uploadArtifact(
'original-name',
[singleFile],
fixtures.uploadDirectory,
{skipArchive: true}
)
// Verify CreateArtifact was called with the file basename, not the original name
expect(createArtifactSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'file1.txt'
})
)
})
})
})
@@ -4,8 +4,8 @@ import {promises as fs} from 'fs'
import {
getUploadZipSpecification,
validateRootDirectory
} from '../src/internal/upload/upload-zip-specification.js'
import {noopLogs} from './common.js'
} from '../src/internal/upload/upload-zip-specification'
import {noopLogs} from './common'
const root = path.join(__dirname, '_temp', 'upload-specification')
const goodItem1Path = path.join(
+3 -3
View File
@@ -1,6 +1,6 @@
import * as config from '../src/internal/shared/config.js'
import * as util from '../src/internal/shared/util.js'
import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util.js'
import * as config from '../src/internal/shared/config'
import * as util from '../src/internal/shared/util'
import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util'
import {setSecret, debug} from '@actions/core'
export const testRuntimeToken =
+690 -932
View File
File diff suppressed because it is too large Load Diff
+15 -28
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "6.2.1",
"version": "2.3.2",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
@@ -10,15 +10,8 @@
],
"homepage": "https://github.com/actions/toolkit/tree/main/packages/artifact",
"license": "MIT",
"type": "module",
"main": "lib/artifact.js",
"types": "lib/artifact.d.ts",
"exports": {
".": {
"types": "./lib/artifact.d.ts",
"import": "./lib/artifact.js"
}
},
"directories": {
"lib": "lib",
"test": "__tests__"
@@ -39,7 +32,7 @@
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
"test": "cd ../../ && npm run test ./packages/artifact",
"bootstrap": "cd ../../ && npm run bootstrap",
"tsc-run": "tsc && cp src/internal/shared/package-version.cjs lib/internal/shared/",
"tsc-run": "tsc",
"tsc": "npm run bootstrap && npm run tsc-run",
"gen:docs": "typedoc --plugin typedoc-plugin-markdown --out docs/generated src/artifact.ts --githubPages false --readme none"
},
@@ -47,30 +40,24 @@
"url": "https://github.com/actions/toolkit/issues"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/github": "^9.0.0",
"@actions/http-client": "^4.0.0",
"@azure/storage-blob": "^12.30.0",
"@octokit/core": "^7.0.6",
"@octokit/plugin-request-log": "^6.0.0",
"@octokit/plugin-retry": "^8.0.0",
"@octokit/request": "^10.0.7",
"@octokit/request-error": "^7.1.0",
"@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",
"@octokit/request-error": "^5.0.0",
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
"@protobuf-ts/runtime": "^2.9.4",
"archiver": "^7.0.1",
"jwt-decode": "^4.0.0",
"jwt-decode": "^3.1.2",
"unzip-stream": "^0.3.1"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/archiver": "^5.3.2",
"@types/unzip-stream": "^0.3.4",
"typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.9.3"
},
"overrides": {
"uri-js": "npm:uri-js-replace@^1.0.1",
"node-fetch": "^3.3.2"
"typedoc": "^0.25.4",
"typedoc-plugin-markdown": "^3.17.1",
"typescript": "^5.2.2"
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
import {ArtifactClient, DefaultArtifactClient} from './internal/client.js'
import {ArtifactClient, DefaultArtifactClient} from './internal/client'
export * from './internal/shared/interfaces.js'
export * from './internal/shared/errors.js'
export * from './internal/client.js'
export * from './internal/shared/interfaces'
export * from './internal/shared/errors'
export * from './internal/client'
const client: ArtifactClient = new DefaultArtifactClient()
export default client
+4 -4
View File
@@ -1,4 +1,4 @@
export * from './google/protobuf/timestamp.js'
export * from './google/protobuf/wrappers.js'
export * from './results/api/v1/artifact.js'
export * from './results/api/v1/artifact.twirp-client.js'
export * from './google/protobuf/timestamp'
export * from './google/protobuf/wrappers'
export * from './results/api/v1/artifact'
export * from './results/api/v1/artifact.twirp-client'
@@ -12,9 +12,69 @@ import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MESSAGE_TYPE } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
import { Int64Value } from "../../../google/protobuf/wrappers.js";
import { StringValue } from "../../../google/protobuf/wrappers.js";
import { Timestamp } from "../../../google/protobuf/timestamp.js";
import { Int64Value } from "../../../google/protobuf/wrappers";
import { StringValue } from "../../../google/protobuf/wrappers";
import { Timestamp } from "../../../google/protobuf/timestamp";
/**
* @generated from protobuf message github.actions.results.api.v1.MigrateArtifactRequest
*/
export interface MigrateArtifactRequest {
/**
* @generated from protobuf field: string workflow_run_backend_id = 1;
*/
workflowRunBackendId: string;
/**
* @generated from protobuf field: string name = 2;
*/
name: string;
/**
* @generated from protobuf field: google.protobuf.Timestamp expires_at = 3;
*/
expiresAt?: Timestamp;
}
/**
* @generated from protobuf message github.actions.results.api.v1.MigrateArtifactResponse
*/
export interface MigrateArtifactResponse {
/**
* @generated from protobuf field: bool ok = 1;
*/
ok: boolean;
/**
* @generated from protobuf field: string signed_upload_url = 2;
*/
signedUploadUrl: string;
}
/**
* @generated from protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactRequest
*/
export interface FinalizeMigratedArtifactRequest {
/**
* @generated from protobuf field: string workflow_run_backend_id = 1;
*/
workflowRunBackendId: string;
/**
* @generated from protobuf field: string name = 2;
*/
name: string;
/**
* @generated from protobuf field: int64 size = 3;
*/
size: string;
}
/**
* @generated from protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactResponse
*/
export interface FinalizeMigratedArtifactResponse {
/**
* @generated from protobuf field: bool ok = 1;
*/
ok: boolean;
/**
* @generated from protobuf field: int64 artifact_id = 2;
*/
artifactId: string;
}
/**
* @generated from protobuf message github.actions.results.api.v1.CreateArtifactRequest
*/
@@ -39,10 +99,6 @@ export interface CreateArtifactRequest {
* @generated from protobuf field: int32 version = 5;
*/
version: number;
/**
* @generated from protobuf field: google.protobuf.StringValue mime_type = 6;
*/
mimeType?: StringValue; // optional
}
/**
* @generated from protobuf message github.actions.results.api.v1.CreateArtifactResponse
@@ -237,6 +293,236 @@ export interface DeleteArtifactResponse {
artifactId: string;
}
// @generated message type with reflection information, may provide speed optimized methods
class MigrateArtifactRequest$Type extends MessageType<MigrateArtifactRequest> {
constructor() {
super("github.actions.results.api.v1.MigrateArtifactRequest", [
{ no: 1, name: "workflow_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "expires_at", kind: "message", T: () => Timestamp }
]);
}
create(value?: PartialMessage<MigrateArtifactRequest>): MigrateArtifactRequest {
const message = { workflowRunBackendId: "", name: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<MigrateArtifactRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MigrateArtifactRequest): MigrateArtifactRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string workflow_run_backend_id */ 1:
message.workflowRunBackendId = reader.string();
break;
case /* string name */ 2:
message.name = reader.string();
break;
case /* google.protobuf.Timestamp expires_at */ 3:
message.expiresAt = Timestamp.internalBinaryRead(reader, reader.uint32(), options, message.expiresAt);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: MigrateArtifactRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string workflow_run_backend_id = 1; */
if (message.workflowRunBackendId !== "")
writer.tag(1, WireType.LengthDelimited).string(message.workflowRunBackendId);
/* string name = 2; */
if (message.name !== "")
writer.tag(2, WireType.LengthDelimited).string(message.name);
/* google.protobuf.Timestamp expires_at = 3; */
if (message.expiresAt)
Timestamp.internalBinaryWrite(message.expiresAt, writer.tag(3, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message github.actions.results.api.v1.MigrateArtifactRequest
*/
export const MigrateArtifactRequest = new MigrateArtifactRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class MigrateArtifactResponse$Type extends MessageType<MigrateArtifactResponse> {
constructor() {
super("github.actions.results.api.v1.MigrateArtifactResponse", [
{ no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 2, name: "signed_upload_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<MigrateArtifactResponse>): MigrateArtifactResponse {
const message = { ok: false, signedUploadUrl: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<MigrateArtifactResponse>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MigrateArtifactResponse): MigrateArtifactResponse {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* bool ok */ 1:
message.ok = reader.bool();
break;
case /* string signed_upload_url */ 2:
message.signedUploadUrl = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: MigrateArtifactResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* bool ok = 1; */
if (message.ok !== false)
writer.tag(1, WireType.Varint).bool(message.ok);
/* string signed_upload_url = 2; */
if (message.signedUploadUrl !== "")
writer.tag(2, WireType.LengthDelimited).string(message.signedUploadUrl);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message github.actions.results.api.v1.MigrateArtifactResponse
*/
export const MigrateArtifactResponse = new MigrateArtifactResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class FinalizeMigratedArtifactRequest$Type extends MessageType<FinalizeMigratedArtifactRequest> {
constructor() {
super("github.actions.results.api.v1.FinalizeMigratedArtifactRequest", [
{ no: 1, name: "workflow_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "size", kind: "scalar", T: 3 /*ScalarType.INT64*/ }
]);
}
create(value?: PartialMessage<FinalizeMigratedArtifactRequest>): FinalizeMigratedArtifactRequest {
const message = { workflowRunBackendId: "", name: "", size: "0" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<FinalizeMigratedArtifactRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FinalizeMigratedArtifactRequest): FinalizeMigratedArtifactRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string workflow_run_backend_id */ 1:
message.workflowRunBackendId = reader.string();
break;
case /* string name */ 2:
message.name = reader.string();
break;
case /* int64 size */ 3:
message.size = reader.int64().toString();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: FinalizeMigratedArtifactRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string workflow_run_backend_id = 1; */
if (message.workflowRunBackendId !== "")
writer.tag(1, WireType.LengthDelimited).string(message.workflowRunBackendId);
/* string name = 2; */
if (message.name !== "")
writer.tag(2, WireType.LengthDelimited).string(message.name);
/* int64 size = 3; */
if (message.size !== "0")
writer.tag(3, WireType.Varint).int64(message.size);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactRequest
*/
export const FinalizeMigratedArtifactRequest = new FinalizeMigratedArtifactRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class FinalizeMigratedArtifactResponse$Type extends MessageType<FinalizeMigratedArtifactResponse> {
constructor() {
super("github.actions.results.api.v1.FinalizeMigratedArtifactResponse", [
{ no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 2, name: "artifact_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ }
]);
}
create(value?: PartialMessage<FinalizeMigratedArtifactResponse>): FinalizeMigratedArtifactResponse {
const message = { ok: false, artifactId: "0" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<FinalizeMigratedArtifactResponse>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FinalizeMigratedArtifactResponse): FinalizeMigratedArtifactResponse {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* bool ok */ 1:
message.ok = reader.bool();
break;
case /* int64 artifact_id */ 2:
message.artifactId = reader.int64().toString();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: FinalizeMigratedArtifactResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* bool ok = 1; */
if (message.ok !== false)
writer.tag(1, WireType.Varint).bool(message.ok);
/* int64 artifact_id = 2; */
if (message.artifactId !== "0")
writer.tag(2, WireType.Varint).int64(message.artifactId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message github.actions.results.api.v1.FinalizeMigratedArtifactResponse
*/
export const FinalizeMigratedArtifactResponse = new FinalizeMigratedArtifactResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
constructor() {
super("github.actions.results.api.v1.CreateArtifactRequest", [
@@ -244,8 +530,7 @@ class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
{ no: 2, name: "workflow_job_run_backend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 4, name: "expires_at", kind: "message", T: () => Timestamp },
{ no: 5, name: "version", kind: "scalar", T: 5 /*ScalarType.INT32*/ },
{ no: 6, name: "mime_type", kind: "message", T: () => StringValue }
{ no: 5, name: "version", kind: "scalar", T: 5 /*ScalarType.INT32*/ }
]);
}
create(value?: PartialMessage<CreateArtifactRequest>): CreateArtifactRequest {
@@ -275,9 +560,6 @@ class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
case /* int32 version */ 5:
message.version = reader.int32();
break;
case /* google.protobuf.StringValue mime_type */ 6:
message.mimeType = StringValue.internalBinaryRead(reader, reader.uint32(), options, message.mimeType);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -305,9 +587,6 @@ class CreateArtifactRequest$Type extends MessageType<CreateArtifactRequest> {
/* int32 version = 5; */
if (message.version !== 0)
writer.tag(5, WireType.Varint).int32(message.version);
/* google.protobuf.StringValue mime_type = 6; */
if (message.mimeType)
StringValue.internalBinaryWrite(message.mimeType, writer.tag(6, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
@@ -573,7 +852,7 @@ export const ListArtifactsRequest = new ListArtifactsRequest$Type();
class ListArtifactsResponse$Type extends MessageType<ListArtifactsResponse> {
constructor() {
super("github.actions.results.api.v1.ListArtifactsResponse", [
{ no: 1, name: "artifacts", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => ListArtifactsResponse_MonolithArtifact }
{ no: 1, name: "artifacts", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => ListArtifactsResponse_MonolithArtifact }
]);
}
create(value?: PartialMessage<ListArtifactsResponse>): ListArtifactsResponse {
@@ -936,5 +1215,7 @@ export const ArtifactService = new ServiceType("github.actions.results.api.v1.Ar
{ name: "FinalizeArtifact", options: {}, I: FinalizeArtifactRequest, O: FinalizeArtifactResponse },
{ name: "ListArtifacts", options: {}, I: ListArtifactsRequest, O: ListArtifactsResponse },
{ name: "GetSignedArtifactURL", options: {}, I: GetSignedArtifactURLRequest, O: GetSignedArtifactURLResponse },
{ name: "DeleteArtifact", options: {}, I: DeleteArtifactRequest, O: DeleteArtifactResponse }
{ name: "DeleteArtifact", options: {}, I: DeleteArtifactRequest, O: DeleteArtifactResponse },
{ name: "MigrateArtifact", options: {}, I: MigrateArtifactRequest, O: MigrateArtifactResponse },
{ name: "FinalizeMigratedArtifact", options: {}, I: FinalizeMigratedArtifactRequest, O: FinalizeMigratedArtifactResponse }
]);
@@ -9,7 +9,7 @@ import {
GetSignedArtifactURLResponse,
DeleteArtifactRequest,
DeleteArtifactResponse,
} from "./artifact.js";
} from "./artifact";
//==================================//
// Client Code //
@@ -229,4 +229,4 @@ export class ArtifactServiceClientProtobuf implements ArtifactServiceClient {
DeleteArtifactResponse.fromBinary(data as Uint8Array)
);
}
}
}
+8 -11
View File
@@ -1,5 +1,5 @@
import {warning} from '@actions/core'
import {isGhes} from './shared/config.js'
import {isGhes} from './shared/config'
import {
UploadArtifactOptions,
UploadArtifactResponse,
@@ -10,22 +10,19 @@ import {
DownloadArtifactResponse,
FindOptions,
DeleteArtifactResponse
} from './shared/interfaces.js'
import {uploadArtifact} from './upload/upload-artifact.js'
} from './shared/interfaces'
import {uploadArtifact} from './upload/upload-artifact'
import {
downloadArtifactPublic,
downloadArtifactInternal
} from './download/download-artifact.js'
} from './download/download-artifact'
import {
deleteArtifactPublic,
deleteArtifactInternal
} from './delete/delete-artifact.js'
import {getArtifactPublic, getArtifactInternal} from './find/get-artifact.js'
import {
listArtifactsPublic,
listArtifactsInternal
} from './find/list-artifacts.js'
import {GHESNotSupportedError} from './shared/errors.js'
} from './delete/delete-artifact'
import {getArtifactPublic, getArtifactInternal} from './find/get-artifact'
import {listArtifactsPublic, listArtifactsInternal} from './find/list-artifacts'
import {GHESNotSupportedError} from './shared/errors'
/**
* Generic interface for the artifact client.
@@ -1,21 +1,21 @@
import {info, debug} from '@actions/core'
import {getOctokit} from '@actions/github'
import {DeleteArtifactResponse} from '../shared/interfaces.js'
import {getUserAgentString} from '../shared/user-agent.js'
import {getRetryOptions} from '../find/retry-options.js'
import {DeleteArtifactResponse} from '../shared/interfaces'
import {getUserAgentString} from '../shared/user-agent'
import {getRetryOptions} from '../find/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 type {OctokitOptions} from '@octokit/core/types'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client.js'
import {getBackendIdsFromToken} from '../shared/util.js'
import {OctokitOptions} from '@octokit/core/dist-types/types'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {getBackendIdsFromToken} from '../shared/util'
import {
DeleteArtifactRequest,
ListArtifactsRequest,
StringValue
} from '../../generated/index.js'
import {getArtifactPublic} from '../find/get-artifact.js'
import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors.js'
} from '../../generated'
import {getArtifactPublic} from '../find/get-artifact'
import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors'
export async function deleteArtifactPublic(
artifactName: string,
@@ -1,8 +1,6 @@
import fs from 'fs/promises'
import * as fsSync from 'fs'
import * as crypto from 'crypto'
import * as stream from 'stream'
import * as path from 'path'
import * as github from '@actions/github'
import * as core from '@actions/core'
@@ -12,17 +10,17 @@ import {
DownloadArtifactOptions,
DownloadArtifactResponse,
StreamExtractResponse
} from '../shared/interfaces.js'
import {getUserAgentString} from '../shared/user-agent.js'
import {getGitHubWorkspaceDir} from '../shared/config.js'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client.js'
} from '../shared/interfaces'
import {getUserAgentString} from '../shared/user-agent'
import {getGitHubWorkspaceDir} from '../shared/config'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {
GetSignedArtifactURLRequest,
Int64Value,
ListArtifactsRequest
} from '../../generated/index.js'
import {getBackendIdsFromToken} from '../shared/util.js'
import {ArtifactNotFoundError} from '../shared/errors.js'
} from '../../generated'
import {getBackendIdsFromToken} from '../shared/util'
import {ArtifactNotFoundError} from '../shared/errors'
const scrubQueryParameters = (url: string): string => {
const parsed = new URL(url)
@@ -45,13 +43,12 @@ async function exists(path: string): Promise<boolean> {
async function streamExtract(
url: string,
directory: string,
skipDecompress?: boolean
directory: string
): Promise<StreamExtractResponse> {
let retryCount = 0
while (retryCount < 5) {
try {
return await streamExtractExternal(url, directory, {skipDecompress})
return await streamExtractExternal(url, directory)
} catch (error) {
retryCount++
core.debug(
@@ -67,10 +64,8 @@ async function streamExtract(
export async function streamExtractExternal(
url: string,
directory: string,
opts: {timeout?: number; skipDecompress?: boolean} = {}
directory: string
): Promise<StreamExtractResponse> {
const {timeout = 30 * 1000, skipDecompress = false} = opts
const client = new httpClient.HttpClient(getUserAgentString())
const response = await client.get(url)
if (response.message.statusCode !== 200) {
@@ -79,97 +74,48 @@ export async function streamExtractExternal(
)
}
const contentType = response.message.headers['content-type'] || ''
const mimeType = contentType.split(';', 1)[0].trim().toLowerCase()
// Check if the URL path ends with .zip (ignoring query parameters)
const urlPath = new URL(url).pathname.toLowerCase()
const urlEndsWithZip = urlPath.endsWith('.zip')
const isZip =
mimeType === 'application/zip' ||
mimeType === 'application/x-zip-compressed' ||
mimeType === 'application/zip-compressed' ||
urlEndsWithZip
// Extract filename from Content-Disposition header
// Prefer filename* (RFC 5987) which supports UTF-8 encoded filenames,
// fall back to filename which may contain ASCII-only replacements
const contentDisposition =
response.message.headers['content-disposition'] || ''
let fileName = 'artifact'
const filenameStar = contentDisposition.match(
/filename\*\s*=\s*UTF-8''([^;\r\n]*)/i
)
const filenamePlain = contentDisposition.match(
/(?<!\*)filename\s*=\s*['"]?([^;\r\n"']*)['"]?/i
)
const rawName = filenameStar?.[1] || filenamePlain?.[1]
if (rawName) {
// Sanitize fileName to prevent path traversal attacks
// Use path.basename to extract only the filename component
fileName = path.basename(decodeURIComponent(rawName.trim()))
}
core.debug(
`Content-Type: ${contentType}, mimeType: ${mimeType}, urlEndsWithZip: ${urlEndsWithZip}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`
)
core.debug(
`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`
)
const timeout = 30 * 1000 // 30 seconds
let sha256Digest: string | undefined = undefined
return new Promise((resolve, reject) => {
const timerFn = (): void => {
const timeoutError = new Error(
`Blob storage chunk did not respond in ${timeout}ms`
response.message.destroy(
new Error(`Blob storage chunk did not respond in ${timeout}ms`)
)
response.message.destroy(timeoutError)
reject(timeoutError)
}
const timer = setTimeout(timerFn, timeout)
const onError = (error: Error): void => {
core.debug(`response.message: Artifact download failed: ${error.message}`)
clearTimeout(timer)
reject(error)
}
const hashStream = crypto.createHash('sha256').setEncoding('hex')
const passThrough = new stream.PassThrough()
.on('data', () => {
timer.refresh()
})
.on('error', onError)
response.message.pipe(passThrough)
passThrough.pipe(hashStream)
const extractStream = passThrough
const onClose = (): void => {
clearTimeout(timer)
if (hashStream) {
hashStream.end()
sha256Digest = hashStream.read() as string
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
}
resolve({sha256Digest: `sha256:${sha256Digest}`})
}
if (isZip && !skipDecompress) {
// Extract zip file
passThrough
.pipe(unzip.Extract({path: directory}))
.on('close', onClose)
.on('error', onError)
} else {
// Save raw file without extracting
const filePath = path.join(directory, fileName)
const writeStream = fsSync.createWriteStream(filePath)
core.info(`Downloading raw file (non-zip) to: ${filePath}`)
passThrough.pipe(writeStream).on('close', onClose).on('error', onError)
}
extractStream
.on('data', () => {
timer.refresh()
})
.on('error', (error: Error) => {
core.debug(
`response.message: Artifact download failed: ${error.message}`
)
clearTimeout(timer)
reject(error)
})
.pipe(unzip.Extract({path: directory}))
.on('close', () => {
clearTimeout(timer)
if (hashStream) {
hashStream.end()
sha256Digest = hashStream.read() as string
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
}
resolve({sha256Digest: `sha256:${sha256Digest}`})
})
.on('error', (error: Error) => {
reject(error)
})
})
}
@@ -215,11 +161,7 @@ export async function downloadArtifactPublic(
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(
location,
downloadPath,
options?.skipDecompress
)
const extractResponse = await streamExtract(location, downloadPath)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
@@ -280,11 +222,7 @@ export async function downloadArtifactInternal(
try {
core.info(`Starting download of artifact to: ${downloadPath}`)
const extractResponse = await streamExtract(
signedUrl,
downloadPath,
options?.skipDecompress
)
const extractResponse = await streamExtract(signedUrl, downloadPath)
core.info(`Artifact download completed successfully.`)
if (options?.expectedHash) {
if (options?.expectedHash !== extractResponse.sha256Digest) {
@@ -1,20 +1,16 @@
import {getOctokit} from '@actions/github'
import {retry} from '@octokit/plugin-retry'
import * as core from '@actions/core'
import type {OctokitOptions} from '@octokit/core/types'
import {OctokitOptions} from '@octokit/core/dist-types/types'
import {defaults as defaultGitHubOptions} from '@actions/github/lib/utils'
import {getRetryOptions} from './retry-options.js'
import {getRetryOptions} from './retry-options'
import {requestLog} from '@octokit/plugin-request-log'
import {GetArtifactResponse} from '../shared/interfaces.js'
import {getBackendIdsFromToken} from '../shared/util.js'
import {getUserAgentString} from '../shared/user-agent.js'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client.js'
import {
ListArtifactsRequest,
StringValue,
Timestamp
} from '../../generated/index.js'
import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors.js'
import {GetArtifactResponse} from '../shared/interfaces'
import {getBackendIdsFromToken} from '../shared/util'
import {getUserAgentString} from '../shared/user-agent'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {ListArtifactsRequest, StringValue, Timestamp} from '../../generated'
import {ArtifactNotFoundError, InvalidResponseError} from '../shared/errors'
export async function getArtifactPublic(
artifactName: string,
@@ -1,20 +1,20 @@
import {info, warning, debug} from '@actions/core'
import {getOctokit} from '@actions/github'
import {ListArtifactsResponse, Artifact} from '../shared/interfaces.js'
import {getUserAgentString} from '../shared/user-agent.js'
import {getRetryOptions} from './retry-options.js'
import {ListArtifactsResponse, Artifact} from '../shared/interfaces'
import {getUserAgentString} from '../shared/user-agent'
import {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 type {OctokitOptions} from '@octokit/core/types'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client.js'
import {getBackendIdsFromToken} from '../shared/util.js'
import {getMaxArtifactListCount} from '../shared/config.js'
import {ListArtifactsRequest, Timestamp} from '../../generated/index.js'
import {OctokitOptions} from '@octokit/core/dist-types/types'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {getBackendIdsFromToken} from '../shared/util'
import {ListArtifactsRequest, Timestamp} from '../../generated'
const maximumArtifactCount = getMaxArtifactListCount()
// Limiting to 1000 for perf reasons
const maximumArtifactCount = 1000
const paginationCount = 100
const maxNumberOfPages = Math.ceil(maximumArtifactCount / paginationCount)
const maxNumberOfPages = maximumArtifactCount / paginationCount
export async function listArtifactsPublic(
workflowRunId: number,
@@ -59,7 +59,7 @@ export async function listArtifactsPublic(
const totalArtifactCount = listArtifactResponse.total_count
if (totalArtifactCount > maximumArtifactCount) {
warning(
`Workflow run ${workflowRunId} has ${totalArtifactCount} artifacts, exceeding the limit of ${maximumArtifactCount}. Results will be incomplete as only the first ${maximumArtifactCount} artifacts will be returned`
`Workflow run ${workflowRunId} has more than 1000 artifacts. Results will be incomplete as only the first ${maximumArtifactCount} artifacts will be returned`
)
numberOfPages = maxNumberOfPages
}
@@ -81,7 +81,7 @@ export async function listArtifactsPublic(
// Iterate over any remaining pages
for (
currentPageNumber;
currentPageNumber <= numberOfPages;
currentPageNumber < numberOfPages;
currentPageNumber++
) {
debug(`Fetching page ${currentPageNumber} of artifact list`)
@@ -1,5 +1,5 @@
import * as core from '@actions/core'
import type {OctokitOptions} from '@octokit/core/types'
import {OctokitOptions} from '@octokit/core/dist-types/types'
import {RequestRequestOptions} from '@octokit/types'
export type RetryOptions = {
@@ -1,11 +1,11 @@
import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client'
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
import {info, debug} from '@actions/core'
import {ArtifactServiceClientJSON} from '../../generated/index.js'
import {getResultsServiceUrl, getRuntimeToken} from './config.js'
import {getUserAgentString} from './user-agent.js'
import {NetworkError, UsageError} from './errors.js'
import {maskSecretUrls} from './util.js'
import {ArtifactServiceClientJSON} from '../../generated'
import {getResultsServiceUrl, getRuntimeToken} from './config'
import {getUserAgentString} from './user-agent'
import {NetworkError, UsageError} from './errors'
import {maskSecretUrls} from './util'
// The twirp http client must implement this interface
interface Rpc {
@@ -97,19 +97,3 @@ export function getUploadChunkTimeout(): number {
return timeout
}
// This value can be changed with ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT variable.
// Defaults to 1000 as a safeguard for rate limiting.
export function getMaxArtifactListCount(): number {
const maxCountVar =
process.env['ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT'] || '1000'
const maxCount = parseInt(maxCountVar)
if (isNaN(maxCount) || maxCount < 1) {
throw new Error(
'Invalid value set for ACTIONS_ARTIFACT_MAX_ARTIFACT_COUNT env variable'
)
}
return maxCount
}
@@ -60,7 +60,7 @@ export class NetworkError extends Error {
export class UsageError extends Error {
constructor() {
const message = `Artifact storage quota has been hit. Unable to upload any new artifacts.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
const message = `Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
super(message)
this.name = 'UsageError'
}
@@ -50,13 +50,6 @@ export interface UploadArtifactOptions {
* For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
*/
compressionLevel?: number
/**
* If true, the artifact will be uploaded without being archived (zipped).
* This is only supported when uploading a single file.
* When using this option, the artifact will not be compressed.
* When using this option, the name parameter passed to the upload is ignored. Instead, the name of the file is used as the name of the artifact.
*/
skipArchive?: boolean
}
/**
@@ -120,12 +113,6 @@ export interface DownloadArtifactOptions {
* matches the expected hash.
*/
expectedHash?: string
/**
* If true, the downloaded artifact will not be automatically extracted/decompressed.
* The artifact will be saved as-is to the destination path.
*/
skipDecompress?: boolean
}
export interface StreamExtractResponse {
@@ -1,7 +0,0 @@
// This file exists as a CommonJS module to read the version from package.json.
// In an ESM package, using `require()` directly in .ts files requires disabling
// ESLint rules and doesn't work reliably across all Node.js versions.
// By keeping this as a .cjs file, we can use require() naturally and export
// the version for the ESM modules to import.
const packageJson = require('../../../package.json')
module.exports = { version: packageJson.version }
@@ -1,8 +1,9 @@
import {version} from './package-version.cjs'
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const 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-${version}`
return `@actions/artifact-${packageJson.version}`
}
@@ -1,6 +1,6 @@
import * as core from '@actions/core'
import {getRuntimeToken} from './config.js'
import {jwtDecode} from 'jwt-decode'
import {getRuntimeToken} from './config'
import jwt_decode from 'jwt-decode'
import {debug, setSecret} from '@actions/core'
export interface BackendIds {
@@ -20,7 +20,7 @@ const InvalidJwtError = new Error(
// workflow run and workflow job run backend ids
export function getBackendIdsFromToken(): BackendIds {
const token = getRuntimeToken()
const decoded = jwtDecode<ActionsToken>(token)
const decoded = jwt_decode<ActionsToken>(token)
if (!decoded.scp) {
throw InvalidJwtError
}
@@ -1,15 +1,15 @@
import {BlobClient, BlockBlobUploadStreamOptions} from '@azure/storage-blob'
import {TransferProgressEvent} from '@azure/core-http-compat'
import {WaterMarkedUploadStream} from './stream.js'
import {TransferProgressEvent} from '@azure/core-http'
import {ZipUploadStream} from './zip'
import {
getUploadChunkSize,
getConcurrency,
getUploadChunkTimeout
} from '../shared/config.js'
} from '../shared/config'
import * as core from '@actions/core'
import * as crypto from 'crypto'
import * as stream from 'stream'
import {NetworkError} from '../shared/errors.js'
import {NetworkError} from '../shared/errors'
export interface BlobUploadResponse {
/**
@@ -23,10 +23,9 @@ export interface BlobUploadResponse {
sha256Hash?: string
}
export async function uploadToBlobStorage(
export async function uploadZipToBlobStorage(
authenticatedUploadURL: string,
uploadStream: WaterMarkedUploadStream,
contentType: string
zipUploadStream: ZipUploadStream
): Promise<BlobUploadResponse> {
let uploadByteCount = 0
let lastProgressTime = Date.now()
@@ -52,7 +51,7 @@ export async function uploadToBlobStorage(
const blockBlobClient = blobClient.getBlockBlobClient()
core.debug(
`Uploading artifact to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}, contentType: ${contentType}`
`Uploading artifact zip to blob storage with maxConcurrency: ${maxConcurrency}, bufferSize: ${bufferSize}`
)
const uploadCallback = (progress: TransferProgressEvent): void => {
@@ -62,24 +61,24 @@ export async function uploadToBlobStorage(
}
const options: BlockBlobUploadStreamOptions = {
blobHTTPHeaders: {blobContentType: contentType},
blobHTTPHeaders: {blobContentType: 'zip'},
onProgress: uploadCallback,
abortSignal: abortController.signal
}
let sha256Hash: string | undefined = undefined
const blobUploadStream = new stream.PassThrough()
const uploadStream = new stream.PassThrough()
const hashStream = crypto.createHash('sha256')
uploadStream.pipe(blobUploadStream) // This stream is used for the upload
uploadStream.pipe(hashStream).setEncoding('hex') // This stream is used to compute a hash of the content for integrity check
zipUploadStream.pipe(uploadStream) // This stream is used for the upload
zipUploadStream.pipe(hashStream).setEncoding('hex') // This stream is used to compute a hash of the zip content that gets used. Integrity check
core.info('Beginning upload of artifact content to blob storage')
try {
await Promise.race([
blockBlobClient.uploadStream(
blobUploadStream,
uploadStream,
bufferSize,
maxConcurrency,
options
@@ -99,7 +98,7 @@ export async function uploadToBlobStorage(
hashStream.end()
sha256Hash = hashStream.read() as string
core.info(`SHA256 digest of uploaded artifact is ${sha256Hash}`)
core.info(`SHA256 digest of uploaded artifact zip is ${sha256Hash}`)
if (uploadByteCount === 0) {
core.warning(
@@ -1,4 +1,4 @@
import {Timestamp} from '../../generated/index.js'
import {Timestamp} from '../../generated'
import * as core from '@actions/core'
export function getExpiration(retentionDays?: number): Timestamp | undefined {
@@ -1,53 +0,0 @@
import * as stream from 'stream'
import * as fs from 'fs'
import {realpath} from 'fs/promises'
import * as core from '@actions/core'
import {getUploadChunkSize} from '../shared/config.js'
// Custom stream transformer so we can set the highWaterMark property
// See https://github.com/nodejs/node/issues/8855
export class WaterMarkedUploadStream extends stream.Transform {
constructor(bufferSize: number) {
super({
highWaterMark: bufferSize
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_transform(chunk: any, enc: any, cb: any): void {
cb(null, chunk)
}
}
export async function createRawFileUploadStream(
filePath: string
): Promise<WaterMarkedUploadStream> {
core.debug(`Creating raw file upload stream for: ${filePath}`)
const bufferSize = getUploadChunkSize()
const uploadStream = new WaterMarkedUploadStream(bufferSize)
// Check if symlink and resolve the source path
let sourcePath = filePath
const stats = await fs.promises.lstat(filePath)
if (stats.isSymbolicLink()) {
sourcePath = await realpath(filePath)
}
// Create a read stream from the file and pipe it to the upload stream
const fileStream = fs.createReadStream(sourcePath, {
highWaterMark: bufferSize
})
fileStream.on('error', error => {
core.error('An error has occurred while reading the file for upload')
core.error(String(error))
uploadStream.destroy(
new Error('An error has occurred during file read for the artifact')
)
})
fileStream.pipe(uploadStream)
return uploadStream
}
@@ -1,82 +0,0 @@
import * as path from 'path'
/**
* Maps file extensions to MIME types
*/
const mimeTypes: Record<string, string> = {
// Text
'.txt': 'text/plain',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.csv': 'text/csv',
'.xml': 'text/xml',
'.md': 'text/markdown',
// JavaScript/JSON
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
// Images
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
// Audio
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
// Video
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Archives
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.rar': 'application/vnd.rar',
'.7z': 'application/x-7z-compressed',
// Code/Data
'.wasm': 'application/wasm',
'.yaml': 'application/x-yaml',
'.yml': 'application/x-yaml',
// Fonts
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.eot': 'application/vnd.ms-fontobject'
}
/**
* Gets the MIME type for a file based on its extension
*/
export function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
return mimeTypes[ext] || 'application/octet-stream'
}
@@ -1,29 +1,25 @@
import * as core from '@actions/core'
import * as fs from 'fs'
import * as path from 'path'
import {
UploadArtifactOptions,
UploadArtifactResponse
} from '../shared/interfaces.js'
import {getExpiration} from './retention.js'
import {validateArtifactName} from './path-and-artifact-name-validation.js'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client.js'
} from '../shared/interfaces'
import {getExpiration} from './retention'
import {validateArtifactName} from './path-and-artifact-name-validation'
import {internalArtifactTwirpClient} from '../shared/artifact-twirp-client'
import {
UploadZipSpecification,
getUploadZipSpecification,
validateRootDirectory
} from './upload-zip-specification.js'
import {getBackendIdsFromToken} from '../shared/util.js'
import {uploadToBlobStorage} from './blob-upload.js'
import {createZipUploadStream} from './zip.js'
import {createRawFileUploadStream, WaterMarkedUploadStream} from './stream.js'
} from './upload-zip-specification'
import {getBackendIdsFromToken} from '../shared/util'
import {uploadZipToBlobStorage} from './blob-upload'
import {createZipUploadStream} from './zip'
import {
CreateArtifactRequest,
FinalizeArtifactRequest,
StringValue
} from '../../generated/index.js'
import {FilesNotFoundError, InvalidResponseError} from '../shared/errors.js'
import {getMimeType} from './types.js'
} from '../../generated'
import {FilesNotFoundError, InvalidResponseError} from '../shared/errors'
export async function uploadArtifact(
name: string,
@@ -31,43 +27,19 @@ export async function uploadArtifact(
rootDirectory: string,
options?: UploadArtifactOptions | undefined
): Promise<UploadArtifactResponse> {
let artifactFileName = `${name}.zip`
if (options?.skipArchive) {
if (files.length === 0) {
throw new FilesNotFoundError([])
}
if (files.length > 1) {
throw new Error(
'skipArchive option is only supported when uploading a single file'
)
}
if (!fs.existsSync(files[0])) {
throw new FilesNotFoundError(files)
}
artifactFileName = path.basename(files[0])
name = artifactFileName
}
validateArtifactName(name)
validateRootDirectory(rootDirectory)
let zipSpecification: UploadZipSpecification[] = []
if (!options?.skipArchive) {
zipSpecification = getUploadZipSpecification(files, rootDirectory)
if (zipSpecification.length === 0) {
throw new FilesNotFoundError(
zipSpecification.flatMap(s => (s.sourcePath ? [s.sourcePath] : []))
)
}
const zipSpecification: UploadZipSpecification[] = getUploadZipSpecification(
files,
rootDirectory
)
if (zipSpecification.length === 0) {
throw new FilesNotFoundError(
zipSpecification.flatMap(s => (s.sourcePath ? [s.sourcePath] : []))
)
}
const contentType = getMimeType(artifactFileName)
// get the IDs needed for the artifact creation
const backendIds = getBackendIdsFromToken()
@@ -79,8 +51,7 @@ export async function uploadArtifact(
workflowRunBackendId: backendIds.workflowRunBackendId,
workflowJobRunBackendId: backendIds.workflowJobRunBackendId,
name,
mimeType: StringValue.create({value: contentType}),
version: 7
version: 4
}
// if there is a retention period, add it to the request
@@ -97,24 +68,15 @@ export async function uploadArtifact(
)
}
let stream: WaterMarkedUploadStream
const zipUploadStream = await createZipUploadStream(
zipSpecification,
options?.compressionLevel
)
if (options?.skipArchive) {
// Upload raw file without archiving
stream = await createRawFileUploadStream(files[0])
} else {
// Create and upload zip archive
stream = await createZipUploadStream(
zipSpecification,
options?.compressionLevel
)
}
core.info(`Uploading artifact: ${artifactFileName}`)
const uploadResult = await uploadToBlobStorage(
// Upload zip to blob storage
const uploadResult = await uploadZipToBlobStorage(
createArtifactResp.signedUploadUrl,
stream,
contentType
zipUploadStream
)
// finalize the artifact
@@ -143,7 +105,7 @@ export async function uploadArtifact(
const artifactId = BigInt(finalizeArtifactResp.artifactId)
core.info(
`Artifact ${name} successfully finalized. Artifact ID ${artifactId}`
`Artifact ${name}.zip successfully finalized. Artifact ID ${artifactId}`
)
return {
@@ -1,7 +1,7 @@
import * as fs from 'fs'
import {info} from '@actions/core'
import {normalize, resolve} from 'path'
import {validateFilePath} from './path-and-artifact-name-validation.js'
import {validateFilePath} from './path-and-artifact-name-validation'
export interface UploadZipSpecification {
/**
+21 -6
View File
@@ -1,16 +1,31 @@
import * as stream from 'stream'
import {realpath} from 'fs/promises'
import archiver from 'archiver'
import * as archiver from 'archiver'
import * as core from '@actions/core'
import {UploadZipSpecification} from './upload-zip-specification.js'
import {getUploadChunkSize} from '../shared/config.js'
import {WaterMarkedUploadStream} from './stream.js'
import {UploadZipSpecification} from './upload-zip-specification'
import {getUploadChunkSize} from '../shared/config'
export const DEFAULT_COMPRESSION_LEVEL = 6
// Custom stream transformer so we can set the highWaterMark property
// See https://github.com/nodejs/node/issues/8855
export class ZipUploadStream extends stream.Transform {
constructor(bufferSize: number) {
super({
highWaterMark: bufferSize
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_transform(chunk: any, enc: any, cb: any): void {
cb(null, chunk)
}
}
export async function createZipUploadStream(
uploadSpecification: UploadZipSpecification[],
compressionLevel: number = DEFAULT_COMPRESSION_LEVEL
): Promise<WaterMarkedUploadStream> {
): Promise<ZipUploadStream> {
core.debug(
`Creating Artifact archive with compressionLevel: ${compressionLevel}`
)
@@ -45,7 +60,7 @@ export async function createZipUploadStream(
}
const bufferSize = getUploadChunkSize()
const zipUploadStream = new WaterMarkedUploadStream(bufferSize)
const zipUploadStream = new ZipUploadStream(bufferSize)
core.debug(
`Zip write high watermark value ${zipUploadStream.writableHighWaterMark}`
-2
View File
@@ -4,8 +4,6 @@
"baseUrl": "./",
"outDir": "./lib",
"rootDir": "./src",
"module": "node16",
"moduleResolution": "node16",
"paths": {
"@actions/core": [
"../core"
-76
View File
@@ -15,14 +15,6 @@ initiated.
See [Using artifact attestations to establish provenance for builds](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)
for more information on artifact attestations.
## Table of Contents
- [Usage](#usage)
- [attest](#attest)
- [attestProvenance](#attestprovenance)
- [Attestation](#attestation)
- [Sigstore Instance](#sigstore-instance)
- [Storage](#storage)
## Usage
### `attest`
@@ -173,74 +165,6 @@ export type Attestation = {
For details about the Sigstore bundle format, see the [Bundle protobuf
specification](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto).
### createStorageRecord
The `createStorageRecord` function creates an
[artifact metadata storage record](https://docs.github.com/en/rest/orgs/artifact-metadata?apiVersion=2022-11-28#create-artifact-metadata-storage-record)
on behalf of an attested artifact. It accepts parameters defining artifact
and package registry details. The storage record contains metadata about where the artifact is stored on a given package registry.
```js
const { createStorageRecord } = require('@actions/attest');
const core = require('@actions/core');
async function run() {
// In order to persist attestations to the repo, this should be a token with
// repository write permissions.
const ghToken = core.getInput('gh-token');
const record = await createStorageRecord(
artifactOptions: {
name: 'my-artifact-name',
digest: { 'sha256': '36ab4667...'},
version: "v1.0.0"
},
packageRegistryOptions: {
registryUrl: "https://my-fave-pkg-registry.com"
},
token: ghToken
);
console.log(record);
}
run();
```
The `createStorageRecord` function supports the following options:
```typescript
// Artifact details to associate the record with
export type ArtifactOptions = {
// The name of the artifact
name: string
// The digest of the artifact
digest: string
// The version of the artifact
version?: string
// The status of the artifact
status?: string
}
// Includes details about the package registry the artifact was published to
export type PackageRegistryOptions = {
// The URL of the package registry
registryUrl: string
// The URL of the artifact in the package registry
artifactUrl?: string
// The package registry repository the artifact was published to.
repo?: string
// The path of the artifact in the package registry repository.
path?: string
}
// GitHub token for writing attestations.
token: string
// Optional parameters for the write operation.
// The number of times to retry the request.
retryAttempts?: number
// HTTP headers to include in request to Artifact Metadata API.
headers?: RequestHeaders
```
## Sigstore Instance
When generating the signed attestation there are two different Sigstore
+11 -45
View File
@@ -1,43 +1,10 @@
# @actions/attest Releases
## 3.2.0
- Add custom user-agent for more API calls [#2321](https://github.com/actions/toolkit/pull/2321)
## 3.1.0
- Add support for `ACTIONS_ORCHESTRATION_ID` in user-agent [#2320](https://github.com/actions/toolkit/pull/2320)
## 3.0.0
- **Breaking change**: Package is now ESM-only
- CommonJS consumers must use dynamic `import()` instead of `require()`
- Bump `@actions/core` to `^3.0.0`
- Bump `@actions/http-client` to `^4.0.0`
## 2.2.1
- Bump `@actions/http-client` to `3.0.2`
- Bump `undici` to `6.23.0`
## 2.2.0
- Bump @actions/core from 1.11.1 to 2.0.2
- Bump @actions/github from 6.0.0 to 7.0.0
- Bump @actions/http-client from 2.2.3 to 3.0.1
## 2.0.0
- Add support for Node 24 [#2110](https://github.com/actions/toolkit/pull/2110)
- Bump @sigstore/bundle from 3.0.0 to 3.1.0
- Bump @sigstore/sign from 3.0.0 to 3.1.0
- Bump jose from 5.2.3 to 5.10.0
## 1.6.0
### 1.6.0
- Update `buildSLSAProvenancePredicate` to populate `workflow.ref` field from the `ref` claim in the OIDC token [#1969](https://github.com/actions/toolkit/pull/1969)
## 1.5.0
### 1.5.0
- Bump @actions/core from 1.10.1 to 1.11.1 [#1847](https://github.com/actions/toolkit/pull/1847)
- Bump @sigstore/bundle from 2.3.2 to 3.0.0 [#1846](https://github.com/actions/toolkit/pull/1846)
@@ -45,24 +12,23 @@
- Support for generating multi-subject attestations [#1864](https://github.com/actions/toolkit/pull/1865)
- Fix bug in `buildSLSAProvenancePredicate` related to `workflow_ref` OIDC token claims containing the "@" symbol in the tag name [#1863](https://github.com/actions/toolkit/pull/1863)
## 1.4.2
### 1.4.2
- Fix bug in `buildSLSAProvenancePredicate`/`attestProvenance` when generating provenance statement for enterprise account using customized OIDC issuer value [#1823](https://github.com/actions/toolkit/pull/1823)
## 1.4.1
### 1.4.1
- Bump @actions/http-client from 2.2.1 to 2.2.3 [#1805](https://github.com/actions/toolkit/pull/1805)
## 1.4.0
### 1.4.0
- Add new `headers` parameter to the `attest` and `attestProvenance` functions [#1790](https://github.com/actions/toolkit/pull/1790)
- Update `buildSLSAProvenancePredicate`/`attestProvenance` to automatically derive default OIDC issuer URL from current execution context [#1796](https://github.com/actions/toolkit/pull/1796)
## 1.3.1
### 1.3.1
- Fix bug with proxy support when retrieving JWKS for OIDC issuer [#1776](https://github.com/actions/toolkit/pull/1776)
## 1.3.0
### 1.3.0
- Dynamic construction of Sigstore API URLs [#1735](https://github.com/actions/toolkit/pull/1735)
- Switch to new GH provenance build type [#1745](https://github.com/actions/toolkit/pull/1745)
@@ -70,21 +36,21 @@
- Bump @sigstore/bundle from 2.3.0 to 2.3.2 [#1738](https://github.com/actions/toolkit/pull/1738)
- Bump @sigstore/sign from 2.3.0 to 2.3.2 [#1738](https://github.com/actions/toolkit/pull/1738)
## 1.2.1
### 1.2.1
- Retry request on attestation persistence failure [#1725](https://github.com/actions/toolkit/pull/1725)
## 1.2.0
### 1.2.0
- Generate attestations using the v0.3 Sigstore bundle format [#1701](https://github.com/actions/toolkit/pull/1701)
- Bump @sigstore/bundle from 2.2.0 to 2.3.0 [#1701](https://github.com/actions/toolkit/pull/1701)
- Bump @sigstore/sign from 2.2.3 to 2.3.0 [#1701](https://github.com/actions/toolkit/pull/1701)
- Remove dependency on make-fetch-happen [#1714](https://github.com/actions/toolkit/pull/1714)
## 1.1.0
### 1.1.0
- Updates the `attestProvenance` function to retrieve a token from the GitHub OIDC provider and use the token claims to populate the provenance statement [#1693](https://github.com/actions/toolkit/pull/1693)
## 1.0.0
### 1.0.0
- Initial release
@@ -1,137 +0,0 @@
import {MockAgent, setGlobalDispatcher} from 'undici'
import {createStorageRecord} from '../src/artifactMetadata'
describe('createStorageRecord', () => {
const originalEnv = process.env
const token = 'token'
const headers = {'X-GitHub-Foo': 'true'}
const artifactOptions = {
name: 'my-lib',
version: '1.0.0',
digest: `sha256:${'a'.repeat(64)}`
}
const packageRegistryOptions = {
registryUrl: 'https://my-registry.org'
}
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
beforeEach(() => {
process.env = {
...originalEnv,
GITHUB_REPOSITORY: 'foo/bar'
}
})
afterEach(() => {
process.env = originalEnv
})
describe('when the api call is successful', () => {
beforeEach(() => {
mockAgent
.get('https://api.github.com')
.intercept({
path: '/orgs/foo/artifacts/metadata/storage-record',
method: 'POST',
headers: {authorization: `token ${token}`, ...headers},
body: JSON.stringify({
name: 'my-lib',
version: '1.0.0',
digest: `sha256:${'a'.repeat(64)}`,
registry_url: 'https://my-registry.org'
})
})
.reply(200, {storage_records: [{id: 123}, {id: 456}]})
})
it('persists the storage record', async () => {
await expect(
createStorageRecord(
artifactOptions,
packageRegistryOptions,
token,
undefined,
headers
)
).resolves.toEqual([123, 456])
})
})
describe('when the api call fails', () => {
beforeEach(() => {
mockAgent
.get('https://api.github.com')
.intercept({
path: '/orgs/foo/artifacts/metadata/storage-record',
method: 'POST',
headers: {authorization: `token ${token}`},
body: JSON.stringify({
name: 'my-lib',
version: '1.0.0',
digest: `sha256:${'a'.repeat(64)}`,
registry_url: 'https://my-registry.org'
})
})
.reply(500, 'oops')
})
it('throws an error', async () => {
await expect(
createStorageRecord(
artifactOptions,
packageRegistryOptions,
token,
0,
headers
)
).rejects.toThrow(/oops/)
})
})
describe('when the api call fails but succeeds on retry', () => {
beforeEach(() => {
const pool = mockAgent.get('https://api.github.com')
pool
.intercept({
path: '/orgs/foo/artifacts/metadata/storage-record',
method: 'POST',
headers: {authorization: `token ${token}`},
body: JSON.stringify({
...artifactOptions,
registry_url: packageRegistryOptions.registryUrl
})
})
.reply(500, 'oops')
.times(1)
pool
.intercept({
path: '/orgs/foo/artifacts/metadata/storage-record',
method: 'POST',
headers: {authorization: `token ${token}`},
body: JSON.stringify({
...artifactOptions,
registry_url: packageRegistryOptions.registryUrl
})
})
.reply(200, {storage_records: [{id: 123}, {id: 456}]})
.times(1)
})
it('persists the storage record', async () => {
await expect(
createStorageRecord(
artifactOptions,
packageRegistryOptions,
token,
undefined,
headers
)
).resolves.toEqual([123, 456])
})
})
})
+1828 -412
View File
File diff suppressed because it is too large Load Diff
+16 -18
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/attest",
"version": "3.2.0",
"version": "1.6.0",
"description": "Actions attestation lib",
"keywords": [
"github",
@@ -9,15 +9,8 @@
],
"homepage": "https://github.com/actions/toolkit/tree/main/packages/attest",
"license": "MIT",
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js"
}
},
"directories": {
"lib": "lib",
"test": "__tests__"
@@ -36,25 +29,30 @@
},
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1",
"tsc": "tsc && cp src/internal/package-version.cjs lib/internal/"
"tsc": "tsc"
},
"bugs": {
"url": "https://github.com/actions/toolkit/issues"
},
"devDependencies": {
"@sigstore/mock": "^0.10.0",
"@sigstore/mock": "^0.8.0",
"@sigstore/rekor-types": "^3.0.0",
"@types/jsonwebtoken": "^9.0.6",
"nock": "^13.5.1",
"undici": "^6.23.0"
"undici": "^5.28.5"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/github": "^9.0.0",
"@actions/http-client": "^4.0.0",
"@octokit/plugin-retry": "^8.0.3",
"@sigstore/bundle": "^3.1.0",
"@sigstore/sign": "^3.1.0",
"jose": "^5.10.0"
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.0",
"@actions/http-client": "^2.2.3",
"@octokit/plugin-retry": "^6.0.1",
"@sigstore/bundle": "^3.0.0",
"@sigstore/sign": "^3.0.0",
"jose": "^5.2.3"
},
"overrides": {
"@octokit/plugin-retry": {
"@octokit/core": "^5.2.0"
}
}
}
-92
View File
@@ -1,92 +0,0 @@
import * as github from '@actions/github'
import {retry} from '@octokit/plugin-retry'
import {RequestHeaders} from '@octokit/types'
import {getUserAgent} from './internal/utils.js'
const CREATE_STORAGE_RECORD_REQUEST =
'POST /orgs/{owner}/artifacts/metadata/storage-record'
const DEFAULT_RETRY_COUNT = 5
/**
* Options for creating a storage record for an attested artifact.
*/
export type ArtifactOptions = {
// Includes details about the attested artifact
// The name of the artifact
name: string
// The digest of the artifact
digest: string
// The version of the artifact
version?: string
// The status of the artifact
status?: string
}
// Includes details about the package registry the artifact was published to
export type PackageRegistryOptions = {
// The URL of the package registry
registryUrl: string
// The URL of the artifact in the package registry
artifactUrl?: string
// The package registry repository the artifact was published to.
repo?: string
// The path of the artifact in the package registry repository.
path?: string
}
/**
* Writes a storage record on behalf of an artifact that has been attested
* @param artifactOptions - parameters for the storage record API request.
* @param packageRegistryOptions - parameters for the package registry API request.
* @param token - GitHub token used to authenticate the request.
* @param retryAttempts - The number of retries to attempt if the request fails.
* @param headers - Additional headers to include in the request.
*
* @returns The ID of the storage record.
* @throws Error if the storage record fails to persist.
*/
export async function createStorageRecord(
artifactOptions: ArtifactOptions,
packageRegistryOptions: PackageRegistryOptions,
token: string,
retryAttempts?: number,
headers?: RequestHeaders
): Promise<number[]> {
const retries = retryAttempts ?? DEFAULT_RETRY_COUNT
const octokit = github.getOctokit(token, {retry: {retries}}, retry)
const headersWithUserAgent = {
'User-Agent': getUserAgent(),
...headers
}
try {
const response = await octokit.request(CREATE_STORAGE_RECORD_REQUEST, {
owner: github.context.repo.owner,
headers: headersWithUserAgent,
...buildRequestParams(artifactOptions, packageRegistryOptions)
})
const data =
typeof response.data == 'string'
? JSON.parse(response.data)
: response.data
return data?.storage_records.map((r: {id: number}) => r.id)
} catch (err) {
const message = err instanceof Error ? err.message : err
throw new Error(`Failed to persist storage record: ${message}`)
}
}
function buildRequestParams(
artifactOptions: ArtifactOptions,
packageRegistryOptions: PackageRegistryOptions
): Record<string, unknown> {
const {registryUrl, artifactUrl, ...rest} = packageRegistryOptions
return {
...artifactOptions,
registry_url: registryUrl,
artifact_url: artifactUrl,
...rest
}
}
+5 -5
View File
@@ -1,12 +1,12 @@
import {bundleToJSON} from '@sigstore/bundle'
import {X509Certificate} from 'crypto'
import {SigstoreInstance, signingEndpoints} from './endpoints.js'
import {buildIntotoStatement} from './intoto.js'
import {Payload, signPayload} from './sign.js'
import {writeAttestation} from './store.js'
import {SigstoreInstance, signingEndpoints} from './endpoints'
import {buildIntotoStatement} from './intoto'
import {Payload, signPayload} from './sign'
import {writeAttestation} from './store'
import type {Bundle} from '@sigstore/sign'
import type {Attestation, Predicate, Subject} from './shared.types.js'
import type {Attestation, Predicate, Subject} from './shared.types'
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
+3 -9
View File
@@ -1,15 +1,9 @@
export {
createStorageRecord,
ArtifactOptions,
PackageRegistryOptions
} from './artifactMetadata.js'
export {AttestOptions, attest} from './attest.js'
export {AttestOptions, attest} from './attest'
export {
AttestProvenanceOptions,
attestProvenance,
buildSLSAProvenancePredicate
} from './provenance.js'
} from './provenance'
export type {SerializedBundle} from '@sigstore/bundle'
export type {Attestation, Predicate, Subject} from './shared.types.js'
export type {SigstoreInstance} from './endpoints.js'
export type {Attestation, Predicate, Subject} from './shared.types'
@@ -1,7 +0,0 @@
// This file exists as a CommonJS module to read the version from package.json.
// In an ESM package, using `require()` directly in .ts files requires disabling
// ESLint rules and doesn't work reliably across all Node.js versions.
// By keeping this as a .cjs file, we can use require() naturally and export
// the version for the ESM modules to import.
const packageJson = require('../../package.json')
module.exports = {version: packageJson.version}
-15
View File
@@ -1,15 +0,0 @@
import {version} from './package-version.cjs'
export const getUserAgent = (): string => {
const baseUserAgent = `@actions/attest-${version}`
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
if (orchId) {
// Sanitize the orchestration ID to ensure it contains only valid characters
// Valid characters: 0-9, a-z, _, -, .
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
return `${baseUserAgent} actions_orchestration_id/${sanitizedId}`
}
return baseUserAgent
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {Predicate, Subject} from './shared.types.js'
import {Predicate, Subject} from './shared.types'
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1'
+3 -3
View File
@@ -1,6 +1,6 @@
import {attest, AttestOptions} from './attest.js'
import {getIDTokenClaims} from './oidc.js'
import type {Attestation, Predicate} from './shared.types.js'
import {attest, AttestOptions} from './attest'
import {getIDTokenClaims} from './oidc'
import type {Attestation, Predicate} from './shared.types'
const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
const GITHUB_BUILD_TYPE = 'https://actions.github.io/buildtypes/workflow/v1'
+2 -12
View File
@@ -1,7 +1,6 @@
import * as github from '@actions/github'
import {retry} from '@octokit/plugin-retry'
import {RequestHeaders} from '@octokit/types'
import {getUserAgent} from './internal/utils.js'
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
const DEFAULT_RETRY_COUNT = 5
@@ -25,21 +24,12 @@ export const writeAttestation = async (
const retries = options.retry ?? DEFAULT_RETRY_COUNT
const octokit = github.getOctokit(token, {retry: {retries}}, retry)
const headers = {
'User-Agent': getUserAgent(),
...options.headers
}
try {
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
headers,
bundle: attestation as {
mediaType?: string
verificationMaterial?: {[key: string]: unknown}
dsseEnvelope?: {[key: string]: unknown}
}
headers: options.headers,
data: {bundle: attestation}
})
const data =
+1 -3
View File
@@ -4,9 +4,7 @@
"baseUrl": "./",
"outDir": "./lib",
"declaration": true,
"rootDir": "./src",
"module": "node16",
"moduleResolution": "node16"
"rootDir": "./src"
},
"include": [
"./src"
+46 -97
View File
@@ -1,73 +1,22 @@
# @actions/cache Releases
## 6.0.0
- **Breaking change**: Package is now ESM-only
- CommonJS consumers must use dynamic `import()` instead of `require()`
## 5.0.5
- Bump `@actions/glob` to `0.5.1`
## 5.0.4
- Bump `@actions/http-client` to `3.0.2`
## 5.0.3
Prevent retries for rate limited cache operations [2243](https://github.com/actions/toolkit/pull/2243).
## 5.0.1
- Fix Node.js 24 punycode deprecation warning by updating `@azure/storage-blob` from `^12.13.0` to `^12.29.1` [#2213](https://github.com/actions/toolkit/pull/2213)
- Newer storage-blob uses `@azure/core-rest-pipeline` instead of deprecated `@azure/core-http`, which eliminates the transitive dependency on `node-fetch@2``whatwg-url@5``tr46@0.0.3` that used the deprecated punycode module
## 5.0.0
- Remove `@azure/ms-rest-js` dependency [#2197](https://github.com/actions/toolkit/pull/2197)
- The `TransferProgressEvent` type is now imported from `@azure/core-rest-pipeline` instead of `@azure/ms-rest-js`
- Bump `@actions/core` from `^1.11.1` to `^2.0.0` [#2198](https://github.com/actions/toolkit/pull/2198)
- Bump `@actions/exec` from `^1.0.1` to `^2.0.0` [#2198](https://github.com/actions/toolkit/pull/2198)
- Bump `@actions/glob` from `^0.1.0` to `^0.5.0` [#2198](https://github.com/actions/toolkit/pull/2198)
- Bump `@actions/http-client` from `^2.1.1` to `^3.0.0` [#2198](https://github.com/actions/toolkit/pull/2198)
- Bump `@actions/io` from `^1.0.1` to `^2.0.0` [#2198](https://github.com/actions/toolkit/pull/2198)
- Add support for Node.js 24 [#2110](https://github.com/actions/toolkit/pull/2110)
- Add `node-fetch` override to resolve audit vulnerabilities [#2110](https://github.com/actions/toolkit/pull/2110)
## 4.1.0
- Remove client side 10GiB cache size limit check & update twirp client [#2118](https://github.com/actions/toolkit/pull/2118)
## 4.0.5
- Reintroduce @protobuf-ts/runtime-rpc as a runtime dependency [#2113](https://github.com/actions/toolkit/pull/2113)
## 4.0.4
⚠️ Faulty patch release. Upgrade to 4.0.5 instead.
- Optimized cache dependencies by moving `@protobuf-ts/plugin` to dev dependencies [#2106](https://github.com/actions/toolkit/pull/2106)
- Improved cache service availability determination for different cache service versions (v1 and v2) [#2100](https://github.com/actions/toolkit/pull/2100)
- Enhanced server error handling: 5xx HTTP errors are now logged as errors instead of warnings [#2099](https://github.com/actions/toolkit/pull/2099)
- Fixed cache hit logging to properly distinguish between exact key matches and restore key matches [#2101](https://github.com/actions/toolkit/pull/2101)
## 4.0.3
### 4.0.3
- Added masking for Shared Access Signature (SAS) cache entry URLs [#1982](https://github.com/actions/toolkit/pull/1982)
- Improved debugging by logging both the cache version alongside the keys requested when a cache restore fails [#1994](https://github.com/actions/toolkit/pull/1994)
## 4.0.2
### 4.0.2
- Wrap create failures in ReserveCacheError [#1966](https://github.com/actions/toolkit/pull/1966)
## 4.0.1
### 4.0.1
- Remove runtime dependency on `twirp-ts` [#1947](https://github.com/actions/toolkit/pull/1947)
- Cache miss as debug, not warning annotation [#1954](https://github.com/actions/toolkit/pull/1954)
## 4.0.0
### 4.0.0
### Important changes
#### Important changes
The cache backend service has been rewritten from the ground up for improved performance and reliability. The [@actions/cache](https://github.com/actions/toolkit/tree/main/packages/cache) package now integrates with the new cache service (v2) APIs.
@@ -81,182 +30,182 @@ Upgrading to the recommended version should not break or require any changes to
Read more about the change & access the migration guide: [reference to the announcement](https://github.com/actions/toolkit/discussions/1890).
### Minor changes
#### Minor changes
- Update `@actions/core` to `1.11.0`
- Update `semver` `6.3.1`
- Add `twirp-ts` `2.5.0` to dependencies
## 3.3.0
### 3.3.0
- Update `@actions/core` to `1.11.1`
- Remove dependency on `uuid` package [#1824](https://github.com/actions/toolkit/pull/1824), [#1842](https://github.com/actions/toolkit/pull/1842)
## 3.2.4
### 3.2.4
- Updated `isGhes` check to include `.ghe.com` and `.ghe.localhost` as accepted hosts
## 3.2.3
### 3.2.3
- Fixed a bug that mutated path arguments to `getCacheVersion` [#1378](https://github.com/actions/toolkit/pull/1378)
## 3.2.2
### 3.2.2
- Add new default cache download method to improve performance and reduce hangs [#1484](https://github.com/actions/toolkit/pull/1484)
## 3.2.1
### 3.2.1
- Updated @azure/storage-blob to `v12.13.0`
## 3.2.0
### 3.2.0
- Add `lookupOnly` to cache restore `DownloadOptions`.
## 3.1.4
### 3.1.4
- Fix zstd not being used due to `zstd --version` output change in zstd 1.5.4 release. See [#1353](https://github.com/actions/toolkit/pull/1353).
## 3.1.3
### 3.1.3
- Fix to prevent from setting MYSYS environement variable globally [#1329](https://github.com/actions/toolkit/pull/1329).
## 3.1.2
### 3.1.2
- Fix issue with symlink restoration on windows.
## 3.1.1
### 3.1.1
- Reverted changes in 3.1.0 to fix issue with symlink restoration on windows.
- Added support for verbose logging about cache version during cache miss.
## 3.1.0
### 3.1.0
- Update actions/cache on windows to use gnu tar and zstd by default
- Update actions/cache on windows to fallback to bsdtar and zstd if gnu tar is not available.
- Added support for fallback to gzip to restore old caches on windows.
## 3.1.0-beta.3
### 3.1.0-beta.3
- Bug Fixes for fallback to gzip to restore old caches on windows and bsdtar if gnutar is not available.
## 3.1.0-beta.2
### 3.1.0-beta.2
- Added support for fallback to gzip to restore old caches on windows.
## 3.0.6
### 3.0.6
- Added `@azure/abort-controller` to dependencies to fix compatibility issue with ESM [#1208](https://github.com/actions/toolkit/issues/1208)
## 3.0.5
### 3.0.5
- Update `@actions/cache` to use `@actions/core@^1.10.0`
## 3.0.4
### 3.0.4
- Fix zstd not working for windows on gnu tar in issues [#888](https://github.com/actions/cache/issues/888) and [#891](https://github.com/actions/cache/issues/891).
- Allowing users to provide a custom timeout as input for aborting download of a cache segment using an environment variable `SEGMENT_DOWNLOAD_TIMEOUT_MINS`. Default is 60 minutes.
## 3.0.3
### 3.0.3
- Bug fixes for download stuck issue [#810](https://github.com/actions/cache/issues/810).
## 3.0.2
### 3.0.2
- Added 1 hour timeout for the download stuck issue [#810](https://github.com/actions/cache/issues/810).
## 3.0.1
### 3.0.1
- Fix [#833](https://github.com/actions/cache/issues/833) - cache doesn't work with github workspace directory.
- Fix [#809](https://github.com/actions/cache/issues/809) `zstd -d: no such file or directory` error on AWS self-hosted runners.
## 3.0.0
### 3.0.0
- Updated actions/cache to suppress Actions cache server error and log warning for those error [#1122](https://github.com/actions/toolkit/pull/1122)
## 2.0.6
### 2.0.6
- Fix `Tar failed with error: The process '/usr/bin/tar' failed with exit code 1` issue when temp directory where tar is getting created is actually the subdirectory of the path mentioned by the user for caching. ([issue](https://github.com/actions/cache/issues/689))
## 2.0.5
### 2.0.5
- Fix to avoid saving empty cache when no files are available for caching. ([issue](https://github.com/actions/cache/issues/624))
## 2.0.4
### 2.0.4
- Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
## 2.0.3
### 2.0.3
- Update to v2.0.0 of `@actions/http-client`
## 2.0.0
### 2.0.0
- Added support to check if Actions cache service feature is available or not [#1028](https://github.com/actions/toolkit/pull/1028)
## 1.0.11
### 1.0.11
- Fix file downloads > 2GB([issue](https://github.com/actions/cache/issues/773))
## 1.0.10
### 1.0.10
- Update `lockfileVersion` to `v2` in `package-lock.json [#1022](https://github.com/actions/toolkit/pull/1022)
## 1.0.9
### 1.0.9
- Use @azure/ms-rest-js v2.6.0
- Use @azure/storage-blob v12.8.0
## 1.0.8
### 1.0.8
- Increase the allowed artifact cache size from 5GB to 10GB ([issue](https://github.com/actions/cache/discussions/497))
## 1.0.7
### 1.0.7
- Fixes permissions issue extracting archives with GNU tar on macOS ([issue](https://github.com/actions/cache/issues/527))
## 1.0.6
### 1.0.6
- Make caching more verbose [#650](https://github.com/actions/toolkit/pull/650)
- Use GNU tar on macOS if available [#701](https://github.com/actions/toolkit/pull/701)
## 1.0.5
### 1.0.5
- Fix to ensure Windows cache paths get resolved correctly
## 1.0.4
### 1.0.4
- Use @actions/core v1.2.6
- Fixes uploadChunk to throw an error if any unsuccessful response code is received
## 1.0.3
### 1.0.3
- Use http-client v1.0.9
- Fixes error handling so retries are not attempted on non-retryable errors (409 Conflict, for example)
- Adds 5 second delay between retry attempts
## 1.0.2
### 1.0.2
- Use posix archive format to add support for some tools
## 1.0.1
### 1.0.1
- Fix bug in downloading large files (> 2 GBs) with the Azure SDK
## 1.0.0
### 1.0.0
- Downloads Azure-hosted caches using the Azure SDK for speed and reliability
- Displays download progress
- Includes changes that break compatibility with earlier versions, including:
- `retry`, `retryTypedResponse`, and `retryHttpClientResponse` moved from `cacheHttpClient` to `requestUtils`
## 0.2.1
### 0.2.1
- Fix to await async function getCompressionMethod
## 0.2.0
### 0.2.0
- Fixes issues with the zstd compression algorithm on Windows and Ubuntu 16.04 [#469](https://github.com/actions/toolkit/pull/469)
## 0.1.0
### 0.1.0
- Initial release
+1 -1
View File
@@ -1,5 +1,5 @@
name: 'Set env variables'
description: 'Sets certain env variables so that e2e restore and save cache can be tested in a shell'
runs:
using: 'node20'
using: 'node12'
main: 'index.js'
+6 -10
View File
@@ -1,18 +1,14 @@
// Certain env variables are not set by default in a shell context and are only available in a node context from a running action
// In order to be able to restore and save cache e2e in a shell when running CI tests, we need these env variables set
import fs from 'fs'
import os from 'os'
const filePath = process.env['GITHUB_ENV']
fs.appendFileSync(filePath, `ACTIONS_CACHE_SERVICE_V2=true${os.EOL}`, {
encoding: 'utf8'
})
fs.appendFileSync(filePath, `ACTIONS_RESULTS_URL=${process.env.ACTIONS_RESULTS_URL}${os.EOL}`, {
encoding: 'utf8'
})
const fs = require('fs');
const os = require('os');
const filePath = process.env[`GITHUB_ENV`]
fs.appendFileSync(filePath, `ACTIONS_RUNTIME_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}${os.EOL}`, {
encoding: 'utf8'
})
fs.appendFileSync(filePath, `ACTIONS_CACHE_URL=${process.env.ACTIONS_CACHE_URL}${os.EOL}`, {
encoding: 'utf8'
})
fs.appendFileSync(filePath, `GITHUB_RUN_ID=${process.env.GITHUB_RUN_ID}${os.EOL}`, {
encoding: 'utf8'
})
+10 -65
View File
@@ -1,69 +1,14 @@
import * as cache from '../src/cache'
describe('isFeatureAvailable', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetModules()
process.env = {...originalEnv}
// Clean cache-related environment variables
test('isFeatureAvailable returns true if server url is set', () => {
try {
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
expect(cache.isFeatureAvailable()).toBe(true)
} finally {
delete process.env['ACTIONS_CACHE_URL']
delete process.env['ACTIONS_RESULTS_URL']
delete process.env['ACTIONS_CACHE_SERVICE_V2']
delete process.env['GITHUB_SERVER_URL']
})
afterAll(() => {
process.env = originalEnv
})
test('returns true for cache service v1 when ACTIONS_CACHE_URL is set', () => {
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
expect(cache.isFeatureAvailable()).toBe(true)
})
test('returns false for cache service v1 when only ACTIONS_RESULTS_URL is set', () => {
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
expect(cache.isFeatureAvailable()).toBe(false)
})
test('returns true for cache service v1 when both URLs are set', () => {
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
expect(cache.isFeatureAvailable()).toBe(true)
})
test('returns true for cache service v2 when ACTIONS_RESULTS_URL is set', () => {
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
expect(cache.isFeatureAvailable()).toBe(true)
})
test('returns false for cache service v2 when only ACTIONS_CACHE_URL is set', () => {
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
expect(cache.isFeatureAvailable()).toBe(false)
})
test('returns false when no cache URLs are set', () => {
expect(cache.isFeatureAvailable()).toBe(false)
})
test('returns false for cache service v2 when no URLs are set', () => {
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
expect(cache.isFeatureAvailable()).toBe(false)
})
test('returns true for GHES with v1 even when v2 flag is set', () => {
process.env['GITHUB_SERVER_URL'] = 'https://my-enterprise.github.com'
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
expect(cache.isFeatureAvailable()).toBe(true)
})
test('returns false for GHES with only ACTIONS_RESULTS_URL', () => {
process.env['GITHUB_SERVER_URL'] = 'https://my-enterprise.github.com'
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
expect(cache.isFeatureAvailable()).toBe(false)
})
}
})
test('isFeatureAvailable returns false if server url is not set', () => {
expect(cache.isFeatureAvailable()).toBe(false)
})
-174
View File
@@ -1,174 +0,0 @@
import * as http from 'http'
import * as net from 'net'
import {HttpClient} from '@actions/http-client'
import * as core from '@actions/core'
import * as config from '../src/internal/config'
import * as cacheUtils from '../src/internal/cacheUtils'
import {internalCacheTwirpClient} from '../src/internal/shared/cacheTwirpClient'
jest.mock('@actions/http-client')
const clientOptions = {
maxAttempts: 5,
retryIntervalMs: 1,
retryMultiplier: 1.5
}
// noopLogs mocks the console.log and core.* functions to prevent output in the console while testing
const noopLogs = (): void => {
jest.spyOn(console, 'log').mockImplementation(() => {})
jest.spyOn(core, 'debug').mockImplementation(() => {})
jest.spyOn(core, 'info').mockImplementation(() => {})
jest.spyOn(core, 'warning').mockImplementation(() => {})
}
describe('cacheTwirpClient', () => {
beforeAll(() => {
noopLogs()
jest
.spyOn(config, 'getCacheServiceURL')
.mockReturnValue('http://localhost:8080')
jest.spyOn(cacheUtils, 'getRuntimeToken').mockReturnValue('token')
})
beforeEach(() => {
jest.clearAllMocks()
})
it('should fail immediately on 429 rate limit without retrying', async () => {
const mockPost = jest.fn(() => {
const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 429
msg.statusMessage = 'Too Many Requests'
return {
message: msg,
readBody: async () => {
return Promise.resolve(`{"ok": false}`)
}
}
})
;(HttpClient as unknown as jest.Mock).mockImplementation(() => {
return {
post: mockPost
}
})
const client = internalCacheTwirpClient(clientOptions)
await expect(
client.CreateCacheEntry({
key: 'test-key',
version: 'test-version'
})
).rejects.toThrow(
'Failed to CreateCacheEntry: Rate limited: Failed request: (429) Too Many Requests'
)
// Should only be called once - no retries for 429
expect(mockPost).toHaveBeenCalledTimes(1)
})
it('should log warning with retry-after header on 429', async () => {
const warningSpy = jest.spyOn(core, 'warning')
const mockPost = jest.fn(() => {
const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 429
msg.statusMessage = 'Too Many Requests'
msg.headers = {'retry-after': '60'}
return {
message: msg,
readBody: async () => {
return Promise.resolve(`{"ok": false}`)
}
}
})
;(HttpClient as unknown as jest.Mock).mockImplementation(() => {
return {
post: mockPost
}
})
const client = internalCacheTwirpClient(clientOptions)
await expect(
client.CreateCacheEntry({
key: 'test-key',
version: 'test-version'
})
).rejects.toThrow('Rate limited')
expect(mockPost).toHaveBeenCalledTimes(1)
expect(warningSpy).toHaveBeenCalledWith(
"You've hit a rate limit, your rate limit will reset in 60 seconds"
)
})
it('should not log warning if retry-after header is missing on 429', async () => {
const warningSpy = jest.spyOn(core, 'warning')
const mockPost = jest.fn(() => {
const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 429
msg.statusMessage = 'Too Many Requests'
// No retry-after header
return {
message: msg,
readBody: async () => {
return Promise.resolve(`{"ok": false}`)
}
}
})
;(HttpClient as unknown as jest.Mock).mockImplementation(() => {
return {
post: mockPost
}
})
const client = internalCacheTwirpClient(clientOptions)
await expect(
client.CreateCacheEntry({
key: 'test-key',
version: 'test-version'
})
).rejects.toThrow('Rate limited')
expect(mockPost).toHaveBeenCalledTimes(1)
expect(warningSpy).not.toHaveBeenCalled()
})
it('should not log warning if retry-after header is invalid on 429', async () => {
const warningSpy = jest.spyOn(core, 'warning')
const mockPost = jest.fn(() => {
const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 429
msg.statusMessage = 'Too Many Requests'
msg.headers = {'retry-after': 'invalid'}
return {
message: msg,
readBody: async () => {
return Promise.resolve(`{"ok": false}`)
}
}
})
;(HttpClient as unknown as jest.Mock).mockImplementation(() => {
return {
post: mockPost
}
})
const client = internalCacheTwirpClient(clientOptions)
await expect(
client.CreateCacheEntry({
key: 'test-key',
version: 'test-version'
})
).rejects.toThrow('Rate limited')
expect(mockPost).toHaveBeenCalledTimes(1)
expect(warningSpy).not.toHaveBeenCalled()
})
})
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env node
// Helper script to restore cache for e2e testing
import * as cache from '../lib/cache.js'
const [prefix, runId, useAzureSdk] = process.argv.slice(2)
if (!prefix || !runId) {
console.error('Usage: restore-cache.mjs <prefix> <runId> [useAzureSdk]')
process.exit(1)
}
const key = `test-${prefix}-${runId}`
const paths = ['test-cache', '~/test-cache']
const options = {useAzureSdk: useAzureSdk !== 'false'}
console.log(`Restoring cache with key: ${key}`)
console.log(`Paths: ${paths.join(', ')}`)
console.log(`Using Azure SDK: ${options.useAzureSdk}`)
const maxRetries = 3
const retryDelayMs = 5000
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt} of ${maxRetries}`)
const restoredKey = await cache.restoreCache(paths, key, [], options)
if (restoredKey) {
console.log(`Cache restored with key: ${restoredKey}`)
process.exit(0)
} else {
console.log('Cache not found on this attempt')
}
} catch (error) {
console.error(`Error on attempt ${attempt}:`, error.message)
}
if (attempt < maxRetries) {
console.log(`Waiting ${retryDelayMs / 1000}s before retry...`)
await new Promise(resolve => setTimeout(resolve, retryDelayMs))
}
}
console.error(`Failed to restore cache after ${maxRetries} attempts`)
process.exit(1)
+6 -18
View File
@@ -6,8 +6,6 @@ import * as cacheUtils from '../src/internal/cacheUtils'
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
import {ArtifactCacheEntry} from '../src/internal/contracts'
import * as tar from '../src/internal/tar'
import {HttpClientError} from '@actions/http-client'
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
jest.mock('../src/internal/cacheHttpClient')
jest.mock('../src/internal/cacheUtils')
@@ -75,28 +73,18 @@ test('restore with no cache found', async () => {
test('restore with server error should fail', async () => {
const paths = ['node_modules']
const key = 'node-test'
const logErrorMock = jest.spyOn(core, 'error')
const logWarningMock = jest.spyOn(core, 'warning')
// Set cache service to V2 to test error logging for server errors
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
process.env['ACTIONS_RESULTS_URL'] = 'https://results.local/'
jest
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
.mockImplementation(() => {
throw new HttpClientError('HTTP Error Occurred', 500)
})
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(() => {
throw new Error('HTTP Error Occurred')
})
const cacheKey = await restoreCache(paths, key)
expect(cacheKey).toBe(undefined)
expect(logErrorMock).toHaveBeenCalledTimes(1)
expect(logErrorMock).toHaveBeenCalledWith(
expect(logWarningMock).toHaveBeenCalledTimes(1)
expect(logWarningMock).toHaveBeenCalledWith(
'Failed to restore: HTTP Error Occurred'
)
// Clean up environment
delete process.env['ACTIONS_CACHE_SERVICE_V2']
delete process.env['ACTIONS_RESULTS_URL']
})
test('restore with restore keys and no cache found', async () => {
+4 -9
View File
@@ -8,7 +8,6 @@ import {restoreCache} from '../src/cache'
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
import {DownloadOptions} from '../src/options'
import {HttpClientError} from '@actions/http-client'
jest.mock('../src/internal/cacheHttpClient')
jest.mock('../src/internal/cacheUtils')
@@ -96,18 +95,18 @@ test('restore with no cache found', async () => {
test('restore with server error should fail', async () => {
const paths = ['node_modules']
const key = 'node-test'
const logErrorMock = jest.spyOn(core, 'error')
const logWarningMock = jest.spyOn(core, 'warning')
jest
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
.mockImplementation(() => {
throw new HttpClientError('HTTP Error Occurred', 500)
throw new Error('HTTP Error Occurred')
})
const cacheKey = await restoreCache(paths, key)
expect(cacheKey).toBe(undefined)
expect(logErrorMock).toHaveBeenCalledTimes(1)
expect(logErrorMock).toHaveBeenCalledWith(
expect(logWarningMock).toHaveBeenCalledTimes(1)
expect(logWarningMock).toHaveBeenCalledWith(
'Failed to restore: HTTP Error Occurred'
)
})
@@ -266,7 +265,6 @@ test('restore with zstd compressed cache found', async () => {
const cacheKey = await restoreCache(paths, key, [], options)
expect(cacheKey).toBe(key)
expect(logInfoMock).toHaveBeenCalledWith(`Cache hit for: ${key}`)
expect(getCacheVersionMock).toHaveBeenCalledWith(
paths,
compressionMethod,
@@ -344,9 +342,6 @@ test('restore with cache found for restore key', async () => {
const cacheKey = await restoreCache(paths, key, restoreKeys, options)
expect(cacheKey).toBe(restoreKeys[0])
expect(logInfoMock).toHaveBeenCalledWith(
`Cache hit for restore-key: ${restoreKeys[0]}`
)
expect(getCacheVersionMock).toHaveBeenCalledWith(
paths,
compressionMethod,
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env node
// Helper script to save cache for e2e testing
import * as cache from '../lib/cache.js'
const [prefix, runId] = process.argv.slice(2)
if (!prefix || !runId) {
console.error('Usage: save-cache.mjs <prefix> <runId>')
process.exit(1)
}
const key = `test-${prefix}-${runId}`
const paths = ['test-cache', '~/test-cache']
console.log(`Saving cache with key: ${key}`)
console.log(`Paths: ${paths.join(', ')}`)
try {
const cacheId = await cache.saveCache(paths, key)
console.log(`Cache saved with ID: ${cacheId}`)
} catch (error) {
console.error('Error saving cache:', error)
process.exit(1)
}
+30 -37
View File
@@ -7,12 +7,11 @@ import * as config from '../src/internal/config'
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
import * as tar from '../src/internal/tar'
import {TypedResponse} from '@actions/http-client/lib/interfaces'
import {HttpClientError} from '@actions/http-client'
import {
ReserveCacheResponse,
ITypedResponseWithError
} from '../src/internal/contracts'
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
import {HttpClientError} from '@actions/http-client'
jest.mock('../src/internal/cacheHttpClient')
jest.mock('../src/internal/cacheUtils')
@@ -224,55 +223,46 @@ test('save with reserve cache failure should fail', async () => {
test('save with server error should fail', async () => {
const filePath = 'node_modules'
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const logErrorMock = jest.spyOn(core, 'error')
// Mock cache service version to V2
const getCacheServiceVersionMock = jest
.spyOn(config, 'getCacheServiceVersion')
.mockReturnValue('v2')
// Mock V2 CreateCacheEntry to succeed
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({
ok: true,
signedUploadUrl: 'https://blob-storage.local?signed=true',
message: ''
})
)
// Mock the FinalizeCacheEntryUpload to succeed (since the error should happen in saveCache)
jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(
Promise.resolve({ok: true, entryId: '4', message: 'Success'})
)
const cachePaths = [path.resolve(filePath)]
const logWarningMock = jest.spyOn(core, 'warning')
const cacheId = 4
const reserveCacheMock = jest
.spyOn(cacheHttpClient, 'reserveCache')
.mockImplementation(async () => {
const response: TypedResponse<ReserveCacheResponse> = {
statusCode: 500,
result: {cacheId},
headers: {}
}
return response
})
const createTarMock = jest.spyOn(tar, 'createTar')
// Mock the saveCache call to throw a server error
const saveCacheMock = jest
.spyOn(cacheHttpClient, 'saveCache')
.mockImplementationOnce(() => {
throw new HttpClientError('HTTP Error Occurred', 500)
throw new Error('HTTP Error Occurred')
})
const compression = CompressionMethod.Zstd
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockReturnValueOnce(Promise.resolve(compression))
await saveCache([filePath], primaryKey)
expect(logErrorMock).toHaveBeenCalledTimes(1)
expect(logErrorMock).toHaveBeenCalledWith(
expect(logWarningMock).toHaveBeenCalledTimes(1)
expect(logWarningMock).toHaveBeenCalledWith(
'Failed to save: HTTP Error Occurred'
)
expect(createCacheEntryMock).toHaveBeenCalledTimes(1)
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, [filePath], {
cacheSize: undefined,
compressionMethod: compression,
enableCrossOsArchive: false
})
const archiveFolder = '/foo/bar'
const cachePaths = [path.resolve(filePath)]
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
expect(createTarMock).toHaveBeenCalledTimes(1)
expect(createTarMock).toHaveBeenCalledWith(
archiveFolder,
@@ -280,10 +270,13 @@ test('save with server error should fail', async () => {
compression
)
expect(saveCacheMock).toHaveBeenCalledTimes(1)
expect(saveCacheMock).toHaveBeenCalledWith(
cacheId,
archiveFile,
'',
undefined
)
expect(getCompressionMock).toHaveBeenCalledTimes(1)
// Restore the getCacheServiceVersion mock to its original state
getCacheServiceVersionMock.mockRestore()
})
test('save with valid inputs uploads a cache', async () => {
+39 -254
View File
@@ -59,6 +59,39 @@ test('save with missing input should fail', async () => {
)
})
test('save with large cache outputs should fail using', async () => {
const paths = 'node_modules'
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const cachePaths = [path.resolve(paths)]
const createTarMock = jest.spyOn(tar, 'createTar')
const logWarningMock = jest.spyOn(core, 'warning')
const cacheSize = 11 * 1024 * 1024 * 1024 //~11GB, over the 10GB limit
jest
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
.mockReturnValueOnce(cacheSize)
const compression = CompressionMethod.Gzip
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockReturnValueOnce(Promise.resolve(compression))
const cacheId = await saveCache([paths], key)
expect(cacheId).toBe(-1)
expect(logWarningMock).toHaveBeenCalledWith(
'Failed to save: Cache size of ~11264 MB (11811160064 B) is over the 10GB limit, not saving cache.'
)
const archiveFolder = '/foo/bar'
expect(createTarMock).toHaveBeenCalledWith(
archiveFolder,
cachePaths,
compression
)
expect(getCompressionMock).toHaveBeenCalledTimes(1)
})
test('create cache entry failure on non-ok response', async () => {
const paths = ['node_modules']
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
@@ -66,7 +99,7 @@ test('create cache entry failure on non-ok response', async () => {
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockResolvedValue({ok: false, signedUploadUrl: '', message: ''})
.mockResolvedValue({ok: false, signedUploadUrl: ''})
const createTarMock = jest.spyOn(tar, 'createTar')
const finalizeCacheEntryMock = jest.spyOn(
@@ -149,7 +182,7 @@ test('save cache fails if a signedUploadURL was not passed', async () => {
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL})
)
const createTarMock = jest.spyOn(tar, 'createTar')
@@ -207,7 +240,7 @@ test('finalize save cache failure', async () => {
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL})
)
const createTarMock = jest.spyOn(tar, 'createTar')
@@ -227,7 +260,7 @@ test('finalize save cache failure', async () => {
const finalizeCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(Promise.resolve({ok: false, entryId: '', message: ''}))
.mockReturnValue(Promise.resolve({ok: false, entryId: ''}))
const cacheId = await saveCache([paths], key, options)
@@ -286,7 +319,7 @@ test('save with valid inputs uploads a cache', async () => {
jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL})
)
const saveCacheMock = jest.spyOn(cacheHttpClient, 'saveCache')
@@ -299,9 +332,7 @@ test('save with valid inputs uploads a cache', async () => {
const finalizeCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(
Promise.resolve({ok: true, entryId: cacheId.toString(), message: ''})
)
.mockReturnValue(Promise.resolve({ok: true, entryId: cacheId.toString()}))
const expectedCacheId = await saveCache([paths], key)
@@ -329,252 +360,6 @@ test('save with valid inputs uploads a cache', async () => {
expect(expectedCacheId).toBe(cacheId)
})
test('save with extremely large cache should succeed in v2 (no size limit)', async () => {
const paths = 'node_modules'
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const cachePaths = [path.resolve(paths)]
const signedUploadURL = 'https://blob-storage.local?signed=true'
const createTarMock = jest.spyOn(tar, 'createTar')
// Simulate a very large cache (20GB)
const archiveFileSize = 20 * 1024 * 1024 * 1024 // 20GB
const options: UploadOptions = {
archiveSizeBytes: archiveFileSize,
useAzureSdk: true,
uploadChunkSize: 64 * 1024 * 1024,
uploadConcurrency: 8
}
jest
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
.mockReturnValueOnce(archiveFileSize)
const cacheId = 4
jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
)
const saveCacheMock = jest.spyOn(cacheHttpClient, 'saveCache')
const compression = CompressionMethod.Zstd
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockReturnValue(Promise.resolve(compression))
const cacheVersion = cacheUtils.getCacheVersion([paths], compression)
const finalizeCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(
Promise.resolve({ok: true, entryId: cacheId.toString(), message: ''})
)
const expectedCacheId = await saveCache([paths], key)
const archiveFolder = '/foo/bar'
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
expect(saveCacheMock).toHaveBeenCalledWith(
-1,
archiveFile,
signedUploadURL,
options
)
expect(createTarMock).toHaveBeenCalledWith(
archiveFolder,
cachePaths,
compression
)
expect(finalizeCacheEntryMock).toHaveBeenCalledWith({
key,
version: cacheVersion,
sizeBytes: archiveFileSize.toString()
})
expect(getCompressionMock).toHaveBeenCalledTimes(1)
expect(expectedCacheId).toBe(cacheId)
})
test('save with create cache entry failure and specific error message', async () => {
const paths = ['node_modules']
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const infoLogMock = jest.spyOn(core, 'info')
const warningLogMock = jest.spyOn(core, 'warning')
const errorMessage = 'Cache storage quota exceeded for repository'
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockResolvedValue({ok: false, signedUploadUrl: '', message: errorMessage})
const createTarMock = jest.spyOn(tar, 'createTar')
const compression = CompressionMethod.Zstd
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockResolvedValueOnce(compression)
const archiveFileSize = 1024
jest
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
.mockReturnValueOnce(archiveFileSize)
const cacheId = await saveCache(paths, key)
expect(cacheId).toBe(-1)
expect(warningLogMock).toHaveBeenCalledWith(
`Cache reservation failed: ${errorMessage}`
)
expect(infoLogMock).toHaveBeenCalledWith(
`Failed to save: Unable to reserve cache with key ${key}, another job may be creating this cache.`
)
expect(createCacheEntryMock).toHaveBeenCalledWith({
key,
version: cacheUtils.getCacheVersion(paths, compression)
})
expect(createTarMock).toHaveBeenCalledTimes(1)
expect(getCompressionMock).toHaveBeenCalledTimes(1)
})
test('save with finalize cache entry failure and specific error message', async () => {
const paths = 'node_modules'
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const cachePaths = [path.resolve(paths)]
const logWarningMock = jest.spyOn(core, 'warning')
const signedUploadURL = 'https://blob-storage.local?signed=true'
const archiveFileSize = 1024
const errorMessage =
'Cache entry finalization failed due to concurrent access'
const options: UploadOptions = {
archiveSizeBytes: archiveFileSize,
useAzureSdk: true,
uploadChunkSize: 64 * 1024 * 1024,
uploadConcurrency: 8
}
const createCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
)
const createTarMock = jest.spyOn(tar, 'createTar')
const saveCacheMock = jest
.spyOn(cacheHttpClient, 'saveCache')
.mockResolvedValue()
const compression = CompressionMethod.Zstd
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockReturnValueOnce(Promise.resolve(compression))
const cacheVersion = cacheUtils.getCacheVersion([paths], compression)
jest
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
.mockReturnValueOnce(archiveFileSize)
const finalizeCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(
Promise.resolve({ok: false, entryId: '', message: errorMessage})
)
const cacheId = await saveCache([paths], key, options)
expect(createCacheEntryMock).toHaveBeenCalledWith({
key,
version: cacheVersion
})
const archiveFolder = '/foo/bar'
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
expect(createTarMock).toHaveBeenCalledWith(
archiveFolder,
cachePaths,
compression
)
expect(saveCacheMock).toHaveBeenCalledWith(
-1,
archiveFile,
signedUploadURL,
options
)
expect(getCompressionMock).toHaveBeenCalledTimes(1)
expect(finalizeCacheEntryMock).toHaveBeenCalledWith({
key,
version: cacheVersion,
sizeBytes: archiveFileSize.toString()
})
expect(cacheId).toBe(-1)
expect(logWarningMock).toHaveBeenCalledWith(errorMessage)
})
test('save with multiple large caches should succeed in v2 (testing 50GB)', async () => {
const paths = ['large-dataset', 'node_modules', 'build-artifacts']
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
const cachePaths = paths.map(p => path.resolve(p))
const signedUploadURL = 'https://blob-storage.local?signed=true'
const createTarMock = jest.spyOn(tar, 'createTar')
// Simulate an extremely large cache (50GB)
const archiveFileSize = 50 * 1024 * 1024 * 1024 // 50GB
const options: UploadOptions = {
archiveSizeBytes: archiveFileSize,
useAzureSdk: true,
uploadChunkSize: 64 * 1024 * 1024,
uploadConcurrency: 8
}
jest
.spyOn(cacheUtils, 'getArchiveFileSizeInBytes')
.mockReturnValueOnce(archiveFileSize)
const cacheId = 7
jest
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
.mockReturnValue(
Promise.resolve({ok: true, signedUploadUrl: signedUploadURL, message: ''})
)
const saveCacheMock = jest.spyOn(cacheHttpClient, 'saveCache')
const compression = CompressionMethod.Zstd
const getCompressionMock = jest
.spyOn(cacheUtils, 'getCompressionMethod')
.mockReturnValue(Promise.resolve(compression))
const cacheVersion = cacheUtils.getCacheVersion(paths, compression)
const finalizeCacheEntryMock = jest
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
.mockReturnValue(
Promise.resolve({ok: true, entryId: cacheId.toString(), message: ''})
)
const expectedCacheId = await saveCache(paths, key)
const archiveFolder = '/foo/bar'
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
expect(saveCacheMock).toHaveBeenCalledWith(
-1,
archiveFile,
signedUploadURL,
options
)
expect(createTarMock).toHaveBeenCalledWith(
archiveFolder,
cachePaths,
compression
)
expect(finalizeCacheEntryMock).toHaveBeenCalledWith({
key,
version: cacheVersion,
sizeBytes: archiveFileSize.toString()
})
expect(getCompressionMock).toHaveBeenCalledTimes(1)
expect(expectedCacheId).toBe(cacheId)
})
test('save with non existing path should not save cache using v2 saveCache', async () => {
const path = 'node_modules'
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
+1 -1
View File
@@ -1,5 +1,5 @@
import * as uploadUtils from '../src/internal/uploadUtils'
import {TransferProgressEvent} from '@azure/core-rest-pipeline'
import {TransferProgressEvent} from '@azure/ms-rest-js'
test('upload progress tracked correctly', () => {
const progress = new uploadUtils.UploadProgress(1000)
+818 -372
View File
File diff suppressed because it is too large Load Diff
+15 -25
View File
@@ -1,6 +1,7 @@
{
"name": "@actions/cache",
"version": "6.0.0",
"version": "4.0.3",
"preview": true,
"description": "Actions cache lib",
"keywords": [
"github",
@@ -9,15 +10,8 @@
],
"homepage": "https://github.com/actions/toolkit/tree/main/packages/cache",
"license": "MIT",
"type": "module",
"main": "lib/cache.js",
"types": "lib/cache.d.ts",
"exports": {
".": {
"types": "./lib/cache.d.ts",
"import": "./lib/cache.js"
}
},
"directories": {
"lib": "lib",
"test": "__tests__"
@@ -37,30 +31,26 @@
"scripts": {
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
"test": "echo \"Error: run tests from root\" && exit 1",
"tsc": "tsc && cp src/internal/shared/package-version.cjs lib/internal/shared/"
"tsc": "tsc"
},
"bugs": {
"url": "https://github.com/actions/toolkit/issues"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.6.1",
"@actions/http-client": "^4.0.0",
"@actions/io": "^3.0.0",
"@azure/core-rest-pipeline": "^1.22.0",
"@azure/storage-blob": "^12.30.0",
"@protobuf-ts/runtime-rpc": "^2.11.1",
"semver": "^7.7.3"
"@actions/core": "^1.11.1",
"@actions/exec": "^1.0.1",
"@actions/glob": "^0.1.0",
"@actions/http-client": "^2.1.1",
"@actions/io": "^1.0.1",
"@azure/abort-controller": "^1.1.0",
"@azure/ms-rest-js": "^2.6.0",
"@azure/storage-blob": "^12.13.0",
"@protobuf-ts/plugin": "^2.9.4",
"semver": "^6.3.1"
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.9.4",
"@types/node": "^25.1.0",
"@types/semver": "^7.7.1",
"@types/node": "^22.13.9",
"@types/semver": "^6.0.0",
"typescript": "^5.2.2"
},
"overrides": {
"uri-js": "npm:uri-js-replace@^1.0.1",
"node-fetch": "^3.3.2"
}
}
+25 -86
View File
@@ -1,20 +1,18 @@
import * as core from '@actions/core'
import * as path from 'path'
import * as utils from './internal/cacheUtils.js'
import * as cacheHttpClient from './internal/cacheHttpClient.js'
import * as cacheTwirpClient from './internal/shared/cacheTwirpClient.js'
import {getCacheServiceVersion, isGhes} from './internal/config.js'
import {DownloadOptions, UploadOptions} from './options.js'
import {createTar, extractTar, listTar} from './internal/tar.js'
import * as utils from './internal/cacheUtils'
import * as cacheHttpClient from './internal/cacheHttpClient'
import * as cacheTwirpClient from './internal/shared/cacheTwirpClient'
import {getCacheServiceVersion, isGhes} from './internal/config'
import {DownloadOptions, UploadOptions} from './options'
import {createTar, extractTar, listTar} from './internal/tar'
import {
CreateCacheEntryRequest,
FinalizeCacheEntryUploadRequest,
FinalizeCacheEntryUploadResponse,
GetCacheEntryDownloadURLRequest
} from './generated/results/api/v1/cache.js'
import {HttpClientError} from '@actions/http-client'
export type {DownloadOptions, UploadOptions}
} from './generated/results/api/v1/cache'
import {CacheFileSizeLimit} from './internal/constants'
export class ValidationError extends Error {
constructor(message: string) {
super(message)
@@ -31,14 +29,6 @@ export class ReserveCacheError extends Error {
}
}
export class FinalizeCacheError extends Error {
constructor(message: string) {
super(message)
this.name = 'FinalizeCacheError'
Object.setPrototypeOf(this, FinalizeCacheError.prototype)
}
}
function checkPaths(paths: string[]): void {
if (!paths || paths.length === 0) {
throw new ValidationError(
@@ -67,18 +57,7 @@ function checkKey(key: string): void {
* @returns boolean return true if Actions cache service feature is available, otherwise false
*/
export function isFeatureAvailable(): boolean {
const cacheServiceVersion = getCacheServiceVersion()
// Check availability based on cache service version
switch (cacheServiceVersion) {
case 'v2':
// For v2, we need ACTIONS_RESULTS_URL
return !!process.env['ACTIONS_RESULTS_URL']
case 'v1':
default:
// For v1, we only need ACTIONS_CACHE_URL
return !!process.env['ACTIONS_CACHE_URL']
}
return !!process.env['ACTIONS_CACHE_URL']
}
/**
@@ -207,17 +186,8 @@ async function restoreCacheV1(
if (typedError.name === ValidationError.name) {
throw error
} else {
// warn on cache restore failure and continue build
// Log server errors (5xx) as errors, all other errors as warnings
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
typedError.statusCode >= 500
) {
core.error(`Failed to restore: ${(error as Error).message}`)
} else {
core.warning(`Failed to restore: ${(error as Error).message}`)
}
// Supress all non-validation cache related errors because caching should be optional
core.warning(`Failed to restore: ${(error as Error).message}`)
}
} finally {
// Try to delete the archive to save space
@@ -294,12 +264,7 @@ async function restoreCacheV2(
return undefined
}
const isRestoreKeyMatch = request.key !== response.matchedKey
if (isRestoreKeyMatch) {
core.info(`Cache hit for restore-key: ${response.matchedKey}`)
} else {
core.info(`Cache hit for: ${response.matchedKey}`)
}
core.info(`Cache hit for: ${request.key}`)
if (options?.lookupOnly) {
core.info('Lookup only - skipping download')
@@ -340,16 +305,7 @@ async function restoreCacheV2(
throw error
} else {
// Supress all non-validation cache related errors because caching should be optional
// Log server errors (5xx) as errors, all other errors as warnings
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
typedError.statusCode >= 500
) {
core.error(`Failed to restore: ${(error as Error).message}`)
} else {
core.warning(`Failed to restore: ${(error as Error).message}`)
}
core.warning(`Failed to restore: ${(error as Error).message}`)
}
} finally {
try {
@@ -481,16 +437,7 @@ async function saveCacheV1(
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`)
} else {
// Log server errors (5xx) as errors, all other errors as warnings
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
typedError.statusCode >= 500
) {
core.error(`Failed to save: ${typedError.message}`)
} else {
core.warning(`Failed to save: ${typedError.message}`)
}
core.warning(`Failed to save: ${typedError.message}`)
}
} finally {
// Try to delete the archive to save space
@@ -559,6 +506,15 @@ async function saveCacheV2(
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath)
core.debug(`File Size: ${archiveFileSize}`)
// For GHES, this check will take place in ReserveCache API with enterprise file size limit
if (archiveFileSize > CacheFileSizeLimit && !isGhes()) {
throw new Error(
`Cache size of ~${Math.round(
archiveFileSize / (1024 * 1024)
)} MB (${archiveFileSize} B) is over the 10GB limit, not saving cache.`
)
}
// Set the archive size in the options, will be used to display the upload progress
options.archiveSizeBytes = archiveFileSize
@@ -578,10 +534,7 @@ async function saveCacheV2(
try {
const response = await twirpClient.CreateCacheEntry(request)
if (!response.ok) {
if (response.message) {
core.warning(`Cache reservation failed: ${response.message}`)
}
throw new Error(response.message || 'Response was not ok')
throw new Error('Response was not ok')
}
signedUploadUrl = response.signedUploadUrl
} catch (error) {
@@ -610,9 +563,6 @@ async function saveCacheV2(
core.debug(`FinalizeCacheEntryUploadResponse: ${finalizeResponse.ok}`)
if (!finalizeResponse.ok) {
if (finalizeResponse.message) {
throw new FinalizeCacheError(finalizeResponse.message)
}
throw new Error(
`Unable to finalize cache with key ${key}, another job may be finalizing this cache.`
)
@@ -625,19 +575,8 @@ async function saveCacheV2(
throw error
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`)
} else if (typedError.name === FinalizeCacheError.name) {
core.warning(typedError.message)
} else {
// Log server errors (5xx) as errors, all other errors as warnings
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
typedError.statusCode >= 500
) {
core.error(`Failed to save: ${typedError.message}`)
} else {
core.warning(`Failed to save: ${typedError.message}`)
}
core.warning(`Failed to save: ${typedError.message}`)
}
} finally {
// Try to delete the archive to save space
+5 -31
View File
@@ -12,7 +12,7 @@ import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MESSAGE_TYPE } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
import { CacheMetadata } from "../../entities/v1/cachemetadata.js";
import { CacheMetadata } from "../../entities/v1/cachemetadata";
/**
* @generated from protobuf message github.actions.results.api.v1.CreateCacheEntryRequest
*/
@@ -50,12 +50,6 @@ export interface CreateCacheEntryResponse {
* @generated from protobuf field: string signed_upload_url = 2;
*/
signedUploadUrl: string;
/**
* When !ok, this field may contain a human-readable error message used to create an annotation
*
* @generated from protobuf field: string message = 3;
*/
message: string;
}
/**
* @generated from protobuf message github.actions.results.api.v1.FinalizeCacheEntryUploadRequest
@@ -100,12 +94,6 @@ export interface FinalizeCacheEntryUploadResponse {
* @generated from protobuf field: int64 entry_id = 2;
*/
entryId: string;
/**
* When !ok, this field may contain a human-readable error message used to create an annotation
*
* @generated from protobuf field: string message = 3;
*/
message: string;
}
/**
* @generated from protobuf message github.actions.results.api.v1.GetCacheEntryDownloadURLRequest
@@ -223,12 +211,11 @@ class CreateCacheEntryResponse$Type extends MessageType<CreateCacheEntryResponse
constructor() {
super("github.actions.results.api.v1.CreateCacheEntryResponse", [
{ no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 2, name: "signed_upload_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "message", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
{ no: 2, name: "signed_upload_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<CreateCacheEntryResponse>): CreateCacheEntryResponse {
const message = { ok: false, signedUploadUrl: "", message: "" };
const message = { ok: false, signedUploadUrl: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<CreateCacheEntryResponse>(this, message, value);
@@ -245,9 +232,6 @@ class CreateCacheEntryResponse$Type extends MessageType<CreateCacheEntryResponse
case /* string signed_upload_url */ 2:
message.signedUploadUrl = reader.string();
break;
case /* string message */ 3:
message.message = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -266,9 +250,6 @@ class CreateCacheEntryResponse$Type extends MessageType<CreateCacheEntryResponse
/* string signed_upload_url = 2; */
if (message.signedUploadUrl !== "")
writer.tag(2, WireType.LengthDelimited).string(message.signedUploadUrl);
/* string message = 3; */
if (message.message !== "")
writer.tag(3, WireType.LengthDelimited).string(message.message);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
@@ -352,12 +333,11 @@ class FinalizeCacheEntryUploadResponse$Type extends MessageType<FinalizeCacheEnt
constructor() {
super("github.actions.results.api.v1.FinalizeCacheEntryUploadResponse", [
{ no: 1, name: "ok", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 2, name: "entry_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ },
{ no: 3, name: "message", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
{ no: 2, name: "entry_id", kind: "scalar", T: 3 /*ScalarType.INT64*/ }
]);
}
create(value?: PartialMessage<FinalizeCacheEntryUploadResponse>): FinalizeCacheEntryUploadResponse {
const message = { ok: false, entryId: "0", message: "" };
const message = { ok: false, entryId: "0" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<FinalizeCacheEntryUploadResponse>(this, message, value);
@@ -374,9 +354,6 @@ class FinalizeCacheEntryUploadResponse$Type extends MessageType<FinalizeCacheEnt
case /* int64 entry_id */ 2:
message.entryId = reader.int64().toString();
break;
case /* string message */ 3:
message.message = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -395,9 +372,6 @@ class FinalizeCacheEntryUploadResponse$Type extends MessageType<FinalizeCacheEnt
/* int64 entry_id = 2; */
if (message.entryId !== "0")
writer.tag(2, WireType.Varint).int64(message.entryId);
/* string message = 3; */
if (message.message !== "")
writer.tag(3, WireType.LengthDelimited).string(message.message);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
@@ -5,7 +5,7 @@ import {
FinalizeCacheEntryUploadResponse,
GetCacheEntryDownloadURLRequest,
GetCacheEntryDownloadURLResponse,
} from "./cache.js";
} from "./cache";
//==================================//
// Client Code //
@@ -11,7 +11,7 @@ import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MESSAGE_TYPE } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
import { CacheScope } from "./cachescope.js";
import { CacheScope } from "./cachescope";
/**
* @generated from protobuf message github.actions.results.entities.v1.CacheMetadata
*/
+8 -8
View File
@@ -7,8 +7,8 @@ import {
} from '@actions/http-client/lib/interfaces'
import * as fs from 'fs'
import {URL} from 'url'
import * as utils from './cacheUtils.js'
import {uploadCacheArchiveSDK} from './uploadUtils.js'
import * as utils from './cacheUtils'
import {uploadCacheArchiveSDK} from './uploadUtils'
import {
ArtifactCacheEntry,
InternalCacheOptions,
@@ -17,25 +17,25 @@ import {
ReserveCacheResponse,
ITypedResponseWithError,
ArtifactCacheList
} from './contracts.js'
} from './contracts'
import {
downloadCacheHttpClient,
downloadCacheHttpClientConcurrent,
downloadCacheStorageSDK
} from './downloadUtils.js'
} from './downloadUtils'
import {
DownloadOptions,
UploadOptions,
getDownloadOptions,
getUploadOptions
} from '../options.js'
} from '../options'
import {
isSuccessStatusCode,
retryHttpClientResponse,
retryTypedResponse
} from './requestUtils.js'
import {getCacheServiceURL} from './config.js'
import {getUserAgentString} from './shared/user-agent.js'
} from './requestUtils'
import {getCacheServiceURL} from './config'
import {getUserAgentString} from './shared/user-agent'
function getCacheApiUrl(resource: string): string {
const baseUrl: string = getCacheServiceURL()
+1 -1
View File
@@ -11,7 +11,7 @@ import {
CacheFilename,
CompressionMethod,
GnuTarPathOnWindows
} from './constants.js'
} from './constants'
const versionSalt = '1.0'
@@ -1,4 +1,4 @@
import {CompressionMethod} from './constants.js'
import {CompressionMethod} from './constants'
import {TypedResponse} from '@actions/http-client/lib/interfaces'
import {HttpClientError} from '@actions/http-client'
+7 -5
View File
@@ -1,16 +1,18 @@
import * as core from '@actions/core'
import {HttpClient, HttpClientResponse} from '@actions/http-client'
import {BlockBlobClient} from '@azure/storage-blob'
import {TransferProgressEvent} from '@azure/core-rest-pipeline'
import {TransferProgressEvent} from '@azure/ms-rest-js'
import * as buffer from 'buffer'
import * as fs from 'fs'
import * as stream from 'stream'
import * as util from 'util'
import * as utils from './cacheUtils.js'
import {SocketTimeout} from './constants.js'
import {DownloadOptions} from '../options.js'
import {retryHttpClientResponse} from './requestUtils.js'
import * as utils from './cacheUtils'
import {SocketTimeout} from './constants'
import {DownloadOptions} from '../options'
import {retryHttpClientResponse} from './requestUtils'
import {AbortController} from '@azure/abort-controller'
/**
* Pipes the body of a HTTP response to a stream
+2 -2
View File
@@ -4,8 +4,8 @@ import {
HttpClientError,
HttpClientResponse
} from '@actions/http-client'
import {DefaultRetryDelay, DefaultRetryAttempts} from './constants.js'
import {ITypedResponseWithError} from './contracts.js'
import {DefaultRetryDelay, DefaultRetryAttempts} from './constants'
import {ITypedResponseWithError} from './contracts'
export function isSuccessStatusCode(statusCode?: number): boolean {
if (!statusCode) {
+9 -27
View File
@@ -1,12 +1,12 @@
import {info, debug, warning} from '@actions/core'
import {getUserAgentString} from './user-agent.js'
import {NetworkError, RateLimitError, UsageError} from './errors.js'
import {getCacheServiceURL} from '../config.js'
import {getRuntimeToken} from '../cacheUtils.js'
import {info, debug} from '@actions/core'
import {getUserAgentString} from './user-agent'
import {NetworkError, UsageError} from './errors'
import {getCacheServiceURL} from '../config'
import {getRuntimeToken} from '../cacheUtils'
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client'
import {CacheServiceClientJSON} from '../../generated/results/api/v1/cache.twirp-client.js'
import {maskSecretUrls} from './util.js'
import {CacheServiceClientJSON} from '../../generated/results/api/v1/cache.twirp-client'
import {maskSecretUrls} from './util'
// The twirp http client must implement this interface
interface Rpc {
@@ -109,21 +109,6 @@ class CacheServiceClient implements Rpc {
errorMessage = `${errorMessage}: ${body.msg}`
}
// Handle rate limiting - don't retry, just warn and exit
// For more info, see https://docs.github.com/en/actions/reference/limits
if (statusCode === HttpCodes.TooManyRequests) {
const retryAfterHeader = response.message.headers['retry-after']
if (retryAfterHeader) {
const parsedSeconds = parseInt(retryAfterHeader, 10)
if (!isNaN(parsedSeconds) && parsedSeconds > 0) {
warning(
`You've hit a rate limit, your rate limit will reset in ${parsedSeconds} seconds`
)
}
}
throw new RateLimitError(`Rate limited: ${errorMessage}`)
}
} catch (error) {
if (error instanceof SyntaxError) {
debug(`Raw Body: ${rawBody}`)
@@ -133,10 +118,6 @@ class CacheServiceClient implements Rpc {
throw error
}
if (error instanceof RateLimitError) {
throw error
}
if (NetworkError.isNetworkErrorCode(error?.code)) {
throw new NetworkError(error?.code)
}
@@ -181,7 +162,8 @@ class CacheServiceClient implements Rpc {
HttpCodes.BadGateway,
HttpCodes.GatewayTimeout,
HttpCodes.InternalServerError,
HttpCodes.ServiceUnavailable
HttpCodes.ServiceUnavailable,
HttpCodes.TooManyRequests
]
return retryableStatusCodes.includes(statusCode)
+1 -8
View File
@@ -60,7 +60,7 @@ export class NetworkError extends Error {
export class UsageError extends Error {
constructor() {
const message = `Cache storage quota has been hit. Unable to upload any new cache entries.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
const message = `Cache storage quota has been hit. Unable to upload any new cache entries. Usage is recalculated every 6-12 hours.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
super(message)
this.name = 'UsageError'
}
@@ -70,10 +70,3 @@ export class UsageError extends Error {
return msg.includes('insufficient usage')
}
}
export class RateLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'RateLimitError'
}
}
@@ -1,7 +0,0 @@
// This file exists as a CommonJS module to read the version from package.json.
// In an ESM package, using `require()` directly in .ts files requires disabling
// ESLint rules and doesn't work reliably across all Node.js versions.
// By keeping this as a .cjs file, we can use require() naturally and export
// the version for the ESM modules to import.
const packageJson = require('../../../package.json')
module.exports = { version: packageJson.version }
+3 -2
View File
@@ -1,8 +1,9 @@
import {version} from './package-version.cjs'
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const 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/cache-${version}`
return `@actions/cache-${packageJson.version}`
}

Some files were not shown because too many files have changed in this diff Show More