From 812dd03728425a3eda68b71feeff7871de50848b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:21:40 +0000 Subject: [PATCH 1/7] Initial plan From 12a2d6a22cd04b0a0ee6e9ff24130d065c531d5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:27:58 +0000 Subject: [PATCH 2/7] feat: support non-zipped artifacts from upload-artifact@v7 Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- main.js | 62 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/main.js b/main.js index a9a385c5..df056ce0 100644 --- a/main.js +++ b/main.js @@ -262,11 +262,11 @@ async function main() { const size = filesize(artifact.size_in_bytes, { base: 10 }) - core.info(`==> Downloading: ${artifact.name}.zip (${size})`) + core.info(`==> Downloading: ${artifact.name} (${size})`) - let zip + let downloadResponse try { - zip = await client.rest.actions.downloadArtifact({ + downloadResponse = await client.rest.actions.downloadArtifact({ owner: owner, repo: repo, artifact_id: artifact.id, @@ -280,9 +280,28 @@ async function main() { } } + core.debug(`Download URL: ${downloadResponse.url}`) + + const response = await fetch(downloadResponse.url) + + if (!response.ok) { + throw new Error(`Failed to download artifact: ${response.statusText}`) + } + + const contentType = response.headers.get('content-type') || '' + const mimeType = contentType.split(';')[0].trim().toLowerCase() + const isZipFile = mimeType === 'application/zip' || + mimeType === 'application/x-zip-compressed' || + mimeType === 'application/zip-compressed' + + core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`) + + const buffer = Buffer.from(await response.arrayBuffer()) + if (skipUnpack) { fs.mkdirSync(path, { recursive: true }) - fs.writeFileSync(`${pathname.join(path, artifact.name)}.zip`, Buffer.from(zip.data), 'binary') + const ext = isZipFile ? '.zip' : '' + fs.writeFileSync(`${pathname.join(path, artifact.name)}${ext}`, buffer, 'binary') continue } @@ -290,23 +309,28 @@ async function main() { fs.mkdirSync(dir, { recursive: true }) - core.startGroup(`==> Extracting: ${artifact.name}.zip`) - if (useUnzip) { - const zipPath = `${pathname.join(dir, artifact.name)}.zip` - fs.writeFileSync(zipPath, Buffer.from(zip.data), 'binary') - await exec.exec("unzip", [zipPath, "-d", dir]) - fs.rmSync(zipPath) + if (!isZipFile) { + core.info(`==> Writing direct file: ${artifact.name}`) + fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary') } else { - const adm = new AdmZip(Buffer.from(zip.data)) - adm.getEntries().forEach((entry) => { - const action = entry.isDirectory ? "creating" : "inflating" - const filepath = pathname.join(dir, entry.entryName) - - core.info(` ${action}: ${filepath}`) - }) - adm.extractAllTo(dir, true) + core.startGroup(`==> Extracting: ${artifact.name}.zip`) + if (useUnzip) { + const zipPath = `${pathname.join(dir, artifact.name)}.zip` + fs.writeFileSync(zipPath, buffer, 'binary') + await exec.exec("unzip", [zipPath, "-d", dir]) + fs.rmSync(zipPath) + } else { + const adm = new AdmZip(buffer) + adm.getEntries().forEach((entry) => { + const action = entry.isDirectory ? "creating" : "inflating" + const filepath = pathname.join(dir, entry.entryName) + + core.info(` ${action}: ${filepath}`) + }) + adm.extractAllTo(dir, true) + } + core.endGroup() } - core.endGroup() } } catch (error) { core.setOutput("found_artifact", false) From 1871e5c796cf7f0674e4d964a11371ac6e6f925e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:11:04 +0000 Subject: [PATCH 3/7] test: add tests for archive:false non-ZIP artifact download Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- main.js | 17 ++++-- main.test.js | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 main.test.js diff --git a/main.js b/main.js index df056ce0..53c5be8f 100644 --- a/main.js +++ b/main.js @@ -6,6 +6,14 @@ import AdmZip from 'adm-zip' import { filesize } from 'filesize' import pathname from 'node:path' import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +export function isZipContentType(contentType) { + const mimeType = (contentType || '').split(';')[0].trim().toLowerCase() + return mimeType === 'application/zip' || + mimeType === 'application/x-zip-compressed' || + mimeType === 'application/zip-compressed' +} async function downloadAction(name, path) { const artifactClient = artifact.create() @@ -289,10 +297,7 @@ async function main() { } const contentType = response.headers.get('content-type') || '' - const mimeType = contentType.split(';')[0].trim().toLowerCase() - const isZipFile = mimeType === 'application/zip' || - mimeType === 'application/x-zip-compressed' || - mimeType === 'application/zip-compressed' + const isZipFile = isZipContentType(contentType) core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`) @@ -356,4 +361,6 @@ async function main() { } } -main() +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} diff --git a/main.test.js b/main.test.js new file mode 100644 index 00000000..16b316f0 --- /dev/null +++ b/main.test.js @@ -0,0 +1,142 @@ +import { test, describe, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import AdmZip from 'adm-zip' + +// main.js is guarded by import.meta.url so importing it does not call main(). +const { isZipContentType } = await import('./main.js') + +// --------------------------------------------------------------------------- +// isZipContentType – unit tests +// --------------------------------------------------------------------------- + +describe('isZipContentType', () => { + test('returns true for application/zip', () => { + assert.equal(isZipContentType('application/zip'), true) + }) + + test('returns true for application/x-zip-compressed', () => { + assert.equal(isZipContentType('application/x-zip-compressed'), true) + }) + + test('returns true for application/zip-compressed', () => { + assert.equal(isZipContentType('application/zip-compressed'), true) + }) + + test('returns true for application/zip with extra parameters', () => { + assert.equal(isZipContentType('application/zip; charset=utf-8'), true) + }) + + test('returns true regardless of case', () => { + assert.equal(isZipContentType('Application/Zip'), true) + assert.equal(isZipContentType('APPLICATION/ZIP'), true) + }) + + // archive: false uploads use application/octet-stream or similar + test('returns false for application/octet-stream (archive: false direct upload)', () => { + assert.equal(isZipContentType('application/octet-stream'), false) + }) + + test('returns false for text/plain', () => { + assert.equal(isZipContentType('text/plain'), false) + }) + + test('returns false for image/png', () => { + assert.equal(isZipContentType('image/png'), false) + }) + + test('returns false for empty string', () => { + assert.equal(isZipContentType(''), false) + }) + + test('returns false for null', () => { + assert.equal(isZipContentType(null), false) + }) + + test('returns false for undefined', () => { + assert.equal(isZipContentType(undefined), false) + }) +}) + +// --------------------------------------------------------------------------- +// Download behaviour – integration tests using a real temp directory +// --------------------------------------------------------------------------- + +describe('artifact download behaviour', () => { + let tmpDir + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test('non-ZIP artifact (archive: false) is written as a plain file', () => { + // Simulate what main.js does when isZipFile is false: + // fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary') + const artifactName = 'my-binary' + const content = 'raw file content' + const buffer = Buffer.from(content) + + // isZipContentType must return false for a direct-upload content type + assert.equal(isZipContentType('application/octet-stream'), false) + + const outputPath = path.join(tmpDir, artifactName) + fs.writeFileSync(outputPath, buffer, 'binary') + + assert.ok(fs.existsSync(outputPath), 'output file should exist') + assert.equal(fs.readFileSync(outputPath, 'utf8'), content) + }) + + test('ZIP artifact is extracted to the target directory', () => { + // Build a minimal in-memory zip containing one entry + const adm = new AdmZip() + adm.addFile('hello.txt', Buffer.from('hello from zip')) + const zipBuffer = adm.toBuffer() + + // isZipContentType must return true for application/zip + assert.equal(isZipContentType('application/zip'), true) + + // Simulate main.js ZIP extraction path (adm-zip branch) + const adm2 = new AdmZip(zipBuffer) + adm2.extractAllTo(tmpDir, true) + + const extracted = path.join(tmpDir, 'hello.txt') + assert.ok(fs.existsSync(extracted), 'extracted file should exist') + assert.equal(fs.readFileSync(extracted, 'utf8'), 'hello from zip') + }) + + test('skip_unpack with non-ZIP writes file without .zip extension', () => { + const artifactName = 'my-report' + const buffer = Buffer.from('report data') + const isZipFile = isZipContentType('application/octet-stream') // false + + const ext = isZipFile ? '.zip' : '' + const outputPath = path.join(tmpDir, `${artifactName}${ext}`) + fs.writeFileSync(outputPath, buffer, 'binary') + + // Should not have .zip extension + assert.ok(fs.existsSync(path.join(tmpDir, 'my-report')), 'file without extension should exist') + assert.ok(!fs.existsSync(path.join(tmpDir, 'my-report.zip')), 'file with .zip extension should not exist') + }) + + test('skip_unpack with ZIP writes file with .zip extension', () => { + const adm = new AdmZip() + adm.addFile('data.txt', Buffer.from('data')) + const artifactName = 'my-archive' + const buffer = adm.toBuffer() + const isZipFile = isZipContentType('application/zip') // true + + const ext = isZipFile ? '.zip' : '' + const outputPath = path.join(tmpDir, `${artifactName}${ext}`) + fs.writeFileSync(outputPath, buffer, 'binary') + + assert.ok(fs.existsSync(path.join(tmpDir, 'my-archive.zip')), 'file with .zip extension should exist') + assert.ok(!fs.existsSync(path.join(tmpDir, 'my-archive')), 'file without extension should not exist') + }) +}) + diff --git a/package.json b/package.json index 90d24314..d0a56a61 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "action-download-artifact", "type": "module", "main": "main.js", + "scripts": { + "test": "node --test main.test.js" + }, "dependencies": { "@actions/artifact": "^6.2.0", "@actions/core": "^3.0.0", From f45bde0077a2f02525845fd2a9d5e96de7982dd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:34:57 +0000 Subject: [PATCH 4/7] ci: add test workflow to run npm test on push and pull_request Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- .github/workflows/test.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..ce3d745b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test From 5136e6dbe57655b638286757b9a47e25d61be097 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:44:53 +0000 Subject: [PATCH 5/7] ci: add upload/download of archive:false (unarchived) artifacts to workflow tests Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- .github/workflows/download.yml | 15 +++++++++++++++ .github/workflows/upload.yml | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/download.yml b/.github/workflows/download.yml index 50390cec..12a2ecbe 100644 --- a/.github/workflows/download.yml +++ b/.github/workflows/download.yml @@ -121,6 +121,20 @@ jobs: ref: ${{github.event.pull_request.head.sha || github.sha}} - name: Test run: cat artifact/sha | grep $GITHUB_SHA + download-unarchived: + runs-on: ubuntu-latest + needs: wait + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download + uses: ./ + with: + workflow: upload.yml + name: artifact-unarchived + path: artifact-unarchived + - name: Test + run: cat artifact-unarchived/artifact-unarchived | grep $GITHUB_SHA download-multiple: runs-on: ubuntu-latest needs: wait @@ -136,6 +150,7 @@ jobs: cat artifact/sha | grep $GITHUB_SHA cat artifact1/sha1 | grep $GITHUB_SHA cat artifact2/sha2 | grep $GITHUB_SHA + cat artifact-unarchived/artifact-unarchived | grep $GITHUB_SHA download-regexp: runs-on: ubuntu-latest needs: wait diff --git a/.github/workflows/upload.yml b/.github/workflows/upload.yml index b5a242d7..102e7b3e 100644 --- a/.github/workflows/upload.yml +++ b/.github/workflows/upload.yml @@ -22,6 +22,17 @@ jobs: with: name: artifact path: artifact + upload-unarchived: + runs-on: ubuntu-latest + steps: + - name: Dump + run: echo $GITHUB_SHA > sha + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: artifact-unarchived + path: sha + archive: false upload-multiple: runs-on: ubuntu-latest steps: From 34686113cd8f54d56f77ee18abd7d2640b80438a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:01:23 +0000 Subject: [PATCH 6/7] ci: fix upload-unarchived artifact name - path is used as name when archive:false Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- .github/workflows/download.yml | 6 +++--- .github/workflows/upload.yml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/download.yml b/.github/workflows/download.yml index 12a2ecbe..3091ac01 100644 --- a/.github/workflows/download.yml +++ b/.github/workflows/download.yml @@ -131,10 +131,10 @@ jobs: uses: ./ with: workflow: upload.yml - name: artifact-unarchived + name: sha path: artifact-unarchived - name: Test - run: cat artifact-unarchived/artifact-unarchived | grep $GITHUB_SHA + run: cat artifact-unarchived/sha | grep $GITHUB_SHA download-multiple: runs-on: ubuntu-latest needs: wait @@ -150,7 +150,7 @@ jobs: cat artifact/sha | grep $GITHUB_SHA cat artifact1/sha1 | grep $GITHUB_SHA cat artifact2/sha2 | grep $GITHUB_SHA - cat artifact-unarchived/artifact-unarchived | grep $GITHUB_SHA + cat sha/sha | grep $GITHUB_SHA download-regexp: runs-on: ubuntu-latest needs: wait diff --git a/.github/workflows/upload.yml b/.github/workflows/upload.yml index 102e7b3e..85479383 100644 --- a/.github/workflows/upload.yml +++ b/.github/workflows/upload.yml @@ -30,7 +30,6 @@ jobs: - name: Upload uses: actions/upload-artifact@v7 with: - name: artifact-unarchived path: sha archive: false upload-multiple: From 4e8c59826ac6fdad7faf468511b0151501e87efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:11:04 +0000 Subject: [PATCH 7/7] fix: use redirect:manual + Location header, URL ZIP fallback, Content-Disposition filename Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com> --- main.js | 20 ++++++++++++++------ main.test.js | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/main.js b/main.js index 53c5be8f..2cfaebe5 100644 --- a/main.js +++ b/main.js @@ -279,6 +279,7 @@ async function main() { repo: repo, artifact_id: artifact.id, archive_format: "zip", + request: { redirect: 'manual' }, }) } catch (error) { if (error.message.startsWith("Artifact has expired")) { @@ -288,18 +289,21 @@ async function main() { } } - core.debug(`Download URL: ${downloadResponse.url}`) + const blobUrl = downloadResponse.headers.location + core.debug(`Download URL: ${blobUrl}`) - const response = await fetch(downloadResponse.url) + const response = await fetch(blobUrl) if (!response.ok) { throw new Error(`Failed to download artifact: ${response.statusText}`) } const contentType = response.headers.get('content-type') || '' - const isZipFile = isZipContentType(contentType) + const urlPath = new URL(blobUrl).pathname.toLowerCase() + const urlIndicatesZip = urlPath.endsWith('.zip') + const isZipFile = isZipContentType(contentType) || urlIndicatesZip - core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`) + core.debug(`Content-Type: ${contentType}, URL path: ${urlPath}, Detected as zip: ${isZipFile}`) const buffer = Buffer.from(await response.arrayBuffer()) @@ -315,8 +319,12 @@ async function main() { fs.mkdirSync(dir, { recursive: true }) if (!isZipFile) { - core.info(`==> Writing direct file: ${artifact.name}`) - fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary') + const contentDisposition = response.headers.get('content-disposition') || '' + const cdMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + const rawFilename = cdMatch ? cdMatch[1].replace(/^['"]|['"]$/g, '').trim() : artifact.name + const filename = pathname.basename(rawFilename) || artifact.name + core.info(`==> Writing direct file: ${filename}`) + fs.writeFileSync(pathname.join(dir, filename), buffer, 'binary') } else { core.startGroup(`==> Extracting: ${artifact.name}.zip`) if (useUnzip) { diff --git a/main.test.js b/main.test.js index 16b316f0..48a50b78 100644 --- a/main.test.js +++ b/main.test.js @@ -77,7 +77,7 @@ describe('artifact download behaviour', () => { test('non-ZIP artifact (archive: false) is written as a plain file', () => { // Simulate what main.js does when isZipFile is false: - // fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary') + // fs.writeFileSync(pathname.join(dir, filename), buffer, 'binary') const artifactName = 'my-binary' const content = 'raw file content' const buffer = Buffer.from(content) @@ -138,5 +138,46 @@ describe('artifact download behaviour', () => { assert.ok(fs.existsSync(path.join(tmpDir, 'my-archive.zip')), 'file with .zip extension should exist') assert.ok(!fs.existsSync(path.join(tmpDir, 'my-archive')), 'file without extension should not exist') }) + + // ------------------------------------------------------------------------- + // URL-based ZIP detection + // ------------------------------------------------------------------------- + test('URL path ending with .zip is detected as ZIP', () => { + const blobUrl = 'https://storage.example.com/artifact-sha.zip?sig=abc' + const urlPath = new URL(blobUrl).pathname.toLowerCase() + assert.ok(urlPath.endsWith('.zip'), 'URL pathname should end with .zip') + }) + + test('URL path not ending with .zip is not detected as ZIP via URL', () => { + const blobUrl = 'https://storage.example.com/artifact-sha?sig=abc' + const urlPath = new URL(blobUrl).pathname.toLowerCase() + assert.ok(!urlPath.endsWith('.zip'), 'URL pathname should not end with .zip') + }) + + // ------------------------------------------------------------------------- + // Content-Disposition filename extraction + // ------------------------------------------------------------------------- + test('Content-Disposition filename is used when present', () => { + // Simulate the filename extraction logic from main.js + function extractFilename(contentDisposition, fallback) { + const cdMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + const rawFilename = cdMatch ? cdMatch[1].replace(/^['"]|['"]$/g, '').trim() : fallback + return path.basename(rawFilename) || fallback + } + + assert.equal(extractFilename('attachment; filename="sha"', 'artifact-name'), 'sha') + assert.equal(extractFilename('attachment; filename=sha', 'artifact-name'), 'sha') + assert.equal(extractFilename('', 'artifact-name'), 'artifact-name') + }) + + test('path.basename sanitizes path traversal in artifact name', () => { + const dangerousName = '../../../etc/passwd' + const safe = path.basename(dangerousName) + assert.equal(safe, 'passwd') + // Ensure writing to a tmpDir using the sanitized name stays within tmpDir + const outputPath = path.join(tmpDir, safe) + fs.writeFileSync(outputPath, 'data', 'utf8') + assert.ok(fs.existsSync(outputPath)) + }) })