mirror of
https://github.com/actions/upload-artifact.git
synced 2026-02-28 15:32:43 +08:00
Compare commits
142 Commits
v4.2.0
...
danwkenned
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa2914a67 | ||
|
|
c36c5fbf26 | ||
|
|
8c63b6d07a | ||
|
|
0508a90968 | ||
|
|
a2008cd1d8 | ||
|
|
7d7cf5541f | ||
|
|
56b3ba9990 | ||
|
|
d025a8c768 | ||
|
|
15be44e9f1 | ||
|
|
f119f587c2 | ||
|
|
47309c993a | ||
|
|
02a8460834 | ||
|
|
b7c566a772 | ||
|
|
e516bc8500 | ||
|
|
ddc45ed9bc | ||
|
|
615b319bd2 | ||
|
|
017748b48f | ||
|
|
38d4c7997f | ||
|
|
7d27270e0c | ||
|
|
5f643d3c94 | ||
|
|
1df1684032 | ||
|
|
b5b1a91840 | ||
|
|
184708f738 | ||
|
|
b340c4ff46 | ||
|
|
867bba2a6b | ||
|
|
471b053ce1 | ||
|
|
0d59ab325a | ||
|
|
38cbf00a3a | ||
|
|
65c405260d | ||
|
|
985108560d | ||
|
|
0d8ecc924d | ||
|
|
75274a8f39 | ||
|
|
f77a7e9888 | ||
|
|
bed0e56780 | ||
|
|
91ba4ec18c | ||
|
|
87458809df | ||
|
|
8b8de53667 | ||
|
|
330a01c490 | ||
|
|
03f2824452 | ||
|
|
905a1ecb59 | ||
|
|
2d9f9cdfa9 | ||
|
|
d2e7727e56 | ||
|
|
9687587dec | ||
|
|
bdfe531d8f | ||
|
|
98d3bf521e | ||
|
|
48ddad3e02 | ||
|
|
2848b2cda0 | ||
|
|
9b511775fd | ||
|
|
cd231ca1ed | ||
|
|
6c33d18d0a | ||
|
|
de65e23aa2 | ||
|
|
8747d8cd76 | ||
|
|
6027e3dd17 | ||
|
|
d7079ed267 | ||
|
|
ea165f8d65 | ||
|
|
08396203c1 | ||
|
|
99ec7df36b | ||
|
|
4cec3d8aa0 | ||
|
|
e9fad966cc | ||
|
|
b26fd06e9d | ||
|
|
65c4c4a1dd | ||
|
|
0207619228 | ||
|
|
1ecca81102 | ||
|
|
97422693d3 | ||
|
|
6f51ac03b9 | ||
|
|
c40c16d999 | ||
|
|
735efb4a0a | ||
|
|
184d73b71b | ||
|
|
b4a0a984a0 | ||
|
|
b4b15b8c7c | ||
|
|
92b01ebffa | ||
|
|
84480863f2 | ||
|
|
b1d4642b69 | ||
|
|
d50e66084c | ||
|
|
aabe6f8050 | ||
|
|
604373da63 | ||
|
|
0150148bdf | ||
|
|
a009b25faa | ||
|
|
9f6f6f402e | ||
|
|
3eadd8b791 | ||
|
|
aeba9f7961 | ||
|
|
b18b1d32f3 | ||
|
|
d7c12077c4 | ||
|
|
50769540e7 | ||
|
|
d52396ac5d | ||
|
|
710f362075 | ||
|
|
3b315f26f6 | ||
|
|
3be2180eb7 | ||
|
|
453e8d0a40 | ||
|
|
0a398c1480 | ||
|
|
a0c40cf602 | ||
|
|
acb59e4776 | ||
|
|
cb6558bb10 | ||
|
|
834a144ee9 | ||
|
|
134dcf33c0 | ||
|
|
73a0b9c954 | ||
|
|
89ef406dd8 | ||
|
|
23d796df36 | ||
|
|
e445c64bc2 | ||
|
|
0b2256b8c0 | ||
|
|
488dcefb9b | ||
|
|
04c51f5766 | ||
|
|
32a9e276a8 | ||
|
|
a954e16c38 | ||
|
|
a1d85e775a | ||
|
|
552bf3722c | ||
|
|
79616d2ded | ||
|
|
65462800fd | ||
|
|
c004fb4bf6 | ||
|
|
90aba496fc | ||
|
|
b06cde36fc | ||
|
|
1746f4ab65 | ||
|
|
31685d04a0 | ||
|
|
18bf333cd2 | ||
|
|
dac413befa | ||
|
|
bb3b4a3cdb | ||
|
|
3e3da837d2 | ||
|
|
e35774f165 | ||
|
|
e63ea677fb | ||
|
|
ef09cdac3e | ||
|
|
00e36f94d8 | ||
|
|
4c0ff1c489 | ||
|
|
5d5d22a312 | ||
|
|
f1e993d966 | ||
|
|
4881bfd3f2 | ||
|
|
a30777e265 | ||
|
|
3a8048248f | ||
|
|
9d63e3f2f8 | ||
|
|
dfa1ab292d | ||
|
|
d00351bf69 | ||
|
|
707f5a7b71 | ||
|
|
26f96dfa69 | ||
|
|
530ed2c9b8 | ||
|
|
53ef6987b3 | ||
|
|
90b0f8eed8 | ||
|
|
199a58f54f | ||
|
|
3f353f9d9e | ||
|
|
997fffa355 | ||
|
|
52899c8c02 | ||
|
|
da58a3f7b2 | ||
|
|
1f64adb853 | ||
|
|
8d531b15a6 |
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
lib/
|
||||
dist/
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
6
.github/workflows/check-dist.yml
vendored
6
.github/workflows/check-dist.yml
vendored
@@ -24,17 +24,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 20
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Rebuild the dist/ directory
|
||||
run: npm run build
|
||||
run: npm run release
|
||||
|
||||
- name: Compare the expected and actual dist/ directories
|
||||
run: |
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
# 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)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -43,4 +43,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
20
.github/workflows/publish-immutable-actions.yml
vendored
Normal file
20
.github/workflows/publish-immutable-actions.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- name: Update the ${{ env.TAG_NAME }} tag
|
||||
id: update-major-tag
|
||||
uses: actions/publish-action@v0.2.1
|
||||
uses: actions/publish-action@v0.3.0
|
||||
with:
|
||||
source-tag: ${{ env.TAG_NAME }}
|
||||
slack-webhook: ${{ secrets.SLACK_WEBHOOK }}
|
||||
|
||||
114
.github/workflows/test-proxy.yml
vendored
Normal file
114
.github/workflows/test-proxy.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
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
|
||||
138
.github/workflows/test.yml
vendored
138
.github/workflows/test.yml
vendored
@@ -25,10 +25,10 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 20
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -46,14 +46,19 @@ jobs:
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
# Test end-to-end by uploading two artifacts and then downloading them
|
||||
# Test end-to-end by uploading a few artifacts and then downloading them
|
||||
- name: Create artifact files
|
||||
run: |
|
||||
mkdir -p path/to/dir-1
|
||||
mkdir -p path/to/dir-2
|
||||
mkdir -p path/to/dir-3
|
||||
mkdir -p symlink/
|
||||
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 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
|
||||
- name: 'Upload artifact #1'
|
||||
@@ -79,6 +84,14 @@ jobs:
|
||||
path/to/dir-[23]/*
|
||||
!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
|
||||
- name: 'Download artifact #1'
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -141,12 +154,44 @@ jobs:
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: 'Download symlinked artifact'
|
||||
uses: actions/download-artifact@v4
|
||||
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'
|
||||
run: |
|
||||
echo "This file has changed" > path/to/dir-1/file1.txt
|
||||
|
||||
# Replace the contents of Artifact #1
|
||||
- name: 'Overwrite artifact #1 again'
|
||||
- name: 'Overwrite artifact #1'
|
||||
uses: ./
|
||||
with:
|
||||
name: 'Artifact-A-${{ matrix.runs-on }}'
|
||||
path: path/to/dir-2/file2.txt
|
||||
path: path/to/dir-1/file1.txt
|
||||
overwrite: true
|
||||
|
||||
# Download replaced Artifact #1 and verify the correctness of the content
|
||||
@@ -158,13 +203,90 @@ jobs:
|
||||
|
||||
- name: 'Verify Artifact #1 again'
|
||||
run: |
|
||||
$file = "overwrite/some/new/path/file2.txt"
|
||||
$file = "overwrite/some/new/path/file1.txt"
|
||||
if(!(Test-Path -path $file))
|
||||
{
|
||||
Write-Error "Expected file does not exist"
|
||||
}
|
||||
if(!((Get-Content $file) -ceq "Hello world from file #2"))
|
||||
if(!((Get-Content $file) -ceq "This file has changed"))
|
||||
{
|
||||
Write-Error "File contents of downloaded artifacts are incorrect"
|
||||
Write-Error "File contents of downloaded artifact are incorrect"
|
||||
}
|
||||
shell: pwsh
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Merge all artifacts from previous jobs
|
||||
- name: Merge all artifacts in run
|
||||
uses: ./merge/
|
||||
with:
|
||||
# our matrix produces artifacts with the same file, this prevents "stomping" on each other, also makes it
|
||||
# easier to identify each of the merged artifacts
|
||||
separate-directories: true
|
||||
- name: 'Download merged artifacts'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: merged-artifacts
|
||||
path: all-merged-artifacts
|
||||
- name: 'Check merged artifact has directories for each artifact'
|
||||
run: |
|
||||
$artifacts = @(
|
||||
"Artifact-A-ubuntu-latest",
|
||||
"Artifact-A-macos-latest",
|
||||
"Artifact-A-windows-latest",
|
||||
"Artifact-Wildcard-ubuntu-latest",
|
||||
"Artifact-Wildcard-macos-latest",
|
||||
"Artifact-Wildcard-windows-latest",
|
||||
"Multi-Path-Artifact-ubuntu-latest",
|
||||
"Multi-Path-Artifact-macos-latest",
|
||||
"Multi-Path-Artifact-windows-latest"
|
||||
)
|
||||
|
||||
foreach ($artifact in $artifacts) {
|
||||
$path = "all-merged-artifacts/$artifact"
|
||||
if (!(Test-Path $path)) {
|
||||
Write-Error "$path does not exist."
|
||||
}
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
# Merge Artifact-A-* from previous jobs
|
||||
- name: Merge all Artifact-A
|
||||
uses: ./merge/
|
||||
with:
|
||||
name: Merged-Artifact-As
|
||||
pattern: 'Artifact-A-*'
|
||||
separate-directories: true
|
||||
|
||||
# Download merged artifacts and verify the correctness of the content
|
||||
- name: 'Download merged artifacts'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: Merged-Artifact-As
|
||||
path: merged-artifact-a
|
||||
|
||||
- name: 'Verify merged artifacts'
|
||||
run: |
|
||||
$files = @(
|
||||
"merged-artifact-a/Artifact-A-ubuntu-latest/file1.txt",
|
||||
"merged-artifact-a/Artifact-A-macos-latest/file1.txt",
|
||||
"merged-artifact-a/Artifact-A-windows-latest/file1.txt"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
if (!(Test-Path $file)) {
|
||||
Write-Error "$file does not exist."
|
||||
}
|
||||
|
||||
if (!((Get-Content $file) -ceq "This file has changed")) {
|
||||
Write-Error "$file has incorrect content."
|
||||
}
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
lib/
|
||||
__tests__/_temp/
|
||||
__tests__/_temp/
|
||||
.DS_Store
|
||||
@@ -1,6 +1,9 @@
|
||||
sources:
|
||||
npm: true
|
||||
|
||||
# Force UTF-8 encoding
|
||||
encoding: 'utf-8'
|
||||
|
||||
allowed:
|
||||
- apache-2.0
|
||||
- bsd-2-clause
|
||||
@@ -9,7 +12,30 @@ allowed:
|
||||
- mit
|
||||
- cc0-1.0
|
||||
- unlicense
|
||||
- 0bsd
|
||||
- blueoak-1.0.0
|
||||
|
||||
reviewed:
|
||||
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
|
||||
6
.licenses/npm/@actions/artifact.dep.yml
generated
6
.licenses/npm/@actions/artifact.dep.yml
generated
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: "@actions/artifact"
|
||||
version: 2.1.0
|
||||
version: 6.2.0
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
summary: Actions artifact lib
|
||||
homepage: https://github.com/actions/toolkit/tree/main/packages/artifact
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
|
||||
6
.licenses/npm/@actions/core.dep.yml
generated
6
.licenses/npm/@actions/core.dep.yml
generated
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: "@actions/core"
|
||||
version: 1.10.0
|
||||
version: 3.0.0
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
summary: Actions core lib
|
||||
homepage: https://github.com/actions/toolkit/tree/main/packages/core
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
|
||||
6
.licenses/npm/@actions/github.dep.yml
generated
6
.licenses/npm/@actions/github.dep.yml
generated
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: "@actions/github"
|
||||
version: 6.0.0
|
||||
version: 9.0.0
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
summary: Actions github lib
|
||||
homepage: https://github.com/actions/toolkit/tree/main/packages/github
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
|
||||
6
.licenses/npm/@actions/glob.dep.yml
generated
6
.licenses/npm/@actions/glob.dep.yml
generated
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: "@actions/glob"
|
||||
version: 0.3.0
|
||||
version: 0.6.1
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
summary: Actions glob lib
|
||||
homepage: https://github.com/actions/toolkit/tree/main/packages/glob
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
|
||||
6
.licenses/npm/@actions/io.dep.yml
generated
6
.licenses/npm/@actions/io.dep.yml
generated
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: "@actions/io"
|
||||
version: 1.1.2
|
||||
version: 3.0.2
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
summary: Actions io lib
|
||||
homepage: https://github.com/actions/toolkit/tree/main/packages/io
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
|
||||
66
.licenses/npm/minimatch.dep.yml
generated
Normal file
66
.licenses/npm/minimatch.dep.yml
generated
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: minimatch
|
||||
version: 10.2.4
|
||||
type: npm
|
||||
summary:
|
||||
homepage:
|
||||
license: blueoak-1.0.0
|
||||
licenses:
|
||||
- sources: LICENSE.md
|
||||
text: |
|
||||
# Blue Oak Model License
|
||||
|
||||
Version 1.0.0
|
||||
|
||||
## Purpose
|
||||
|
||||
This license gives everyone as much permission to work with
|
||||
this software as possible, while protecting contributors
|
||||
from liability.
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to receive this license, you must agree to its
|
||||
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: []
|
||||
36
.vscode/launch.json
vendored
Normal file
36
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
75
README.md
75
README.md
@@ -1,10 +1,17 @@
|
||||
# `@actions/upload-artifact`
|
||||
|
||||
> [!WARNING]
|
||||
> actions/upload-artifact@v3 is scheduled for deprecation on **November 30, 2024**. [Learn more.](https://github.blog/changelog/2024-04-16-deprecation-notice-v3-of-the-artifact-actions/)
|
||||
> Similarly, v1/v2 are scheduled for deprecation on **June 30, 2024**.
|
||||
> Please update your workflow to use v4 of the artifact actions.
|
||||
> This deprecation will not impact any existing versions of GitHub Enterprise Server being used by customers.
|
||||
|
||||
Upload [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) from your Workflow Runs. Internally powered by [@actions/artifact](https://github.com/actions/toolkit/tree/main/packages/artifact) package.
|
||||
|
||||
See also [download-artifact](https://github.com/actions/download-artifact).
|
||||
|
||||
- [`@actions/upload-artifact`](#actionsupload-artifact)
|
||||
- [v6 - What's new](#v6---whats-new)
|
||||
- [v4 - What's new](#v4---whats-new)
|
||||
- [Improvements](#improvements)
|
||||
- [Breaking Changes](#breaking-changes)
|
||||
@@ -32,15 +39,26 @@ See also [download-artifact](https://github.com/actions/download-artifact).
|
||||
- [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
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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).
|
||||
> 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).
|
||||
|
||||
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.
|
||||
|
||||
For more information, see the [`@actions/artifact`](https://github.com/actions/toolkit/tree/main/packages/artifact) documentation.
|
||||
|
||||
There is also a new sub-action, `actions/upload-artifact/merge`. For more info, check out that action's [README](./merge/README.md).
|
||||
|
||||
### Improvements
|
||||
|
||||
1. Uploads are significantly faster, upwards of 90% improvement in worst case scenarios.
|
||||
@@ -56,9 +74,28 @@ For more information, see the [`@actions/artifact`](https://github.com/actions/t
|
||||
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.
|
||||
4. With `v4.4` and later, hidden files are excluded by default.
|
||||
|
||||
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 we’re working on and what stage they’re 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
|
||||
|
||||
### Inputs
|
||||
@@ -99,6 +136,12 @@ For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md).
|
||||
# Does not fail if the artifact does not exist.
|
||||
# Optional. Default is 'false'
|
||||
overwrite:
|
||||
|
||||
# Whether to include hidden files in the provided path in the artifact
|
||||
# The file contents of any hidden files in the path should be validated before
|
||||
# enabled this to avoid uploading sensitive information.
|
||||
# Optional. Default is 'false'
|
||||
include-hidden-files:
|
||||
```
|
||||
|
||||
### Outputs
|
||||
@@ -107,6 +150,7 @@ For assistance with breaking changes, see [MIGRATION.md](docs/MIGRATION.md).
|
||||
| - | - | - |
|
||||
| `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-digest` | SHA-256 digest of an Artifact | 0fde654d4c6e659b45783a725dc92f1bfb0baa6c2de64b34e814dc206ff4aaaf |
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -402,11 +446,33 @@ jobs:
|
||||
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
|
||||
|
||||
### Number of Artifacts
|
||||
|
||||
Within an individual job, there is a limit of 10 artifacts that can be created for that job.
|
||||
Within an individual job, there is a limit of 500 artifacts that can be created for that job.
|
||||
|
||||
You may also be limited by Artifacts if you have exceeded your shared storage quota. Storage is calculated every 6-12 hours. See [the documentation](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending) for more info.
|
||||
|
||||
@@ -435,8 +501,9 @@ 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:
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/16109154/103645952-223c6880-4f59-11eb-8268-8dca6937b5f9.png" width="700" height="300">
|
||||
<img src="https://github.com/user-attachments/assets/bcb7120f-f445-4a3e-9596-77f85f7e0af0" 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.
|
||||
|
||||
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 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.
|
||||
|
||||
220
__tests__/merge.test.ts
Normal file
220
__tests__/merge.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {jest, describe, test, expect, beforeEach} from '@jest/globals'
|
||||
|
||||
// Mock @actions/github before importing modules that use it
|
||||
jest.unstable_mockModule('@actions/github', () => ({
|
||||
context: {
|
||||
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 = {
|
||||
artifactName: 'my-merged-artifact',
|
||||
tmpDirectory: '/tmp/merge-artifact',
|
||||
filesToUpload: [
|
||||
'/some/artifact/path/file-a.txt',
|
||||
'/some/artifact/path/file-b.txt',
|
||||
'/some/artifact/path/file-c.txt'
|
||||
],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'my-artifact-a',
|
||||
id: 1,
|
||||
size: 100,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z')
|
||||
},
|
||||
{
|
||||
name: 'my-artifact-b',
|
||||
id: 2,
|
||||
size: 100,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z')
|
||||
},
|
||||
{
|
||||
name: 'my-artifact-c',
|
||||
id: 3,
|
||||
size: 100,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockInputs = (
|
||||
overrides?: Partial<{[K in (typeof Inputs)[keyof typeof Inputs]]?: any}>
|
||||
) => {
|
||||
const inputs: Record<string, any> = {
|
||||
[Inputs.Name]: 'my-merged-artifact',
|
||||
[Inputs.Pattern]: '*',
|
||||
[Inputs.SeparateDirectories]: false,
|
||||
[Inputs.RetentionDays]: 0,
|
||||
[Inputs.CompressionLevel]: 6,
|
||||
[Inputs.DeleteMerged]: false,
|
||||
...overrides
|
||||
}
|
||||
|
||||
;(core.getInput as jest.Mock<typeof core.getInput>).mockImplementation(
|
||||
(name: string) => {
|
||||
return inputs[name]
|
||||
}
|
||||
)
|
||||
;(
|
||||
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
|
||||
).mockImplementation((name: string) => {
|
||||
return inputs[name]
|
||||
})
|
||||
|
||||
return inputs
|
||||
}
|
||||
|
||||
describe('merge', () => {
|
||||
beforeEach(async () => {
|
||||
mockInputs()
|
||||
jest.clearAllMocks()
|
||||
|
||||
jest
|
||||
.spyOn(artifact.default, 'listArtifacts')
|
||||
.mockResolvedValue({artifacts: fixtures.artifacts})
|
||||
|
||||
jest.spyOn(artifact.default, 'downloadArtifact').mockResolvedValue({
|
||||
downloadPath: fixtures.tmpDirectory
|
||||
})
|
||||
|
||||
mockFindFilesToUpload.mockResolvedValue({
|
||||
filesToUpload: fixtures.filesToUpload,
|
||||
rootDirectory: fixtures.tmpDirectory
|
||||
})
|
||||
|
||||
jest.spyOn(artifact.default, 'uploadArtifact').mockResolvedValue({
|
||||
size: 123,
|
||||
id: 1337
|
||||
})
|
||||
|
||||
jest
|
||||
.spyOn(artifact.default, 'deleteArtifact')
|
||||
.mockImplementation(async (artifactName: string) => {
|
||||
const found = fixtures.artifacts.find(a => a.name === artifactName)
|
||||
if (!found) throw new Error(`Artifact ${artifactName} not found`)
|
||||
return {id: found.id}
|
||||
})
|
||||
})
|
||||
|
||||
test('merges artifacts', async () => {
|
||||
await run()
|
||||
|
||||
for (const a of fixtures.artifacts) {
|
||||
expect(artifact.default.downloadArtifact).toHaveBeenCalledWith(a.id, {
|
||||
path: fixtures.tmpDirectory
|
||||
})
|
||||
}
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.tmpDirectory,
|
||||
{compressionLevel: 6}
|
||||
)
|
||||
})
|
||||
|
||||
test('fails if no artifacts found', async () => {
|
||||
mockInputs({[Inputs.Pattern]: 'this-does-not-match'})
|
||||
|
||||
await expect(run()).rejects.toThrow()
|
||||
|
||||
expect(artifact.default.uploadArtifact).not.toHaveBeenCalled()
|
||||
expect(artifact.default.downloadArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('supports custom compression level', async () => {
|
||||
mockInputs({
|
||||
[Inputs.CompressionLevel]: 2
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.tmpDirectory,
|
||||
{compressionLevel: 2}
|
||||
)
|
||||
})
|
||||
|
||||
test('supports custom retention days', async () => {
|
||||
mockInputs({
|
||||
[Inputs.RetentionDays]: 7
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.tmpDirectory,
|
||||
{retentionDays: 7, compressionLevel: 6}
|
||||
)
|
||||
})
|
||||
|
||||
test('supports deleting artifacts after merge', async () => {
|
||||
mockInputs({
|
||||
[Inputs.DeleteMerged]: true
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
for (const a of fixtures.artifacts) {
|
||||
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith(a.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,37 @@
|
||||
import * as core from '@actions/core'
|
||||
import {jest, describe, test, expect, beforeAll} from '@jest/globals'
|
||||
import * as path from 'path'
|
||||
import * as io from '@actions/io'
|
||||
import {promises as fs} from 'fs'
|
||||
import {findFilesToUpload} from '../src/search'
|
||||
import {fileURLToPath} from 'url'
|
||||
|
||||
// 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 searchItem1Path = path.join(
|
||||
@@ -61,13 +90,24 @@ const lonelyFilePath = path.join(
|
||||
'lonely-file.txt'
|
||||
)
|
||||
|
||||
const hiddenFile = path.join(root, '.hidden-file.txt')
|
||||
const fileInHiddenFolderPath = path.join(
|
||||
root,
|
||||
'.hidden-folder',
|
||||
'folder-in-hidden-folder',
|
||||
'file.txt'
|
||||
)
|
||||
const fileInHiddenFolderInFolderA = path.join(
|
||||
root,
|
||||
'folder-a',
|
||||
'.hidden-folder-in-folder-a',
|
||||
'file.txt'
|
||||
)
|
||||
|
||||
describe('Search', () => {
|
||||
beforeAll(async () => {
|
||||
// mock all output so that there is less noise when running tests
|
||||
// mock console.log to reduce noise
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(core, 'warning').mockImplementation(() => {})
|
||||
|
||||
// clear temp directory
|
||||
await io.rmRF(root)
|
||||
@@ -93,6 +133,14 @@ describe('Search', () => {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
await fs.mkdir(
|
||||
path.join(root, '.hidden-folder', 'folder-in-hidden-folder'),
|
||||
{recursive: true}
|
||||
)
|
||||
await fs.mkdir(path.join(root, 'folder-a', '.hidden-folder-in-folder-a'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
await fs.writeFile(searchItem1Path, 'search item1 file')
|
||||
await fs.writeFile(searchItem2Path, 'search item2 file')
|
||||
await fs.writeFile(searchItem3Path, 'search item3 file')
|
||||
@@ -110,37 +158,13 @@ describe('Search', () => {
|
||||
await fs.writeFile(amazingFileInFolderHPath, 'amazing file')
|
||||
|
||||
await fs.writeFile(lonelyFilePath, 'all by itself')
|
||||
/*
|
||||
Directory structure of files that get created:
|
||||
root/
|
||||
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
|
||||
search-item5.txt
|
||||
*/
|
||||
|
||||
await fs.writeFile(hiddenFile, 'hidden file')
|
||||
await fs.writeFile(fileInHiddenFolderPath, 'file in hidden directory')
|
||||
await fs.writeFile(fileInHiddenFolderInFolderA, 'file in hidden directory')
|
||||
})
|
||||
|
||||
it('Single file search - Absolute Path', async () => {
|
||||
test('Single file search - Absolute Path', async () => {
|
||||
const searchResult = await findFilesToUpload(extraFileInFolderCPath)
|
||||
expect(searchResult.filesToUpload.length).toEqual(1)
|
||||
expect(searchResult.filesToUpload[0]).toEqual(extraFileInFolderCPath)
|
||||
@@ -149,7 +173,7 @@ describe('Search', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Single file search - Relative Path', async () => {
|
||||
test('Single file search - Relative Path', async () => {
|
||||
const relativePath = path.join(
|
||||
'__tests__',
|
||||
'_temp',
|
||||
@@ -168,7 +192,7 @@ describe('Search', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Single file using wildcard', async () => {
|
||||
test('Single file using wildcard', async () => {
|
||||
const expectedRoot = path.join(root, 'folder-h')
|
||||
const searchPath = path.join(root, 'folder-h', '**/*lonely*')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
@@ -177,7 +201,7 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(expectedRoot)
|
||||
})
|
||||
|
||||
it('Single file using directory', async () => {
|
||||
test('Single file using directory', async () => {
|
||||
const searchPath = path.join(root, 'folder-h', 'folder-j')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
expect(searchResult.filesToUpload.length).toEqual(1)
|
||||
@@ -185,7 +209,7 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(searchPath)
|
||||
})
|
||||
|
||||
it('Directory search - Absolute Path', async () => {
|
||||
test('Directory search - Absolute Path', async () => {
|
||||
const searchPath = path.join(root, 'folder-h')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
expect(searchResult.filesToUpload.length).toEqual(4)
|
||||
@@ -204,7 +228,7 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(searchPath)
|
||||
})
|
||||
|
||||
it('Directory search - Relative Path', async () => {
|
||||
test('Directory search - Relative Path', async () => {
|
||||
const searchPath = path.join('__tests__', '_temp', 'search', 'folder-h')
|
||||
const expectedRootDirectory = path.join(root, 'folder-h')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
@@ -224,7 +248,7 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(expectedRootDirectory)
|
||||
})
|
||||
|
||||
it('Wildcard search - Absolute Path', async () => {
|
||||
test('Wildcard search - Absolute Path', async () => {
|
||||
const searchPath = path.join(root, '**/*[Ss]earch*')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
expect(searchResult.filesToUpload.length).toEqual(10)
|
||||
@@ -253,7 +277,7 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(root)
|
||||
})
|
||||
|
||||
it('Wildcard search - Relative Path', async () => {
|
||||
test('Wildcard search - Relative Path', async () => {
|
||||
const searchPath = path.join(
|
||||
'__tests__',
|
||||
'_temp',
|
||||
@@ -287,11 +311,11 @@ describe('Search', () => {
|
||||
expect(searchResult.rootDirectory).toEqual(root)
|
||||
})
|
||||
|
||||
it('Multi path search - root directory', async () => {
|
||||
test('Multi path search - root directory', async () => {
|
||||
const searchPath1 = path.join(root, 'folder-a')
|
||||
const searchPath2 = path.join(root, 'folder-d')
|
||||
|
||||
const searchPaths = searchPath1 + '\n' + searchPath2
|
||||
const searchPaths = `${searchPath1}\n${searchPath2}`
|
||||
const searchResult = await findFilesToUpload(searchPaths)
|
||||
|
||||
expect(searchResult.rootDirectory).toEqual(root)
|
||||
@@ -311,13 +335,13 @@ describe('Search', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Multi path search - with exclude character', async () => {
|
||||
test('Multi path search - with exclude character', async () => {
|
||||
const searchPath1 = path.join(root, 'folder-a')
|
||||
const searchPath2 = path.join(root, 'folder-d')
|
||||
const searchPath3 = path.join(root, 'folder-a', 'folder-b', '**/extra*.txt')
|
||||
|
||||
// negating the third search path
|
||||
const searchPaths = searchPath1 + '\n' + searchPath2 + '\n!' + searchPath3
|
||||
const searchPaths = `${searchPath1}\n${searchPath2}\n!${searchPath3}`
|
||||
const searchResult = await findFilesToUpload(searchPaths)
|
||||
|
||||
expect(searchResult.rootDirectory).toEqual(root)
|
||||
@@ -331,7 +355,7 @@ describe('Search', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Multi path search - non root directory', async () => {
|
||||
test('Multi path search - non root directory', async () => {
|
||||
const searchPath1 = path.join(root, 'folder-h', 'folder-i')
|
||||
const searchPath2 = path.join(root, 'folder-h', 'folder-j', 'folder-k')
|
||||
const searchPath3 = amazingFileInFolderHPath
|
||||
@@ -352,4 +376,24 @@ describe('Search', () => {
|
||||
)
|
||||
expect(searchResult.filesToUpload.includes(lonelyFilePath)).toEqual(true)
|
||||
})
|
||||
|
||||
test('Hidden files ignored by default', async () => {
|
||||
const searchPath = path.join(root, '**/*')
|
||||
const searchResult = await findFilesToUpload(searchPath)
|
||||
|
||||
expect(searchResult.filesToUpload).not.toContain(hiddenFile)
|
||||
expect(searchResult.filesToUpload).not.toContain(fileInHiddenFolderPath)
|
||||
expect(searchResult.filesToUpload).not.toContain(
|
||||
fileInHiddenFolderInFolderA
|
||||
)
|
||||
})
|
||||
|
||||
test('Hidden files included', async () => {
|
||||
const searchPath = path.join(root, '**/*')
|
||||
const searchResult = await findFilesToUpload(searchPath, true)
|
||||
|
||||
expect(searchResult.filesToUpload).toContain(hiddenFile)
|
||||
expect(searchResult.filesToUpload).toContain(fileInHiddenFolderPath)
|
||||
expect(searchResult.filesToUpload).toContain(fileInHiddenFolderInFolderA)
|
||||
})
|
||||
})
|
||||
|
||||
276
__tests__/upload.test.ts
Normal file
276
__tests__/upload.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import {jest, describe, test, expect, beforeEach} from '@jest/globals'
|
||||
|
||||
// Mock @actions/github before importing modules that use it
|
||||
jest.unstable_mockModule('@actions/github', () => ({
|
||||
context: {
|
||||
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 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 = {
|
||||
artifactName: 'artifact-name',
|
||||
rootDirectory: '/some/artifact/path',
|
||||
filesToUpload: [
|
||||
'/some/artifact/path/file1.txt',
|
||||
'/some/artifact/path/file2.txt'
|
||||
]
|
||||
}
|
||||
|
||||
const mockInputs = (
|
||||
overrides?: Partial<{[K in (typeof Inputs)[keyof typeof Inputs]]?: any}>
|
||||
) => {
|
||||
const inputs: Record<string, any> = {
|
||||
[Inputs.Name]: 'artifact-name',
|
||||
[Inputs.Path]: '/some/artifact/path',
|
||||
[Inputs.IfNoFilesFound]: 'warn',
|
||||
[Inputs.RetentionDays]: 0,
|
||||
[Inputs.CompressionLevel]: 6,
|
||||
[Inputs.Overwrite]: false,
|
||||
...overrides
|
||||
}
|
||||
|
||||
;(core.getInput as jest.Mock<typeof core.getInput>).mockImplementation(
|
||||
(name: string) => {
|
||||
return inputs[name]
|
||||
}
|
||||
)
|
||||
;(
|
||||
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
|
||||
).mockImplementation((name: string) => {
|
||||
return inputs[name]
|
||||
})
|
||||
|
||||
return inputs
|
||||
}
|
||||
|
||||
describe('upload', () => {
|
||||
beforeEach(async () => {
|
||||
mockInputs()
|
||||
jest.clearAllMocks()
|
||||
|
||||
mockFindFilesToUpload.mockResolvedValue({
|
||||
filesToUpload: fixtures.filesToUpload,
|
||||
rootDirectory: fixtures.rootDirectory
|
||||
})
|
||||
|
||||
jest.spyOn(artifact.default, 'uploadArtifact').mockResolvedValue({
|
||||
size: 123,
|
||||
id: 1337,
|
||||
digest: 'facefeed'
|
||||
})
|
||||
})
|
||||
|
||||
test('uploads a single file', async () => {
|
||||
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('uploads multiple files', async () => {
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.rootDirectory,
|
||||
{compressionLevel: 6}
|
||||
)
|
||||
})
|
||||
|
||||
test('sets outputs', async () => {
|
||||
await run()
|
||||
|
||||
expect(core.setOutput).toHaveBeenCalledWith('artifact-id', 1337)
|
||||
expect(core.setOutput).toHaveBeenCalledWith('artifact-digest', 'facefeed')
|
||||
expect(core.setOutput).toHaveBeenCalledWith(
|
||||
'artifact-url',
|
||||
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}/artifacts/${1337}`
|
||||
)
|
||||
})
|
||||
|
||||
test('supports custom compression level', async () => {
|
||||
mockInputs({
|
||||
[Inputs.CompressionLevel]: 2
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.rootDirectory,
|
||||
{compressionLevel: 2}
|
||||
)
|
||||
})
|
||||
|
||||
test('supports custom retention days', async () => {
|
||||
mockInputs({
|
||||
[Inputs.RetentionDays]: 7
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.rootDirectory,
|
||||
{retentionDays: 7, compressionLevel: 6}
|
||||
)
|
||||
})
|
||||
|
||||
test('supports warn if-no-files-found', async () => {
|
||||
mockInputs({
|
||||
[Inputs.IfNoFilesFound]: 'warn'
|
||||
})
|
||||
|
||||
mockFindFilesToUpload.mockResolvedValue({
|
||||
filesToUpload: [],
|
||||
rootDirectory: fixtures.rootDirectory
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.warning).toHaveBeenCalledWith(
|
||||
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
|
||||
)
|
||||
})
|
||||
|
||||
test('supports error if-no-files-found', async () => {
|
||||
mockInputs({
|
||||
[Inputs.IfNoFilesFound]: 'error'
|
||||
})
|
||||
|
||||
mockFindFilesToUpload.mockResolvedValue({
|
||||
filesToUpload: [],
|
||||
rootDirectory: fixtures.rootDirectory
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
|
||||
)
|
||||
})
|
||||
|
||||
test('supports ignore if-no-files-found', async () => {
|
||||
mockInputs({
|
||||
[Inputs.IfNoFilesFound]: 'ignore'
|
||||
})
|
||||
|
||||
mockFindFilesToUpload.mockResolvedValue({
|
||||
filesToUpload: [],
|
||||
rootDirectory: fixtures.rootDirectory
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.info).toHaveBeenCalledWith(
|
||||
`No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.`
|
||||
)
|
||||
})
|
||||
|
||||
test('supports overwrite', async () => {
|
||||
mockInputs({
|
||||
[Inputs.Overwrite]: true
|
||||
})
|
||||
|
||||
jest.spyOn(artifact.default, 'deleteArtifact').mockResolvedValue({
|
||||
id: 1337
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.rootDirectory,
|
||||
{compressionLevel: 6}
|
||||
)
|
||||
|
||||
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName
|
||||
)
|
||||
})
|
||||
|
||||
test('supports overwrite and continues if not found', async () => {
|
||||
mockInputs({
|
||||
[Inputs.Overwrite]: true
|
||||
})
|
||||
|
||||
jest
|
||||
.spyOn(artifact.default, 'deleteArtifact')
|
||||
.mockRejectedValue(new ArtifactNotFoundError('not found'))
|
||||
|
||||
await run()
|
||||
|
||||
expect(artifact.default.uploadArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName,
|
||||
fixtures.filesToUpload,
|
||||
fixtures.rootDirectory,
|
||||
{compressionLevel: 6}
|
||||
)
|
||||
|
||||
expect(artifact.default.deleteArtifact).toHaveBeenCalledWith(
|
||||
fixtures.artifactName
|
||||
)
|
||||
expect(core.debug).toHaveBeenCalledWith(
|
||||
`Skipping deletion of '${fixtures.artifactName}', it does not exist`
|
||||
)
|
||||
})
|
||||
})
|
||||
12
action.yml
12
action.yml
@@ -40,6 +40,11 @@ inputs:
|
||||
If false, the action will fail if an artifact for the given name already exists.
|
||||
Does not fail if the artifact does not exist.
|
||||
default: 'false'
|
||||
include-hidden-files:
|
||||
description: >
|
||||
If true, hidden files will be included in the artifact.
|
||||
If false, hidden files will be excluded from the artifact.
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
artifact-id:
|
||||
@@ -56,6 +61,9 @@ 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.
|
||||
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:
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
using: 'node24'
|
||||
main: 'dist/upload/index.js'
|
||||
|
||||
183950
dist/index.js → dist/merge/index.js
vendored
183950
dist/index.js → dist/merge/index.js
vendored
File diff suppressed because one or more lines are too long
3
dist/merge/package.json
vendored
Normal file
3
dist/merge/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
130599
dist/upload/index.js
vendored
Normal file
130599
dist/upload/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
dist/upload/package.json
vendored
Normal file
3
dist/upload/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
- [Migration](#migration)
|
||||
- [Multiple uploads to the same named Artifact](#multiple-uploads-to-the-same-named-artifact)
|
||||
- [Overwriting an Artifact](#overwriting-an-artifact)
|
||||
- [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`.
|
||||
|
||||
@@ -32,6 +34,7 @@ jobs:
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: my-artifact
|
||||
path: my-artifact
|
||||
- run: ls -R my-artifact
|
||||
```
|
||||
@@ -72,6 +75,7 @@ jobs:
|
||||
- uses: actions/download-artifact@v3
|
||||
+ uses: actions/download-artifact@v4
|
||||
with:
|
||||
- name: my-artifact
|
||||
path: my-artifact
|
||||
+ pattern: my-artifact-*
|
||||
+ merge-multiple: true
|
||||
@@ -142,3 +146,103 @@ jobs:
|
||||
```
|
||||
|
||||
Note that this will create an _entirely_ new Artifact, with a different ID from the previous.
|
||||
|
||||
## Merging multiple artifacts
|
||||
|
||||
In `v3`, multiple uploads from multiple jobs could be done to the same Artifact. This would result in a single archive, which could be useful for sending to upstream systems outside of Actions via API or UI downloads.
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
upload:
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Create a File
|
||||
run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: all-my-files # NOTE: same artifact name
|
||||
path: file-${{ matrix.runs-on }}.txt
|
||||
```
|
||||
|
||||
The single `all-my-files` artifact would contain the following:
|
||||
|
||||
```
|
||||
.
|
||||
∟ file-ubuntu-latest.txt
|
||||
∟ file-macos-latest.txt
|
||||
∟ file-windows-latest.txt
|
||||
```
|
||||
|
||||
To achieve the same in `v4` you can change it like so:
|
||||
|
||||
```diff
|
||||
jobs:
|
||||
upload:
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Create a File
|
||||
run: echo "hello from ${{ matrix.runs-on }}" > file-${{ matrix.runs-on }}.txt
|
||||
- name: Upload Artifact
|
||||
- uses: actions/upload-artifact@v3
|
||||
+ uses: actions/upload-artifact@v4
|
||||
with:
|
||||
- name: all-my-files
|
||||
+ name: my-artifact-${{ matrix.runs-on }}
|
||||
path: file-${{ matrix.runs-on }}.txt
|
||||
+ merge:
|
||||
+ runs-on: ubuntu-latest
|
||||
+ needs: upload
|
||||
+ steps:
|
||||
+ - name: Merge Artifacts
|
||||
+ uses: actions/upload-artifact/merge@v4
|
||||
+ with:
|
||||
+ name: all-my-files
|
||||
+ 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).
|
||||
|
||||
## Hidden Files
|
||||
|
||||
By default, hidden files are ignored by this action to avoid unintentionally uploading sensitive
|
||||
information.
|
||||
|
||||
In versions of this action before v4.4.0, these hidden files were included by default.
|
||||
|
||||
If you need to upload hidden files, you can use the `include-hidden-files` input.
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create a Hidden File
|
||||
run: echo "hello from a hidden file" > .hidden-file.txt
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: .hidden-file.txt
|
||||
```
|
||||
|
||||
|
||||
```diff
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create a Hidden File
|
||||
run: echo "hello from a hidden file" > .hidden-file.txt
|
||||
- name: Upload Artifact
|
||||
- uses: actions/upload-artifact@v3
|
||||
+ uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: .hidden-file.txt
|
||||
+ include-hidden-files: true
|
||||
```
|
||||
52
eslint.config.mjs
Normal file
52
eslint.config.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
module.exports = {
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ['js', 'ts'],
|
||||
roots: ['<rootDir>'],
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
testRunner: 'jest-circus/runner',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest'
|
||||
},
|
||||
verbose: true
|
||||
}
|
||||
24
jest.config.ts
Normal file
24
jest.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
}
|
||||
201
merge/README.md
Normal file
201
merge/README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# `@actions/upload-artifact/merge`
|
||||
|
||||
Merge multiple [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) in Workflow Runs. Internally powered by [@actions/artifact](https://github.com/actions/toolkit/tree/main/packages/artifact) package.
|
||||
|
||||
- [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge)
|
||||
- [Usage](#usage)
|
||||
- [Inputs](#inputs)
|
||||
- [Outputs](#outputs)
|
||||
- [Examples](#examples)
|
||||
- [Combining all artifacts in a workflow run](#combining-all-artifacts-in-a-workflow-run)
|
||||
- [Prefix directories in merged artifact](#prefix-directories-in-merged-artifact)
|
||||
- [Deleting artifacts after merge](#deleting-artifacts-after-merge)
|
||||
- [Retention and Compression Level](#retention-and-compression-level)
|
||||
|
||||
## Usage
|
||||
|
||||
> [!IMPORTANT]
|
||||
> upload-artifact/merge@v4+ is not currently supported on GHES.
|
||||
|
||||
Note: this actions can only merge artifacts created with actions/upload-artifact@v4+
|
||||
|
||||
This sub-action is a helper to merge multiple artifacts after they are created. To do so, it will download multiple artifacts to a temporary directory and reupload them as a single artifact.
|
||||
|
||||
For most cases, this may not be the most efficient solution. See [the migration docs](../docs/MIGRATION.md#multiple-uploads-to-the-same-named-artifact) on how to download multiple artifacts to the same directory on a runner. This action should only be necessary for cases where multiple artifacts will need to be downloaded outside the runner environment, like downloads via the UI or REST API.
|
||||
|
||||
### Inputs
|
||||
|
||||
```yaml
|
||||
- uses: actions/upload-artifact/merge@v4
|
||||
with:
|
||||
# The name of the artifact that the artifacts will be merged into
|
||||
# Optional. Default is 'merged-artifacts'
|
||||
name:
|
||||
|
||||
# A glob pattern matching the artifacts that should be merged.
|
||||
# Optional. Default is '*'
|
||||
pattern:
|
||||
|
||||
# If true, the artifacts will be merged into separate directories.
|
||||
# If false, the artifacts will be merged into the root of the destination.
|
||||
# Optional. Default is 'false'
|
||||
separate-directories:
|
||||
|
||||
# If true, the artifacts that were merged will be deleted.
|
||||
# If false, the artifacts will still exist.
|
||||
# Optional. Default is 'false'
|
||||
delete-merged:
|
||||
|
||||
# Duration after which artifact will expire in days. 0 means using default retention.
|
||||
# Minimum 1 day.
|
||||
# Maximum 90 days unless changed from the repository settings page.
|
||||
# Optional. Defaults to repository settings.
|
||||
retention-days:
|
||||
|
||||
# The level of compression for Zlib to be applied to the artifact archive.
|
||||
# The value can range from 0 to 9.
|
||||
# For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
|
||||
# Optional. Default is '6'
|
||||
compression-level:
|
||||
```
|
||||
|
||||
### Outputs
|
||||
|
||||
| Name | Description | Example |
|
||||
| - | - | - |
|
||||
| `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-digest` | SHA-256 digest of an Artifact | 0fde654d4c6e659b45783a725dc92f1bfb0baa6c2de64b34e814dc206ff4aaaf |
|
||||
|
||||
## Examples
|
||||
|
||||
For each of these examples, assume we have a prior job matrix that generates three artifacts: `my-artifact-a`, `my-artifact-b` and `my-artifact-c`.
|
||||
|
||||
e.g.
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
foo: [a, b, c]
|
||||
|
||||
steps:
|
||||
- name: Run a one-line script
|
||||
run: echo "hello from job ${{ matrix.foo }}" > file-${{ matrix.foo }}.txt
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: my-artifact-${{ matrix.foo }}
|
||||
path: file-${{ matrix.foo }}.txt
|
||||
```
|
||||
|
||||
Each of the following examples will use the `needs: upload` as a prerequesite before any merging operations.
|
||||
|
||||
### Combining all artifacts in a workflow run
|
||||
|
||||
By default (with no inputs), calling this action will take all the artifacts in the workflow run and combined them into a single artifact called `merged-artifacts`:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# ... <upload job> ...
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: upload
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v4
|
||||
```
|
||||
|
||||
This will result in an artifact called `merged-artifacts` with the following content:
|
||||
|
||||
```
|
||||
.
|
||||
∟ file-a.txt
|
||||
∟ file-b.txt
|
||||
∟ file-c.txt
|
||||
```
|
||||
|
||||
To change the name of the artifact and filter on what artifacts are added, you can use the `name` and `pattern` inputs:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# ... <upload job> ...
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: upload
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v4
|
||||
with:
|
||||
name: my-amazing-merged-artifact
|
||||
pattern: my-artifact-*
|
||||
```
|
||||
|
||||
### Prefix directories in merged artifact
|
||||
|
||||
To prevent overwriting files in artifacts that may have the same name, you can use the `separate-directories` to prefix the extracted files with directories (named after the original artifact):
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# ... <upload job> ...
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: upload
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v4
|
||||
with:
|
||||
separate-directories: true
|
||||
```
|
||||
|
||||
This will result in the following artifact structure:
|
||||
|
||||
```
|
||||
.
|
||||
∟ my-artifact-a
|
||||
∟ file-a.txt
|
||||
∟ my-artifact-b
|
||||
∟ file-b.txt
|
||||
∟ my-artifact-c
|
||||
∟ file-c.txt
|
||||
```
|
||||
|
||||
### Deleting artifacts after merge
|
||||
|
||||
After merge, the old artifacts may no longer be required. To automatically delete them after they are merged into a new artifact, you can use `delete-merged` like so:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# ... <upload job> ...
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: upload
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v4
|
||||
with:
|
||||
delete-merged: true
|
||||
```
|
||||
|
||||
After this runs, the matching artifact (`my-artifact-a`, `my-artifact-b` and `my-artifact-c`) will be merged.
|
||||
|
||||
### Retention and Compression Level
|
||||
|
||||
Similar to actions/upload-artifact, both [`retention-days`](../README.md#retention-period) and [`compression-level`](../README.md#altering-compressions-level-speed-v-size) are supported:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# ... <upload job> ...
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: upload
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v4
|
||||
with:
|
||||
retention-days: 1
|
||||
compression-level: 9
|
||||
```
|
||||
65
merge/action.yml
Normal file
65
merge/action.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
name: 'Merge Build Artifacts'
|
||||
description: 'Merge one or more build Artifacts'
|
||||
author: 'GitHub'
|
||||
inputs:
|
||||
name:
|
||||
description: 'The name of the artifact that the artifacts will be merged into.'
|
||||
required: true
|
||||
default: 'merged-artifacts'
|
||||
pattern:
|
||||
description: 'A glob pattern matching the artifact names that should be merged.'
|
||||
default: '*'
|
||||
separate-directories:
|
||||
description: 'When multiple artifacts are matched, this changes the behavior of how they are merged in the archive.
|
||||
If true, the matched artifacts will be extracted into individual named directories within the specified path.
|
||||
If false, the matched artifacts will combined in the same directory.'
|
||||
default: 'false'
|
||||
retention-days:
|
||||
description: >
|
||||
Duration after which artifact will expire in days. 0 means using default retention.
|
||||
|
||||
Minimum 1 day.
|
||||
Maximum 90 days unless changed from the repository settings page.
|
||||
compression-level:
|
||||
description: >
|
||||
The level of compression for Zlib to be applied to the artifact archive.
|
||||
The value can range from 0 to 9:
|
||||
- 0: No compression
|
||||
- 1: Best speed
|
||||
- 6: Default compression (same as GNU Gzip)
|
||||
- 9: Best compression
|
||||
Higher levels will result in better compression, but will take longer to complete.
|
||||
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
|
||||
default: '6'
|
||||
delete-merged:
|
||||
description: >
|
||||
If true, the artifacts that were merged will be deleted.
|
||||
If false, the artifacts will still exist.
|
||||
default: 'false'
|
||||
include-hidden-files:
|
||||
description: >
|
||||
If true, hidden files will be included in the merged artifact.
|
||||
If false, hidden files will be excluded from the merged artifact.
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
artifact-id:
|
||||
description: >
|
||||
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
|
||||
|
||||
This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
|
||||
artifact-url:
|
||||
description: >
|
||||
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
|
||||
|
||||
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
|
||||
If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact
|
||||
|
||||
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.
|
||||
artifact-digest:
|
||||
description: >
|
||||
SHA-256 digest for the artifact that was just uploaded. Empty if the artifact upload failed.
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: '../dist/merge/index.js'
|
||||
14971
package-lock.json
generated
14971
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "upload-artifact",
|
||||
"version": "4.2.0",
|
||||
"version": "7.0.0",
|
||||
"description": "Upload an Actions Artifact in a workflow run",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"main": "dist/upload/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"release": "ncc build src/upload-artifact.ts && git add -f dist/index.js",
|
||||
"release": "ncc build src/upload/index.ts -o dist/upload && ncc build src/merge/index.ts -o dist/merge",
|
||||
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build\"",
|
||||
"format": "prettier --write **/*.ts",
|
||||
"format-check": "prettier --check **/*.ts",
|
||||
"lint": "eslint **/*.ts",
|
||||
"test": "jest --testTimeout 10000"
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout 10000"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -28,27 +29,35 @@
|
||||
"url": "https://github.com/actions/upload-artifact/issues"
|
||||
},
|
||||
"homepage": "https://github.com/actions/upload-artifact#readme",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/artifact": "^2.1.0",
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@actions/glob": "^0.3.0",
|
||||
"@actions/io": "^1.1.2"
|
||||
"@actions/artifact": "^6.1.0",
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/github": "^9.0.0",
|
||||
"@actions/glob": "^0.6.1",
|
||||
"@actions/io": "^3.0.2",
|
||||
"minimatch": "^10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.2.5",
|
||||
"@types/node": "^18.11.18",
|
||||
"@typescript-eslint/parser": "^5.48.0",
|
||||
"@vercel/ncc": "^0.36.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-plugin-github": "^4.6.0",
|
||||
"eslint-plugin-jest": "^27.2.0",
|
||||
"glob": "^8.0.3",
|
||||
"jest": "^29.3.1",
|
||||
"jest-circus": "^29.3.1",
|
||||
"prettier": "^2.8.1",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.4"
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vercel/ncc": "^0.38.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-github": "^6.0.0",
|
||||
"eslint-plugin-jest": "^29.12.1",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"ts-jest": "^29.2.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"uri-js": "npm:uri-js-replace@^1.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
10
src/merge/constants.ts
Normal file
10
src/merge/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum Inputs {
|
||||
Name = 'name',
|
||||
Pattern = 'pattern',
|
||||
SeparateDirectories = 'separate-directories',
|
||||
RetentionDays = 'retention-days',
|
||||
CompressionLevel = 'compression-level',
|
||||
DeleteMerged = 'delete-merged',
|
||||
IncludeHiddenFiles = 'include-hidden-files'
|
||||
}
|
||||
6
src/merge/index.ts
Normal file
6
src/merge/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as core from '@actions/core'
|
||||
import {run} from './merge-artifacts.js'
|
||||
|
||||
run().catch(error => {
|
||||
core.setFailed((error as Error).message)
|
||||
})
|
||||
46
src/merge/input-helper.ts
Normal file
46
src/merge/input-helper.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as core from '@actions/core'
|
||||
import {Inputs} from './constants.js'
|
||||
import {MergeInputs} from './merge-inputs.js'
|
||||
|
||||
/**
|
||||
* Helper to get all the inputs for the action
|
||||
*/
|
||||
export function getInputs(): MergeInputs {
|
||||
const name = core.getInput(Inputs.Name, {required: true})
|
||||
const pattern = core.getInput(Inputs.Pattern, {required: true})
|
||||
const separateDirectories = core.getBooleanInput(Inputs.SeparateDirectories)
|
||||
const deleteMerged = core.getBooleanInput(Inputs.DeleteMerged)
|
||||
const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles)
|
||||
|
||||
const inputs = {
|
||||
name,
|
||||
pattern,
|
||||
separateDirectories,
|
||||
deleteMerged,
|
||||
retentionDays: 0,
|
||||
compressionLevel: 6,
|
||||
includeHiddenFiles
|
||||
} as MergeInputs
|
||||
|
||||
const retentionDaysStr = core.getInput(Inputs.RetentionDays)
|
||||
if (retentionDaysStr) {
|
||||
inputs.retentionDays = parseInt(retentionDaysStr)
|
||||
if (isNaN(inputs.retentionDays)) {
|
||||
core.setFailed('Invalid retention-days')
|
||||
}
|
||||
}
|
||||
|
||||
const compressionLevelStr = core.getInput(Inputs.CompressionLevel)
|
||||
if (compressionLevelStr) {
|
||||
inputs.compressionLevel = parseInt(compressionLevelStr)
|
||||
if (isNaN(inputs.compressionLevel)) {
|
||||
core.setFailed('Invalid compression-level')
|
||||
}
|
||||
|
||||
if (inputs.compressionLevel < 0 || inputs.compressionLevel > 9) {
|
||||
core.setFailed('Invalid compression-level. Valid values are 0-9')
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
}
|
||||
96
src/merge/merge-artifacts.ts
Normal file
96
src/merge/merge-artifacts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as path from 'path'
|
||||
import {mkdtemp, rm} from 'fs/promises'
|
||||
import * as core from '@actions/core'
|
||||
import {Minimatch} from 'minimatch'
|
||||
import artifactClient, {UploadArtifactOptions} from '@actions/artifact'
|
||||
import {getInputs} from './input-helper.js'
|
||||
import {uploadArtifact} from '../shared/upload-artifact.js'
|
||||
import {findFilesToUpload} from '../shared/search.js'
|
||||
|
||||
const PARALLEL_DOWNLOADS = 5
|
||||
|
||||
export const chunk = <T>(arr: T[], n: number): T[][] =>
|
||||
arr.reduce((acc, cur, i) => {
|
||||
const index = Math.floor(i / n)
|
||||
acc[index] = [...(acc[index] || []), cur]
|
||||
return acc
|
||||
}, [] as T[][])
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const inputs = getInputs()
|
||||
const tmpDir = await mkdtemp('merge-artifact')
|
||||
|
||||
const listArtifactResponse = await artifactClient.listArtifacts({
|
||||
latest: true
|
||||
})
|
||||
const matcher = new Minimatch(inputs.pattern)
|
||||
const artifacts = listArtifactResponse.artifacts.filter(artifact =>
|
||||
matcher.match(artifact.name)
|
||||
)
|
||||
core.debug(
|
||||
`Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts`
|
||||
)
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(`No artifacts found matching pattern '${inputs.pattern}'`)
|
||||
}
|
||||
|
||||
core.info(`Preparing to download the following artifacts:`)
|
||||
artifacts.forEach(artifact => {
|
||||
core.info(`- ${artifact.name} (ID: ${artifact.id}, Size: ${artifact.size})`)
|
||||
})
|
||||
|
||||
const downloadPromises = artifacts.map(artifact =>
|
||||
artifactClient.downloadArtifact(artifact.id, {
|
||||
path: inputs.separateDirectories
|
||||
? path.join(tmpDir, artifact.name)
|
||||
: tmpDir
|
||||
})
|
||||
)
|
||||
|
||||
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS)
|
||||
for (const chunk of chunkedPromises) {
|
||||
await Promise.all(chunk)
|
||||
}
|
||||
|
||||
const options: UploadArtifactOptions = {}
|
||||
if (inputs.retentionDays) {
|
||||
options.retentionDays = inputs.retentionDays
|
||||
}
|
||||
|
||||
if (typeof inputs.compressionLevel !== 'undefined') {
|
||||
options.compressionLevel = inputs.compressionLevel
|
||||
}
|
||||
|
||||
const searchResult = await findFilesToUpload(
|
||||
tmpDir,
|
||||
inputs.includeHiddenFiles
|
||||
)
|
||||
|
||||
await uploadArtifact(
|
||||
inputs.name,
|
||||
searchResult.filesToUpload,
|
||||
searchResult.rootDirectory,
|
||||
options
|
||||
)
|
||||
|
||||
core.info(
|
||||
`The ${artifacts.length} artifact(s) have been successfully merged!`
|
||||
)
|
||||
|
||||
if (inputs.deleteMerged) {
|
||||
const deletePromises = artifacts.map(artifact =>
|
||||
artifactClient.deleteArtifact(artifact.name)
|
||||
)
|
||||
await Promise.all(deletePromises)
|
||||
core.info(`The ${artifacts.length} artifact(s) have been deleted`)
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(tmpDir, {recursive: true})
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Unable to remove temporary directory: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
38
src/merge/merge-inputs.ts
Normal file
38
src/merge/merge-inputs.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface MergeInputs {
|
||||
/**
|
||||
* The name of the artifact that the artifacts will be merged into
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* A glob pattern matching the artifacts that should be merged.
|
||||
*/
|
||||
pattern: string
|
||||
|
||||
/**
|
||||
* Duration after which artifact will expire in days
|
||||
*/
|
||||
retentionDays: number
|
||||
|
||||
/**
|
||||
* The level of compression for Zlib to be applied to the artifact archive.
|
||||
*/
|
||||
compressionLevel?: number
|
||||
|
||||
/**
|
||||
* If true, the artifacts that were merged will be deleted.
|
||||
* If false, the artifacts will still exist.
|
||||
*/
|
||||
deleteMerged: boolean
|
||||
|
||||
/**
|
||||
* If true, the artifacts will be merged into separate directories.
|
||||
* If false, the artifacts will be merged into the root of the destination.
|
||||
*/
|
||||
separateDirectories: boolean
|
||||
|
||||
/**
|
||||
* Whether or not to include hidden files in the artifact
|
||||
*/
|
||||
includeHiddenFiles: boolean
|
||||
}
|
||||
@@ -11,11 +11,12 @@ export interface SearchResult {
|
||||
rootDirectory: string
|
||||
}
|
||||
|
||||
function getDefaultGlobOptions(): glob.GlobOptions {
|
||||
function getDefaultGlobOptions(includeHiddenFiles: boolean): glob.GlobOptions {
|
||||
return {
|
||||
followSymbolicLinks: true,
|
||||
implicitDescendants: true,
|
||||
omitBrokenSymbolicLinks: true
|
||||
omitBrokenSymbolicLinks: true,
|
||||
excludeHiddenFiles: !includeHiddenFiles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +81,12 @@ function getMultiPathLCA(searchPaths: string[]): string {
|
||||
|
||||
export async function findFilesToUpload(
|
||||
searchPath: string,
|
||||
globOptions?: glob.GlobOptions
|
||||
includeHiddenFiles?: boolean
|
||||
): Promise<SearchResult> {
|
||||
const searchResults: string[] = []
|
||||
const globber = await glob.create(
|
||||
searchPath,
|
||||
globOptions || getDefaultGlobOptions()
|
||||
getDefaultGlobOptions(includeHiddenFiles || false)
|
||||
)
|
||||
const rawSearchResults: string[] = await globber.glob()
|
||||
|
||||
29
src/shared/upload-artifact.ts
Normal file
29
src/shared/upload-artifact.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import artifact, {UploadArtifactOptions} from '@actions/artifact'
|
||||
|
||||
export async function uploadArtifact(
|
||||
artifactName: string,
|
||||
filesToUpload: string[],
|
||||
rootDirectory: string,
|
||||
options: UploadArtifactOptions
|
||||
) {
|
||||
const uploadResponse = await artifact.uploadArtifact(
|
||||
artifactName,
|
||||
filesToUpload,
|
||||
rootDirectory,
|
||||
options
|
||||
)
|
||||
|
||||
core.info(
|
||||
`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-digest', uploadResponse.digest)
|
||||
|
||||
const repository = github.context.repo
|
||||
const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}`
|
||||
|
||||
core.info(`Artifact download URL: ${artifactURL}`)
|
||||
core.setOutput('artifact-url', artifactURL)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import artifact, {
|
||||
UploadArtifactOptions,
|
||||
ArtifactNotFoundError
|
||||
} from '@actions/artifact'
|
||||
import {findFilesToUpload} from './search'
|
||||
import {getInputs} from './input-helper'
|
||||
import {NoFileOptions} from './constants'
|
||||
|
||||
async function deleteArtifactIfExists(artifactName: string): Promise<void> {
|
||||
try {
|
||||
await artifact.deleteArtifact(artifactName)
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactNotFoundError) {
|
||||
core.debug(`Skipping deletion of '${artifactName}', it does not exist`)
|
||||
return
|
||||
}
|
||||
|
||||
// Best effort, we don't want to fail the action if this fails
|
||||
core.debug(`Unable to delete artifact: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const inputs = getInputs()
|
||||
const searchResult = await findFilesToUpload(inputs.searchPath)
|
||||
if (searchResult.filesToUpload.length === 0) {
|
||||
// No files were found, different use cases warrant different types of behavior if nothing is found
|
||||
switch (inputs.ifNoFilesFound) {
|
||||
case NoFileOptions.warn: {
|
||||
core.warning(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
case NoFileOptions.error: {
|
||||
core.setFailed(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
case NoFileOptions.ignore: {
|
||||
core.info(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const s = searchResult.filesToUpload.length === 1 ? '' : 's'
|
||||
core.info(
|
||||
`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`
|
||||
)
|
||||
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
|
||||
|
||||
if (inputs.overwrite) {
|
||||
await deleteArtifactIfExists(inputs.artifactName)
|
||||
}
|
||||
|
||||
const options: UploadArtifactOptions = {}
|
||||
if (inputs.retentionDays) {
|
||||
options.retentionDays = inputs.retentionDays
|
||||
}
|
||||
|
||||
if (typeof inputs.compressionLevel !== 'undefined') {
|
||||
options.compressionLevel = inputs.compressionLevel
|
||||
}
|
||||
|
||||
const uploadResponse = await artifact.uploadArtifact(
|
||||
inputs.artifactName,
|
||||
searchResult.filesToUpload,
|
||||
searchResult.rootDirectory,
|
||||
options
|
||||
)
|
||||
|
||||
core.info(
|
||||
`Artifact ${inputs.artifactName} has been successfully uploaded! Final size is ${uploadResponse.size} bytes. Artifact ID is ${uploadResponse.id}`
|
||||
)
|
||||
core.setOutput('artifact-id', uploadResponse.id)
|
||||
|
||||
const repository = github.context.repo
|
||||
const artifactURL = `${github.context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${github.context.runId}/artifacts/${uploadResponse.id}`
|
||||
|
||||
core.info(`Artifact download URL: ${artifactURL}`)
|
||||
core.setOutput('artifact-url', artifactURL)
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -5,7 +5,8 @@ export enum Inputs {
|
||||
IfNoFilesFound = 'if-no-files-found',
|
||||
RetentionDays = 'retention-days',
|
||||
CompressionLevel = 'compression-level',
|
||||
Overwrite = 'overwrite'
|
||||
Overwrite = 'overwrite',
|
||||
IncludeHiddenFiles = 'include-hidden-files'
|
||||
}
|
||||
|
||||
export enum NoFileOptions {
|
||||
6
src/upload/index.ts
Normal file
6
src/upload/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as core from '@actions/core'
|
||||
import {run} from './upload-artifact.js'
|
||||
|
||||
run().catch(error => {
|
||||
core.setFailed((error as Error).message)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as core from '@actions/core'
|
||||
import {Inputs, NoFileOptions} from './constants'
|
||||
import {UploadInputs} from './upload-inputs'
|
||||
import {Inputs, NoFileOptions} from './constants.js'
|
||||
import {UploadInputs} from './upload-inputs.js'
|
||||
|
||||
/**
|
||||
* Helper to get all the inputs for the action
|
||||
@@ -9,6 +9,7 @@ export function getInputs(): UploadInputs {
|
||||
const name = core.getInput(Inputs.Name)
|
||||
const path = core.getInput(Inputs.Path, {required: true})
|
||||
const overwrite = core.getBooleanInput(Inputs.Overwrite)
|
||||
const includeHiddenFiles = core.getBooleanInput(Inputs.IncludeHiddenFiles)
|
||||
|
||||
const ifNoFilesFound = core.getInput(Inputs.IfNoFilesFound)
|
||||
const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound]
|
||||
@@ -27,7 +28,8 @@ export function getInputs(): UploadInputs {
|
||||
artifactName: name,
|
||||
searchPath: path,
|
||||
ifNoFilesFound: noFileBehavior,
|
||||
overwrite: overwrite
|
||||
overwrite: overwrite,
|
||||
includeHiddenFiles: includeHiddenFiles
|
||||
} as UploadInputs
|
||||
|
||||
const retentionDaysStr = core.getInput(Inputs.RetentionDays)
|
||||
80
src/upload/upload-artifact.ts
Normal file
80
src/upload/upload-artifact.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as core from '@actions/core'
|
||||
import artifact, {
|
||||
UploadArtifactOptions,
|
||||
ArtifactNotFoundError
|
||||
} from '@actions/artifact'
|
||||
import {findFilesToUpload} from '../shared/search.js'
|
||||
import {getInputs} from './input-helper.js'
|
||||
import {NoFileOptions} from './constants.js'
|
||||
import {uploadArtifact} from '../shared/upload-artifact.js'
|
||||
|
||||
async function deleteArtifactIfExists(artifactName: string): Promise<void> {
|
||||
try {
|
||||
await artifact.deleteArtifact(artifactName)
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactNotFoundError) {
|
||||
core.debug(`Skipping deletion of '${artifactName}', it does not exist`)
|
||||
return
|
||||
}
|
||||
|
||||
// Best effort, we don't want to fail the action if this fails
|
||||
core.debug(`Unable to delete artifact: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const inputs = getInputs()
|
||||
const searchResult = await findFilesToUpload(
|
||||
inputs.searchPath,
|
||||
inputs.includeHiddenFiles
|
||||
)
|
||||
if (searchResult.filesToUpload.length === 0) {
|
||||
// No files were found, different use cases warrant different types of behavior if nothing is found
|
||||
switch (inputs.ifNoFilesFound) {
|
||||
case NoFileOptions.warn: {
|
||||
core.warning(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
case NoFileOptions.error: {
|
||||
core.setFailed(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
case NoFileOptions.ignore: {
|
||||
core.info(
|
||||
`No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const s = searchResult.filesToUpload.length === 1 ? '' : 's'
|
||||
core.info(
|
||||
`With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded`
|
||||
)
|
||||
core.debug(`Root artifact directory is ${searchResult.rootDirectory}`)
|
||||
|
||||
if (inputs.overwrite) {
|
||||
await deleteArtifactIfExists(inputs.artifactName)
|
||||
}
|
||||
|
||||
const options: UploadArtifactOptions = {}
|
||||
if (inputs.retentionDays) {
|
||||
options.retentionDays = inputs.retentionDays
|
||||
}
|
||||
|
||||
if (typeof inputs.compressionLevel !== 'undefined') {
|
||||
options.compressionLevel = inputs.compressionLevel
|
||||
}
|
||||
|
||||
await uploadArtifact(
|
||||
inputs.artifactName,
|
||||
searchResult.filesToUpload,
|
||||
searchResult.rootDirectory,
|
||||
options
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {NoFileOptions} from './constants'
|
||||
import {NoFileOptions} from './constants.js'
|
||||
|
||||
export interface UploadInputs {
|
||||
/**
|
||||
@@ -30,4 +30,9 @@ export interface UploadInputs {
|
||||
* Whether or not to replace an existing artifact with the same name
|
||||
*/
|
||||
overwrite: boolean
|
||||
|
||||
/**
|
||||
* Whether or not to include hidden files in the artifact
|
||||
*/
|
||||
includeHiddenFiles: boolean
|
||||
}
|
||||
8
tsconfig.eslint.json
Normal file
8
tsconfig.eslint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "__tests__/**/*.ts", "*.ts"],
|
||||
"exclude": ["node_modules", "lib", "dist"]
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"lib": ["es6"]
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts", "jest.config.ts", "__tests__"]
|
||||
}
|
||||
Reference in New Issue
Block a user