From 654f31d38dc12f46a50667d178d7238e6be91a6d Mon Sep 17 00:00:00 2001 From: Ivan Porto Wigner Date: Wed, 10 Jun 2026 20:31:48 +0200 Subject: [PATCH 1/5] fix: skip dist check and guard ow.package when packages is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a manifest declares `packages: {}` the build step produces no output so the dist directory is never created. The existing dist check was throwing "missing files in dist/…, maybe you forgot to build your actions?" even though there was nothing to build. * deploy-actions.js: gate the dist check on `hasAnyActions` — only validate the build directory when at least one package actually defines actions. Two tests cover both branches of `pkg.actions || {}` (empty packages and package with no actions key), keeping 100% branch coverage on deploy-actions.js. * utils.js (replacePackagePlaceHolder): guard the `packageNames[0]` assignment so an empty packages object no longer clobbers `ow.package` with `undefined`. Co-Authored-By: Claude Sonnet 4.6 --- src/deploy-actions.js | 5 ++++- src/utils.js | 4 +++- test/deploy.actions.test.js | 14 +++++++++++++ test/utils.test.js | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/deploy-actions.js b/src/deploy-actions.js index 8138eb6f..83b6c371 100644 --- a/src/deploy-actions.js +++ b/src/deploy-actions.js @@ -57,9 +57,12 @@ async function deployActions (config, deployConfig = {}, logFunc) { // checks // a. missing credentials utils.checkOpenWhiskCredentials(config) - // b. missing build files + // b. missing build files — only required when at least one package defines actions const dist = config.actions.dist + const hasAnyActions = Object.values(config.manifest.full.packages) + .some(pkg => Object.keys(pkg.actions || {}).length > 0) if ( + hasAnyActions && (!deployConfig.filterEntities || deployConfig.filterEntities.actions) && (!fs.pathExistsSync(dist) || !fs.lstatSync(dist).isDirectory() || !fs.readdirSync(dist).length === 0) ) { diff --git a/src/utils.js b/src/utils.js index ff978afe..04e947f2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2104,7 +2104,9 @@ function replacePackagePlaceHolder (config) { // Using custom package name. // Set config.ow.package so that syncProject can use it as project name for annotations. const packageNames = Object.keys(packages) - modifiedConfig.ow.package = packageNames[0] + if (packageNames.length > 0) { + modifiedConfig.ow.package = packageNames[0] + } } return modifiedConfig } diff --git a/test/deploy.actions.test.js b/test/deploy.actions.test.js index 2912fa97..20538c32 100644 --- a/test/deploy.actions.test.js +++ b/test/deploy.actions.test.js @@ -583,6 +583,20 @@ test('Deploy actions should pass if there are no build files and filter does not await expect(deployActions(global.sampleAppConfig, { filterEntities: { triggers: ['trigger1'] } })).resolves.toEqual({}) }) +test('Deploy actions should succeed when packages: {} (empty packages, no actions defined)', async () => { + const emptyPackagesConfig = deepCopy(global.sampleAppConfig) + emptyPackagesConfig.manifest.full.packages = {} + runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) + await expect(deployActions(emptyPackagesConfig)).resolves.toBeDefined() +}) + +test('Deploy actions should succeed when a package has no actions key (pkg.actions || {} guard)', async () => { + const noActionsPkgConfig = deepCopy(global.sampleAppConfig) + noActionsPkgConfig.manifest.full.packages = { emptyPkg: {} } + runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) + await expect(deployActions(noActionsPkgConfig)).resolves.toBeDefined() +}) + // lonely test('if actions are deployed and part of the manifest it should return their url', async () => { addSampleAppReducedFiles() diff --git a/test/utils.test.js b/test/utils.test.js index 7a36de31..6be08f33 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -3097,3 +3097,43 @@ describe('loadIMSCredentialsFromEnv', () => { expect(result.scopes).toBe('not json') }) }) + +describe('replacePackagePlaceHolder', () => { + test('leaves ow.package unchanged when packages is empty (packages: {})', () => { + const config = { + ow: { package: 'my-pkg' }, + manifest: { + packagePlaceholder: '__APP_PACKAGE__', + full: { packages: {} } + } + } + const result = utils.replacePackagePlaceHolder(config) + expect(result.ow.package).toBe('my-pkg') + }) + + test('renames placeholder package to ow.package', () => { + const config = { + ow: { package: 'my-pkg' }, + manifest: { + packagePlaceholder: '__APP_PACKAGE__', + full: { packages: { __APP_PACKAGE__: { actions: {} } } } + } + } + const result = utils.replacePackagePlaceHolder(config) + expect(result.ow.package).toBe('my-pkg') + expect(result.manifest.full.packages['my-pkg']).toBeDefined() + expect(result.manifest.full.packages.__APP_PACKAGE__).toBeUndefined() + }) + + test('sets ow.package to first package name when no placeholder matches', () => { + const config = { + ow: { package: 'ignored' }, + manifest: { + packagePlaceholder: '__APP_PACKAGE__', + full: { packages: { 'custom-pkg': { actions: {} } } } + } + } + const result = utils.replacePackagePlaceHolder(config) + expect(result.ow.package).toBe('custom-pkg') + }) +}) From 56f4275ef68bad7388de890f8ac3d7d602247349 Mon Sep 17 00:00:00 2001 From: Ivan Porto Wigner Date: Thu, 11 Jun 2026 12:05:38 +0200 Subject: [PATCH 2/5] fix: warn when packages is empty instead of silently deploying a no-op Co-Authored-By: Claude Sonnet 4.6 --- src/deploy-actions.js | 3 +++ test/deploy.actions.test.js | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/deploy-actions.js b/src/deploy-actions.js index 83b6c371..2e10fcec 100644 --- a/src/deploy-actions.js +++ b/src/deploy-actions.js @@ -61,6 +61,9 @@ async function deployActions (config, deployConfig = {}, logFunc) { const dist = config.actions.dist const hasAnyActions = Object.values(config.manifest.full.packages) .some(pkg => Object.keys(pkg.actions || {}).length > 0) + if (!hasAnyActions) { + log('Warning: no actions defined in manifest — deploy is a no-op and will undeploy any previously-deployed actions for this project.') + } if ( hasAnyActions && (!deployConfig.filterEntities || deployConfig.filterEntities.actions) && diff --git a/test/deploy.actions.test.js b/test/deploy.actions.test.js index 20538c32..a37ba44e 100644 --- a/test/deploy.actions.test.js +++ b/test/deploy.actions.test.js @@ -583,18 +583,22 @@ test('Deploy actions should pass if there are no build files and filter does not await expect(deployActions(global.sampleAppConfig, { filterEntities: { triggers: ['trigger1'] } })).resolves.toEqual({}) }) -test('Deploy actions should succeed when packages: {} (empty packages, no actions defined)', async () => { +test('Deploy actions should succeed and warn when packages: {} (empty packages, no actions defined)', async () => { const emptyPackagesConfig = deepCopy(global.sampleAppConfig) emptyPackagesConfig.manifest.full.packages = {} runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) - await expect(deployActions(emptyPackagesConfig)).resolves.toBeDefined() + const logSpy = jest.fn() + await expect(deployActions(emptyPackagesConfig, {}, logSpy)).resolves.toBeDefined() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('no actions defined in manifest')) }) -test('Deploy actions should succeed when a package has no actions key (pkg.actions || {} guard)', async () => { +test('Deploy actions should succeed and warn when a package has no actions key (pkg.actions || {} guard)', async () => { const noActionsPkgConfig = deepCopy(global.sampleAppConfig) noActionsPkgConfig.manifest.full.packages = { emptyPkg: {} } runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) - await expect(deployActions(noActionsPkgConfig)).resolves.toBeDefined() + const logSpy = jest.fn() + await expect(deployActions(noActionsPkgConfig, {}, logSpy)).resolves.toBeDefined() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('no actions defined in manifest')) }) // lonely From 8bdd723380e39681e0bd0560c57686d70c8fad73 Mon Sep 17 00:00:00 2001 From: Ivan Porto Wigner Date: Thu, 11 Jun 2026 12:08:32 +0200 Subject: [PATCH 3/5] remove em dash --- src/deploy-actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy-actions.js b/src/deploy-actions.js index 2e10fcec..4e66d868 100644 --- a/src/deploy-actions.js +++ b/src/deploy-actions.js @@ -62,7 +62,7 @@ async function deployActions (config, deployConfig = {}, logFunc) { const hasAnyActions = Object.values(config.manifest.full.packages) .some(pkg => Object.keys(pkg.actions || {}).length > 0) if (!hasAnyActions) { - log('Warning: no actions defined in manifest — deploy is a no-op and will undeploy any previously-deployed actions for this project.') + log('Warning: no actions defined in manifest, deploy is a no-op and will undeploy any previously-deployed actions for this project.') } if ( hasAnyActions && From 8aca9bc4bb2f2c4102bfd9b890c70db4768dc1d6 Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 11 Jun 2026 08:34:30 -0700 Subject: [PATCH 4/5] fix: throw missing-files error when build dir exists but is empty The dist-existence check had an operator-precedence bug: `!fs.readdirSync(dist).length === 0` parses as `(!length) === 0`, which is always false, so the empty-build-directory branch never fired. Correct it to `fs.readdirSync(dist).length === 0` so a dist directory that exists but contains no built actions now raises the "missing files" error as intended. Adds a test covering that branch. --- src/deploy-actions.js | 2 +- test/deploy.actions.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/deploy-actions.js b/src/deploy-actions.js index 4e66d868..d7f6669c 100644 --- a/src/deploy-actions.js +++ b/src/deploy-actions.js @@ -67,7 +67,7 @@ async function deployActions (config, deployConfig = {}, logFunc) { if ( hasAnyActions && (!deployConfig.filterEntities || deployConfig.filterEntities.actions) && - (!fs.pathExistsSync(dist) || !fs.lstatSync(dist).isDirectory() || !fs.readdirSync(dist).length === 0) + (!fs.pathExistsSync(dist) || !fs.lstatSync(dist).isDirectory() || fs.readdirSync(dist).length === 0) ) { throw new Error(`missing files in ${utils._relApp(config.root, dist)}, maybe you forgot to build your actions ?`) } diff --git a/test/deploy.actions.test.js b/test/deploy.actions.test.js index a37ba44e..b67e56c5 100644 --- a/test/deploy.actions.test.js +++ b/test/deploy.actions.test.js @@ -577,6 +577,14 @@ test('Deploy actions should fail if there are no build files and action filter', .rejects.toThrow('missing files in dist') }) +test('Deploy actions should fail if the build directory exists but is empty', async () => { + addSampleAppFiles() + // dist exists as a directory but contains no built actions + global.fakeFileSystem.addJson({ [global.sampleAppConfig.actions.dist]: null }) + await expect(deployActions(global.sampleAppConfig)) + .rejects.toThrow('missing files in dist') +}) + test('Deploy actions should pass if there are no build files and filter does not include actions', async () => { addSampleAppFiles() runtimeLibUtils.processPackage.mockReturnValue({}) From f87a478daf138e23a7b547466d22c6bba4eaf50c Mon Sep 17 00:00:00 2001 From: Patrick Russell Date: Thu, 11 Jun 2026 08:59:57 -0700 Subject: [PATCH 5/5] fix: skip deploy as a no-op when no packages are declared When the manifest declares `packages: {}` (e.g. only to trigger database auto-provisioning), deployActions previously fell through to a full sync against an empty manifest, which undeploys every previously-deployed entity for the project. Return early instead so an empty manifest is a true no-op that leaves existing entities untouched. `aio app undeploy` remains the explicit way to remove everything. --- src/deploy-actions.js | 17 ++++++++++++----- test/deploy.actions.test.js | 18 +++++------------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/deploy-actions.js b/src/deploy-actions.js index d7f6669c..08591b73 100644 --- a/src/deploy-actions.js +++ b/src/deploy-actions.js @@ -57,13 +57,20 @@ async function deployActions (config, deployConfig = {}, logFunc) { // checks // a. missing credentials utils.checkOpenWhiskCredentials(config) - // b. missing build files — only required when at least one package defines actions + // b. no packages declared (e.g. `packages: {}`, used only to trigger database + // auto-provisioning). Skip deployment entirely. Proceeding would run a full + // sync against an empty manifest, undeploying every previously-deployed entity + // for the project. Use `aio app undeploy` to intentionally remove all entities. + const packages = config.manifest.full.packages + if (Object.keys(packages).length === 0) { + log('Warning: no packages defined in the manifest, skipping deployment. Existing deployed entities are left untouched; use \'aio app undeploy\' to remove them.') + return {} + } + + // c. missing build files — only required when at least one package defines actions const dist = config.actions.dist - const hasAnyActions = Object.values(config.manifest.full.packages) + const hasAnyActions = Object.values(packages) .some(pkg => Object.keys(pkg.actions || {}).length > 0) - if (!hasAnyActions) { - log('Warning: no actions defined in manifest, deploy is a no-op and will undeploy any previously-deployed actions for this project.') - } if ( hasAnyActions && (!deployConfig.filterEntities || deployConfig.filterEntities.actions) && diff --git a/test/deploy.actions.test.js b/test/deploy.actions.test.js index b67e56c5..3a55c66e 100644 --- a/test/deploy.actions.test.js +++ b/test/deploy.actions.test.js @@ -591,22 +591,14 @@ test('Deploy actions should pass if there are no build files and filter does not await expect(deployActions(global.sampleAppConfig, { filterEntities: { triggers: ['trigger1'] } })).resolves.toEqual({}) }) -test('Deploy actions should succeed and warn when packages: {} (empty packages, no actions defined)', async () => { +test('Deploy actions should be a no-op (no undeploy) and warn when packages: {} (empty packages)', async () => { const emptyPackagesConfig = deepCopy(global.sampleAppConfig) emptyPackagesConfig.manifest.full.packages = {} - runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) - const logSpy = jest.fn() - await expect(deployActions(emptyPackagesConfig, {}, logSpy)).resolves.toBeDefined() - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('no actions defined in manifest')) -}) - -test('Deploy actions should succeed and warn when a package has no actions key (pkg.actions || {} guard)', async () => { - const noActionsPkgConfig = deepCopy(global.sampleAppConfig) - noActionsPkgConfig.manifest.full.packages = { emptyPkg: {} } - runtimeLibUtils.processPackage.mockReturnValue(deepCopy(mockEntities)) const logSpy = jest.fn() - await expect(deployActions(noActionsPkgConfig, {}, logSpy)).resolves.toBeDefined() - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('no actions defined in manifest')) + await expect(deployActions(emptyPackagesConfig, {}, logSpy)).resolves.toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('no packages defined')) + // must not run a full sync that would undeploy previously-deployed entities + expect(runtimeLibUtils.syncProject).not.toHaveBeenCalled() }) // lonely