Compare commits

..

3 Commits

Author SHA1 Message Date
Josh Gross
f626d210e8 linting 2024-08-15 20:37:17 -04:00
Josh Gross
fa37431cef npm ci and npm run release 2024-08-15 20:34:01 -04:00
Josh Gross
3412bb46a4 Exclude the .git directory by default 2024-08-15 20:29:20 -04:00
47 changed files with 166980 additions and 154566 deletions

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
lib/
dist/

16
.eslintrc.json Normal file
View File

@@ -0,0 +1,16 @@
{
"env": { "node": true, "jest": true },
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": 9, "sourceType": "module" },
"extends": [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/no-empty-function": "off"
},
"plugins": ["@typescript-eslint", "jest"]
}

View File

@@ -24,10 +24,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node 24 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24.x node-version: 20.x
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

View File

@@ -17,11 +17,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
# Override language selection by uncommenting this and choosing your languages # Override language selection by uncommenting this and choosing your languages
# with: # with:
# languages: go, javascript, csharp, python, cpp, java # languages: go, javascript, csharp, python, cpp, java
@@ -29,7 +29,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -43,4 +43,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2

View File

@@ -1,20 +0,0 @@
name: 'Publish Immutable Action Version'
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
steps:
- name: Checking out
uses: actions/checkout@v4
- name: Publish
id: publish
uses: actions/publish-immutable-action@0.0.3

View File

@@ -1,114 +0,0 @@
name: Test Proxy
on:
push:
branches:
- main
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'
permissions:
contents: read
jobs:
# End to end upload with proxy
test-proxy-upload:
runs-on: ubuntu-latest
container:
image: ubuntu:latest
options: --cap-add=NET_ADMIN
services:
squid-proxy:
image: ubuntu/squid:latest
ports:
- 3128:3128
env:
http_proxy: http://squid-proxy:3128
https_proxy: http://squid-proxy:3128
steps:
- name: Wait for proxy to be ready
shell: bash
run: |
echo "Waiting for squid proxy to be ready..."
echo "Resolving squid-proxy hostname:"
getent hosts squid-proxy || echo "DNS resolution failed"
for i in $(seq 1 30); do
if (echo > /dev/tcp/squid-proxy/3128) 2>/dev/null; then
echo "Proxy is ready!"
exit 0
fi
echo "Attempt $i: Proxy not ready, waiting..."
sleep 2
done
echo "Proxy failed to become ready"
exit 1
env:
http_proxy: ""
https_proxy: ""
- name: Install dependencies
run: |
apt-get update
apt-get install -y iptables curl
- name: Verify proxy is working
run: |
echo "Testing proxy connectivity..."
curl -s -o /dev/null -w "%{http_code}" --proxy http://squid-proxy:3128 http://github.com || true
echo "Proxy verification complete"
- name: Block direct traffic (enforce proxy usage)
run: |
# Get the squid-proxy container IP
PROXY_IP=$(getent hosts squid-proxy | awk '{ print $1 }')
echo "Proxy IP: $PROXY_IP"
# Allow loopback traffic
iptables -A OUTPUT -o lo -j ACCEPT
# Allow traffic to the proxy container
iptables -A OUTPUT -d $PROXY_IP -j ACCEPT
# Allow established connections
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow DNS (needed for initial resolution)
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
# Block all other outbound traffic (HTTP/HTTPS)
iptables -A OUTPUT -p tcp --dport 80 -j REJECT
iptables -A OUTPUT -p tcp --dport 443 -j REJECT
# Log the iptables rules for debugging
iptables -L -v -n
- name: Verify direct HTTPS is blocked
run: |
echo "Testing that direct HTTPS requests fail..."
if curl --noproxy '*' -s --connect-timeout 5 https://github.com > /dev/null 2>&1; then
echo "ERROR: Direct HTTPS request succeeded - blocking is not working!"
exit 1
else
echo "SUCCESS: Direct HTTPS request was blocked as expected"
fi
echo "Testing that HTTPS through proxy succeeds..."
if curl --proxy http://squid-proxy:3128 -s --connect-timeout 10 https://github.com > /dev/null 2>&1; then
echo "SUCCESS: HTTPS request through proxy succeeded"
else
echo "ERROR: HTTPS request through proxy failed!"
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Create artifact file
run: |
mkdir -p test-artifacts
echo "Proxy test artifact - $GITHUB_RUN_ID" > test-artifacts/proxy-test.txt
echo "Random data: $RANDOM $RANDOM $RANDOM" >> test-artifacts/proxy-test.txt
cat test-artifacts/proxy-test.txt
- name: Upload artifact through proxy
uses: ./
with:
name: 'Proxy-Test-Artifact-${{ github.run_id }}'
path: test-artifacts/proxy-test.txt

View File

