Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ jobs:

| Input | Required | Default | Description |
|---|---|---|---|
| `working-directory` | No | `.` | Working directory for execution |
| `working-directory` | No | `.` | Working directory for execution. Must exist in non-isolated mode (with your `apm.yml`). In `isolated`, `pack`, or `bundle` modes the directory is created automatically. |
| `apm-version` | No | `latest` | APM version to install |
| `script` | No | | APM script to run after install |
| `dependencies` | No | | YAML array of extra dependencies to install (additive to apm.yml) |
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ branding:

inputs:
working-directory:
description: 'Working directory for execution'
description: 'Working directory for execution. In non-isolated mode, this directory must exist and contain your apm.yml. In isolated, pack, or bundle modes the action creates it automatically.'
required: false
default: '.'
apm-version:
Expand Down
42 changes: 32 additions & 10 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36968,10 +36968,14 @@ async function resolveLocalBundle(pattern, workspaceDir) {
throw new Error(`Multiple bundles match '${pattern}': ${list}. Use an exact path.`);
}
const resolvedBundle = external_path_.resolve(matches[0]);
// Path traversal protection: ensure resolved path is within workspace
const relative = external_path_.relative(resolvedWorkspace, resolvedBundle);
if (relative.startsWith('..') || external_path_.isAbsolute(relative)) {
throw new Error(`Bundle path "${pattern}" resolves outside the workspace`);
// Path traversal protection for relative patterns: ensure resolved path stays
// within the workspace. Absolute patterns are user-explicit and not checked —
// the user intentionally specified a location (e.g. /tmp/gh-aw/apm-bundle/).
if (!external_path_.isAbsolute(pattern)) {
const relative = external_path_.relative(resolvedWorkspace, resolvedBundle);
if (relative.startsWith('..') || external_path_.isAbsolute(relative)) {
throw new Error(`Bundle path "${pattern}" resolves outside the workspace`);
}
}
return resolvedBundle;
}
Expand Down Expand Up @@ -37125,17 +37129,35 @@ function countFilesRecursive(dir) {
*/
async function run() {
try {
// 0. Resolve working directory (needed by all modes)
// 0. Resolve working directory and read mode flags
const workingDir = getInput('working-directory') || '.';
const resolvedDir = external_path_.resolve(workingDir);
info(`Working directory: ${resolvedDir}`);
// 0b. Read mode inputs
const bundleInput = getInput('bundle').trim();
const packInput = getInput('pack') === 'true';
const isolated = getInput('isolated') === 'true';
// Validate inputs before touching the filesystem.
if (bundleInput && packInput) {
throw new Error("'pack' and 'bundle' inputs are mutually exclusive");
}
// RESTORE MODE: extract bundle, skip APM installation entirely
// Directory creation contract:
// - isolated / pack / bundle (restore) modes: the action owns the workspace
// lifecycle and creates the directory automatically. These modes bootstrap
// everything from scratch — there is no pre-existing project to find.
// - non-isolated mode: the caller owns the project directory (which must
// contain apm.yml). If it doesn't exist, we fail fast with a clear message
// rather than silently creating an empty directory that would just fail later.
const actionOwnsDir = isolated || packInput || !!bundleInput;
if (actionOwnsDir) {
external_fs_namespaceObject.mkdirSync(resolvedDir, { recursive: true });
}
else if (!external_fs_namespaceObject.existsSync(resolvedDir)) {
throw new Error(`Working directory does not exist: ${resolvedDir}. ` +
'In non-isolated mode the directory must already contain your project (with apm.yml). ' +
'Use isolated: true if you want the action to create it automatically.');
}
info(`Working directory: ${resolvedDir}`);
// RESTORE MODE: extract bundle, skip APM installation entirely.
// Directory was already created above (actionOwnsDir = true for bundle mode).
if (bundleInput) {
const bundlePath = await resolveLocalBundle(bundleInput, resolvedDir);
info(`Restoring bundle: ${bundlePath}`);
Expand All @@ -37152,8 +37174,8 @@ async function run() {
await ensureApmInstalled();
// 2. Parse inputs
const depsInput = getInput('dependencies').trim();
const isolated = getInput('isolated') === 'true';
// 4. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only
// 3. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only.
// Directory was already created above (actionOwnsDir = true for isolated mode).
if (isolated) {
if (!depsInput) {
throw new Error('isolated mode requires dependencies input');
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ describe('resolveLocalBundle', () => {
await expect(resolveLocalBundle('../outside/evil.tar.gz', workspace))
.rejects.toThrow('resolves outside the workspace');
});

it('allows absolute bundle paths outside workspace', async () => {
// gh-aw uses: bundle: /tmp/gh-aw/apm-bundle/*.tar.gz
// The bundle is downloaded by actions/download-artifact to /tmp/, which is
// outside GITHUB_WORKSPACE. Absolute paths are user-explicit and should not
// be rejected by the traversal check.
const workspace = '/home/runner/work/gh-aw/gh-aw';
const match = '/tmp/gh-aw/apm-bundle/claude.tar.gz';

mockGlobCreate.mockResolvedValue({
glob: jest.fn<() => Promise<string[]>>().mockResolvedValue([match]),
getSearchPaths: jest.fn<() => string[]>().mockReturnValue([]),
globGenerator: jest.fn(),
});

const result = await resolveLocalBundle('/tmp/gh-aw/apm-bundle/*.tar.gz', workspace);
expect(result).toBe(match);
});
});

describe('extractBundle', () => {
Expand Down
93 changes: 89 additions & 4 deletions src/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,35 @@ import os from 'node:os';
import path from 'node:path';

const mockInfo = jest.fn();
const mockGetInput = jest.fn();
const mockSetOutput = jest.fn();
const mockSetFailed = jest.fn();

jest.unstable_mockModule('@actions/core', () => ({
info: mockInfo,
warning: jest.fn(),
getInput: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
getInput: mockGetInput,
setOutput: mockSetOutput,
setFailed: mockSetFailed,
}));

const { clearPrimitives } = await import('../runner.js');
const mockExec = jest.fn<() => Promise<number>>();
jest.unstable_mockModule('@actions/exec', () => ({
exec: mockExec,
}));
Comment on lines +19 to +22
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

mockExec is typed as a no-arg function, but runApm calls @actions/exec.exec with (command, args, options). Consider typing the mock with the real signature so TypeScript can catch accidental misuse and so expectations like toHaveBeenCalledWith(...) are easier to write/maintain.

Copilot uses AI. Check for mistakes.

const mockEnsureApmInstalled = jest.fn<() => Promise<void>>();
jest.unstable_mockModule('../installer.js', () => ({
ensureApmInstalled: mockEnsureApmInstalled,
}));

jest.unstable_mockModule('../bundler.js', () => ({
resolveLocalBundle: jest.fn(),
extractBundle: jest.fn(),
runPackStep: jest.fn(),
}));

const { clearPrimitives, run } = await import('../runner.js');

describe('clearPrimitives', () => {
let tmpDir: string;
Expand Down Expand Up @@ -112,3 +131,69 @@ describe('clearPrimitives', () => {
expect(clearedCalls).toHaveLength(0);
});
});

describe('run', () => {
let tmpDir: string;

beforeEach(() => {
jest.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apm-action-run-'));
mockEnsureApmInstalled.mockResolvedValue(undefined);
mockExec.mockResolvedValue(0);
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('creates working directory when it does not exist (isolated mode)', async () => {
const nonExistentDir = path.join(tmpDir, 'nested', 'workdir');
expect(fs.existsSync(nonExistentDir)).toBe(false);

mockGetInput.mockImplementation((name: unknown) => {
switch (name) {
case 'working-directory': return nonExistentDir;
case 'dependencies': return 'microsoft/some-package';
case 'isolated': return 'true';
case 'bundle': return '';
case 'pack': return 'false';
case 'compile': return 'false';
case 'script': return '';
default: return '';
}
});

await run();

// Directory was created
expect(fs.existsSync(nonExistentDir)).toBe(true);
// apm.yml was generated inside it
expect(fs.existsSync(path.join(nonExistentDir, 'apm.yml'))).toBe(true);
expect(mockSetFailed).not.toHaveBeenCalled();
});

it('fails fast when working directory does not exist in non-isolated mode', async () => {
const nonExistentDir = path.join(tmpDir, 'does-not-exist');

mockGetInput.mockImplementation((name: unknown) => {
switch (name) {
case 'working-directory': return nonExistentDir;
case 'dependencies': return '';
case 'isolated': return 'false';
case 'bundle': return '';
case 'pack': return 'false';
case 'compile': return 'false';
case 'script': return '';
default: return '';
}
});

await run();

expect(mockSetFailed).toHaveBeenCalledWith(
expect.stringContaining('Working directory does not exist'),
);
// Directory should NOT have been created
expect(fs.existsSync(nonExistentDir)).toBe(false);
});
});
12 changes: 8 additions & 4 deletions src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ export async function resolveLocalBundle(pattern: string, workspaceDir: string):

const resolvedBundle = path.resolve(matches[0]);

// Path traversal protection: ensure resolved path is within workspace
const relative = path.relative(resolvedWorkspace, resolvedBundle);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Bundle path "${pattern}" resolves outside the workspace`);
// Path traversal protection for relative patterns: ensure resolved path stays
// within the workspace. Absolute patterns are user-explicit and not checked —
// the user intentionally specified a location (e.g. /tmp/gh-aw/apm-bundle/).
if (!path.isAbsolute(pattern)) {
const relative = path.relative(resolvedWorkspace, resolvedBundle);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Bundle path "${pattern}" resolves outside the workspace`);
}
}

return resolvedBundle;
Expand Down
33 changes: 26 additions & 7 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,39 @@ import { resolveLocalBundle, extractBundle, runPackStep } from './bundler.js';
*/
export async function run(): Promise<void> {
try {
// 0. Resolve working directory (needed by all modes)
// 0. Resolve working directory and read mode flags
const workingDir = core.getInput('working-directory') || '.';
const resolvedDir = path.resolve(workingDir);
core.info(`Working directory: ${resolvedDir}`);

// 0b. Read mode inputs
const bundleInput = core.getInput('bundle').trim();
const packInput = core.getInput('pack') === 'true';
const isolated = core.getInput('isolated') === 'true';

// Validate inputs before touching the filesystem.
if (bundleInput && packInput) {
throw new Error("'pack' and 'bundle' inputs are mutually exclusive");
}

// RESTORE MODE: extract bundle, skip APM installation entirely
// Directory creation contract:
// - isolated / pack / bundle (restore) modes: the action owns the workspace
// lifecycle and creates the directory automatically. These modes bootstrap
// everything from scratch — there is no pre-existing project to find.
// - non-isolated mode: the caller owns the project directory (which must
// contain apm.yml). If it doesn't exist, we fail fast with a clear message
// rather than silently creating an empty directory that would just fail later.
const actionOwnsDir = isolated || packInput || !!bundleInput;
if (actionOwnsDir) {
fs.mkdirSync(resolvedDir, { recursive: true });
} else if (!fs.existsSync(resolvedDir)) {
throw new Error(
Comment thread
danielmeppiel marked this conversation as resolved.
`Working directory does not exist: ${resolvedDir}. ` +
'In non-isolated mode the directory must already contain your project (with apm.yml). ' +
'Use isolated: true if you want the action to create it automatically.',
);
}
core.info(`Working directory: ${resolvedDir}`);

// RESTORE MODE: extract bundle, skip APM installation entirely.
// Directory was already created above (actionOwnsDir = true for bundle mode).
if (bundleInput) {
const bundlePath = await resolveLocalBundle(bundleInput, resolvedDir);
core.info(`Restoring bundle: ${bundlePath}`);
Expand All @@ -52,9 +71,9 @@ export async function run(): Promise<void> {

// 2. Parse inputs
const depsInput = core.getInput('dependencies').trim();
const isolated = core.getInput('isolated') === 'true';

// 4. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only
// 3. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only.
// Directory was already created above (actionOwnsDir = true for isolated mode).
if (isolated) {
if (!depsInput) {
throw new Error('isolated mode requires dependencies input');
Expand Down