From 07adf660c556d8f850baa7e7cd372dead462aae7 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 04:46:34 +0100 Subject: [PATCH 1/6] fix: create working directory before generating manifest In isolated mode with a non-existent working directory (e.g. /tmp/gh-aw/apm-workspace), generateManifest fails with ENOENT because the directory doesn't exist yet. Add fs.mkdirSync(resolvedDir, { recursive: true }) in run() right after resolving the working directory, before any file operations. Also adds run() test coverage for isolated mode with a non-existent directory, and expands mock setup to support full run() testing. --- dist/index.js | 1 + src/__tests__/runner.test.ts | 69 +++++++++++++++++++++++++++++++++--- src/runner.ts | 1 + 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/dist/index.js b/dist/index.js index 113532c..3390d6b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -37128,6 +37128,7 @@ async function run() { // 0. Resolve working directory (needed by all modes) const workingDir = getInput('working-directory') || '.'; const resolvedDir = external_path_.resolve(workingDir); + external_fs_namespaceObject.mkdirSync(resolvedDir, { recursive: true }); info(`Working directory: ${resolvedDir}`); // 0b. Read mode inputs const bundleInput = getInput('bundle').trim(); diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index fff193a..388e53a 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -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>(); +jest.unstable_mockModule('@actions/exec', () => ({ + exec: mockExec, +})); + +const mockEnsureApmInstalled = jest.fn<() => Promise>(); +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; @@ -112,3 +131,45 @@ 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); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGetInput.mockImplementation(((name: string) => { + 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 ''; + } + }) as any); + + 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(); + }); +}); diff --git a/src/runner.ts b/src/runner.ts index d56cf0a..82c4714 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -22,6 +22,7 @@ export async function run(): Promise { // 0. Resolve working directory (needed by all modes) const workingDir = core.getInput('working-directory') || '.'; const resolvedDir = path.resolve(workingDir); + fs.mkdirSync(resolvedDir, { recursive: true }); core.info(`Working directory: ${resolvedDir}`); // 0b. Read mode inputs From e9eaba84696053fa1ea347d423e0497f5796d74e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 04:57:52 +0100 Subject: [PATCH 2/6] fix: allow absolute bundle paths in restore mode resolveLocalBundle rejected any bundle path outside the working directory, including absolute paths like /tmp/gh-aw/apm-bundle/*.tar.gz that the user explicitly specified. This blocks gh-aw's restore step where download-artifact puts the bundle in /tmp/. The path traversal check now only applies to relative patterns (where traversal via ../ is the actual risk). Absolute paths are user-explicit and bypass the containment check. Added test: 'allows absolute bundle paths outside workspace'. --- dist/index.js | 12 ++++++++---- src/__tests__/bundler.test.ts | 18 ++++++++++++++++++ src/bundler.ts | 12 ++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/dist/index.js b/dist/index.js index 3390d6b..f28b40c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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; } diff --git a/src/__tests__/bundler.test.ts b/src/__tests__/bundler.test.ts index aaaf53a..d515c9e 100644 --- a/src/__tests__/bundler.test.ts +++ b/src/__tests__/bundler.test.ts @@ -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>().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', () => { diff --git a/src/bundler.ts b/src/bundler.ts index c559996..8d1bd58 100644 --- a/src/bundler.ts +++ b/src/bundler.ts @@ -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; From 3225bfa45dd37bed4f0068f9910b83b8d8f10e7a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 05:03:02 +0100 Subject: [PATCH 3/6] fix: resolve eslint no-explicit-any in runner test --- src/__tests__/runner.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index 388e53a..f389607 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -150,8 +150,7 @@ describe('run', () => { const nonExistentDir = path.join(tmpDir, 'nested', 'workdir'); expect(fs.existsSync(nonExistentDir)).toBe(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockGetInput.mockImplementation(((name: string) => { + mockGetInput.mockImplementation((name: unknown) => { switch (name) { case 'working-directory': return nonExistentDir; case 'dependencies': return 'microsoft/some-package'; @@ -162,7 +161,7 @@ describe('run', () => { case 'script': return ''; default: return ''; } - }) as any); + }); await run(); From 876e0d7502466ea95499ce0ff4801e27cdd044a2 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 05:10:45 +0100 Subject: [PATCH 4/6] refactor: make directory creation mode-aware with fail-fast for default mode - isolated/pack/bundle modes: action creates working directory (owns lifecycle) - default mode: fail fast if directory doesn't exist (caller owns the project) - Clear comments explaining the contract in runner.ts - Updated action.yml and README.md input descriptions - Added test: 'fails fast when working directory does not exist in default mode' --- README.md | 2 +- action.yml | 2 +- dist/index.js | 30 +++++++++++++++++++++++------- src/__tests__/runner.test.ts | 25 +++++++++++++++++++++++++ src/runner.ts | 33 +++++++++++++++++++++++++-------- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8de4ae5..5324581 100644 --- a/README.md +++ b/README.md @@ -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 default 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) | diff --git a/action.yml b/action.yml index 302cd6d..5caccf4 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: working-directory: - description: 'Working directory for execution' + description: 'Working directory for execution. In default 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: diff --git a/dist/index.js b/dist/index.js index f28b40c..1afac98 100644 --- a/dist/index.js +++ b/dist/index.js @@ -37129,18 +37129,34 @@ 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); - external_fs_namespaceObject.mkdirSync(resolvedDir, { recursive: true }); - info(`Working directory: ${resolvedDir}`); - // 0b. Read mode inputs const bundleInput = getInput('bundle').trim(); const packInput = getInput('pack') === 'true'; + const isolated = getInput('isolated') === 'true'; + // 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. + // - default 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 default 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}`); if (bundleInput && packInput) { throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); } - // RESTORE MODE: extract bundle, skip APM installation entirely + // 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}`); @@ -37157,8 +37173,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'); diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index f389607..7e7a416 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -171,4 +171,29 @@ describe('run', () => { expect(fs.existsSync(path.join(nonExistentDir, 'apm.yml'))).toBe(true); expect(mockSetFailed).not.toHaveBeenCalled(); }); + + it('fails fast when working directory does not exist in default 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); + }); }); diff --git a/src/runner.ts b/src/runner.ts index 82c4714..e3d0c56 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -19,21 +19,38 @@ import { resolveLocalBundle, extractBundle, runPackStep } from './bundler.js'; */ export async function run(): Promise { 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); - fs.mkdirSync(resolvedDir, { recursive: true }); - 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'; + + // 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. + // - default 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( + `Working directory does not exist: ${resolvedDir}. ` + + 'In default 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}`); if (bundleInput && packInput) { throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); } - // RESTORE MODE: extract bundle, skip APM installation entirely + // 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}`); @@ -53,9 +70,9 @@ export async function run(): Promise { // 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'); From 49052403ff9b7347781f9845471ea64ffe91c007 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 05:14:57 +0100 Subject: [PATCH 5/6] docs: use 'non-isolated mode' instead of 'default mode' for clarity --- README.md | 2 +- action.yml | 2 +- dist/index.js | 8 ++++---- src/__tests__/runner.test.ts | 2 +- src/runner.ts | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5324581..d8d5ccf 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ jobs: | Input | Required | Default | Description | |---|---|---|---| -| `working-directory` | No | `.` | Working directory for execution. Must exist in default mode (with your `apm.yml`). In `isolated`, `pack`, or `bundle` modes the directory is created automatically. | +| `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) | diff --git a/action.yml b/action.yml index 5caccf4..c35dfa8 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: working-directory: - description: 'Working directory for execution. In default mode, this directory must exist and contain your apm.yml. In isolated, pack, or bundle modes the action creates it automatically.' + 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: diff --git a/dist/index.js b/dist/index.js index 1afac98..406ca8a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -37139,16 +37139,16 @@ async function run() { // - 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. - // - default 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. + // - 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 default mode the directory must already contain your project (with apm.yml). ' + + '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}`); diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index 7e7a416..bf48f79 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -172,7 +172,7 @@ describe('run', () => { expect(mockSetFailed).not.toHaveBeenCalled(); }); - it('fails fast when working directory does not exist in default mode', async () => { + 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) => { diff --git a/src/runner.ts b/src/runner.ts index e3d0c56..5aceebe 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -30,16 +30,16 @@ export async function run(): Promise { // - 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. - // - default 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. + // - 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( `Working directory does not exist: ${resolvedDir}. ` + - 'In default mode the directory must already contain your project (with apm.yml). ' + + '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.', ); } From 2dbb5000084f36f92228dbf8f6c58d4a70948f40 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 11 Mar 2026 05:24:17 +0100 Subject: [PATCH 6/6] refactor: validate inputs before touching filesystem Move mutual-exclusion check above directory creation so invalid configs never create directories as a side effect. --- dist/index.js | 7 ++++--- src/runner.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dist/index.js b/dist/index.js index 406ca8a..6e2b571 100644 --- a/dist/index.js +++ b/dist/index.js @@ -37135,6 +37135,10 @@ async function run() { 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"); + } // Directory creation contract: // - isolated / pack / bundle (restore) modes: the action owns the workspace // lifecycle and creates the directory automatically. These modes bootstrap @@ -37152,9 +37156,6 @@ async function run() { 'Use isolated: true if you want the action to create it automatically.'); } info(`Working directory: ${resolvedDir}`); - if (bundleInput && packInput) { - throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); - } // RESTORE MODE: extract bundle, skip APM installation entirely. // Directory was already created above (actionOwnsDir = true for bundle mode). if (bundleInput) { diff --git a/src/runner.ts b/src/runner.ts index 5aceebe..520771c 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -26,6 +26,11 @@ export async function run(): Promise { 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"); + } + // Directory creation contract: // - isolated / pack / bundle (restore) modes: the action owns the workspace // lifecycle and creates the directory automatically. These modes bootstrap @@ -45,10 +50,6 @@ export async function run(): Promise { } core.info(`Working directory: ${resolvedDir}`); - if (bundleInput && packInput) { - throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); - } - // RESTORE MODE: extract bundle, skip APM installation entirely. // Directory was already created above (actionOwnsDir = true for bundle mode). if (bundleInput) {