@@ -10,10 +10,6 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
permissions:
contents: read
actions: write
jobs: jobs:
build: build:
name: Build name: Build
@@ -29,10 +25,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 24 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24.x node-version: 20.x
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@@ -50,19 +46,14 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
# Test end-to-end by uploading a few artifacts and then downloading them # Test end-to-end by uploading two artifacts and then downloading them
- name: Create artifact files - name: Create artifact files
run: | run: |
mkdir -p path/to/dir-1 mkdir -p path/to/dir-1
mkdir -p path/to/dir-2 mkdir -p path/to/dir-2
mkdir -p path/to/dir-3 mkdir -p path/to/dir-3
mkdir -p symlink/
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
echo "Hello world from file #2" > path/to/dir-2/file2.txt echo "Hello world from file #2" > path/to/dir-2/file2.txt
echo "Hello from a symlinked file" > symlink/original.txt
ln -s $(pwd)/symlink/original.txt symlink/abs.txt
ln -s original.txt symlink/rel.txt
shell: bash
# Upload a single file artifact # Upload a single file artifact
- name: 'Upload artifact #1' - name: 'Upload artifact #1'
@@ -88,17 +79,9 @@ jobs:
path/to/dir-[23]/* path/to/dir-[23]/*
!path/to/dir-3/*.txt !path/to/dir-3/*.txt
- name: 'Upload symlinked artifact'
uses: ./
with:
name: 'Symlinked-Artifact-${{ matrix.runs-on }}'
path: |
symlink/abs.txt
symlink/rel.txt
# Download Artifact #1 and verify the correctness of the content # Download Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1' - name: 'Download artifact #1'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: 'Artifact-A-${{ matrix.runs-on }}' name: 'Artifact-A-${{ matrix.runs-on }}'
path: some/new/path path: some/new/path
@@ -118,7 +101,7 @@ jobs:
# Download Artifact #2 and verify the correctness of the content # Download Artifact #2 and verify the correctness of the content
- name: 'Download artifact #2' - name: 'Download artifact #2'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: 'Artifact-Wildcard-${{ matrix.runs-on }}' name: 'Artifact-Wildcard-${{ matrix.runs-on }}'
path: some/other/path path: some/other/path
@@ -139,7 +122,7 @@ jobs:
# Download Artifact #4 and verify the correctness of the content # Download Artifact #4 and verify the correctness of the content
- name: 'Download artifact #4' - name: 'Download artifact #4'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: 'Multi-Path-Artifact-${{ matrix.runs-on }}' name: 'Multi-Path-Artifact-${{ matrix.runs-on }}'
path: multi/artifact path: multi/artifact
@@ -158,34 +141,6 @@ jobs:
} }
shell: pwsh shell: pwsh
- name: 'Download symlinked artifact'
uses: actions/download-artifact@main
with:
name: 'Symlinked-Artifact-${{ matrix.runs-on }}'
path: from/symlink
- name: 'Verify symlinked artifact'
run: |
$abs = "from/symlink/abs.txt"
if(!(Test-Path -path $abs))
{
Write-Error "Expected file does not exist"
}
if(!((Get-Content $abs) -ceq "Hello from a symlinked file"))
{
Write-Error "File contents of downloaded artifact are incorrect"
}
$rel = "from/symlink/rel.txt"
if(!(Test-Path -path $rel))
{
Write-Error "Expected file does not exist"
}
if(!((Get-Content $rel) -ceq "Hello from a symlinked file"))
{
Write-Error "File contents of downloaded artifact are incorrect"
}
shell: pwsh
- name: 'Alter file 1 content' - name: 'Alter file 1 content'
run: | run: |
echo "This file has changed" > path/to/dir-1/file1.txt echo "This file has changed" > path/to/dir-1/file1.txt
@@ -200,7 +155,7 @@ jobs:
# Download replaced Artifact #1 and verify the correctness of the content # Download replaced Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1 again' - name: 'Download artifact #1 again'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: 'Artifact-A-${{ matrix.runs-on }}' name: 'Artifact-A-${{ matrix.runs-on }}'
path: overwrite/some/new/path path: overwrite/some/new/path
@@ -217,101 +172,6 @@ jobs:
Write-Error "File contents of downloaded artifact are incorrect" Write-Error "File contents of downloaded artifact are incorrect"
} }
shell: pwsh shell: pwsh
# Upload a single file without archiving (direct file upload)
- name: 'Create direct upload file'
run: echo -n 'direct file upload content' > direct-upload-${{ matrix.runs-on }}.txt
shell: bash
- name: 'Upload direct file artifact'
uses: ./
with:
name: 'Direct-File-${{ matrix.runs-on }}'
path: direct-upload-${{ matrix.runs-on }}.txt
archive: false
- name: 'Download direct file artifact'
uses: actions/download-artifact@main
with:
name: direct-upload-${{ matrix.runs-on }}.txt
path: direct-download
- name: 'Verify direct file artifact'
run: |
$file = "direct-download/direct-upload-${{ matrix.runs-on }}.txt"
if(!(Test-Path -path $file))
{
Write-Error "Expected file does not exist"
}
if(!((Get-Content $file -Raw).TrimEnd() -ceq "direct file upload content"))
{
Write-Error "File contents of downloaded artifact are incorrect"
}
shell: pwsh
upload-html-report:
name: Upload HTML Report
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: 24.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Compile
run: npm run build
- name: Create HTML report
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>archive: false</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 report (no archive)
uses: ./
with:
name: 'test-report'
path: report.html
archive: false
merge: merge:
name: Merge name: Merge
needs: build needs: build
@@ -329,7 +189,7 @@ jobs:
# easier to identify each of the merged artifacts # easier to identify each of the merged artifacts
separate-directories: true separate-directories: true
- name: 'Download merged artifacts' - name: 'Download merged artifacts'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: merged-artifacts name: merged-artifacts
path: all-merged-artifacts path: all-merged-artifacts
@@ -365,7 +225,7 @@ jobs:
# Download merged artifacts and verify the correctness of the content # Download merged artifacts and verify the correctness of the content
- name: 'Download merged artifacts' - name: 'Download merged artifacts'
uses: actions/download-artifact@main uses: actions/download-artifact@v4
with: with:
name: Merged-Artifact-As name: Merged-Artifact-As
path: merged-artifact-a path: merged-artifact-a
@@ -389,40 +249,3 @@ jobs:
} }
shell: pwsh shell: pwsh
cleanup:
name: Cleanup Artifacts
needs: [build, merge]
runs-on: ubuntu-latest
steps:
- name: Delete test artifacts
uses: actions/github-script@v8
with:
script: |
const keep = ['report.html'];
const owner = context.repo.owner;
const repo = context.repo.repo;
const runId = context.runId;
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: runId
});
for (const a of artifacts) {
if (keep.includes(a.name)) {
console.log(`Keeping artifact '${a.name}'`);
continue;
}
try {
await github.rest.actions.deleteArtifact({
owner,
repo,
artifact_id: a.id
});
console.log(`Deleted artifact '${a.name}'`);
} catch (err) {
console.log(`Could not delete artifact '${a.name}': ${err.message}`);
}
}

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules/ node_modules/
lib/ lib/
__tests__/_temp/ __tests__/_temp/
.DS_Store

View File

@@ -1,9 +1,6 @@
sources: sources:
npm: true npm: true
# Force UTF-8 encoding
encoding: 'utf-8'
allowed: allowed:
- apache-2.0 - apache-2.0
- bsd-2-clause - bsd-2-clause
@@ -12,30 +9,7 @@ allowed:
- mit - mit
- cc0-1.0 - cc0-1.0
- unlicense - unlicense
- 0bsd
- blueoak-1.0.0
reviewed: reviewed:
npm: npm:
- fs.realpath - fs.realpath
- "@actions/http-client" # MIT
- "@bufbuild/protobuf" # Apache-2.0
- "@pkgjs/parseargs" # MIT
- "@protobuf-ts/runtime" # Apache-2.0
- argparse # Python-2.0
- buffers # MIT
- chainsaw # MIT
- color-convert # MIT
- ieee754 # BSD-3-Clause
- lodash # MIT
- mdurl # MIT
- neo-async # MIT
- package-json-from-dist # ISC
- readable-stream # MIT
- sax # ISC
- source-map # BSD-3-Clause
- string_decoder # MIT
- traverse # MIT
- tslib # 0BSD
- uglify-js # BSD-2-Clause
- wordwrap # MIT

View File

@@ -1,9 +1,9 @@
--- ---
name: "@actions/artifact" name: "@actions/artifact"
version: 6.2.0 version: 2.1.8
type: npm type: npm
summary: Actions artifact lib summary:
homepage: https://github.com/actions/toolkit/tree/main/packages/artifact homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE.md

View File

@@ -1,9 +1,9 @@
--- ---
name: "@actions/core" name: "@actions/core"
version: 3.0.0 version: 1.10.1
type: npm type: npm
summary: Actions core lib summary:
homepage: https://github.com/actions/toolkit/tree/main/packages/core homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE.md

View File

@@ -1,9 +1,9 @@
--- ---
name: "@actions/github" name: "@actions/github"
version: 9.0.0 version: 6.0.0
type: npm type: npm
summary: Actions github lib summary:
homepage: https://github.com/actions/toolkit/tree/main/packages/github homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE.md

View File

@@ -1,9 +1,9 @@
--- ---
name: "@actions/glob" name: "@actions/glob"
version: 0.6.1 version: 0.3.0
type: npm type: npm
summary: Actions glob lib summary:
homepage: https://github.com/actions/toolkit/tree/main/packages/glob homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE.md

View File

@@ -1,9 +1,9 @@
--- ---
name: "@actions/io" name: "@actions/io"
version: 3.0.2 version: 1.1.2
type: npm type: npm
summary: Actions io lib summary:
homepage: https://github.com/actions/toolkit/tree/main/packages/io homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE.md

View File

@@ -1,66 +1,26 @@
--- ---
name: minimatch name: minimatch
version: 10.2.4 version: 9.0.3
type: npm type: npm
summary: summary:
homepage: homepage:
license: blueoak-1.0.0 license: isc
licenses: licenses:
- sources: LICENSE.md - sources: LICENSE
text: | text: |
# Blue Oak Model License The ISC License
Version 1.0.0 Copyright (c) 2011-2023 Isaac Z. Schlueter and Contributors
## Purpose Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
This license gives everyone as much permission to work with THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
this software as possible, while protecting contributors WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
from liability. MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
## Acceptance WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
In order to receive this license, you must agree to its IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
**_As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim._**
notices: [] notices: []

36
.vscode/launch.json vendored
View File

@@ -1,36 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"args": [
"--runInBand",
"--testTimeout",
"10000"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"args": [
"--runInBand",
"--testTimeout",
"10000",
"${relativeFile}"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true
}
]
}

View File

@@ -11,12 +11,12 @@ Upload [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/st
See also [download-artifact](https://github.com/actions/download-artifact). See also [download-artifact](https://github.com/actions/download-artifact).
- [`@actions/upload-artifact`](#actionsupload-artifact) - [`@actions/upload-artifact`](#actionsupload-artifact)
- [v6 - What's new](#v6---whats-new)
- [v4 - What's new](#v4---whats-new) - [v4 - What's new](#v4---whats-new)
- [Improvements](#improvements) - [Improvements](#improvements)
- [Breaking Changes](#breaking-changes) - [Breaking Changes](#breaking-changes)
- [Usage](#usage) - [Usage](#usage)
- [Inputs](#inputs) - [Inputs](#inputs)
- [Uploading the `.git` directory](#uploading-the-git-directory)
- [Outputs](#outputs) - [Outputs](#outputs)
- [Examples](#examples) - [Examples](#examples)
- [Upload an Individual File](#upload-an-individual-file) - [Upload an Individual File](#upload-an-individual-file)
@@ -39,19 +39,10 @@ See also [download-artifact](https://github.com/actions/download-artifact).
- [Where does the upload go?](#where-does-the-upload-go) - [Where does the upload go?](#where-does-the-upload-go)
## v6 - What's new
> [!IMPORTANT]
> actions/upload-artifact@v6 now runs on Node.js 24 (`runs.using: node24`) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.
### Node.js 24
This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.
## v4 - What's new ## v4 - What's new
> [!IMPORTANT] > [!IMPORTANT]
> upload-artifact@v4+ is not currently supported on GitHub Enterprise Server (GHES) yet. If you are on GHES, you must use [v3](https://github.com/actions/upload-artifact/releases/tag/v3) (Node 16) or [v3-node20](https://github.com/actions/upload-artifact/releases/tag/v3-node20) (Node 20). > upload-artifact@v4+ is not currently supported on GHES yet. If you are on GHES, you must use [v3](https://github.com/actions/upload-artifact/releases/tag/v3).
The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements. The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.
@@ -74,28 +65,10 @@ There is also a new sub-action, `actions/upload-artifact/merge`. For more info,
Due to how Artifacts are created in this new version, it is no longer possible to upload to the same named Artifact multiple times. You must either split the uploads into multiple Artifacts with different names, or only upload once. Otherwise you _will_ encounter an error. Due to how Artifacts are created in this new version, it is no longer possible to upload to the same named Artifact multiple times. You must either split the uploads into multiple Artifacts with different names, or only upload once. Otherwise you _will_ encounter an error.
3. Limit of Artifacts for an individual job. Each job in a workflow run now has a limit of 500 artifacts. 3. Limit of Artifacts for an individual job. Each job in a workflow run now has a limit of 500 artifacts.
4. With `v4.4` and later, hidden files are excluded by default. 4. With `v4.4` and later, the `.git` directory is excluded by default.
For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md). For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md).
## Note
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.
## Usage ## Usage
### Inputs ### Inputs
@@ -136,12 +109,30 @@ You are welcome to still raise bugs in this repo.
# Does not fail if the artifact does not exist. # Does not fail if the artifact does not exist.
# Optional. Default is 'false' # Optional. Default is 'false'
overwrite: overwrite:
```
# Whether to include hidden files in the provided path in the artifact #### Uploading the `.git` directory
# The file contents of any hidden files in the path should be validated before
# enabled this to avoid uploading sensitive information. By default, files in a `.git` directory are ignored in the uploaded artifact.
# Optional. Default is 'false' This is intended to prevent accidentally uploading Git credentials into an artifact that could then
include-hidden-files: be extracted.
If files in the `.git` directory are needed, ensure that `actions/checkout` is being used with
`persist-credentials: false`.
```yaml
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false # Ensure credentials are not saved in `.git/config`
- uses: actions/upload-artifact@v4
with:
path: .
include-git-directory: true
``` ```
### Outputs ### Outputs
@@ -150,7 +141,6 @@ You are welcome to still raise bugs in this repo.
| - | - | - | | - | - | - |
| `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` | | `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` |
| `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` | | `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` |
| `artifact-digest` | SHA-256 digest of an Artifact | 0fde654d4c6e659b45783a725dc92f1bfb0baa6c2de64b34e814dc206ff4aaaf |
## Examples ## Examples
@@ -446,28 +436,6 @@ jobs:
overwrite: true overwrite: true
``` ```
### Uploading Hidden Files
By default, hidden files are ignored by this action to avoid unintentionally uploading sensitive information.
If you need to upload hidden files, you can use the `include-hidden-files` input.
Any files that contain sensitive information that should not be in the uploaded artifact can be excluded
using the `path`:
```yaml
- uses: actions/upload-artifact@v4
with:
name: my-artifact
include-hidden-files: true
path: |
path/output/
!path/output/.production.env
```
Hidden files are defined as any file beginning with `.` or files within folders beginning with `.`.
On Windows, files and directories with the hidden attribute are not considered hidden files unless
they have the `.` prefix.
## Limitations ## Limitations
### Number of Artifacts ### Number of Artifacts
@@ -501,9 +469,8 @@ If you must preserve permissions, you can `tar` all of your files together befor
At the bottom of the workflow summary page, there is a dedicated section for artifacts. Here's a screenshot of something you might see: At the bottom of the workflow summary page, there is a dedicated section for artifacts. Here's a screenshot of something you might see:
<img src="https://github.com/user-attachments/assets/bcb7120f-f445-4a3e-9596-77f85f7e0af0" width="700" height="300"> <img src="https://user-images.githubusercontent.com/16109154/103645952-223c6880-4f59-11eb-8268-8dca6937b5f9.png" width="700" height="300">
There is a trashcan icon that can be used to delete the artifact. This icon will only appear for users who have write permissions to the repository. There is a trashcan icon that can be used to delete the artifact. This icon will only appear for users who have write permissions to the repository.
The size of the artifact is denoted in bytes. The displayed artifact size denotes the size of the zip that `upload-artifact` creates during upload. The Digest column will display the SHA256 digest of the artifact being uploaded. The size of the artifact is denoted in bytes. The displayed artifact size denotes the size of the zip that `upload-artifact` creates during upload.

View File

@@ -1,65 +1,8 @@
import {jest, describe, test, expect, beforeEach} from '@jest/globals' import * as core from '@actions/core'
import artifact from '@actions/artifact'
// Mock @actions/github before importing modules that use it import {run} from '../src/merge/merge-artifacts'
jest.unstable_mockModule('@actions/github', () => ({ import {Inputs} from '../src/merge/constants'
context: { import * as search from '../src/shared/search'
repo: {
owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
},
getOctokit: jest.fn()
}))
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(),
getBooleanInput: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
startGroup: jest.fn(),
endGroup: jest.fn(),
isDebug: jest.fn(() => false),
getState: jest.fn(),
saveState: jest.fn(),
exportVariable: jest.fn(),
addPath: jest.fn(),
group: jest.fn((name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}))
// Mock fs/promises
const actualFsPromises = await import('fs/promises')
jest.unstable_mockModule('fs/promises', () => ({
...actualFsPromises,
mkdtemp: jest
.fn<() => Promise<string>>()
.mockResolvedValue('/tmp/merge-artifact'),
rm: jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
}))
// Mock shared search module
const mockFindFilesToUpload =
jest.fn<() => Promise<{filesToUpload: string[]; rootDirectory: string}>>()
jest.unstable_mockModule('../src/shared/search.js', () => ({
findFilesToUpload: mockFindFilesToUpload
}))
// Dynamic imports after mocking
const core = await import('@actions/core')
const artifact = await import('@actions/artifact')
const {run} = await import('../src/merge/merge-artifacts.js')
const {Inputs} = await import('../src/merge/constants.js')
const fixtures = { const fixtures = {
artifactName: 'my-merged-artifact', artifactName: 'my-merged-artifact',
@@ -91,10 +34,27 @@ const fixtures = {
] ]
} }
const mockInputs = ( jest.mock('@actions/github', () => ({
overrides?: Partial<{[K in (typeof Inputs)[keyof typeof Inputs]]?: any}> context: {
) => { repo: {
const inputs: Record<string, any> = { owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
}
}))
jest.mock('@actions/core')
jest.mock('fs/promises', () => ({
mkdtemp: jest.fn().mockResolvedValue('/tmp/merge-artifact'),
rm: jest.fn().mockResolvedValue(undefined)
}))
/* eslint-disable no-unused-vars */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
[Inputs.Name]: 'my-merged-artifact', [Inputs.Name]: 'my-merged-artifact',
[Inputs.Pattern]: '*', [Inputs.Pattern]: '*',
[Inputs.SeparateDirectories]: false, [Inputs.SeparateDirectories]: false,
@@ -104,14 +64,10 @@ const mockInputs = (
...overrides ...overrides
} }
;(core.getInput as jest.Mock<typeof core.getInput>).mockImplementation( ;(core.getInput as jest.Mock).mockImplementation((name: string) => {
(name: string) => { return inputs[name]
return inputs[name] })
} ;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
)
;(
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
).mockImplementation((name: string) => {
return inputs[name] return inputs[name]
}) })
@@ -121,45 +77,44 @@ const mockInputs = (
describe('merge', () => { describe('merge', () => {
beforeEach(async () => { beforeEach(async () => {
mockInputs() mockInputs()
jest.clearAllMocks()
jest jest
.spyOn(artifact.default, 'listArtifacts') .spyOn(artifact, 'listArtifacts')
.mockResolvedValue({artifacts: fixtures.artifacts}) .mockResolvedValue({artifacts: fixtures.artifacts})
jest.spyOn(artifact.default, 'downloadArtifact').mockResolvedValue({ jest.spyOn(artifact, 'downloadArtifact').mockResolvedValue({
downloadPath: fixtures.tmpDirectory downloadPath: fixtures.tmpDirectory
}) })
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: fixtures.filesToUpload, filesToUpload: fixtures.filesToUpload,
rootDirectory: fixtures.tmpDirectory rootDirectory: fixtures.tmpDirectory
}) })
jest.spyOn(artifact.default, 'uploadArtifact').mockResolvedValue({ jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
size: 123, size: 123,
id: 1337 id: 1337
}) })
jest jest
.spyOn(artifact.default, 'deleteArtifact') .spyOn(artifact, 'deleteArtifact')
.mockImplementation(async (artifactName: string) => { .mockImplementation(async artifactName => {
const found = fixtures.artifacts.find(a => a.name === artifactName) const artifact = fixtures.artifacts.find(a => a.name === artifactName)
if (!found) throw new Error(`Artifact ${artifactName} not found`) if (!artifact) throw new Error(`Artifact ${artifactName} not found`)
return {id: found.id} return {id: artifact.id}
}) })
}) })
test('merges artifacts', async () => { it('merges artifacts', async () => {
await run() await run()
for (const a of fixtures.artifacts) { for (const a of fixtures.artifacts) {
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(a.id, { expect(artifact.downloadArtifact).toHaveBeenCalledWith(a.id, {
path: fixtures.tmpDirectory path: fixtures.tmpDirectory
}) })
} }
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.tmpDirectory, fixtures.tmpDirectory,
@@ -167,23 +122,23 @@ describe('merge', () => {
) )
}) })
test('fails if no artifacts found', async () => { it('fails if no artifacts found', async () => {
mockInputs({[Inputs.Pattern]: 'this-does-not-match'}) mockInputs({[Inputs.Pattern]: 'this-does-not-match'})
await expect(run()).rejects.toThrow() expect(run()).rejects.toThrow()
expect(artifact.default.uploadArtifact).not.toHaveBeenCalled() expect(artifact.uploadArtifact).not.toBeCalled()
expect(artifact.default.downloadArtifact).not.toHaveBeenCalled() expect(artifact.downloadArtifact).not.toBeCalled()
}) })
test('supports custom compression level', async () => { it('supports custom compression level', async () => {
mockInputs({ mockInputs({
[Inputs.CompressionLevel]: 2 [Inputs.CompressionLevel]: 2
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.tmpDirectory, fixtures.tmpDirectory,
@@ -191,14 +146,14 @@ describe('merge', () => {
) )
}) })
test('supports custom retention days', async () => { it('supports custom retention days', async () => {
mockInputs({ mockInputs({
[Inputs.RetentionDays]: 7 [Inputs.RetentionDays]: 7
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.tmpDirectory, fixtures.tmpDirectory,
@@ -206,7 +161,7 @@ describe('merge', () => {
) )
}) })
test('supports deleting artifacts after merge', async () => { it('supports deleting artifacts after merge', async () => {
mockInputs({ mockInputs({
[Inputs.DeleteMerged]: true [Inputs.DeleteMerged]: true
}) })
@@ -214,7 +169,7 @@ describe('merge', () => {
await run() await run()
for (const a of fixtures.artifacts) { for (const a of fixtures.artifacts) {
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith(a.name) expect(artifact.deleteArtifact).toHaveBeenCalledWith(a.name)
} }
}) })
}) })

View File

@@ -1,37 +1,8 @@
import {jest, describe, test, expect, beforeAll} from '@jest/globals' import * as core from '@actions/core'
import * as path from 'path' import * as path from 'path'
import * as io from '@actions/io' import * as io from '@actions/io'
import {promises as fs} from 'fs' import {promises as fs} from 'fs'
import {fileURLToPath} from 'url' import {findFilesToUpload} from '../src/shared/search'
// Mock @actions/core to suppress output during tests
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(),
getBooleanInput: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
startGroup: jest.fn(),
endGroup: jest.fn(),
isDebug: jest.fn(() => false),
getState: jest.fn(),
saveState: jest.fn(),
exportVariable: jest.fn(),
addPath: jest.fn(),
group: jest.fn((name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}))
const {findFilesToUpload} = await import('../src/shared/search.js')
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.join(__dirname, '_temp', 'search') const root = path.join(__dirname, '_temp', 'search')
const searchItem1Path = path.join( const searchItem1Path = path.join(
@@ -90,24 +61,19 @@ const lonelyFilePath = path.join(
'lonely-file.txt' 'lonely-file.txt'
) )
const hiddenFile = path.join(root, '.hidden-file.txt') const gitConfigPath = path.join(root, '.git', 'config')
const fileInHiddenFolderPath = path.join( const gitHeadPath = path.join(root, '.git', 'HEAD')
root,
'.hidden-folder', const nestedGitConfigPath = path.join(root, 'repository-name', '.git', 'config')
'folder-in-hidden-folder', const nestedGitHeadPath = path.join(root, 'repository-name', '.git', 'HEAD')
'file.txt'
)
const fileInHiddenFolderInFolderA = path.join(
root,
'folder-a',
'.hidden-folder-in-folder-a',
'file.txt'
)
describe('Search', () => { describe('Search', () => {
beforeAll(async () => { beforeAll(async () => {
// mock console.log to reduce noise // mock all output so that there is less noise when running tests
jest.spyOn(console, 'log').mockImplementation(() => {}) jest.spyOn(console, 'log').mockImplementation(() => {})
jest.spyOn(core, 'debug').mockImplementation(() => {})
jest.spyOn(core, 'info').mockImplementation(() => {})
jest.spyOn(core, 'warning').mockImplementation(() => {})
// clear temp directory // clear temp directory
await io.rmRF(root) await io.rmRF(root)
@@ -133,11 +99,8 @@ describe('Search', () => {
recursive: true recursive: true
}) })
await fs.mkdir( await fs.mkdir(path.join(root, '.git'))
path.join(root, '.hidden-folder', 'folder-in-hidden-folder'), await fs.mkdir(path.join(root, 'repository-name', '.git'), {
{recursive: true}
)
await fs.mkdir(path.join(root, 'folder-a', '.hidden-folder-in-folder-a'), {
recursive: true recursive: true
}) })
@@ -159,12 +122,48 @@ describe('Search', () => {
await fs.writeFile(lonelyFilePath, 'all by itself') await fs.writeFile(lonelyFilePath, 'all by itself')
await fs.writeFile(hiddenFile, 'hidden file') await fs.writeFile(gitConfigPath, 'git config file')
await fs.writeFile(fileInHiddenFolderPath, 'file in hidden directory') await fs.writeFile(gitHeadPath, 'git head file')
await fs.writeFile(fileInHiddenFolderInFolderA, 'file in hidden directory') await fs.writeFile(nestedGitConfigPath, 'nested git config file')
await fs.writeFile(nestedGitHeadPath, 'nested git head file')
/*
Directory structure of files that get created:
root/
.git/
config
HEAD
folder-a/
folder-b/
folder-c/
search-item1.txt
extraSearch-item1.txt
extra-file-in-folder-c.txt
folder-e/
folder-d/
search-item2.txt
search-item3.txt
search-item4.txt
extraSearch-item2.txt
folder-f/
extraSearch-item3.txt
folder-g/
folder-h/
amazing-item.txt
folder-i/
extraSearch-item4.txt
extraSearch-item5.txt
folder-j/
folder-k/
lonely-file.txt
repository-name/
.git/
config
HEAD
search-item5.txt
*/
}) })
test('Single file search - Absolute Path', async () => { it('Single file search - Absolute Path', async () => {
const searchResult = await findFilesToUpload(extraFileInFolderCPath) const searchResult = await findFilesToUpload(extraFileInFolderCPath)
expect(searchResult.filesToUpload.length).toEqual(1) expect(searchResult.filesToUpload.length).toEqual(1)
expect(searchResult.filesToUpload[0]).toEqual(extraFileInFolderCPath) expect(searchResult.filesToUpload[0]).toEqual(extraFileInFolderCPath)
@@ -173,7 +172,7 @@ describe('Search', () => {
) )
}) })
test('Single file search - Relative Path', async () => { it('Single file search - Relative Path', async () => {
const relativePath = path.join( const relativePath = path.join(
'__tests__', '__tests__',
'_temp', '_temp',
@@ -192,7 +191,7 @@ describe('Search', () => {
) )
}) })
test('Single file using wildcard', async () => { it('Single file using wildcard', async () => {
const expectedRoot = path.join(root, 'folder-h') const expectedRoot = path.join(root, 'folder-h')
const searchPath = path.join(root, 'folder-h', '**/*lonely*') const searchPath = path.join(root, 'folder-h', '**/*lonely*')
const searchResult = await findFilesToUpload(searchPath) const searchResult = await findFilesToUpload(searchPath)
@@ -201,7 +200,7 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(expectedRoot) expect(searchResult.rootDirectory).toEqual(expectedRoot)
}) })
test('Single file using directory', async () => { it('Single file using directory', async () => {
const searchPath = path.join(root, 'folder-h', 'folder-j') const searchPath = path.join(root, 'folder-h', 'folder-j')
const searchResult = await findFilesToUpload(searchPath) const searchResult = await findFilesToUpload(searchPath)
expect(searchResult.filesToUpload.length).toEqual(1) expect(searchResult.filesToUpload.length).toEqual(1)
@@ -209,7 +208,7 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(searchPath) expect(searchResult.rootDirectory).toEqual(searchPath)
}) })
test('Directory search - Absolute Path', async () => { it('Directory search - Absolute Path', async () => {
const searchPath = path.join(root, 'folder-h') const searchPath = path.join(root, 'folder-h')
const searchResult = await findFilesToUpload(searchPath) const searchResult = await findFilesToUpload(searchPath)
expect(searchResult.filesToUpload.length).toEqual(4) expect(searchResult.filesToUpload.length).toEqual(4)
@@ -228,7 +227,7 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(searchPath) expect(searchResult.rootDirectory).toEqual(searchPath)
}) })
test('Directory search - Relative Path', async () => { it('Directory search - Relative Path', async () => {
const searchPath = path.join('__tests__', '_temp', 'search', 'folder-h') const searchPath = path.join('__tests__', '_temp', 'search', 'folder-h')
const expectedRootDirectory = path.join(root, 'folder-h') const expectedRootDirectory = path.join(root, 'folder-h')
const searchResult = await findFilesToUpload(searchPath) const searchResult = await findFilesToUpload(searchPath)
@@ -248,7 +247,7 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(expectedRootDirectory) expect(searchResult.rootDirectory).toEqual(expectedRootDirectory)
}) })
test('Wildcard search - Absolute Path', async () => { it('Wildcard search - Absolute Path', async () => {
const searchPath = path.join(root, '**/*[Ss]earch*') const searchPath = path.join(root, '**/*[Ss]earch*')
const searchResult = await findFilesToUpload(searchPath) const searchResult = await findFilesToUpload(searchPath)
expect(searchResult.filesToUpload.length).toEqual(10) expect(searchResult.filesToUpload.length).toEqual(10)
@@ -277,7 +276,7 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(root) expect(searchResult.rootDirectory).toEqual(root)
}) })
test('Wildcard search - Relative Path', async () => { it('Wildcard search - Relative Path', async () => {
const searchPath = path.join( const searchPath = path.join(
'__tests__', '__tests__',
'_temp', '_temp',
@@ -311,11 +310,11 @@ describe('Search', () => {
expect(searchResult.rootDirectory).toEqual(root) expect(searchResult.rootDirectory).toEqual(root)
}) })
test('Multi path search - root directory', async () => { it('Multi path search - root directory', async () => {
const searchPath1 = path.join(root, 'folder-a') const searchPath1 = path.join(root, 'folder-a')
const searchPath2 = path.join(root, 'folder-d') const searchPath2 = path.join(root, 'folder-d')
const searchPaths = `${searchPath1}\n${searchPath2}` const searchPaths = searchPath1 + '\n' + searchPath2
const searchResult = await findFilesToUpload(searchPaths) const searchResult = await findFilesToUpload(searchPaths)
expect(searchResult.rootDirectory).toEqual(root) expect(searchResult.rootDirectory).toEqual(root)
@@ -335,13 +334,13 @@ describe('Search', () => {
) )
}) })
test('Multi path search - with exclude character', async () => { it('Multi path search - with exclude character', async () => {
const searchPath1 = path.join(root, 'folder-a') const searchPath1 = path.join(root, 'folder-a')
const searchPath2 = path.join(root, 'folder-d') const searchPath2 = path.join(root, 'folder-d')
const searchPath3 = path.join(root, 'folder-a', 'folder-b', '**/extra*.txt') const searchPath3 = path.join(root, 'folder-a', 'folder-b', '**/extra*.txt')
// negating the third search path // negating the third search path
const searchPaths = `${searchPath1}\n${searchPath2}\n!${searchPath3}` const searchPaths = searchPath1 + '\n' + searchPath2 + '\n!' + searchPath3
const searchResult = await findFilesToUpload(searchPaths) const searchResult = await findFilesToUpload(searchPaths)
expect(searchResult.rootDirectory).toEqual(root) expect(searchResult.rootDirectory).toEqual(root)
@@ -355,7 +354,7 @@ describe('Search', () => {
) )
}) })
test('Multi path search - non root directory', async () => { it('Multi path search - non root directory', async () => {
const searchPath1 = path.join(root, 'folder-h', 'folder-i') const searchPath1 = path.join(root, 'folder-h', 'folder-i')
const searchPath2 = path.join(root, 'folder-h', 'folder-j', 'folder-k') const searchPath2 = path.join(root, 'folder-h', 'folder-j', 'folder-k')
const searchPath3 = amazingFileInFolderHPath const searchPath3 = amazingFileInFolderHPath
@@ -377,23 +376,17 @@ describe('Search', () => {
expect(searchResult.filesToUpload.includes(lonelyFilePath)).toEqual(true) expect(searchResult.filesToUpload.includes(lonelyFilePath)).toEqual(true)
}) })
test('Hidden files ignored by default', async () => { it('Excludes .git directory by default', async () => {
const searchPath = path.join(root, '**/*') const searchResult = await findFilesToUpload(root)
const searchResult = await findFilesToUpload(searchPath) expect(searchResult.filesToUpload.length).toEqual(13)
expect(searchResult.filesToUpload).not.toContain(gitConfigPath)
expect(searchResult.filesToUpload).not.toContain(hiddenFile)
expect(searchResult.filesToUpload).not.toContain(fileInHiddenFolderPath)
expect(searchResult.filesToUpload).not.toContain(
fileInHiddenFolderInFolderA
)
}) })
test('Hidden files included', async () => { it('Includes .git directory when includeGitDirectory is true', async () => {
const searchPath = path.join(root, '**/*') const searchResult = await findFilesToUpload(root, {
const searchResult = await findFilesToUpload(searchPath, true) includeGitDirectory: true
})
expect(searchResult.filesToUpload).toContain(hiddenFile) expect(searchResult.filesToUpload.length).toEqual(17)
expect(searchResult.filesToUpload).toContain(fileInHiddenFolderPath) expect(searchResult.filesToUpload).toContain(gitConfigPath)
expect(searchResult.filesToUpload).toContain(fileInHiddenFolderInFolderA)
}) })
}) })

View File

@@ -1,57 +1,9 @@
import {jest, describe, test, expect, beforeEach} from '@jest/globals' import * as core from '@actions/core'
import * as github from '@actions/github'
// Mock @actions/github before importing modules that use it import artifact, {ArtifactNotFoundError} from '@actions/artifact'
jest.unstable_mockModule('@actions/github', () => ({ import {run} from '../src/upload/upload-artifact'
context: { import {Inputs} from '../src/upload/constants'
repo: { import * as search from '../src/shared/search'
owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
},
getOctokit: jest.fn()
}))
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(),
getBooleanInput: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
startGroup: jest.fn(),
endGroup: jest.fn(),
isDebug: jest.fn(() => false),
getState: jest.fn(),
saveState: jest.fn(),
exportVariable: jest.fn(),
addPath: jest.fn(),
group: jest.fn((name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}))
// Mock shared search module
const mockFindFilesToUpload =
jest.fn<() => Promise<{filesToUpload: string[]; rootDirectory: string}>>()
jest.unstable_mockModule('../src/shared/search.js', () => ({
findFilesToUpload: mockFindFilesToUpload
}))
// Dynamic imports after mocking
const core = await import('@actions/core')
const github = await import('@actions/github')
const artifact = await import('@actions/artifact')
const {run} = await import('../src/upload/upload-artifact.js')
const {Inputs} = await import('../src/upload/constants.js')
const {ArtifactNotFoundError} = artifact
const fixtures = { const fixtures = {
artifactName: 'artifact-name', artifactName: 'artifact-name',
@@ -62,28 +14,35 @@ const fixtures = {
] ]
} }
const mockInputs = ( jest.mock('@actions/github', () => ({
overrides?: Partial<{[K in (typeof Inputs)[keyof typeof Inputs]]?: any}> context: {
) => { repo: {
const inputs: Record<string, any> = { owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
}
}))
jest.mock('@actions/core')
/* eslint-disable no-unused-vars */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
[Inputs.Name]: 'artifact-name', [Inputs.Name]: 'artifact-name',
[Inputs.Path]: '/some/artifact/path', [Inputs.Path]: '/some/artifact/path',
[Inputs.IfNoFilesFound]: 'warn', [Inputs.IfNoFilesFound]: 'warn',
[Inputs.RetentionDays]: 0, [Inputs.RetentionDays]: 0,
[Inputs.CompressionLevel]: 6, [Inputs.CompressionLevel]: 6,
[Inputs.Overwrite]: false, [Inputs.Overwrite]: false,
[Inputs.Archive]: true,
...overrides ...overrides
} }
;(core.getInput as jest.Mock<typeof core.getInput>).mockImplementation( ;(core.getInput as jest.Mock).mockImplementation((name: string) => {
(name: string) => { return inputs[name]
return inputs[name] })
} ;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
)
;(
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
).mockImplementation((name: string) => {
return inputs[name] return inputs[name]
}) })
@@ -93,29 +52,27 @@ const mockInputs = (
describe('upload', () => { describe('upload', () => {
beforeEach(async () => { beforeEach(async () => {
mockInputs() mockInputs()
jest.clearAllMocks()
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: fixtures.filesToUpload, filesToUpload: fixtures.filesToUpload,
rootDirectory: fixtures.rootDirectory rootDirectory: fixtures.rootDirectory
}) })
jest.spyOn(artifact.default, 'uploadArtifact').mockResolvedValue({ jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
size: 123, size: 123,
id: 1337, id: 1337
digest: 'facefeed'
}) })
}) })
test('uploads a single file', async () => { it('uploads a single file', async () => {
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]], filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory rootDirectory: fixtures.rootDirectory
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
[fixtures.filesToUpload[0]], [fixtures.filesToUpload[0]],
fixtures.rootDirectory, fixtures.rootDirectory,
@@ -123,10 +80,10 @@ describe('upload', () => {
) )
}) })
test('uploads multiple files', async () => { it('uploads multiple files', async () => {
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.rootDirectory, fixtures.rootDirectory,
@@ -134,25 +91,26 @@ describe('upload', () => {
) )
}) })
test('sets outputs', async () => { it('sets outputs', async () => {
await run() await run()
expect(core.setOutput).toHaveBeenCalledWith('artifact-id', 1337) expect(core.setOutput).toHaveBeenCalledWith('artifact-id', 1337)
expect(core.setOutput).toHaveBeenCalledWith('artifact-digest', 'facefeed')
expect(core.setOutput).toHaveBeenCalledWith( expect(core.setOutput).toHaveBeenCalledWith(
'artifact-url', 'artifact-url',
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}/artifacts/${1337}` `${github.context.serverUrl}/${github.context.repo.owner}/${
github.context.repo.repo
}/actions/runs/${github.context.runId}/artifacts/${1337}`
) )
}) })
test('supports custom compression level', async () => { it('supports custom compression level', async () => {
mockInputs({ mockInputs({
[Inputs.CompressionLevel]: 2 [Inputs.CompressionLevel]: 2
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.rootDirectory, fixtures.rootDirectory,
@@ -160,14 +118,14 @@ describe('upload', () => {
) )
}) })
test('supports custom retention days', async () => { it('supports custom retention days', async () => {
mockInputs({ mockInputs({
[Inputs.RetentionDays]: 7 [Inputs.RetentionDays]: 7
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.rootDirectory, fixtures.rootDirectory,
@@ -175,12 +133,12 @@ describe('upload', () => {
) )
}) })
test('supports warn if-no-files-found', async () => { it('supports warn if-no-files-found', async () => {
mockInputs({ mockInputs({
[Inputs.IfNoFilesFound]: 'warn' [Inputs.IfNoFilesFound]: 'warn'
}) })
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [], filesToUpload: [],
rootDirectory: fixtures.rootDirectory rootDirectory: fixtures.rootDirectory
}) })
@@ -192,12 +150,12 @@ describe('upload', () => {
) )
}) })
test('supports error if-no-files-found', async () => { it('supports error if-no-files-found', async () => {
mockInputs({ mockInputs({
[Inputs.IfNoFilesFound]: 'error' [Inputs.IfNoFilesFound]: 'error'
}) })
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [], filesToUpload: [],
rootDirectory: fixtures.rootDirectory rootDirectory: fixtures.rootDirectory
}) })
@@ -209,12 +167,12 @@ describe('upload', () => {
) )
}) })
test('supports ignore if-no-files-found', async () => { it('supports ignore if-no-files-found', async () => {
mockInputs({ mockInputs({
[Inputs.IfNoFilesFound]: 'ignore' [Inputs.IfNoFilesFound]: 'ignore'
}) })
mockFindFilesToUpload.mockResolvedValue({ jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: [], filesToUpload: [],
rootDirectory: fixtures.rootDirectory rootDirectory: fixtures.rootDirectory
}) })
@@ -226,105 +184,48 @@ describe('upload', () => {
) )
}) })
test('supports overwrite', async () => { it('supports overwrite', async () => {
mockInputs({ mockInputs({
[Inputs.Overwrite]: true [Inputs.Overwrite]: true
}) })
jest.spyOn(artifact.default, 'deleteArtifact').mockResolvedValue({ jest.spyOn(artifact, 'deleteArtifact').mockResolvedValue({
id: 1337 id: 1337
}) })
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.rootDirectory, fixtures.rootDirectory,
{compressionLevel: 6} {compressionLevel: 6}
) )
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith( expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName)
fixtures.artifactName
)
}) })
test('supports overwrite and continues if not found', async () => { it('supports overwrite and continues if not found', async () => {
mockInputs({ mockInputs({
[Inputs.Overwrite]: true [Inputs.Overwrite]: true
}) })
jest jest
.spyOn(artifact.default, 'deleteArtifact') .spyOn(artifact, 'deleteArtifact')
.mockRejectedValue(new ArtifactNotFoundError('not found')) .mockRejectedValue(new ArtifactNotFoundError('not found'))
await run() await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith( expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName, fixtures.artifactName,
fixtures.filesToUpload, fixtures.filesToUpload,
fixtures.rootDirectory, fixtures.rootDirectory,
{compressionLevel: 6} {compressionLevel: 6}
) )
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith( expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName)
fixtures.artifactName
)
expect(core.debug).toHaveBeenCalledWith( expect(core.debug).toHaveBeenCalledWith(
`Skipping deletion of '${fixtures.artifactName}', it does not exist` `Skipping deletion of '${fixtures.artifactName}', it does not exist`
) )
}) })
test('passes skipArchive when archive is false', async () => {
mockInputs({
[Inputs.Archive]: false
})
mockFindFilesToUpload.mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
[fixtures.filesToUpload[0]],
fixtures.rootDirectory,
{compressionLevel: 6, skipArchive: true}
)
})
test('does not pass skipArchive when archive is true', async () => {
mockInputs({
[Inputs.Archive]: true
})
mockFindFilesToUpload.mockResolvedValue({
filesToUpload: [fixtures.filesToUpload[0]],
rootDirectory: fixtures.rootDirectory
})
await run()
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
[fixtures.filesToUpload[0]],
fixtures.rootDirectory,
{compressionLevel: 6}
)
})
test('fails when archive is false and multiple files are provided', async () => {
mockInputs({
[Inputs.Archive]: false
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
`When 'archive' is set to false, only a single file can be uploaded. Found ${fixtures.filesToUpload.length} files to upload.`
)
expect(artifact.default.uploadArtifact).not.toHaveBeenCalled()
})
}) })

View File

@@ -3,10 +3,10 @@ description: 'Upload a build artifact that can be used by subsequent workflow st
author: 'GitHub' author: 'GitHub'
inputs: inputs:
name: name:
description: 'Artifact name. If the `archive` input is `false`, the name of the file uploaded will be the artifact name.' description: 'Artifact name'
default: 'artifact' default: 'artifact'
path: path:
description: 'A file, directory or wildcard pattern that describes what to upload.' description: 'A file, directory or wildcard pattern that describes what to upload'
required: true required: true
if-no-files-found: if-no-files-found:
description: > description: >
@@ -40,17 +40,9 @@ inputs:
If false, the action will fail if an artifact for the given name already exists. If false, the action will fail if an artifact for the given name already exists.
Does not fail if the artifact does not exist. Does not fail if the artifact does not exist.
default: 'false' default: 'false'
include-hidden-files: include-git-directory:
description: > description: 'Include files in the .git directory in the artifact.'
If true, hidden files will be included in the artifact.
If false, hidden files will be excluded from the artifact.
default: 'false' default: 'false'
archive:
description: >
If true, the artifact will be archived (zipped) before uploading.
If false, the artifact will be uploaded as-is without archiving.
When `archive` is `false`, only a single file can be uploaded. The name of the file will be used as the artifact name (ignoring the `name` parameter).
default: 'true'
outputs: outputs:
artifact-id: artifact-id:
@@ -67,9 +59,6 @@ outputs:
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work. This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues. Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
artifact-digest:
description: >
SHA-256 digest for the artifact that was just uploaded. Empty if the artifact upload failed.
runs: runs:
using: 'node24' using: 'node20'
main: 'dist/upload/index.js' main: 'dist/upload/index.js'

153718
dist/merge/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

151340
dist/upload/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -4,7 +4,6 @@
- [Multiple uploads to the same named Artifact](#multiple-uploads-to-the-same-named-artifact) - [Multiple uploads to the same named Artifact](#multiple-uploads-to-the-same-named-artifact)
- [Overwriting an Artifact](#overwriting-an-artifact) - [Overwriting an Artifact](#overwriting-an-artifact)
- [Merging multiple artifacts](#merging-multiple-artifacts) - [Merging multiple artifacts](#merging-multiple-artifacts)
- [Hidden files](#hidden-files)
Several behavioral differences exist between Artifact actions `v3` and below vs `v4`. This document outlines common scenarios in `v3`, and how they would be handled in `v4`. Several behavioral differences exist between Artifact actions `v3` and below vs `v4`. This document outlines common scenarios in `v3`, and how they would be handled in `v4`.
@@ -190,45 +189,44 @@ jobs:
- name: Create a File - name: Create a File
run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt
- name: Upload Artifact - name: Upload Artifact
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4
with: with:
- name: all-my-files - name: all-my-files
+ name: my-artifact-${{ matrix.runs-on }} + name: my-artifact-${{ matrix.runs-on }}
path: file-${{ matrix.runs-on }}.txt path: file-${{ matrix.runs-on }}.txt
+ merge: + merge:
+ runs-on: ubuntu-latest + runs-on: ubuntu-latest
+ needs: upload + needs: upload
+ steps: + steps:
+ - name: Merge Artifacts + - name: Merge Artifacts
+ uses: actions/upload-artifact/merge@v4 + uses: actions/upload-artifact/merge@v4
+ with: + with:
+ name: all-my-files + name: all-my-files
+ pattern: my-artifact-* + pattern: my-artifact-*
``` ```
Note that this will download all artifacts to a temporary directory and reupload them as a single artifact. For more information on inputs and other use cases for `actions/upload-artifact/merge@v4`, see [the action documentation](../merge/README.md). Note that this will download all artifacts to a temporary directory and reupload them as a single artifact. For more information on inputs and other use cases for `actions/upload-artifact/merge@v4`, see [the action documentation](../merge/README.md).
## Hidden Files ## `.git` Directory
By default, hidden files are ignored by this action to avoid unintentionally uploading sensitive By default, files in the `.git` directory are ignored to avoid unintentionally uploading
information. credentials.
In versions of this action before v4.4.0, these hidden files were included by default. In versions of this action before `v4.4.0`, files in the `.git` directory were included by default.
If this directory is required, ensure credentials are not saved in `.git/config` and then
If you need to upload hidden files, you can use the `include-hidden-files` input. enable the `include-git-directory` input.
```yaml ```yaml
jobs: jobs:
upload: upload:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Create a Hidden File - uses: actions/checkout@v4
run: echo "hello from a hidden file" > .hidden-file.txt
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
path: .hidden-file.txt path: .
``` ```
@@ -237,12 +235,13 @@ jobs:
upload: upload:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Create a Hidden File - uses: actions/checkout@v4
run: echo "hello from a hidden file" > .hidden-file.txt + with:
+ persist-credentials: false
- name: Upload Artifact - name: Upload Artifact
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4
with: with:
path: .hidden-file.txt path: .
+ include-hidden-files: true + include-git-directory: true
``` ```

View File

@@ -1,52 +0,0 @@
import github from 'eslint-plugin-github'
import jest from 'eslint-plugin-jest'
import prettier from 'eslint-plugin-prettier/recommended'
const githubConfigs = github.getFlatConfigs()
export default [
{
ignores: ['**/node_modules/**', '**/lib/**', '**/dist/**']
},
githubConfigs.recommended,
...githubConfigs.typescript,
prettier,
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json'
}
},
rules: {
'prettier/prettier': ['error', {endOfLine: 'auto'}],
'eslint-comments/no-use': 'off',
'github/no-then': 'off',
'github/filenames-match-regex': 'off',
'github/array-foreach': 'off',
'import/no-namespace': 'off',
'import/named': 'off',
'import/no-unresolved': 'off',
'i18n-text/no-en': 'off',
'filenames/match-regex': 'off',
'no-shadow': 'off',
'no-unused-vars': 'off',
'no-undef': 'off',
camelcase: 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/no-require-imports': 'off'
}
},
{
files: ['**/__tests__/**/*.ts'],
...jest.configs['flat/recommended'],
rules: {
...jest.configs['flat/recommended'].rules,
'jest/expect-expect': 'off',
'jest/no-conditional-expect': 'off'
}
}
]

12
jest.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
roots: ['<rootDir>'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

View File

@@ -1,24 +0,0 @@
export default {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
roots: ['<rootDir>'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
diagnostics: {
ignoreCodes: [151002]
}
}
]
},
extensionsToTreatAsEsm: ['.ts'],
transformIgnorePatterns: ['node_modules/(?!(@actions)/)'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
verbose: true
}

View File

@@ -5,6 +5,7 @@ Merge multiple [Actions Artifacts](https://docs.github.com/en/actions/using-work
- [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge) - [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge)
- [Usage](#usage) - [Usage](#usage)
- [Inputs](#inputs) - [Inputs](#inputs)
- [Uploading the `.git` directory](#uploading-the-git-directory)
- [Outputs](#outputs) - [Outputs](#outputs)
- [Examples](#examples) - [Examples](#examples)
- [Combining all artifacts in a workflow run](#combining-all-artifacts-in-a-workflow-run) - [Combining all artifacts in a workflow run](#combining-all-artifacts-in-a-workflow-run)
@@ -59,13 +60,50 @@ For most cases, this may not be the most efficient solution. See [the migration
compression-level: compression-level:
``` ```
#### Uploading the `.git` directory
By default, files in a `.git` directory are ignored in the merged artifact.
This is intended to prevent accidentally uploading Git credentials into an artifact that could then
be extracted.
If files in the `.git` directory are needed, ensure that `actions/checkout` is being used with
`persist-credentials: false`.
```yaml
jobs:
upload:
runs-on: ubuntu-latest
strategy:
matrix:
foo: [a, b, c]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false # Ensure credentials are not saved in `.git/config`
- name: Upload
uses: actions/upload-artifact@v4
with:
name: my-artifact-${{ matrix.foo }}
path: .
include-git-directory: true
merge:
runs-on: ubuntu-latest
steps:
- uses: actions/upload-artifact/merge@v4
with:
include-git-directory: true
```
### Outputs ### Outputs
| Name | Description | Example | | Name | Description | Example |
| - | - | - | | - | - | - |
| `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` | | `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` |
| `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` | | `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` |
| `artifact-digest` | SHA-256 digest of an Artifact | 0fde654d4c6e659b45783a725dc92f1bfb0baa6c2de64b34e814dc206ff4aaaf |
## Examples ## Examples

View File

@@ -36,10 +36,8 @@ inputs:
If true, the artifacts that were merged will be deleted. If true, the artifacts that were merged will be deleted.
If false, the artifacts will still exist. If false, the artifacts will still exist.
default: 'false' default: 'false'
include-hidden-files: include-git-directory:
description: > description: 'Include files in the .git directory in the merged artifact.'
If true, hidden files will be included in the merged artifact.
If false, hidden files will be excluded from the merged artifact.
default: 'false' default: 'false'
outputs: outputs:
@@ -57,9 +55,6 @@ outputs:
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work. This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues. Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
artifact-digest:
description: >
SHA-256 digest for the artifact that was just uploaded. Empty if the artifact upload failed.
runs: runs:
using: 'node24' using: 'node20'
main: '../dist/merge/index.js' main: '../dist/merge/index.js'

14905
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
{ {
"name": "upload-artifact", "name": "upload-artifact",
"version": "7.0.0", "version": "4.4.0",
"description": "Upload an Actions Artifact in a workflow run", "description": "Upload an Actions Artifact in a workflow run",
"type": "module",
"main": "dist/upload/index.js", "main": "dist/upload/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
@@ -11,7 +10,7 @@
"format": "prettier --write **/*.ts", "format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts", "format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts", "lint": "eslint **/*.ts",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout 10000" "test": "jest --testTimeout 10000"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -29,35 +28,28 @@
"url": "https://github.com/actions/upload-artifact/issues" "url": "https://github.com/actions/upload-artifact/issues"
}, },
"homepage": "https://github.com/actions/upload-artifact#readme", "homepage": "https://github.com/actions/upload-artifact#readme",
"engines": {
"node": ">=24"
},
"dependencies": { "dependencies": {
"@actions/artifact": "^6.2.0", "@actions/artifact": "2.1.8",
"@actions/core": "^3.0.0", "@actions/core": "^1.10.1",
"@actions/github": "^9.0.0", "@actions/github": "^6.0.0",
"@actions/glob": "^0.6.1", "@actions/glob": "^0.3.0",
"@actions/io": "^3.0.2", "@actions/io": "^1.1.2",
"minimatch": "^10.1.1" "minimatch": "^9.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^29.2.5",
"@types/node": "^25.1.0", "@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^5.48.0",
"@typescript-eslint/parser": "^8.54.0", "@vercel/ncc": "^0.36.0",
"@vercel/ncc": "^0.38.4", "concurrently": "^7.6.0",
"concurrently": "^9.2.1", "eslint": "^8.31.0",
"eslint": "^9.39.2", "eslint-plugin-github": "^4.6.0",
"eslint-plugin-github": "^6.0.0", "eslint-plugin-jest": "^27.2.0",
"eslint-plugin-jest": "^29.12.1", "glob": "^8.0.3",
"eslint-plugin-prettier": "^5.5.5", "jest": "^29.3.1",
"jest": "^30.2.0", "jest-circus": "^29.3.1",
"prettier": "^3.8.1", "prettier": "^2.8.1",
"ts-jest": "^29.2.6", "ts-jest": "^29.0.3",
"ts-node": "^10.9.2", "typescript": "^4.9.4"
"typescript": "^5.3.3"
},
"overrides": {
"uri-js": "npm:uri-js-replace@^1.0.1"
} }
} }

View File

@@ -6,5 +6,5 @@ export enum Inputs {
RetentionDays = 'retention-days', RetentionDays = 'retention-days',
CompressionLevel = 'compression-level', CompressionLevel = 'compression-level',
DeleteMerged = 'delete-merged', DeleteMerged = 'delete-merged',
IncludeHiddenFiles = 'include-hidden-files' IncludeGitDirectory = 'include-git-directory'
} }

View File

@@ -1,5 +1,5 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {run} from './merge-artifacts.js' import {run} from './merge-artifacts'
run().catch(error => { run().catch(error => {
core.setFailed((error as Error).message) core.setFailed((error as Error).message)

View File

@@ -1,6 +1,6 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {Inputs} from './constants.js' import {Inputs} from './constants'
import {MergeInputs} from './merge-inputs.js' import {MergeInputs} from './merge-inputs'
/** /**
* Helper to get all the inputs for the action * Helper to get all the inputs for the action
@@ -10,7 +10,7 @@ export function getInputs(): MergeInputs {
const pattern = core.getInput(Inputs.Pattern, {required: true}) const pattern = core.getInput(Inputs.Pattern, {required: true})
const separateDirectories = core.getBooleanInput(Inputs.SeparateDirectories) const separateDirectories = core.getBooleanInput(Inputs.SeparateDirectories)
const deleteMerged = core.getBooleanInput(Inputs.DeleteMerged) const deleteMerged = core.getBooleanInput(Inputs.DeleteMerged)
const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles) const includeGitDirectory = core.getBooleanInput(Inputs.IncludeGitDirectory)
const inputs = { const inputs = {
name, name,
@@ -19,7 +19,7 @@ export function getInputs(): MergeInputs {
deleteMerged, deleteMerged,
retentionDays: 0, retentionDays: 0,
compressionLevel: 6, compressionLevel: 6,
includeHiddenFiles includeGitDirectory
} as MergeInputs } as MergeInputs
const retentionDaysStr = core.getInput(Inputs.RetentionDays) const retentionDaysStr = core.getInput(Inputs.RetentionDays)

View File

@@ -3,9 +3,9 @@ import {mkdtemp, rm} from 'fs/promises'
import * as core from '@actions/core' import * as core from '@actions/core'
import {Minimatch} from 'minimatch' import {Minimatch} from 'minimatch'
import artifactClient, {UploadArtifactOptions} from '@actions/artifact' import artifactClient, {UploadArtifactOptions} from '@actions/artifact'
import {getInputs} from './input-helper.js' import {getInputs} from './input-helper'
import {uploadArtifact} from '../shared/upload-artifact.js' import {uploadArtifact} from '../shared/upload-artifact'
import {findFilesToUpload} from '../shared/search.js' import {findFilesToUpload} from '../shared/search'
const PARALLEL_DOWNLOADS = 5 const PARALLEL_DOWNLOADS = 5
@@ -62,10 +62,9 @@ export async function run(): Promise<void> {
options.compressionLevel = inputs.compressionLevel options.compressionLevel = inputs.compressionLevel
} }
const searchResult = await findFilesToUpload( const searchResult = await findFilesToUpload(tmpDir, {
tmpDir, includeGitDirectory: inputs.includeGitDirectory
inputs.includeHiddenFiles })
)
await uploadArtifact( await uploadArtifact(
inputs.name, inputs.name,

View File

@@ -32,7 +32,7 @@ export interface MergeInputs {
separateDirectories: boolean separateDirectories: boolean
/** /**
* Whether or not to include hidden files in the artifact * Include files in the `.git` directory in the artifact
*/ */
includeHiddenFiles: boolean includeGitDirectory: boolean
} }

View File

@@ -11,12 +11,11 @@ export interface SearchResult {
rootDirectory: string rootDirectory: string
} }
function getDefaultGlobOptions(includeHiddenFiles: boolean): glob.GlobOptions { function getDefaultGlobOptions(): glob.GlobOptions {
return { return {
followSymbolicLinks: true, followSymbolicLinks: true,
implicitDescendants: true, implicitDescendants: true,
omitBrokenSymbolicLinks: true, omitBrokenSymbolicLinks: true
excludeHiddenFiles: !includeHiddenFiles
} }
} }
@@ -79,15 +78,21 @@ function getMultiPathLCA(searchPaths: string[]): string {
return path.join(...commonPaths) return path.join(...commonPaths)
} }
export interface SearchOptions {
/**
* Indicates whether files in the .git directory should be included in the artifact
*
* @default false
*/
includeGitDirectory: boolean
}
export async function findFilesToUpload( export async function findFilesToUpload(
searchPath: string, searchPath: string,
includeHiddenFiles?: boolean searchOptions?: SearchOptions
): Promise<SearchResult> { ): Promise<SearchResult> {
const searchResults: string[] = [] const searchResults: string[] = []
const globber = await glob.create( const globber = await glob.create(searchPath, getDefaultGlobOptions())
searchPath,
getDefaultGlobOptions(includeHiddenFiles || false)
)
const rawSearchResults: string[] = await globber.glob() const rawSearchResults: string[] = await globber.glob()
/* /*
@@ -105,6 +110,12 @@ export async function findFilesToUpload(
// isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead // isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead
if (!fileStats.isDirectory()) { if (!fileStats.isDirectory()) {
debug(`File:${searchResult} was found using the provided searchPath`) debug(`File:${searchResult} was found using the provided searchPath`)
if (!searchOptions?.includeGitDirectory && inGitDirectory(searchResult)) {
debug(`Ignoring ${searchResult} because it is in the .git directory`)
continue
}
searchResults.push(searchResult) searchResults.push(searchResult)
// detect any files that would be overwritten because of case insensitivity // detect any files that would be overwritten because of case insensitivity
@@ -156,3 +167,15 @@ export async function findFilesToUpload(
rootDirectory: searchPaths[0] rootDirectory: searchPaths[0]
} }
} }
function inGitDirectory(filePath: string): boolean {
// The .git directory is a directory, so we need to check if the file path is a directory
// and if it is a .git directory
for (const part of filePath.split(path.sep)) {
if (part === '.git') {
return true
}
}
return false
}

View File

@@ -19,7 +19,6 @@ export async function uploadArtifact(
`Artifact ${artifactName} has been successfully uploaded! Final size is ${uploadResponse.size} bytes. Artifact ID is ${uploadResponse.id}` `Artifact ${artifactName} has been successfully uploaded! Final size is ${uploadResponse.size} bytes. Artifact ID is ${uploadResponse.id}`
) )
core.setOutput('artifact-id', uploadResponse.id) core.setOutput('artifact-id', uploadResponse.id)
core.setOutput('artifact-digest', uploadResponse.digest)
const repository = github.context.repo const repository = github.context.repo
const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}` const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}`

View File

@@ -6,8 +6,7 @@ export enum Inputs {
RetentionDays = 'retention-days', RetentionDays = 'retention-days',
CompressionLevel = 'compression-level', CompressionLevel = 'compression-level',
Overwrite = 'overwrite', Overwrite = 'overwrite',
IncludeHiddenFiles = 'include-hidden-files', IncludeGitDirectory = 'include-git-directory'
Archive = 'archive'
} }
export enum NoFileOptions { export enum NoFileOptions {

View File

@@ -1,5 +1,5 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {run} from './upload-artifact.js' import {run} from './upload-artifact'
run().catch(error => { run().catch(error => {
core.setFailed((error as Error).message) core.setFailed((error as Error).message)

View File

@@ -1,6 +1,6 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {Inputs, NoFileOptions} from './constants.js' import {Inputs, NoFileOptions} from './constants'
import {UploadInputs} from './upload-inputs.js' import {UploadInputs} from './upload-inputs'
/** /**
* Helper to get all the inputs for the action * Helper to get all the inputs for the action
@@ -9,8 +9,7 @@ export function getInputs(): UploadInputs {
const name = core.getInput(Inputs.Name) const name = core.getInput(Inputs.Name)
const path = core.getInput(Inputs.Path, {required: true}) const path = core.getInput(Inputs.Path, {required: true})
const overwrite = core.getBooleanInput(Inputs.Overwrite) const overwrite = core.getBooleanInput(Inputs.Overwrite)
const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles) const includeGitDirectory = core.getBooleanInput(Inputs.IncludeGitDirectory)
const archive = core.getBooleanInput(Inputs.Archive)
const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound) const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound)
const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound] const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound]
@@ -30,8 +29,7 @@ export function getInputs(): UploadInputs {
searchPath: path, searchPath: path,
ifNoFilesFound: noFileBehavior, ifNoFilesFound: noFileBehavior,
overwrite: overwrite, overwrite: overwrite,
includeHiddenFiles: includeHiddenFiles, includeGitDirectory: includeGitDirectory
archive: archive
} as UploadInputs } as UploadInputs
const retentionDaysStr = core.getInput(Inputs.RetentionDays) const retentionDaysStr = core.getInput(Inputs.RetentionDays)

View File

@@ -3,10 +3,10 @@ import artifact, {
UploadArtifactOptions, UploadArtifactOptions,
ArtifactNotFoundError ArtifactNotFoundError
} from '@actions/artifact' } from '@actions/artifact'
import {findFilesToUpload} from '../shared/search.js' import {findFilesToUpload} from '../shared/search'
import {getInputs} from './input-helper.js' import {getInputs} from './input-helper'
import {NoFileOptions} from './constants.js' import {NoFileOptions} from './constants'
import {uploadArtifact} from '../shared/upload-artifact.js' import {uploadArtifact} from '../shared/upload-artifact'
async function deleteArtifactIfExists(artifactName: string): Promise<void> { async function deleteArtifactIfExists(artifactName: string): Promise<void> {
try { try {
@@ -24,10 +24,9 @@ async function deleteArtifactIfExists(artifactName: string): Promise<void> {
export async function run(): Promise<void> { export async function run(): Promise<void> {
const inputs = getInputs() const inputs = getInputs()
const searchResult = await findFilesToUpload( const searchResult = await findFilesToUpload(inputs.searchPath, {
inputs.searchPath, includeGitDirectory: inputs.includeGitDirectory
inputs.includeHiddenFiles })
)
if (searchResult.filesToUpload.length === 0) { if (searchResult.filesToUpload.length === 0) {
// No files were found, different use cases warrant different types of behavior if nothing is found // No files were found, different use cases warrant different types of behavior if nothing is found
switch (inputs.ifNoFilesFound) { switch (inputs.ifNoFilesFound) {
@@ -57,14 +56,6 @@ export async function run(): Promise<void> {
) )
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`) core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
// Validate that only a single file is uploaded when archive is false
if (!inputs.archive && searchResult.filesToUpload.length > 1) {
core.setFailed(
`When 'archive' is set to false, only a single file can be uploaded. Found ${searchResult.filesToUpload.length} files to upload.`
)
return
}
if (inputs.overwrite) { if (inputs.overwrite) {
await deleteArtifactIfExists(inputs.artifactName) await deleteArtifactIfExists(inputs.artifactName)
} }
@@ -78,10 +69,6 @@ export async function run(): Promise<void> {
options.compressionLevel = inputs.compressionLevel options.compressionLevel = inputs.compressionLevel
} }
if (!inputs.archive) {
options.skipArchive = true
}
await uploadArtifact( await uploadArtifact(
inputs.artifactName, inputs.artifactName,
searchResult.filesToUpload, searchResult.filesToUpload,

View File

@@ -1,4 +1,4 @@
import {NoFileOptions} from './constants.js' import {NoFileOptions} from './constants'
export interface UploadInputs { export interface UploadInputs {
/** /**
@@ -32,13 +32,7 @@ export interface UploadInputs {
overwrite: boolean overwrite: boolean
/** /**
* Whether or not to include hidden files in the artifact * Include files in the `.git` directory in the artifact
*/ */
includeHiddenFiles: boolean includeGitDirectory: boolean
/**
* Whether or not to archive (zip) the artifact before uploading.
* When false, only a single file can be uploaded.
*/
archive: boolean
} }

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src/**/*.ts", "__tests__/**/*.ts", "*.ts"],
"exclude": ["node_modules", "lib", "dist"]
}

View File

@@ -1,13 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "es6",
"module": "NodeNext", "module": "commonjs",
"outDir": "./lib", "outDir": "./lib",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,
"noImplicitAny": false, "noImplicitAny": false,
"moduleResolution": "NodeNext", "moduleResolution": "node",
"esModuleInterop": true "allowSyntheticDefaultImports": true,
}, "esModuleInterop": true,
"exclude": ["node_modules", "**/*.test.ts", "jest.config.ts", "__tests__"] "declaration": false,
} "sourceMap": true,
"lib": ["es6"]
},
"exclude": ["node_modules", "**/*.test.ts"]
}