Skip to content

feat: Handle non-zipped artifacts from upload-artifact@v7 (archive: false)#1

Open
Copilot wants to merge 7 commits into
masterfrom
copilot/fix-artifact-download-error
Open

feat: Handle non-zipped artifacts from upload-artifact@v7 (archive: false)#1
Copilot wants to merge 7 commits into
masterfrom
copilot/fix-artifact-download-error

Conversation

Copy link
Copy Markdown

Copilot AI commented Mar 2, 2026

upload-artifact@v7 supports direct file uploads without zipping (archive: false). The action unconditionally treated all downloads as ZIPs, failing with ADM-ZIP: Invalid or unsupported zip format. No END header found.

Changes

  • main.js: Replace blind ZIP extraction with robust format-aware download handling:
    • Use request: { redirect: 'manual' } with downloadArtifact() and read downloadResponse.headers.location to obtain the signed blob URL directly — avoids a second unauthenticated request and works correctly on private repos
    • Detect format via Content-Type using the exported isZipContentType() helper (application/zip, application/x-zip-compressed, application/zip-compressed) — same logic as @actions/artifact
    • Add URL path fallback for ZIP detection: if the blob URL pathname ends with .zip, the artifact is treated as a ZIP regardless of Content-Type — handles cases where ZIP artifacts are served as application/octet-stream
    • ZIP → extract with AdmZip (existing behavior preserved)
    • Non-ZIP → derive filename from Content-Disposition response header, sanitized with path.basename() to prevent path traversal; falls back to artifact.name
    • skip_unpack → omit .zip extension for non-ZIP artifacts
  • main.test.js: Test file using Node.js built-in node:test runner (no extra dependencies) with 19 tests covering:
    • isZipContentType unit tests: all ZIP MIME variants, non-ZIP types (including application/octet-stream used by archive: false), edge cases (null, undefined, case-insensitivity, parameters)
    • Integration tests for download behavior: direct file write for non-ZIP, ZIP extraction, skip_unpack extension handling for both ZIP and non-ZIP
    • URL path-based ZIP detection (.zip pathname suffix)
    • Content-Disposition filename extraction and path.basename path-traversal sanitization
  • package.json: Added "test": "node --test main.test.js" script
  • .github/workflows/test.yml: New CI workflow that runs npm test on every push and pull request (uses Node 20, matching the action runtime)
  • .github/workflows/upload.yml: Added upload-unarchived job that uploads a single file with archive: false (omitting name: since upload-artifact@v7 uses the path value as the artifact name when archive: false)
  • .github/workflows/download.yml: Added download-unarchived job that downloads the artifact named sha (the path used at upload time) and verifies the raw file content; updated download-multiple to also verify the unarchived artifact at sha/sha
const blobUrl = downloadResponse.headers.location
const response = await fetch(blobUrl)

const contentType = response.headers.get('content-type') || ''
const urlPath = new URL(blobUrl).pathname.toLowerCase()
const isZipFile = isZipContentType(contentType) || urlPath.endsWith('.zip')

if (!isZipFile) {
    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
    fs.writeFileSync(pathname.join(dir, filename), buffer, 'binary')
} else {
    const adm = new AdmZip(buffer)
    adm.extractAllTo(dir, true)
}

Fully backward-compatible: legacy zipped artifacts and v7 zipped artifacts continue to work unchanged.

Original prompt

Problem

With the upgrade to upload-artifact@v7, GitHub Actions now supports direct file uploads without zipping (using archive: false). This action currently fails when trying to download these non-zipped artifacts with the error:

Error: ADM-ZIP: Invalid or unsupported zip format. No END header found.

The action assumes all downloaded artifacts are ZIP files and attempts to extract them with adm-zip, which fails for direct file uploads.

Root Cause

The current implementation in main.js always treats downloaded artifacts as ZIP files:

const zip = await client.rest.actions.downloadArtifact({
    owner: owner,
    repo: repo,
    artifact_id: artifact.id,
    archive_format: "zip",
})

// Always tries to extract as ZIP
const adm = new AdmZip(Buffer.from(zip.data))

When artifacts are uploaded with archive: false in v7, they are stored as raw files, not ZIP archives.

Solution

Implement the same detection approach used by the official @actions/artifact package (from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/download/download-artifact.ts):

  1. Fetch the artifact using the URL returned by downloadArtifact() to access HTTP headers
  2. Check the Content-Type header to determine if it's a ZIP file
  3. Handle both cases appropriately:
    • If ZIP: Extract using existing logic
    • If not ZIP: Write the file directly without extraction

Implementation Requirements

1. Add node-fetch dependency

Update package.json to include:

