Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'.
Expand All @@ -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:
Expand Down
16 changes: 9 additions & 7 deletions dist/970.index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions dist/bundler.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 47 additions & 14 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]);
}
Expand Down
6 changes: 3 additions & 3 deletions dist/multibundle.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export declare function buildStrippedEnv(): Record<string, string>;
* - '..' 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).
Expand Down
51 changes: 50 additions & 1 deletion src/__tests__/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
24 changes: 16 additions & 8 deletions src/__tests__/multibundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'/);
});
});

Expand Down
Loading
Loading