From 45300bc698dba34302a84420f230891dabbcc152 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 12 Jun 2026 07:32:01 +0200 Subject: [PATCH] fix(bundler): accept .zip archives produced by apm pack (apm 0.20+) apm 0.20 changed `apm pack --archive` to emit a `.zip` archive by default (microsoft/apm#1720). The action's bundle-detection paths still assumed `.tar.gz`, so against apm 0.20+ they silently failed: - `findBundleOrNull` filtered `entries.endsWith('.tar.gz')`, found zero matches for the produced `.zip`, returned null, and the pack step errored "apm pack produced no bundle" (the GH-AW Compatibility job in microsoft/apm release run 27394583646). - `detectBundleFormat` listed contents with `tar tzf`, which cannot read a `.zip` on GNU tar (Linux runners), failing before the format gate. - The `bundles-file` (multibundle restore) parser hard-rejected any entry not ending in `.tar.gz`. Fix: accept BOTH `.zip` and `.tar.gz` on the detection side rather than pinning `--archive-format` on the pack call. The action supports a range of apm versions via `apm-version`; pinning the flag would break older CLIs that do not know it. A new `listArchiveEntries` helper is format-aware (`unzip -Z1` for `.zip`, `tar tzf` for `.tar.gz`); both runners ship `unzip` and its short listing matches the existing marker regexes. Scope note: the `extractBundle` tar-fallback (reached only when apm is unavailable -- a near-dead path, since the primary `apm unpack` handles .zip natively) still extracts `.tar.gz` only. `unzip` has no `--strip-components`/exclude equivalent, so zip-aware fallback extraction is deferred with an in-code NOTE. Tests: +4 regression traps (single .zip found; .zip+.tar.gz ambiguity; .zip format detection via unzip -Z1; multibundle .zip acceptance), each proven by the mutation-break gate. dist/ rebuilt via ncc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- action.yml | 10 ++--- dist/970.index.js | 16 ++++---- dist/bundler.d.ts | 4 +- dist/index.js | 61 ++++++++++++++++++++------- dist/multibundle.d.ts | 6 +-- src/__tests__/bundler.test.ts | 51 ++++++++++++++++++++++- src/__tests__/multibundle.test.ts | 24 +++++++---- src/bundler.ts | 68 +++++++++++++++++++++++-------- src/multibundle.ts | 16 ++++---- 9 files changed, 192 insertions(+), 64 deletions(-) diff --git a/action.yml b/action.yml index 30d5826..676bb6b 100644 --- a/action.yml +++ b/action.yml @@ -35,7 +35,7 @@ inputs: required: false default: 'false' pack: - description: 'Pack a bundle after install. Format is controlled by `bundle-format` (default: apm). Produces .tar.gz by default.' + description: 'Pack a bundle after install. Format is controlled by `bundle-format` (default: apm). Archived output is .zip by default (apm 0.20+); older CLIs emit .tar.gz. Both are accepted on restore.' required: false default: 'false' bundle-format: @@ -57,9 +57,9 @@ inputs: bundles-file: description: | Path to a UTF-8 text file with one bundle path per line (paths must end - in '.tar.gz'). Lines starting with '#' are comments; blank lines are - ignored. Glob patterns are NOT expanded -- generate the list yourself - with 'find ... | sort' or equivalent. + in '.zip' or '.tar.gz'). Lines starting with '#' are comments; blank + lines are ignored. Glob patterns are NOT expanded -- generate the list + yourself with 'find ... | sort' or equivalent. Bundles are restored in caller-specified order (later bundles win on file collisions; the action emits a warning before the loop runs so the policy is explicit). Mutually exclusive with 'pack' and 'bundle'. @@ -70,7 +70,7 @@ inputs: required: false default: '' archive: - description: 'Produce .tar.gz instead of directory (used with pack: true)' + description: 'Produce a single-file archive instead of a directory (used with pack: true). The archive is .zip by default on apm 0.20+ (.tar.gz on older CLIs); both extensions are recognized.' required: false default: 'true' marketplace: diff --git a/dist/970.index.js b/dist/970.index.js index 578d26d..22db0a4 100644 --- a/dist/970.index.js +++ b/dist/970.index.js @@ -78,9 +78,9 @@ function buildStrippedEnv() { * - '..' segment in any path -> reject with line number (B3). * - Relative paths resolved against opts.workspaceDir; rejected if they escape it (B1). * - Absolute paths allowed (matches existing bundle: behaviour, B1). - * - Each entry must end in `.tar.gz` (defence-in-depth + clear early failure - * if a user accidentally points at a directory or wrong file). Glob patterns - * are NOT expanded; use `find ... | sort` to generate the list yourself. + * - Each entry must end in `.zip` or `.tar.gz` (defence-in-depth + clear early + * failure if a user accidentally points at a directory or wrong file). Glob + * patterns are NOT expanded; use `find ... | sort` to generate the list. * - Empty list after stripping -> hard error. * - Duplicates deduped silently (first occurrence wins). * - Cap at opts.maxBundles (default 64, env APM_MAX_BUNDLES) (B5). @@ -131,11 +131,13 @@ function parseBundleListFile(filePath, opts) { if (segments.some(seg => seg === '..')) { throw new Error(`bundles-file line ${lineNum}: rejected '..' segment in path: ${trimmed}`); } - // Require .tar.gz extension. Globs are not expanded; bare paths only. - // Catches mis-configured list files (typo, directory, or wildcard left + // Require a recognized archive extension (.zip default on apm 0.20+, or + // legacy .tar.gz). Globs are not expanded; bare paths only. Catches + // mis-configured list files (typo, directory, or wildcard left // unexpanded) at parse time rather than surfacing as a confusing tar error. - if (!trimmed.toLowerCase().endsWith('.tar.gz')) { - throw new Error(`bundles-file line ${lineNum}: entry must end in '.tar.gz' ` + const lower = trimmed.toLowerCase(); + if (!lower.endsWith('.zip') && !lower.endsWith('.tar.gz')) { + throw new Error(`bundles-file line ${lineNum}: entry must end in '.zip' or '.tar.gz' ` + `(globs are not expanded; use find or ls to generate the list): ${trimmed}`); } const isAbs = path__WEBPACK_IMPORTED_MODULE_3__.isAbsolute(trimmed); diff --git a/dist/bundler.d.ts b/dist/bundler.d.ts index 33730a6..eaeafd9 100644 --- a/dist/bundler.d.ts +++ b/dist/bundler.d.ts @@ -12,8 +12,8 @@ export declare function resolveLocalBundle(pattern: string, workspaceDir: string /** * Inspect a bundle archive to determine its format without extracting it. * - * Reads the tar table-of-contents (`tar tzf`) and looks for the format - * markers: + * Reads the archive table-of-contents (format-aware: `tar tzf` for .tar.gz, + * `unzip -Z1` for .zip) and looks for the format markers: * - APM bundle: `apm.lock.yaml` (lockfile-driven, .github/.claude trees) * - Plugin bundle: `plugin.json` at the bundle root (Claude Code marketplace * layout, flat agents/skills/commands/instructions/ dirs, no lockfile) diff --git a/dist/index.js b/dist/index.js index d4ab7e7..40e4d72 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32684,11 +32684,35 @@ async function resolveLocalBundle(pattern, workspaceDir) { } return resolvedBundle; } +/** + * List the entry names inside a bundle archive without extracting it. + * + * Format-aware so the action tolerates both archive shapes apm emits: + * .tar.gz (read with `tar tzf`) and .zip (read with `unzip -Z1`, the + * zipinfo short listing -- one entry name per line). GitHub-hosted Linux + * and macOS runners ship both `tar` and `unzip`. Entry names share the + * same shape across both tools (`wrapper/apm.lock.yaml`), so callers can + * apply identical marker matching regardless of archive format. + */ +async function listArchiveEntries(bundlePath) { + const isZip = bundlePath.endsWith('.zip'); + const cmd = isZip ? 'unzip' : 'tar'; + const args = isZip ? ['-Z1', bundlePath] : ['tzf', bundlePath]; + const list = await exec/* getExecOutput */.H(cmd, args, { + ignoreReturnCode: true, + silent: true, + }); + if (list.exitCode !== 0) { + throw new Error(`Failed to list bundle contents (${cmd} ${args[0]} exit ${list.exitCode}): ` + + (list.stderr.trim() || 'unknown error')); + } + return list.stdout.split('\n').map(l => l.trim()).filter(Boolean); +} /** * Inspect a bundle archive to determine its format without extracting it. * - * Reads the tar table-of-contents (`tar tzf`) and looks for the format - * markers: + * Reads the archive table-of-contents (format-aware: `tar tzf` for .tar.gz, + * `unzip -Z1` for .zip) and looks for the format markers: * - APM bundle: `apm.lock.yaml` (lockfile-driven, .github/.claude trees) * - Plugin bundle: `plugin.json` at the bundle root (Claude Code marketplace * layout, flat agents/skills/commands/instructions/ dirs, no lockfile) @@ -32701,15 +32725,7 @@ async function resolveLocalBundle(pattern, workspaceDir) { * inside the wrapper to stay tolerant of archive shape changes. */ async function detectBundleFormat(bundlePath) { - const list = await exec/* getExecOutput */.H('tar', ['tzf', bundlePath], { - ignoreReturnCode: true, - silent: true, - }); - if (list.exitCode !== 0) { - throw new Error(`Failed to list bundle contents (tar tzf exit ${list.exitCode}): ` - + (list.stderr.trim() || 'unknown error')); - } - const entries = list.stdout.split('\n').map(l => l.trim()).filter(Boolean); + const entries = await listArchiveEntries(bundlePath); // APM and plugin bundles always wrap their contents in a single top-level // directory named after the package (e.g. `roundtrip-1.0.0/`). Match the // format markers ONLY at that depth to avoid false positives from a nested @@ -32778,6 +32794,12 @@ async function extractBundle(bundlePath, outputDir) { // deployable output -- the same files that `apm unpack` (the primary path) // intentionally never copies. Leaking them into a git checkout dirties the // workspace and breaks downstream `git checkout` steps. See microsoft/apm-action#26. + // + // NOTE: this fallback handles .tar.gz only. A .zip bundle reaching here + // (apm unavailable) is unsupported -- the primary `apm unpack` path above + // handles .zip natively, so this matters solely for the near-dead + // apm-missing path. Zip-aware fallback extraction (no `tar --strip-components` + // equivalent in `unzip`) is deliberately deferred. lib_core/* info */.pq('APM not available -- extracting with tar (no verification)...'); const rc = await exec/* exec */.m('tar', [ 'xzf', resolvedBundle, @@ -32902,9 +32924,18 @@ async function runPackStep(workingDir, opts) { } return { bundlePath, format: opts.format, marketplaceJsonPath }; } +/** + * Recognized archive extensions, in detection order. `apm pack --archive` + * emits .zip by default as of apm 0.20; older CLIs (and pipelines that opt + * back in with --archive-format tar.gz) emit .tar.gz. The action accepts + * either so it stays compatible across the apm-version range it supports -- + * pinning --archive-format on the pack call would break older CLIs that do + * not know the flag. + */ +const ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz']; /** * Find the bundle output in the build directory. - * For archives: look for .tar.gz files. + * For archives: look for .zip or .tar.gz files. * For directories: look for non-hidden directories. * * Returns null when the build directory is missing or contains no bundle @@ -32923,12 +32954,14 @@ function findBundleOrNull(buildDir, archive) { } const entries = external_fs_.readdirSync(buildDir); if (archive) { - const archives = entries.filter(e => e.endsWith('.tar.gz')).sort(); + const archives = entries + .filter(e => ARCHIVE_EXTENSIONS.some(ext => e.endsWith(ext))) + .sort(); if (archives.length === 0) { return null; } if (archives.length > 1) { - throw new Error(`Multiple .tar.gz archives found in build directory after apm pack: ${archives.join(', ')}`); + throw new Error(`Multiple bundle archives found in build directory after apm pack: ${archives.join(', ')}`); } return external_path_.join(buildDir, archives[0]); } diff --git a/dist/multibundle.d.ts b/dist/multibundle.d.ts index b3d4015..97a39bd 100644 --- a/dist/multibundle.d.ts +++ b/dist/multibundle.d.ts @@ -68,9 +68,9 @@ export declare function buildStrippedEnv(): Record; * - '..' segment in any path -> reject with line number (B3). * - Relative paths resolved against opts.workspaceDir; rejected if they escape it (B1). * - Absolute paths allowed (matches existing bundle: behaviour, B1). - * - Each entry must end in `.tar.gz` (defence-in-depth + clear early failure - * if a user accidentally points at a directory or wrong file). Glob patterns - * are NOT expanded; use `find ... | sort` to generate the list yourself. + * - Each entry must end in `.zip` or `.tar.gz` (defence-in-depth + clear early + * failure if a user accidentally points at a directory or wrong file). Glob + * patterns are NOT expanded; use `find ... | sort` to generate the list. * - Empty list after stripping -> hard error. * - Duplicates deduped silently (first occurrence wins). * - Cap at opts.maxBundles (default 64, env APM_MAX_BUNDLES) (B5). diff --git a/src/__tests__/bundler.test.ts b/src/__tests__/bundler.test.ts index 971eb7d..9070a35 100644 --- a/src/__tests__/bundler.test.ts +++ b/src/__tests__/bundler.test.ts @@ -182,6 +182,37 @@ describe('extractBundle', () => { await expect(extractBundle(bundlePath, tmpDir)) .rejects.toThrow('apm unpack failed with exit code 1'); }); + + it('detects format of a .zip bundle via unzip -Z1 (apm 0.20+ default)', async () => { + const zipBundle = path.join(tmpDir, 'test-bundle.zip'); + fs.writeFileSync(zipBundle, 'fake-zip'); + // .zip must be listed with `unzip -Z1`, not `tar tzf` (GNU tar cannot + // read zip). Return an APM-format listing only for that command. + mockGetExecOutput.mockImplementation(async (cmd, args) => { + if (cmd === 'unzip' && args?.[0] === '-Z1') { + return { + exitCode: 0, + stdout: 'pkg-1.0.0/\npkg-1.0.0/apm.lock.yaml\npkg-1.0.0/.github/agents/foo.md\n', + stderr: '', + }; + } + return { exitCode: 1, stdout: '', stderr: 'wrong tool for archive format' }; + }); + mockExec.mockImplementation(async (cmd, args?) => { + if (cmd === 'apm' && args?.[0] === '--version') return 0; + if (cmd === 'apm' && args?.[0] === 'unpack') return 0; + return 1; + }); + + const result = await extractBundle(zipBundle, tmpDir); + expect(result.format).toBe('apm'); + + const listCall = mockGetExecOutput.mock.calls.find(c => c[0] === 'unzip'); + expect(listCall).toBeTruthy(); + expect(listCall![1]).toEqual(['-Z1', zipBundle]); + // tar tzf must NOT be used for a .zip path. + expect(mockGetExecOutput.mock.calls.some(c => c[0] === 'tar' && (c[1] as string[])?.[0] === 'tzf')).toBe(false); + }); }); describe('runPackStep', () => { @@ -249,13 +280,31 @@ describe('runPackStep', () => { expect(result.bundlePath).toContain('test-pkg-1.0.0'); }); + it('finds a single .zip archive (apm 0.20+ default)', async () => { + fs.writeFileSync(path.join(buildDir, 'test-pkg-1.0.0.zip'), 'fake'); + mockExec.mockResolvedValue(0); + + const result = await runPackStep(tmpDir, { archive: true, format: 'apm' }); + expect(result.bundlePath).toContain('test-pkg-1.0.0.zip'); + expect(result.format).toBe('apm'); + }); + it('throws when multiple archives found', async () => { fs.writeFileSync(path.join(buildDir, 'pkg-a-1.0.tar.gz'), 'fake'); fs.writeFileSync(path.join(buildDir, 'pkg-b-2.0.tar.gz'), 'fake'); mockExec.mockResolvedValue(0); await expect(runPackStep(tmpDir, { archive: true, format: 'apm' })) - .rejects.toThrow('Multiple .tar.gz archives found in build directory after apm pack'); + .rejects.toThrow('Multiple bundle archives found in build directory after apm pack'); + }); + + it('throws when a .zip and a .tar.gz both linger in build/ (stale build dir)', async () => { + fs.writeFileSync(path.join(buildDir, 'pkg-1.0.0.zip'), 'fake'); + fs.writeFileSync(path.join(buildDir, 'pkg-1.0.0.tar.gz'), 'fake'); + mockExec.mockResolvedValue(0); + + await expect(runPackStep(tmpDir, { archive: true, format: 'apm' })) + .rejects.toThrow('Multiple bundle archives found in build directory after apm pack'); }); it('throws when multiple bundle directories found', async () => { diff --git a/src/__tests__/multibundle.test.ts b/src/__tests__/multibundle.test.ts index 81797da..b6a6299 100644 --- a/src/__tests__/multibundle.test.ts +++ b/src/__tests__/multibundle.test.ts @@ -175,24 +175,32 @@ describe('parseBundleListFile', () => { .toThrow(/empty after stripping/); }); - it('rejects entries that do not end in .tar.gz with line number', () => { + it('rejects entries that are not a recognized archive extension with line number', () => { const ok = path.join(workspaceDir, 'ok.tar.gz'); - fs.writeFileSync(listFile, [ok, 'bundle.zip'].join('\n')); + fs.writeFileSync(listFile, [ok, 'bundle.txt'].join('\n')); expect(() => parseBundleListFile(listFile, { workspaceDir })) - .toThrow(/line 2: entry must end in '\.tar\.gz'.*bundle\.zip/); + .toThrow(/line 2: entry must end in '\.zip' or '\.tar\.gz'.*bundle\.txt/); + }); + + it('accepts .zip entries (apm 0.20+ default archive format)', () => { + const tar = path.join(workspaceDir, 'legacy.tar.gz'); + const zip = path.join(workspaceDir, 'modern.zip'); + fs.writeFileSync(listFile, [tar, zip].join('\n')); + const result = parseBundleListFile(listFile, { workspaceDir }); + expect(result).toEqual([tar, zip]); }); it("rejects glob patterns left unexpanded (no shell expansion)", () => { fs.writeFileSync(listFile, '/tmp/bundles/*.tar.gz\n'); // The glob is not a literal .tar.gz file path either (the workspace check // on a literal '*' character is tolerated; the extension check would pass - // since the suffix is .tar.gz). Globs that DON'T end in .tar.gz are caught - // here; literal '*'-suffix paths are caught at unpack time by the OS. - // This test pins the wildcard-without-extension case which is the common - // user mistake (e.g. '/tmp/bundles/*'). + // since the suffix is .tar.gz). Globs that DON'T end in an archive + // extension are caught here; literal '*'-suffix paths are caught at + // unpack time by the OS. This test pins the wildcard-without-extension + // case which is the common user mistake (e.g. '/tmp/bundles/*'). fs.writeFileSync(listFile, '/tmp/bundles/*\n'); expect(() => parseBundleListFile(listFile, { workspaceDir })) - .toThrow(/entry must end in '\.tar\.gz'/); + .toThrow(/entry must end in '\.zip' or '\.tar\.gz'/); }); }); diff --git a/src/bundler.ts b/src/bundler.ts index 825d06e..46d9b48 100644 --- a/src/bundler.ts +++ b/src/bundler.ts @@ -49,11 +49,38 @@ export async function resolveLocalBundle(pattern: string, workspaceDir: string): return resolvedBundle; } +/** + * List the entry names inside a bundle archive without extracting it. + * + * Format-aware so the action tolerates both archive shapes apm emits: + * .tar.gz (read with `tar tzf`) and .zip (read with `unzip -Z1`, the + * zipinfo short listing -- one entry name per line). GitHub-hosted Linux + * and macOS runners ship both `tar` and `unzip`. Entry names share the + * same shape across both tools (`wrapper/apm.lock.yaml`), so callers can + * apply identical marker matching regardless of archive format. + */ +async function listArchiveEntries(bundlePath: string): Promise { + const isZip = bundlePath.endsWith('.zip'); + const cmd = isZip ? 'unzip' : 'tar'; + const args = isZip ? ['-Z1', bundlePath] : ['tzf', bundlePath]; + const list = await exec.getExecOutput(cmd, args, { + ignoreReturnCode: true, + silent: true, + }); + if (list.exitCode !== 0) { + throw new Error( + `Failed to list bundle contents (${cmd} ${args[0]} exit ${list.exitCode}): ` + + (list.stderr.trim() || 'unknown error'), + ); + } + return list.stdout.split('\n').map(l => l.trim()).filter(Boolean); +} + /** * Inspect a bundle archive to determine its format without extracting it. * - * Reads the tar table-of-contents (`tar tzf`) and looks for the format - * markers: + * Reads the archive table-of-contents (format-aware: `tar tzf` for .tar.gz, + * `unzip -Z1` for .zip) and looks for the format markers: * - APM bundle: `apm.lock.yaml` (lockfile-driven, .github/.claude trees) * - Plugin bundle: `plugin.json` at the bundle root (Claude Code marketplace * layout, flat agents/skills/commands/instructions/ dirs, no lockfile) @@ -66,18 +93,7 @@ export async function resolveLocalBundle(pattern: string, workspaceDir: string): * inside the wrapper to stay tolerant of archive shape changes. */ export async function detectBundleFormat(bundlePath: string): Promise { - const list = await exec.getExecOutput('tar', ['tzf', bundlePath], { - ignoreReturnCode: true, - silent: true, - }); - if (list.exitCode !== 0) { - throw new Error( - `Failed to list bundle contents (tar tzf exit ${list.exitCode}): ` - + (list.stderr.trim() || 'unknown error'), - ); - } - - const entries = list.stdout.split('\n').map(l => l.trim()).filter(Boolean); + const entries = await listArchiveEntries(bundlePath); // APM and plugin bundles always wrap their contents in a single top-level // directory named after the package (e.g. `roundtrip-1.0.0/`). Match the // format markers ONLY at that depth to avoid false positives from a nested @@ -157,6 +173,12 @@ export async function extractBundle(bundlePath: string, outputDir: string): Prom // deployable output -- the same files that `apm unpack` (the primary path) // intentionally never copies. Leaking them into a git checkout dirties the // workspace and breaks downstream `git checkout` steps. See microsoft/apm-action#26. + // + // NOTE: this fallback handles .tar.gz only. A .zip bundle reaching here + // (apm unavailable) is unsupported -- the primary `apm unpack` path above + // handles .zip natively, so this matters solely for the near-dead + // apm-missing path. Zip-aware fallback extraction (no `tar --strip-components` + // equivalent in `unzip`) is deliberately deferred. core.info('APM not available -- extracting with tar (no verification)...'); const rc = await exec.exec('tar', [ 'xzf', resolvedBundle, @@ -337,9 +359,19 @@ export async function runPackStep( return { bundlePath, format: opts.format, marketplaceJsonPath }; } +/** + * Recognized archive extensions, in detection order. `apm pack --archive` + * emits .zip by default as of apm 0.20; older CLIs (and pipelines that opt + * back in with --archive-format tar.gz) emit .tar.gz. The action accepts + * either so it stays compatible across the apm-version range it supports -- + * pinning --archive-format on the pack call would break older CLIs that do + * not know the flag. + */ +const ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz']; + /** * Find the bundle output in the build directory. - * For archives: look for .tar.gz files. + * For archives: look for .zip or .tar.gz files. * For directories: look for non-hidden directories. * * Returns null when the build directory is missing or contains no bundle @@ -360,13 +392,15 @@ function findBundleOrNull(buildDir: string, archive: boolean): string | null { const entries = fs.readdirSync(buildDir); if (archive) { - const archives = entries.filter(e => e.endsWith('.tar.gz')).sort(); + const archives = entries + .filter(e => ARCHIVE_EXTENSIONS.some(ext => e.endsWith(ext))) + .sort(); if (archives.length === 0) { return null; } if (archives.length > 1) { throw new Error( - `Multiple .tar.gz archives found in build directory after apm pack: ${archives.join(', ')}`, + `Multiple bundle archives found in build directory after apm pack: ${archives.join(', ')}`, ); } return path.join(buildDir, archives[0]); diff --git a/src/multibundle.ts b/src/multibundle.ts index 8ba850c..76c79f6 100644 --- a/src/multibundle.ts +++ b/src/multibundle.ts @@ -105,9 +105,9 @@ export function buildStrippedEnv(): Record { * - '..' segment in any path -> reject with line number (B3). * - Relative paths resolved against opts.workspaceDir; rejected if they escape it (B1). * - Absolute paths allowed (matches existing bundle: behaviour, B1). - * - Each entry must end in `.tar.gz` (defence-in-depth + clear early failure - * if a user accidentally points at a directory or wrong file). Glob patterns - * are NOT expanded; use `find ... | sort` to generate the list yourself. + * - Each entry must end in `.zip` or `.tar.gz` (defence-in-depth + clear early + * failure if a user accidentally points at a directory or wrong file). Glob + * patterns are NOT expanded; use `find ... | sort` to generate the list. * - Empty list after stripping -> hard error. * - Duplicates deduped silently (first occurrence wins). * - Cap at opts.maxBundles (default 64, env APM_MAX_BUNDLES) (B5). @@ -169,12 +169,14 @@ export function parseBundleListFile(filePath: string, opts?: ParseOptions): stri ); } - // Require .tar.gz extension. Globs are not expanded; bare paths only. - // Catches mis-configured list files (typo, directory, or wildcard left + // Require a recognized archive extension (.zip default on apm 0.20+, or + // legacy .tar.gz). Globs are not expanded; bare paths only. Catches + // mis-configured list files (typo, directory, or wildcard left // unexpanded) at parse time rather than surfacing as a confusing tar error. - if (!trimmed.toLowerCase().endsWith('.tar.gz')) { + const lower = trimmed.toLowerCase(); + if (!lower.endsWith('.zip') && !lower.endsWith('.tar.gz')) { throw new Error( - `bundles-file line ${lineNum}: entry must end in '.tar.gz' ` + `bundles-file line ${lineNum}: entry must end in '.zip' or '.tar.gz' ` + `(globs are not expanded; use find or ls to generate the list): ${trimmed}`, ); }