{
  "dependencies": {
    "@actions/artifact": "^6.2.0",
    "@actions/core": "^3.0.0",
    "@actions/github": "^9.0.0",
    "adm-zip": "^0.5.16",
    "filesize": "^11.0.13",
    "node-fetch": "^3.3.2"
  }
}

2. Update main.js download logic

Replace the artifact download and extraction section (around lines 267-310) with the following approach:

// Get the download URL
const downloadResponse = await client.rest.actions.downloadArtifact({
    owner: owner,
    repo: repo,
    artifact_id: artifact.id,
    archive_format: "zip",
})

core.debug(`Download URL: ${downloadResponse.url}`)

// Fetch the artifact with full HTTP response access
const fetch = (await import('node-fetch')).default
const response = await fetch(downloadResponse.url)

if (!response.ok) {
    throw new Error(`Failed to download artifact: ${response.statusText}`)
}

// Get the Content-Type header
const contentType = response.headers.get('content-type') || ''
const mimeType = contentType.split(';')[0].trim().toLowerCase()

// Check if it's a zip based on content-type (same logic as @actions/artifact)
const isZipFile = mimeType === 'application/zip' ||
                  mimeType === 'application/x-zip-compressed' ||
                  mimeType === 'application/zip-compressed'

core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`)

// Get the data
const buffer = Buffer.from(await response.arrayBuffer())

if (skipUnpack) {
    fs.mkdirSync(path, { recursive: true })
    const ext = isZipFile ? '.zip' : ''
    fs.writeFileSync(`${pathname.join(path, artifact.name)}${ext}`, buffer, 'binary')
    continue
}

const dir = name && (!nameIsRegExp || mergeMultiple) ? path : pathname.join(path, artifact.name)
fs.mkdirSync(dir, { recursive: true })

if (!isZipFile) {
    // Direct file upload - write the file as-is
    core.info(`==> Writing direct file: ${artifact.name}`)
    fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary')
} else {
    // ZIP file - extract as before
    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()
}

Benefits

  1. Standards-Based: Uses HTTP Content-Type header (same as official actions)
  2. Backward Compatible: Existing zipped artifacts continue to work
  3. Future-Proof: Automatically handles new artifact formats
  4. Reliable: More robust than magic byte detection
  5. Consistent: Matches the official @actions/artifact implementation

Testing Considerations

The implementation should work with:

  • ✅ Legacy zipped artifacts (pre-v7)
  • ✅ Zipped artifacts from v7+ (default archive: true)
  • ✅ Direct file uploads from v7+ (archive: false)
  • ✅ The skip_unpack option
  • ✅ The use_unzip option

References

This pull request was created from Copilot chat.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Fix artifact download error for non-zipped uploads feat: Handle non-zipped artifacts from upload-artifact@v7 (archive: false) Mar 2, 2026
Copilot AI and others added 2 commits March 2, 2026 15:55
Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
@wdconinc wdconinc force-pushed the copilot/fix-artifact-download-error branch from c1c99ec to 12a2d6a Compare March 2, 2026 22:02
@wdconinc
Copy link
Copy Markdown
Owner

wdconinc commented Mar 2, 2026

@copilot Add tests for the upload-artifact functionality with archive: false.

@wdconinc wdconinc marked this pull request as ready for review March 2, 2026 22:03
Copilot AI review requested due to automatic review settings March 2, 2026 22:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the artifact download/extract flow to support upload-artifact@v7 artifacts uploaded with archive: false (raw/non-zip), avoiding failures from attempting ZIP extraction on non-zip downloads.

Changes:

  • Switch artifact handling from unconditional ZIP extraction to detecting ZIP vs non-ZIP via Content-Type.
  • For non-zip downloads, write the payload directly to disk; for zip downloads, preserve existing unzip/AdmZip extraction paths.
  • Adjust skip_unpack behavior to avoid appending .zip for non-zip artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.js Outdated
Comment on lines +283 to +286
core.debug(`Download URL: ${downloadResponse.url}`)

const response = await fetch(downloadResponse.url)

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadArtifact() is called without request: { redirect: 'manual' }, then the code fetches downloadResponse.url (the API endpoint) without any auth headers. For private repos this fetch will likely 401/404, and it may also trigger an unnecessary extra download. Consider mirroring @actions/artifact: request manual redirect, read downloadResponse.headers.location, and fetch the signed blob URL instead.

Copilot uses AI. Check for mistakes.
Comment thread main.js Outdated
Comment on lines +293 to +297
const isZipFile = mimeType === 'application/zip' ||
mimeType === 'application/x-zip-compressed' ||
mimeType === 'application/zip-compressed'

core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ZIP detection only checks Content-Type. Blob downloads can come back as application/octet-stream, and @actions/artifact also falls back to checking whether the final URL path ends with .zip (ignoring query params). Without that fallback, some ZIP artifacts may be misdetected as non-zip and written as a raw file.

Suggested change
const isZipFile = mimeType === 'application/zip' ||
mimeType === 'application/x-zip-compressed' ||
mimeType === 'application/zip-compressed'
core.debug(`Content-Type: ${contentType}, Detected as zip: ${isZipFile}`)
const urlPath = new URL(downloadResponse.url).pathname.toLowerCase()
const urlIndicatesZip = urlPath.endsWith('.zip')
const isZipFile = mimeType === 'application/zip' ||
mimeType === 'application/x-zip-compressed' ||
mimeType === 'application/zip-compressed' ||
urlIndicatesZip
core.debug(`Content-Type: ${contentType}, URL path: ${urlPath}, Detected as zip: ${isZipFile}`)

Copilot uses AI. Check for mistakes.
Comment thread main.js Outdated
Comment on lines +312 to +314
if (!isZipFile) {
core.info(`==> Writing direct file: ${artifact.name}`)
fs.writeFileSync(pathname.join(dir, artifact.name), buffer, 'binary')
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For non-zip artifacts, the file is written as path.join(dir, artifact.name). artifact.name is user-controlled and may not match the original uploaded filename (for archive:false), and it can also contain path separators. Consider deriving the filename from the Content-Disposition header (and sanitizing with path.basename) similar to @actions/artifact, to preserve the correct name and prevent path traversal.

Copilot uses AI. Check for mistakes.
Comment thread main.js Outdated
Comment on lines +285 to +286
const response = await fetch(downloadResponse.url)

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions adding node-fetch, but the implementation uses the Node 20 global fetch and package.json does not include node-fetch. Please either update the description (and avoid adding an unused dependency) or explicitly import/use the intended dependency for consistency.

Copilot uses AI. Check for mistakes.
Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Mar 2, 2026

@copilot Add tests for the upload-artifact functionality with archive: false.

Added tests in commit 1871e5c using Node.js's built-in node:test runner (no new test dependencies needed).

Changes:

  • main.js: Extracted isZipContentType(contentType) as an exported named function (replacing the inline logic), and added an import.meta.url guard so the module can be imported by tests without running main().
  • main.test.js: 15 tests across two suites:
    • isZipContentType – 11 unit tests covering all ZIP MIME types, non-ZIP types (including application/octet-stream used by archive: false), edge cases (empty string, null, undefined, case-insensitivity, parameters)
    • artifact download behaviour – 4 integration tests using real temp directories: direct file write for non-ZIP, ZIP extraction with adm-zip, and skip_unpack extension behavior for both ZIP and non-ZIP
  • package.json: Added "test": "node --test main.test.js" script

@wdconinc

This comment was marked as resolved.

Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Mar 2, 2026

@copilot Add a test that is run in CI as well.

Added .github/workflows/test.yml in commit f45bde0. It runs on every push and pull request, checks out the code, sets up Node 20 (matching the action runtime), installs dependencies, and runs npm test.

@wdconinc

This comment was marked as resolved.

…rkflow tests

Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Mar 3, 2026

@copilot Modify the .github/workflows/upload.yml and .github/workflows/download.yml tests to upload both archived and regular files, and to verify that downloads of both of them succeed.

Done in commit 5136e6d.

  • upload.yml: Added upload-unarchived job that uploads a single file (sha containing $GITHUB_SHA) with archive: false.
  • download.yml: Added download-unarchived job that downloads artifact-unarchived and verifies cat artifact-unarchived/artifact-unarchived | grep $GITHUB_SHA. Also updated download-multiple to include the same check so the all-artifacts download path is covered too.

Comment thread .github/workflows/upload.yml
…rchive:false

Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
@wdconinc
Copy link
Copy Markdown
Owner

wdconinc commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

…-Disposition filename

Co-authored-by: wdconinc <4656391+wdconinc@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

Applied all three review changes in commit 4e8c598:

  1. Redirect handling (main.js:291-294): Added request: { redirect: 'manual' } to downloadArtifact() and use downloadResponse.headers.location as the signed blob URL — avoids the double-download and auth issues on private repos.

  2. URL path ZIP fallback (main.js:293-297): Added new URL(blobUrl).pathname.toLowerCase().endsWith('.zip') as an OR condition alongside isZipContentType(contentType), so ZIP artifacts served as application/octet-stream are still correctly identified.

  3. Content-Disposition filename (main.js:317-319): For non-ZIP writes, parse the Content-Disposition response header to get the actual uploaded filename, sanitized with pathname.basename() to prevent path traversal. Falls back to artifact.name when the header is absent.

Also added 4 new tests covering URL-based ZIP detection and Content-Disposition filename extraction/sanitization (19 total, all passing).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants