From 513029260cffabdad6fefeb4735bfbafcf61be30 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 29 Sep 2026 04:18:54 +0000 Subject: [PATCH 1/9] feat: install nodejs workspace dependencies once per monorepo root with --build-in-source In an npm workspaces monorepo npm keeps one dependency tree, hoisted to the workspace root, and every member's install reconciles that whole tree. Building N functions with --build-in-source therefore reconciled the same tree N times (#6567). WorkspaceAwareBuildStrategy wraps the strategy stack outermost, resolves each nodejs function's npm project root (npm's own answer, cached), installs each distinct root once through LambdaBuilder.install_shared_dependencies, and marks the grouped functions with download_dependencies=False - the lever IncrementalBuildStrategy already uses. Scope: Zip nodejs functions on the npm or esbuild workflow, building in source, not cached, not container builds, and only when npm resolves the project root outside the function's own code directory (a workspace member). Everything else, plus any group whose shared install fails or whose aws-lambda-builders does not expose the API, keeps today's per-function install. Sitting outside ParallelBuildStrategy makes the install a barrier: no grouped function build starts before the shared tree is written. Measured on an 8-function esbuild monorepo (~300 packages): 21.9s -> 1.5s; 20 functions: 57.8s -> 3.0s (bench_monorepo.py, median of 3). --- samcli/lib/build/app_builder.py | 77 ++++++++++++ samcli/lib/build/build_strategy.py | 110 ++++++++++++++++ .../lib/build_module/test_build_strategy.py | 119 ++++++++++++++++++ 3 files changed, 306 insertions(+) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index c30c948c007..00025481b9c 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -25,6 +25,7 @@ CachedOrIncrementalBuildStrategyWrapper, DefaultBuildStrategy, ParallelBuildStrategy, + WorkspaceAwareBuildStrategy, ) from samcli.lib.build.constants import BUILD_PROPERTIES, DEPRECATED_RUNTIMES from samcli.lib.build.exceptions import ( @@ -198,6 +199,8 @@ def __init__( self._mount_symlinks = mount_symlinks self._use_buildkit = use_buildkit self._image_build_client: Optional[ImageBuildClient] = None + # one SubprocessNpm per build session, so `npm prefix` answers are cached per directory + self._npm_subprocess: Optional[object] = None @property def _container_client(self) -> ContainerClient: @@ -260,6 +263,19 @@ def build(self) -> ApplicationBuildResult: bool(self._container_manager), ) + if self._build_in_source and not self._cached and not self._container_manager: + # npm workspaces monorepo: install the shared dependency tree once per workspace root + # instead of once per function. Outside ParallelBuildStrategy on purpose - the install + # must complete before any grouped function build starts. --cached is excluded because + # it supplies dependencies from .aws-sam/deps and manages download_dependencies itself. + build_strategy = WorkspaceAwareBuildStrategy( + build_graph, + build_strategy, + self._base_dir, + self._resolve_npm_project_root, + self._install_workspace_shared_dependencies, + ) + return ApplicationBuildResult(build_graph, build_strategy.build()) def _get_build_graph( @@ -677,6 +693,67 @@ def _build_layer( # Not including subfolder in return so that we copy subfolder, instead of copying artifacts inside it. return artifact_dir + def _resolve_npm_project_root(self, code_dir: str) -> Optional[str]: + """ + Ask npm (through aws-lambda-builders) which project root covers code_dir. + + Returns None when the answer is unavailable for any reason - npm missing, an + aws-lambda-builders too old to expose the resolver - which disables workspace grouping for + that function and keeps today's per-function install. + """ + try: + from aws_lambda_builders.workflows.nodejs_npm.npm import SubprocessNpm + from aws_lambda_builders.workflows.nodejs_npm.utils import OSUtils as NpmOSUtils + except ImportError: + return None + + if self._npm_subprocess is None: + self._npm_subprocess = SubprocessNpm(NpmOSUtils()) + resolver = getattr(self._npm_subprocess, "resolve_project_root", None) + if resolver is None: + return None + try: + return cast(Optional[str], resolver(code_dir)) + except OSError as ex: + LOG.debug("Could not resolve the npm project root of %s: %s", code_dir, ex) + return None + + def _install_workspace_shared_dependencies( + self, project_root: str, build_definition: "FunctionBuildDefinition" + ) -> bool: + """ + Install, once, the dependency tree shared by every workspace function under project_root. + + The build definition supplies the workflow (npm or npm-esbuild) whose builder runs the + install; both funnel into the same npm project tree. Returns False on any failure so the + caller leaves the grouped functions on per-function installs. + """ + code_dir = str(pathlib.Path(self._base_dir, cast(str, build_definition.codeuri)).resolve()) + specified_workflow = build_definition.metadata.get("BuildMethod") if build_definition.metadata else None + try: + config = get_workflow_config( + build_definition.runtime, code_dir, self._base_dir, specified_workflow=specified_workflow + ) + builder = LambdaBuilder( + language=config.language, + dependency_manager=config.dependency_manager, + application_framework=config.application_framework, + ) + installer = getattr(builder, "install_shared_dependencies", None) + if installer is None: + LOG.debug("Installed aws-lambda-builders has no shared dependency install; skipping grouping") + return False + installer(project_root) + return True + except (UnsupportedRuntimeException, LambdaBuilderError) as ex: + LOG.warning( + "Installing shared dependencies for workspace root %s failed (%s); " + "falling back to per-function installs", + project_root, + ex, + ) + return False + def _build_function( # pylint: disable=R1710 self, function_name: str, diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 4b88553b9e5..27a14f3c033 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -693,3 +693,113 @@ def _is_incremental_build_supported(self, runtime: Optional[str]) -> bool: return True return False + + +class WorkspaceAwareBuildStrategy(BuildStrategy): + """ + Wraps another build strategy to install nodejs dependencies once per npm workspace root. + + In an npm workspaces monorepo npm keeps ONE dependency tree, hoisted to the workspace root, and + every member's install reconciles that whole tree. Building N functions from one monorepo with + --build-in-source therefore reconciles the same tree N times. This strategy resolves each nodejs + function's npm project root before any function builds, installs each distinct root once through + aws-lambda-builders, and marks the grouped functions with download_dependencies=False - the same + lever IncrementalBuildStrategy uses - so their builds link the artifacts to the tree that one + install wrote. + + A function is grouped only when all of these hold, so anything else keeps today's per-function + install: + + - Zip package type, nodejs runtime, npm or esbuild build method + - npm resolves the function's project root OUTSIDE its own code directory, which is what + distinguishes a workspace member from a standalone package + - the shared install itself succeeded; a failed root leaves its functions untouched + + This strategy must wrap OUTSIDE ParallelBuildStrategy, whose _build_functions fires every + definition into one AsyncContext: running the install first is the barrier that keeps parallel + function builds from racing the shared tree. It is wired only for non-cached builds; --cached + supplies dependencies from .aws-sam/deps and manages download_dependencies itself. + """ + + def __init__( + self, + build_graph: BuildGraph, + delegate_build_strategy: BuildStrategy, + base_dir: str, + project_root_resolver: Callable[[str], Optional[str]], + shared_install_function: Callable[[str, FunctionBuildDefinition], bool], + ) -> None: + """ + Parameters + ---------- + project_root_resolver: + Given a function's resolved code directory, returns npm's project root for it, or None + when it cannot be resolved (npm missing, too-old aws-lambda-builders, non-npm project). + shared_install_function: + Runs the shared install for (project_root, one grouped build definition); returns True + on success. Failures must be handled inside and reported with False. + """ + super().__init__(build_graph) + self._delegate_build_strategy = delegate_build_strategy + self._base_dir = base_dir + self._project_root_resolver = project_root_resolver + self._shared_install_function = shared_install_function + + def build(self) -> Dict[str, str]: + with self._delegate_build_strategy: + return super().build() + + def _build_layers(self, build_graph: BuildGraph) -> Dict[str, str]: + return self._delegate_build_strategy._build_layers(build_graph) + + def _build_functions(self, build_graph: BuildGraph) -> Dict[str, str]: + self._install_shared_dependencies_for_workspace_groups(build_graph) + return self._delegate_build_strategy._build_functions(build_graph) + + def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]: + return self._delegate_build_strategy.build_single_function_definition(build_definition) + + def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) -> Dict[str, str]: + return self._delegate_build_strategy.build_single_layer_definition(layer_definition) + + def _install_shared_dependencies_for_workspace_groups(self, build_graph: BuildGraph) -> None: + groups: Dict[str, List[FunctionBuildDefinition]] = {} + for build_definition in build_graph.get_function_build_definitions(): + project_root = self._workspace_root_for(build_definition) + if project_root: + groups.setdefault(project_root, []).append(build_definition) + + for project_root, build_definitions in groups.items(): + LOG.info( + "Installing dependencies once for workspace root %s, shared by (%s)", + project_root, + ", ".join(definition.get_resource_full_paths() for definition in build_definitions), + ) + if self._shared_install_function(project_root, build_definitions[0]): + for build_definition in build_definitions: + build_definition.download_dependencies = False + + def _workspace_root_for(self, build_definition: FunctionBuildDefinition) -> Optional[str]: + """ + The npm project root the function's install would write to, or None when the function is not + a workspace member covered by this strategy. + """ + if build_definition.packagetype != ZIP or not build_definition.codeuri: + return None + runtime = build_definition.runtime or "" + if not runtime.startswith("nodejs"): + return None + # only the npm workflows install through the npm project tree; BuildMethod naming the + # runtime is the template's way of spelling the default + build_method = (build_definition.metadata or {}).get("BuildMethod") + if build_method not in (None, "esbuild", runtime): + return None + + code_dir = str(pathlib.Path(self._base_dir, build_definition.codeuri).resolve()) + project_root = self._project_root_resolver(code_dir) + if not project_root: + return None + if os.path.realpath(project_root) == os.path.realpath(code_dir): + # npm answers with the directory itself for anything that is not a workspace member + return None + return project_root diff --git a/tests/unit/lib/build_module/test_build_strategy.py b/tests/unit/lib/build_module/test_build_strategy.py index 8004923c324..9d42c6cfcef 100644 --- a/tests/unit/lib/build_module/test_build_strategy.py +++ b/tests/unit/lib/build_module/test_build_strategy.py @@ -17,6 +17,7 @@ CachedBuildStrategy, CachedOrIncrementalBuildStrategyWrapper, IncrementalBuildStrategy, + WorkspaceAwareBuildStrategy, clean_redundant_folders, ) from samcli.lib.utils import osutils @@ -879,3 +880,121 @@ def test_wrapper_with_or_without_container(self, mocked_read, mocked_write, runt else: patched_cached_build_strategy.build_single_function_definition.assert_called_with(build_definition) patched_incremental_build_strategy.assert_not_called() + + +@patch("samcli.lib.build.build_graph.BuildGraph._write") +@patch("samcli.lib.build.build_graph.BuildGraph._read") +class WorkspaceAwareBuildStrategyTest(TestCase): + BASE_DIR = "/base" + + def setUp(self): + self.delegate = MagicMock() + self.resolver = Mock(return_value=None) + self.installer = Mock(return_value=True) + + def _strategy(self, build_graph): + return WorkspaceAwareBuildStrategy(build_graph, self.delegate, self.BASE_DIR, self.resolver, self.installer) + + def _graph(self, *definitions): + build_graph = BuildGraph("build_dir") + for index, definition in enumerate(definitions): + function = Mock() + function.inlinecode = None + function.full_path = f"function{index}" + build_graph.put_function_build_definition(definition, function) + return build_graph + + @staticmethod + def _definition(runtime="nodejs22.x", codeuri="endpoints/fn", packagetype=ZIP, metadata=None): + return FunctionBuildDefinition(runtime, codeuri, None, packagetype, X86_64, metadata or {}, "handler") + + def _code_dir(self, codeuri): + return str(Path(self.BASE_DIR, codeuri).resolve()) + + def test_functions_sharing_a_workspace_root_install_once_and_skip_their_own_installs(self, _read, _write): + definition1 = self._definition(codeuri="endpoints/fn1") + definition2 = self._definition(codeuri="endpoints/fn2", metadata={"BuildMethod": "esbuild"}) + build_graph = self._graph(definition1, definition2) + self.resolver.return_value = "/base/mono" + + strategy = self._strategy(build_graph) + result = strategy._build_functions(build_graph) + + self.installer.assert_called_once_with("/base/mono", definition1) + self.assertFalse(definition1.download_dependencies) + self.assertFalse(definition2.download_dependencies) + self.delegate._build_functions.assert_called_once_with(build_graph) + self.assertEqual(result, self.delegate._build_functions.return_value) + + def test_a_failed_shared_install_leaves_per_function_installs(self, _read, _write): + definition = self._definition() + build_graph = self._graph(definition) + self.resolver.return_value = "/base/mono" + self.installer.return_value = False + + self._strategy(build_graph)._build_functions(build_graph) + + self.assertTrue(definition.download_dependencies) + self.delegate._build_functions.assert_called_once_with(build_graph) + + def test_a_standalone_package_is_not_grouped(self, _read, _write): + # npm answers with the directory itself for anything that is not a workspace member + definition = self._definition(codeuri="standalone") + build_graph = self._graph(definition) + self.resolver.return_value = self._code_dir("standalone") + + self._strategy(build_graph)._build_functions(build_graph) + + self.installer.assert_not_called() + self.assertTrue(definition.download_dependencies) + + def test_out_of_scope_functions_never_resolve_a_root(self, _read, _write): + out_of_scope = [ + ("python3.12", ZIP, None), + ("nodejs22.x", IMAGE, None), + ("nodejs22.x", ZIP, {"BuildMethod": "makefile"}), + ] + for runtime, packagetype, metadata in out_of_scope: + with self.subTest(runtime=runtime, packagetype=packagetype, metadata=metadata): + definition = self._definition(runtime=runtime, packagetype=packagetype, metadata=metadata) + build_graph = self._graph(definition) + + self._strategy(build_graph)._build_functions(build_graph) + + self.resolver.assert_not_called() + self.installer.assert_not_called() + + def test_a_build_method_naming_the_runtime_is_in_scope(self, _read, _write): + definition = self._definition(metadata={"BuildMethod": "nodejs22.x"}) + build_graph = self._graph(definition) + self.resolver.return_value = "/base/mono" + + self._strategy(build_graph)._build_functions(build_graph) + + self.installer.assert_called_once() + self.assertFalse(definition.download_dependencies) + + def test_distinct_workspace_roots_install_separately(self, _read, _write): + definition1 = self._definition(codeuri="monoA/endpoints/fn1") + definition2 = self._definition(codeuri="monoB/endpoints/fn2") + build_graph = self._graph(definition1, definition2) + self.resolver.side_effect = ["/base/monoA", "/base/monoB"] + + self._strategy(build_graph)._build_functions(build_graph) + + self.installer.assert_has_calls([call("/base/monoA", definition1), call("/base/monoB", definition2)]) + self.assertFalse(definition1.download_dependencies) + self.assertFalse(definition2.download_dependencies) + + def test_build_enters_the_delegate_context_and_delegates_everything(self, _read, _write): + definition = self._definition(runtime="python3.12") + build_graph = self._graph(definition) + self.delegate._build_layers.return_value = {} + self.delegate._build_functions.return_value = {"function0": "artifacts"} + + result = self._strategy(build_graph).build() + + self.delegate.__enter__.assert_called_once() + self.delegate.__exit__.assert_called_once() + self.delegate._build_layers.assert_called_once_with(build_graph) + self.assertEqual(result, {"function0": "artifacts"}) From cd885af73cccedd962688d62c74a4f99b8801590 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 29 Sep 2026 04:55:13 +0000 Subject: [PATCH 2/9] test: integration tests for the workspace-root shared install under --build-in-source An npm workspaces fixture with two functions holding disjoint dependencies plus a shared workspace package; the lockfile pins lodash 4.17.20 below its ^4.17.20 range so a lockfile-ignoring build is detectable, and the shared package pins lodash 4.17.15 exactly so a hoisting mistake is detectable. An npm shim on PATH logs every invocation, asserting the whole build runs exactly one install (also under --parallel), without --omit=dev, leaving the lockfile bytes untouched; handlers execute from the artifact dirs and see the nested conflicting version; a standalone project keeps its single per-function 'npm update --omit=dev'; --use-container is mutually exclusive with --build-in-source so container builds can never group. --- .../buildcmd/test_build_in_source.py | 194 +++++++++- .../functions/fn-axios/index.js | 10 + .../functions/fn-axios/package.json | 9 + .../functions/fn-lodash/index.js | 10 + .../functions/fn-lodash/package.json | 9 + .../buildcmd/NodeWorkspaces/package-lock.json | 342 ++++++++++++++++++ .../buildcmd/NodeWorkspaces/package.json | 9 + .../NodeWorkspaces/packages/shared/index.js | 3 + .../packages/shared/package.json | 8 + .../buildcmd/NodeWorkspaces/template.yaml | 18 + 10 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/package-lock.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspaces/template.yaml diff --git a/tests/integration/buildcmd/test_build_in_source.py b/tests/integration/buildcmd/test_build_in_source.py index 8807e9feb28..079b9457686 100644 --- a/tests/integration/buildcmd/test_build_in_source.py +++ b/tests/integration/buildcmd/test_build_in_source.py @@ -1,4 +1,6 @@ +import json import os +import shutil from pathlib import Path import pytest import logging @@ -6,11 +8,15 @@ from samcli.lib.utils import osutils from tests.integration.buildcmd.build_integ_base import ( + BuildIntegBase, BuildIntegNodeBase, BuildIntegProvidedBase, BuildIntegEsbuildBase, ) -from tests.testing_utils import run_command +from tests.testing_utils import ( + IS_WINDOWS, + run_command, +) LOG = logging.getLogger(__name__) @@ -160,3 +166,189 @@ def test_builds_successfully_with_local_dependency(self): run_command(command_list, self.working_dir) self.validate_node_modules(True) + + +WORKSPACE_INSTALL_LOG_MESSAGE = "Installing dependencies once for workspace root" + + +class NpmCallLogMixin: + """ + Puts an npm shim first on PATH that appends every npm invocation to a log file before + delegating to the real npm, so tests can assert on how many installs a build ran. + """ + + def setup_npm_call_log(self): + real_npm = shutil.which("npm") + if not real_npm: + self.skipTest("npm is not available on PATH") + + self.npm_call_log = Path(self.scratch_dir, "npm-calls.log") + self._npm_shim_dir = Path(self.scratch_dir, "npm-shim") + self._npm_shim_dir.mkdir() + + if IS_WINDOWS: + shim = self._npm_shim_dir / "npm.cmd" + shim.write_text('@echo off\r\necho %* >> "%NPM_CALL_LOG%"\r\ncall "{}" %*\r\n'.format(real_npm)) + else: + shim = self._npm_shim_dir / "npm" + shim.write_text('#!/bin/sh\necho "$@" >> "$NPM_CALL_LOG"\nexec "{}" "$@"\n'.format(real_npm)) + shim.chmod(0o755) + + def npm_logging_env(self): + env = os.environ.copy() + env["PATH"] = str(self._npm_shim_dir) + os.pathsep + env["PATH"] + env["NPM_CALL_LOG"] = str(self.npm_call_log) + return env + + def npm_install_commands(self): + """Every logged npm invocation that resolves dependencies (install/update/ci).""" + if not self.npm_call_log.exists(): + return [] + lines = [line.strip() for line in self.npm_call_log.read_text().splitlines() if line.strip()] + return [line for line in lines if line.split()[0] in ("install", "update", "ci")] + + +class TestBuildCommand_BuildInSource_NodejsWorkspaces(NpmCallLogMixin, BuildIntegBase): + """ + An npm workspaces monorepo builds all member functions off ONE install at the workspace + root. The fixture has two functions with disjoint dependencies plus a shared workspace + package, and its lockfile pins versions below what the manifests' ranges allow (lodash + 4.17.20 for a ^4.17.20 range), so a lockfile-ignoring build is detectable. The shared + package pins lodash 4.17.15 exactly, giving a version conflict npm keeps as a nested copy. + """ + + template = None # the template lives inside the copied fixture + + def setUp(self): + super().setUp() + osutils.copytree(Path(self.test_data_path, "NodeWorkspaces"), self.working_dir) + self.template_path = str(Path(self.working_dir, "template.yaml")) + self.lockfile_path = Path(self.working_dir, "package-lock.json") + self.setup_npm_call_log() + + def artifact_modules(self, logical_id) -> Path: + return Path(self.default_build_dir, logical_id, "node_modules") + + def run_built_handler(self, logical_id) -> dict: + result = run_command( + ["node", "-e", 'require("./index.js").handler().then(r => console.log(r.body))'], + cwd=str(Path(self.default_build_dir, logical_id)), + ) + self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) + return json.loads(result.stdout.decode("utf-8").strip()) + + def build_workspace(self, parallel=False): + command_list = self.get_command_list(build_in_source=True, parallel=parallel, debug=True) + result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) + self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) + return result + + def assert_artifacts_are_disjoint(self): + lodash_modules = self.artifact_modules("LodashFunction") + self.assertTrue((lodash_modules / "lodash").is_dir(), "LodashFunction is missing its own dependency") + self.assertTrue((lodash_modules / "@mono" / "shared").is_dir(), "LodashFunction is missing the shared package") + self.assertFalse((lodash_modules / "axios").exists(), "LodashFunction got its sibling's dependency") + + axios_modules = self.artifact_modules("AxiosFunction") + self.assertTrue((axios_modules / "axios").is_dir(), "AxiosFunction is missing its own dependency") + self.assertTrue((axios_modules / "@mono" / "shared").is_dir(), "AxiosFunction is missing the shared package") + self.assertFalse((axios_modules / "lodash").exists(), "AxiosFunction got its sibling's dependency") + + @pytest.mark.flaky(reruns=3) + def test_installs_once_with_disjoint_artifacts_and_untouched_lockfile(self): + lockfile_bytes_before = self.lockfile_path.read_bytes() + + result = self.build_workspace() + + self.assertIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + + # one install for the whole workspace, not one per function + installs = self.npm_install_commands() + self.assertEqual(len(installs), 1, f"expected exactly one npm install, saw: {installs}") + # the root install must keep the root's own devDependencies (build tools like esbuild) + self.assertNotIn("--omit=dev", installs[0]) + + self.assert_artifacts_are_disjoint() + + # the lockfile was honoured: it pins 4.17.20 although the manifest range allows newer + lodash_manifest = self.artifact_modules("LodashFunction") / "lodash" / "package.json" + self.assertEqual(json.loads(lodash_manifest.read_text())["version"], "4.17.20") + + # and its bytes are untouched by the build + self.assertEqual(self.lockfile_path.read_bytes(), lockfile_bytes_before) + + @pytest.mark.flaky(reruns=3) + def test_handlers_resolve_from_artifact_dirs(self): + self.build_workspace() + + lodash_body = self.run_built_handler("LodashFunction") + self.assertEqual(lodash_body["ownLodashVersion"], "4.17.20") + # the shared package's conflicting lodash 4.17.15 stayed a nested copy: the function + # resolves its own version while the shared package resolves the pinned one + self.assertEqual(lodash_body["sharedLodashVersion"], "4.17.15") + + axios_body = self.run_built_handler("AxiosFunction") + self.assertEqual(axios_body["axiosVersion"], "1.6.0") + self.assertEqual(axios_body["sharedLodashVersion"], "4.17.15") + + @pytest.mark.flaky(reruns=3) + def test_parallel_build_installs_once_before_function_builds(self): + result = self.build_workspace(parallel=True) + + self.assertIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + installs = self.npm_install_commands() + self.assertEqual(len(installs), 1, f"expected exactly one npm install, saw: {installs}") + + # a function build racing ahead of the install would link dependencies that do not + # exist yet, so complete artifacts prove the install was a barrier + self.assert_artifacts_are_disjoint() + + @pytest.mark.flaky(reruns=3) + def test_use_container_cannot_reach_the_workspace_grouping(self): + # workspace grouping only activates under --build-in-source, and the CLI refuses that + # flag together with --use-container, so a container build can never group: it keeps + # today's per-function installs by construction + command_list = self.get_command_list(build_in_source=True, use_container=True) + result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) + + self.assertNotEqual(result.process.returncode, 0) + self.assertIn( + "must not provide both the --build-in-source and --use-container", + result.stderr.decode("utf-8"), + ) + self.assertNotIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + self.assertEqual(self.npm_install_commands(), []) + + +class TestBuildCommand_BuildInSource_NodejsStandaloneNotGrouped(NpmCallLogMixin, BuildIntegNodeBase): + """A standalone (non-workspace) nodejs project keeps its per-function install unchanged.""" + + template = "template.yaml" + + def setUp(self): + super().setUp() + osutils.copytree(Path(self.test_data_path, "Esbuild"), self.working_dir) + self.setup_npm_call_log() + + @pytest.mark.flaky(reruns=3) + def test_standalone_project_keeps_per_function_install(self): + codeuri = Path(self.working_dir, "Node") + + overrides = self.get_override( + runtime="nodejs24.x", code_uri=str(codeuri), architecture="x86_64", handler="main.lambdaHandler" + ) + command_list = self.get_command_list(build_in_source=True, parameter_overrides=overrides, debug=True) + result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) + self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) + + # no workspace grouping for a project whose npm root is its own directory + self.assertNotIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + + # the same single per-function install as before the change: this fixture has no + # lockfile, so building in source resolves dependencies with `npm update --omit=dev` + installs = self.npm_install_commands() + self.assertEqual(len(installs), 1, f"expected exactly one npm install, saw: {installs}") + self.assertEqual(installs[0].split()[0], "update") + self.assertIn("--omit=dev", installs[0]) + + self.assertTrue(Path(codeuri, "node_modules").is_dir()) diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/index.js b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/index.js new file mode 100644 index 00000000000..dbf66c4a032 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/index.js @@ -0,0 +1,10 @@ +const axios = require("axios"); +const shared = require("@mono/shared"); + +exports.handler = async () => ({ + statusCode: 200, + body: JSON.stringify({ + axiosVersion: axios.VERSION, + sharedLodashVersion: shared.sharedLodashVersion(), + }), +}); diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/package.json b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/package.json new file mode 100644 index 00000000000..5221d0dff40 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-axios/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mono/fn-axios", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@mono/shared": "^1.0.0", + "axios": "^1.6.0" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/index.js b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/index.js new file mode 100644 index 00000000000..f9797ed00b9 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/index.js @@ -0,0 +1,10 @@ +const _ = require("lodash"); +const shared = require("@mono/shared"); + +exports.handler = async () => ({ + statusCode: 200, + body: JSON.stringify({ + ownLodashVersion: _.VERSION, + sharedLodashVersion: shared.sharedLodashVersion(), + }), +}); diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/package.json b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/package.json new file mode 100644 index 00000000000..a905566cf5f --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/functions/fn-lodash/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mono/fn-lodash", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@mono/shared": "^1.0.0", + "lodash": "^4.17.20" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/package-lock.json b/tests/integration/testdata/buildcmd/NodeWorkspaces/package-lock.json new file mode 100644 index 00000000000..e0bacf75628 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/package-lock.json @@ -0,0 +1,342 @@ +{ + "name": "node-workspaces-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-workspaces-fixture", + "version": "1.0.0", + "workspaces": [ + "packages/*", + "functions/*" + ] + }, + "functions/fn-axios": { + "name": "@mono/fn-axios", + "version": "1.0.0", + "dependencies": { + "@mono/shared": "^1.0.0", + "axios": "^1.6.0" + } + }, + "functions/fn-lodash": { + "name": "@mono/fn-lodash", + "version": "1.0.0", + "dependencies": { + "@mono/shared": "^1.0.0", + "lodash": "^4.17.20" + } + }, + "node_modules/@mono/fn-axios": { + "resolved": "functions/fn-axios", + "link": true + }, + "node_modules/@mono/fn-lodash": { + "resolved": "functions/fn-lodash", + "link": true + }, + "node_modules/@mono/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "packages/shared": { + "name": "@mono/shared", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } + }, + "packages/shared/node_modules/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "license": "MIT" + } + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/package.json b/tests/integration/testdata/buildcmd/NodeWorkspaces/package.json new file mode 100644 index 00000000000..31a157cfad7 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/package.json @@ -0,0 +1,9 @@ +{ + "name": "node-workspaces-fixture", + "version": "1.0.0", + "private": true, + "workspaces": [ + "packages/*", + "functions/*" + ] +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/index.js b/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/index.js new file mode 100644 index 00000000000..1f1ff18c58d --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/index.js @@ -0,0 +1,3 @@ +const _ = require("lodash"); + +exports.sharedLodashVersion = () => _.VERSION; diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/package.json b/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/package.json new file mode 100644 index 00000000000..ac5e3199244 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/packages/shared/package.json @@ -0,0 +1,8 @@ +{ + "name": "@mono/shared", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "lodash": "4.17.15" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspaces/template.yaml b/tests/integration/testdata/buildcmd/NodeWorkspaces/template.yaml new file mode 100644 index 00000000000..993d913129f --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspaces/template.yaml @@ -0,0 +1,18 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + LodashFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fn-lodash + Handler: index.handler + Runtime: nodejs22.x + Timeout: 600 + AxiosFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fn-axios + Handler: index.handler + Runtime: nodejs22.x + Timeout: 600 From e0165b053151d7facabc495ebf4abb49bb4c7030 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 29 Sep 2026 16:47:04 +0000 Subject: [PATCH 3/9] test: assert the workspace grouping is wired only where it is safe The gate in ApplicationBuilder.build - build_in_source and not cached and not container_manager - is what keeps every other build mode on today's behaviour, and nothing covered it: the grouping strategy had unit tests, its wiring had none. Five tests: --build-in-source wraps the delegate strategy and passes the two npm callbacks; with --parallel the wrapper sits OUTSIDE ParallelBuildStrategy, since the shared install must finish before any grouped function build starts; and --cached, a container build, and a plain build each leave the wrapper out entirely. Verified as guards rather than decoration: weakening the gate to `if self._build_in_source:` fails exactly the --cached and container tests. --- .../unit/lib/build_module/test_app_builder.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 2294c670225..9b0a7b84278 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -453,6 +453,173 @@ def test_parallel_and_cached_run_should_pick_parallel_with_incremental( mock_parallel_build_strategy.build.assert_called_once() self.assertEqual(result, mock_parallel_build_strategy.build()) + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + def test_build_in_source_run_should_wrap_with_workspace_aware_strategy( + self, + mock_workspace_aware_build_strategy_class, + mock_default_build_strategy_class, + mock_get_validated_client, + ): + mock_default_build_strategy = Mock() + mock_default_build_strategy_class.return_value = mock_default_build_strategy + mock_workspace_aware_build_strategy = Mock() + mock_workspace_aware_build_strategy_class.return_value = mock_workspace_aware_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), + "builddir", + "basedir", + "cachedir", + build_in_source=True, + stream_writer=StreamWriter(sys.stderr), + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + # the wrapper takes the strategy it wraps, plus the two callbacks it delegates the npm work to + mock_workspace_aware_build_strategy_class.assert_called_once_with( + ANY, + mock_default_build_strategy, + "basedir", + builder._resolve_npm_project_root, + builder._install_workspace_shared_dependencies, + ) + mock_workspace_aware_build_strategy.build.assert_called_once() + self.assertEqual(result, mock_workspace_aware_build_strategy.build()) + # the wrapped strategy is only reached through the wrapper + mock_default_build_strategy.build.assert_not_called() + + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.ParallelBuildStrategy") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + def test_build_in_source_with_parallel_should_wrap_outside_the_parallel_strategy( + self, + mock_workspace_aware_build_strategy_class, + mock_parallel_build_strategy_class, + mock_get_validated_client, + ): + # the shared install has to finish before any grouped function build starts, so the wrapper + # must sit OUTSIDE ParallelBuildStrategy rather than inside it + mock_parallel_build_strategy = Mock() + mock_parallel_build_strategy_class.return_value = mock_parallel_build_strategy + mock_workspace_aware_build_strategy = Mock() + mock_workspace_aware_build_strategy_class.return_value = mock_workspace_aware_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), + "builddir", + "basedir", + "cachedir", + parallel=True, + build_in_source=True, + stream_writer=StreamWriter(sys.stderr), + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + self.assertEqual( + mock_workspace_aware_build_strategy_class.call_args[0][1], + mock_parallel_build_strategy, + ) + self.assertEqual(result, mock_workspace_aware_build_strategy.build()) + + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.CachedOrIncrementalBuildStrategyWrapper") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + def test_cached_run_should_not_wrap_with_workspace_aware_strategy( + self, + mock_workspace_aware_build_strategy_class, + mock_cached_and_incremental_build_strategy_class, + mock_get_validated_client, + ): + # --cached supplies dependencies from .aws-sam/deps and manages download_dependencies + # itself, so the shared install must not take that over + mock_cached_and_incremental_build_strategy = Mock() + mock_cached_and_incremental_build_strategy_class.return_value = mock_cached_and_incremental_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), + "builddir", + "basedir", + "cachedir", + cached=True, + build_in_source=True, + stream_writer=StreamWriter(sys.stderr), + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + mock_workspace_aware_build_strategy_class.assert_not_called() + self.assertEqual(result, mock_cached_and_incremental_build_strategy.build()) + + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + def test_container_run_should_not_wrap_with_workspace_aware_strategy( + self, + mock_workspace_aware_build_strategy_class, + mock_default_build_strategy_class, + mock_get_validated_client, + ): + # the install would run on the host while the build runs in the container + mock_default_build_strategy = Mock() + mock_default_build_strategy_class.return_value = mock_default_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), + "builddir", + "basedir", + "cachedir", + container_manager=Mock(), + build_in_source=True, + stream_writer=StreamWriter(sys.stderr), + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + mock_workspace_aware_build_strategy_class.assert_not_called() + self.assertEqual(result, mock_default_build_strategy.build()) + + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + def test_default_run_should_not_wrap_with_workspace_aware_strategy( + self, + mock_workspace_aware_build_strategy_class, + mock_default_build_strategy_class, + mock_get_validated_client, + ): + # without --build-in-source the dependencies are installed into the build directory, where + # there is no shared tree to hoist into + mock_default_build_strategy = Mock() + mock_default_build_strategy_class.return_value = mock_default_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), "builddir", "basedir", "cachedir", stream_writer=StreamWriter(sys.stderr) + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + mock_workspace_aware_build_strategy_class.assert_not_called() + self.assertEqual(result, mock_default_build_strategy.build()) + @patch("samcli.lib.build.build_graph.BuildGraph._write") @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.build_graph.BuildGraph._read") From 73dd96cd405a91c4cbc5c98c205d08ec3fc9595b Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 29 Sep 2026 16:47:13 +0000 Subject: [PATCH 4/9] test: end-to-end esbuild monorepo build off one shared install aws/aws-sam-cli#6567 is an esbuild report, but the integration coverage was plain npm only. The esbuild workflow reaches the same grouping - both nodejs workflows funnel their installs through the same npm project tree - while its artifact side differs: it bundles what each entry point imports instead of linking a node_modules, so the functions build with no dependency directory at all and the bundle has to have resolved through the tree npm hoisted to the workspace root. The NodeWorkspacesEsbuild fixture carries `esbuild` as the ROOT's own devDependency, which is exactly what the shared install's missing --omit=dev protects, and gives the shared package a conflicting lodash npm keeps nested. The test asserts one install for the whole workspace, that the root's esbuild survived it, that each artifact is a bundle with no node_modules, and that each bundle reports the hoisted 4.17.20 alongside the shared package's nested 4.17.15. The fixture proves its own point: with grouping disabled, the per-function installs prune the root's esbuild and EsbuildBundle fails outright. --- .../buildcmd/test_build_in_source.py | 70 ++ .../functions/fn-axios/index.js | 10 + .../functions/fn-axios/package.json | 9 + .../functions/fn-lodash/index.js | 10 + .../functions/fn-lodash/package.json | 9 + .../NodeWorkspacesEsbuild/package-lock.json | 739 ++++++++++++++++++ .../NodeWorkspacesEsbuild/package.json | 12 + .../packages/shared/index.js | 3 + .../packages/shared/package.json | 8 + .../NodeWorkspacesEsbuild/template.yaml | 30 + 10 files changed, 900 insertions(+) create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package-lock.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/index.js create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/package.json create mode 100644 tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/template.yaml diff --git a/tests/integration/buildcmd/test_build_in_source.py b/tests/integration/buildcmd/test_build_in_source.py index 079b9457686..6e34f91b789 100644 --- a/tests/integration/buildcmd/test_build_in_source.py +++ b/tests/integration/buildcmd/test_build_in_source.py @@ -320,6 +320,76 @@ def test_use_container_cannot_reach_the_workspace_grouping(self): self.assertEqual(self.npm_install_commands(), []) +class TestBuildCommand_BuildInSource_NodejsWorkspacesEsbuild(NpmCallLogMixin, BuildIntegBase): + """ + The esbuild workflow reaches the same grouping, because both nodejs workflows funnel their + installs through the same npm project tree. What differs is the artifact side: esbuild bundles + what each entry point imports instead of linking a node_modules, so these functions are built + with no dependency directory at all and the bundle has to have resolved through the tree npm + hoisted to the workspace root. + + This is the workflow aws/aws-sam-cli#6567 actually reports, and the fixture carries the reason + the shared install must not pass --omit=dev: `esbuild` is the ROOT's own devDependency, so an + install that pruned it would delete the bundler this build needs. + """ + + template = None # the template lives inside the copied fixture + + def setUp(self): + super().setUp() + osutils.copytree(Path(self.test_data_path, "NodeWorkspacesEsbuild"), self.working_dir) + self.template_path = str(Path(self.working_dir, "template.yaml")) + self.setup_npm_call_log() + + def artifact_dir(self, logical_id) -> Path: + return Path(self.default_build_dir, logical_id) + + def run_bundle(self, logical_id) -> dict: + result = run_command( + ["node", "-e", 'require("./index.js").handler().then(r => console.log(r.body))'], + cwd=str(self.artifact_dir(logical_id)), + ) + self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) + return json.loads(result.stdout.decode("utf-8").strip()) + + @pytest.mark.flaky(reruns=3) + def test_installs_once_then_bundles_each_function(self): + command_list = self.get_command_list(build_in_source=True, debug=True) + result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) + self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) + + self.assertIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + + # one install for the whole workspace, not one per function + installs = self.npm_install_commands() + self.assertEqual(len(installs), 1, f"expected exactly one npm install, saw: {installs}") + # --omit=dev here would remove the root's own esbuild and the bundling below could not run + self.assertNotIn("--omit=dev", installs[0]) + self.assertTrue( + Path(self.working_dir, "node_modules", "esbuild").is_dir(), + "the shared install pruned the root's own esbuild devDependency", + ) + + for logical_id in ("LodashFunction", "AxiosFunction"): + artifacts = self.artifact_dir(logical_id) + self.assertTrue((artifacts / "index.js").is_file(), f"{logical_id} produced no bundle") + # esbuild inlines what it needs, so unlike the plain npm workflow there is nothing to link + self.assertFalse( + (artifacts / "node_modules").exists(), + f"{logical_id} got a node_modules; the bundle should carry its dependencies", + ) + + # each bundle resolved through the hoisted root tree, including the shared package's + # conflicting lodash, which npm keeps as a nested copy + lodash_body = self.run_bundle("LodashFunction") + self.assertEqual(lodash_body["ownLodashVersion"], "4.17.20") + self.assertEqual(lodash_body["sharedLodashVersion"], "4.17.15") + + axios_body = self.run_bundle("AxiosFunction") + self.assertEqual(axios_body["axiosVersion"], "1.6.0") + self.assertEqual(axios_body["sharedLodashVersion"], "4.17.15") + + class TestBuildCommand_BuildInSource_NodejsStandaloneNotGrouped(NpmCallLogMixin, BuildIntegNodeBase): """A standalone (non-workspace) nodejs project keeps its per-function install unchanged.""" diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/index.js b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/index.js new file mode 100644 index 00000000000..dbf66c4a032 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/index.js @@ -0,0 +1,10 @@ +const axios = require("axios"); +const shared = require("@mono/shared"); + +exports.handler = async () => ({ + statusCode: 200, + body: JSON.stringify({ + axiosVersion: axios.VERSION, + sharedLodashVersion: shared.sharedLodashVersion(), + }), +}); diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/package.json b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/package.json new file mode 100644 index 00000000000..bc7b26a19e0 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-axios/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mono/fn-axios", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@mono/shared": "^1.0.0", + "axios": "1.6.0" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/index.js b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/index.js new file mode 100644 index 00000000000..f9797ed00b9 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/index.js @@ -0,0 +1,10 @@ +const _ = require("lodash"); +const shared = require("@mono/shared"); + +exports.handler = async () => ({ + statusCode: 200, + body: JSON.stringify({ + ownLodashVersion: _.VERSION, + sharedLodashVersion: shared.sharedLodashVersion(), + }), +}); diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/package.json b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/package.json new file mode 100644 index 00000000000..f00c0da92e6 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/functions/fn-lodash/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mono/fn-lodash", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@mono/shared": "^1.0.0", + "lodash": "4.17.20" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package-lock.json b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package-lock.json new file mode 100644 index 00000000000..46bb0fe2410 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package-lock.json @@ -0,0 +1,739 @@ +{ + "name": "node-workspaces-esbuild-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-workspaces-esbuild-fixture", + "version": "1.0.0", + "workspaces": [ + "packages/*", + "functions/*" + ], + "devDependencies": { + "esbuild": "^0.14.14" + } + }, + "functions/fn-axios": { + "name": "@mono/fn-axios", + "version": "1.0.0", + "dependencies": { + "@mono/shared": "^1.0.0", + "axios": "1.6.0" + } + }, + "functions/fn-lodash": { + "name": "@mono/fn-lodash", + "version": "1.0.0", + "dependencies": { + "@mono/shared": "^1.0.0", + "lodash": "4.17.20" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", + "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@mono/fn-axios": { + "resolved": "functions/fn-axios", + "link": true + }, + "node_modules/@mono/fn-lodash": { + "resolved": "functions/fn-lodash", + "link": true + }, + "node_modules/@mono/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", + "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/linux-loong64": "0.14.54", + "esbuild-android-64": "0.14.54", + "esbuild-android-arm64": "0.14.54", + "esbuild-darwin-64": "0.14.54", + "esbuild-darwin-arm64": "0.14.54", + "esbuild-freebsd-64": "0.14.54", + "esbuild-freebsd-arm64": "0.14.54", + "esbuild-linux-32": "0.14.54", + "esbuild-linux-64": "0.14.54", + "esbuild-linux-arm": "0.14.54", + "esbuild-linux-arm64": "0.14.54", + "esbuild-linux-mips64le": "0.14.54", + "esbuild-linux-ppc64le": "0.14.54", + "esbuild-linux-riscv64": "0.14.54", + "esbuild-linux-s390x": "0.14.54", + "esbuild-netbsd-64": "0.14.54", + "esbuild-openbsd-64": "0.14.54", + "esbuild-sunos-64": "0.14.54", + "esbuild-windows-32": "0.14.54", + "esbuild-windows-64": "0.14.54", + "esbuild-windows-arm64": "0.14.54" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz", + "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz", + "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz", + "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz", + "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz", + "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz", + "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz", + "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", + "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz", + "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz", + "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz", + "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz", + "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz", + "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz", + "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz", + "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz", + "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz", + "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz", + "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz", + "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz", + "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "packages/shared": { + "name": "@mono/shared", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } + }, + "packages/shared/node_modules/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "license": "MIT" + } + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package.json b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package.json new file mode 100644 index 00000000000..08165ed04ef --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/package.json @@ -0,0 +1,12 @@ +{ + "name": "node-workspaces-esbuild-fixture", + "version": "1.0.0", + "private": true, + "workspaces": [ + "packages/*", + "functions/*" + ], + "devDependencies": { + "esbuild": "^0.14.14" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/index.js b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/index.js new file mode 100644 index 00000000000..1f1ff18c58d --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/index.js @@ -0,0 +1,3 @@ +const _ = require("lodash"); + +exports.sharedLodashVersion = () => _.VERSION; diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/package.json b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/package.json new file mode 100644 index 00000000000..ac5e3199244 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/packages/shared/package.json @@ -0,0 +1,8 @@ +{ + "name": "@mono/shared", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "lodash": "4.17.15" + } +} diff --git a/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/template.yaml b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/template.yaml new file mode 100644 index 00000000000..9f6ecfafce1 --- /dev/null +++ b/tests/integration/testdata/buildcmd/NodeWorkspacesEsbuild/template.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + LodashFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fn-lodash + Handler: index.handler + Runtime: nodejs22.x + Timeout: 600 + Metadata: + BuildMethod: esbuild + BuildProperties: + EntryPoints: + - index.js + Target: es2020 + AxiosFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fn-axios + Handler: index.handler + Runtime: nodejs22.x + Timeout: 600 + Metadata: + BuildMethod: esbuild + BuildProperties: + EntryPoints: + - index.js + Target: es2020 From e2d568418bdc38c7b9a75d173e95fb4d7d150193 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 29 Sep 2026 17:04:06 +0000 Subject: [PATCH 5/9] fix: compare the npm project root case-insensitively _workspace_root_for decides whether a function is a workspace member by comparing npm's reported project root against the code directory, and the two are spelled independently. On Windows two spellings differing only in case - a drive letter included - name the same directory, so reading them as different roots would group a project that is not a workspace at all. normcase is a no-op off Windows; 310 unit and 6 workspace integration tests unchanged. --- samcli/lib/build/build_strategy.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 27a14f3c033..2b083b94771 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -799,7 +799,10 @@ def _workspace_root_for(self, build_definition: FunctionBuildDefinition) -> Opti project_root = self._project_root_resolver(code_dir) if not project_root: return None - if os.path.realpath(project_root) == os.path.realpath(code_dir): - # npm answers with the directory itself for anything that is not a workspace member + # npm answers with the directory itself for anything that is not a workspace member. normcase + # because npm's stdout and this path are spelled independently, and on Windows two spellings + # differing only in case - a drive letter included - name the same directory; reading those as + # different roots would group a project that is not a workspace at all. No-op off Windows. + if os.path.normcase(os.path.realpath(project_root)) == os.path.normcase(os.path.realpath(code_dir)): return None return project_root From eeb5d243aa88ee81ebd643e762abfd21f24a4e23 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 30 Sep 2026 14:02:40 +0000 Subject: [PATCH 6/9] feat: put the nodejs monorepo grouping behind an experimental flag The workspace grouping changes how every function in an npm workspaces monorepo gets its dependencies, so it ships opt-in: ExperimentalFlag.NodejsMonorepo, enabled by SAM_CLI_BETA_NODEJS_MONOREPO=1, by --beta-features, or by SAM_CLI_BETA_FEATURES=1 with the rest. get_all_experimental() discovers members reflectively, so the new flag registers itself in the beta prompts, the env-var list and telemetry with no other wiring. With the flag off, ApplicationBuilder never wraps WorkspaceAwareBuildStrategy and every function installs for itself - the behaviour of every release so far. Two test changes beyond the new flag-off unit test. The five gating tests now patch the flag ON, so each still tests the gate it names rather than passing because the flag is off; and the integration tests that exercise the grouping ask for --beta-features, including the standalone-project one, where what must keep a per-function install is its own npm root rather than a disabled flag. One new integration test pins the rollout guarantee end to end: the same monorepo fixture with beta features off takes one install per function and still produces correct, disjoint artifacts. --- samcli/commands/_utils/experimental.py | 1 + samcli/lib/build/app_builder.py | 15 ++++++- .../buildcmd/test_build_in_source.py | 36 +++++++++++++-- .../unit/commands/_utils/test_experimental.py | 8 ++-- .../unit/lib/build_module/test_app_builder.py | 45 +++++++++++++++++++ 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/samcli/commands/_utils/experimental.py b/samcli/commands/_utils/experimental.py index 34b1f7e4d34..e74dc3fd973 100644 --- a/samcli/commands/_utils/experimental.py +++ b/samcli/commands/_utils/experimental.py @@ -57,6 +57,7 @@ class ExperimentalFlag: UvPackageManager = ExperimentalEntry( "experimentalUvPackageManager", EXPERIMENTAL_ENV_VAR_PREFIX + "UV_PACKAGE_MANAGER" ) + NodejsMonorepo = ExperimentalEntry("experimentalNodejsMonorepo", EXPERIMENTAL_ENV_VAR_PREFIX + "NODEJS_MONOREPO") def is_experimental_enabled(config_entry: ExperimentalEntry) -> bool: diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 00025481b9c..be6fe058b35 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -18,7 +18,11 @@ from aws_lambda_builders.builder import LambdaBuilder from aws_lambda_builders.exceptions import LambdaBuilderError -from samcli.commands._utils.experimental import get_enabled_experimental_flags +from samcli.commands._utils.experimental import ( + ExperimentalFlag, + get_enabled_experimental_flags, + is_experimental_enabled, +) from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition from samcli.lib.build.build_strategy import ( BuildStrategy, @@ -263,11 +267,18 @@ def build(self) -> ApplicationBuildResult: bool(self._container_manager), ) - if self._build_in_source and not self._cached and not self._container_manager: + if ( + is_experimental_enabled(ExperimentalFlag.NodejsMonorepo) + and self._build_in_source + and not self._cached + and not self._container_manager + ): # npm workspaces monorepo: install the shared dependency tree once per workspace root # instead of once per function. Outside ParallelBuildStrategy on purpose - the install # must complete before any grouped function build starts. --cached is excluded because # it supplies dependencies from .aws-sam/deps and manages download_dependencies itself. + # Behind SAM_CLI_BETA_NODEJS_MONOREPO while the grouping rolls out: without it every + # function keeps its own install, which is the behaviour every release so far has had. build_strategy = WorkspaceAwareBuildStrategy( build_graph, build_strategy, diff --git a/tests/integration/buildcmd/test_build_in_source.py b/tests/integration/buildcmd/test_build_in_source.py index 6e34f91b789..12ac96eac0a 100644 --- a/tests/integration/buildcmd/test_build_in_source.py +++ b/tests/integration/buildcmd/test_build_in_source.py @@ -237,8 +237,11 @@ def run_built_handler(self, logical_id) -> dict: self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) return json.loads(result.stdout.decode("utf-8").strip()) - def build_workspace(self, parallel=False): - command_list = self.get_command_list(build_in_source=True, parallel=parallel, debug=True) + def build_workspace(self, parallel=False, beta_features=True): + # the grouping is behind SAM_CLI_BETA_NODEJS_MONOREPO, which --beta-features turns on + command_list = self.get_command_list( + build_in_source=True, parallel=parallel, debug=True, beta_features=beta_features + ) result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) return result @@ -303,6 +306,27 @@ def test_parallel_build_installs_once_before_function_builds(self): # exist yet, so complete artifacts prove the install was a barrier self.assert_artifacts_are_disjoint() + @pytest.mark.flaky(reruns=3) + def test_without_beta_features_the_monorepo_behaves_exactly_as_it_does_today(self): + # What the flag holds back, stated as behaviour rather than as a promise. Off, this fixture gets + # the shape every release so far has produced: one install per function, no grouping, and - the + # part worth being explicit about - a build that SUCCEEDS while emitting artifacts with no + # dependencies in them at all. That is aws/aws-lambda-builders#933, and the flag is what opts a + # user into its fix along with the grouping. Turning the flag on flips every assertion here, + # which the other tests in this class cover. + result = self.build_workspace(beta_features=False) + + self.assertNotIn(WORKSPACE_INSTALL_LOG_MESSAGE, result.stderr.decode("utf-8")) + installs = self.npm_install_commands() + self.assertEqual(len(installs), 2, f"expected one install per function, saw: {installs}") + + for logical_id in ("LodashFunction", "AxiosFunction"): + modules = self.artifact_modules(logical_id) + self.assertFalse( + modules.exists() and any(modules.iterdir()), + f"{logical_id} has dependencies without the flag; #933 would then already be fixed", + ) + @pytest.mark.flaky(reruns=3) def test_use_container_cannot_reach_the_workspace_grouping(self): # workspace grouping only activates under --build-in-source, and the CLI refuses that @@ -354,7 +378,7 @@ def run_bundle(self, logical_id) -> dict: @pytest.mark.flaky(reruns=3) def test_installs_once_then_bundles_each_function(self): - command_list = self.get_command_list(build_in_source=True, debug=True) + command_list = self.get_command_list(build_in_source=True, debug=True, beta_features=True) result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) @@ -407,7 +431,11 @@ def test_standalone_project_keeps_per_function_install(self): overrides = self.get_override( runtime="nodejs24.x", code_uri=str(codeuri), architecture="x86_64", handler="main.lambdaHandler" ) - command_list = self.get_command_list(build_in_source=True, parameter_overrides=overrides, debug=True) + # beta features ON deliberately: what keeps this project on a per-function install has to be + # its own npm root, not the flag being off + command_list = self.get_command_list( + build_in_source=True, parameter_overrides=overrides, debug=True, beta_features=True + ) result = run_command(command_list, cwd=self.working_dir, env=self.npm_logging_env()) self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) diff --git a/tests/unit/commands/_utils/test_experimental.py b/tests/unit/commands/_utils/test_experimental.py index 2e202dd95b5..770a7e18277 100644 --- a/tests/unit/commands/_utils/test_experimental.py +++ b/tests/unit/commands/_utils/test_experimental.py @@ -57,16 +57,16 @@ def test_set_experimental(self): self.gc_mock.return_value.set_value.assert_called_once_with(config_entry, False, is_flag=True, flush=False) def test_get_all_experimental(self): - self.assertEqual(len(get_all_experimental()), 5) + self.assertEqual(len(get_all_experimental()), 6) def test_get_all_experimental_statues(self): - self.assertEqual(len(get_all_experimental_statues()), 5) + self.assertEqual(len(get_all_experimental_statues()), 6) def test_get_all_experimental_env_vars(self): - self.assertEqual(len(get_all_experimental_env_vars()), 5) + self.assertEqual(len(get_all_experimental_env_vars()), 6) def test_get_enabled_experimental_flags(self): - self.assertEqual(len(get_enabled_experimental_flags()), 5) + self.assertEqual(len(get_enabled_experimental_flags()), 6) @patch("samcli.commands._utils.experimental.set_experimental") @patch("samcli.commands._utils.experimental.get_all_experimental") diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 9b0a7b84278..96be719f26f 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -11,6 +11,7 @@ import docker from parameterized import parameterized +from samcli.commands._utils.experimental import ExperimentalFlag from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionPropertyType from samcli.lib.build.app_builder import ( ApplicationBuilder, @@ -456,8 +457,10 @@ def test_parallel_and_cached_run_should_pick_parallel_with_incremental( @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=True) def test_build_in_source_run_should_wrap_with_workspace_aware_strategy( self, + _flag_enabled, mock_workspace_aware_build_strategy_class, mock_default_build_strategy_class, mock_get_validated_client, @@ -497,8 +500,10 @@ def test_build_in_source_run_should_wrap_with_workspace_aware_strategy( @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.app_builder.ParallelBuildStrategy") @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=True) def test_build_in_source_with_parallel_should_wrap_outside_the_parallel_strategy( self, + _flag_enabled, mock_workspace_aware_build_strategy_class, mock_parallel_build_strategy_class, mock_get_validated_client, @@ -534,8 +539,10 @@ def test_build_in_source_with_parallel_should_wrap_outside_the_parallel_strategy @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.app_builder.CachedOrIncrementalBuildStrategyWrapper") @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=True) def test_cached_run_should_not_wrap_with_workspace_aware_strategy( self, + _flag_enabled, mock_workspace_aware_build_strategy_class, mock_cached_and_incremental_build_strategy_class, mock_get_validated_client, @@ -566,8 +573,10 @@ def test_cached_run_should_not_wrap_with_workspace_aware_strategy( @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=True) def test_container_run_should_not_wrap_with_workspace_aware_strategy( self, + _flag_enabled, mock_workspace_aware_build_strategy_class, mock_default_build_strategy_class, mock_get_validated_client, @@ -597,8 +606,10 @@ def test_container_run_should_not_wrap_with_workspace_aware_strategy( @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=True) def test_default_run_should_not_wrap_with_workspace_aware_strategy( self, + _flag_enabled, mock_workspace_aware_build_strategy_class, mock_default_build_strategy_class, mock_get_validated_client, @@ -620,6 +631,40 @@ def test_default_run_should_not_wrap_with_workspace_aware_strategy( mock_workspace_aware_build_strategy_class.assert_not_called() self.assertEqual(result, mock_default_build_strategy.build()) + @patch("samcli.lib.build.app_builder.get_validated_container_client") + @patch("samcli.lib.build.app_builder.DefaultBuildStrategy") + @patch("samcli.lib.build.app_builder.WorkspaceAwareBuildStrategy") + @patch("samcli.lib.build.app_builder.is_experimental_enabled", return_value=False) + def test_workspace_grouping_is_off_without_the_experimental_flag( + self, + mock_is_experimental_enabled, + mock_workspace_aware_build_strategy_class, + mock_default_build_strategy_class, + mock_get_validated_client, + ): + # everything else about this build asks for the grouping; only the flag is missing, and that + # alone keeps every function on its own install - the behaviour of every release so far + mock_default_build_strategy = Mock() + mock_default_build_strategy_class.return_value = mock_default_build_strategy + + mock_get_validated_client.return_value = Mock() + + builder = ApplicationBuilder( + MagicMock(), + "builddir", + "basedir", + "cachedir", + build_in_source=True, + stream_writer=StreamWriter(sys.stderr), + ) + builder._get_build_graph = Mock(return_value=Mock()) + + result = builder.build().artifacts + + mock_is_experimental_enabled.assert_called_once_with(ExperimentalFlag.NodejsMonorepo) + mock_workspace_aware_build_strategy_class.assert_not_called() + self.assertEqual(result, mock_default_build_strategy.build()) + @patch("samcli.lib.build.build_graph.BuildGraph._write") @patch("samcli.lib.build.app_builder.get_validated_container_client") @patch("samcli.lib.build.build_graph.BuildGraph._read") From 9b9c0b8461cf5275d4cec09647ba3442298b7e87 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 19 Oct 2026 20:40:59 +0000 Subject: [PATCH 7/9] fix(build): pass the experimental flags to the builders shared-project hook The call site invoked the hook with the project root alone. aws-lambda-builders#941 makes prepare_shared_project refuse without experimentalNodejsMonorepo - the per-function artifacts link is gated on that same flag, so preparing the root without it would ship artifacts carrying no dependencies. Unflagged, the refusal arrives as a LambdaBuilderError, which this method already catches and turns into a silent fall back to per-function installs: correct artifacts by the slow path, with the feature inert and nothing red to show it. Renamed to follow the hook: install_shared_dependencies became prepare_shared_project upstream, because the same phase for a Cargo workspace compiles every member and installs nothing. The defect survived because nothing tested this method - one assertion pinned the callable into the strategy and stopped there. Adds a test class over what it hands the builders: the enabled flags forwarded, an empty list forwarded rather than omitted, an aws-lambda-builders too old to have the hook declined, and both failure paths falling back. Removing the kwarg again fails two of them. --- samcli/lib/build/app_builder.py | 29 +++++---- samcli/lib/build/build_strategy.py | 12 ++-- .../unit/lib/build_module/test_app_builder.py | 64 ++++++++++++++++++- .../lib/build_module/test_build_strategy.py | 2 +- 4 files changed, 85 insertions(+), 22 deletions(-) diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index be6fe058b35..18ebdc49422 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -284,7 +284,7 @@ def build(self) -> ApplicationBuildResult: build_strategy, self._base_dir, self._resolve_npm_project_root, - self._install_workspace_shared_dependencies, + self._prepare_workspace_shared_project, ) return ApplicationBuildResult(build_graph, build_strategy.build()) @@ -729,15 +729,14 @@ def _resolve_npm_project_root(self, code_dir: str) -> Optional[str]: LOG.debug("Could not resolve the npm project root of %s: %s", code_dir, ex) return None - def _install_workspace_shared_dependencies( - self, project_root: str, build_definition: "FunctionBuildDefinition" - ) -> bool: + def _prepare_workspace_shared_project(self, project_root: str, build_definition: "FunctionBuildDefinition") -> bool: """ - Install, once, the dependency tree shared by every workspace function under project_root. + Do once, at project_root, the work every workspace function under it shares. - The build definition supplies the workflow (npm or npm-esbuild) whose builder runs the - install; both funnel into the same npm project tree. Returns False on any failure so the - caller leaves the grouped functions on per-function installs. + For the npm workflows that is installing the hoisted dependency tree. The build definition + supplies the workflow (npm or npm-esbuild) whose builder does it; both funnel into the same + npm project tree. Returns False on any failure so the caller leaves the grouped functions on + per-function installs. """ code_dir = str(pathlib.Path(self._base_dir, cast(str, build_definition.codeuri)).resolve()) specified_workflow = build_definition.metadata.get("BuildMethod") if build_definition.metadata else None @@ -750,16 +749,18 @@ def _install_workspace_shared_dependencies( dependency_manager=config.dependency_manager, application_framework=config.application_framework, ) - installer = getattr(builder, "install_shared_dependencies", None) - if installer is None: - LOG.debug("Installed aws-lambda-builders has no shared dependency install; skipping grouping") + prepare = getattr(builder, "prepare_shared_project", None) + if prepare is None: + LOG.debug("Installed aws-lambda-builders has no shared-project hook; skipping grouping") return False - installer(project_root) + # The same flags the per-function build() calls get: the builders hook refuses without + # experimentalNodejsMonorepo, because the per-function artifacts link is gated on it too + # and preparing the root without it would ship artifacts carrying no dependencies. + prepare(project_root, experimental_flags=get_enabled_experimental_flags()) return True except (UnsupportedRuntimeException, LambdaBuilderError) as ex: LOG.warning( - "Installing shared dependencies for workspace root %s failed (%s); " - "falling back to per-function installs", + "Preparing the shared project root %s failed (%s); falling back to per-function installs", project_root, ex, ) diff --git a/samcli/lib/build/build_strategy.py b/samcli/lib/build/build_strategy.py index 2b083b94771..318185ea3cf 100644 --- a/samcli/lib/build/build_strategy.py +++ b/samcli/lib/build/build_strategy.py @@ -727,7 +727,7 @@ def __init__( delegate_build_strategy: BuildStrategy, base_dir: str, project_root_resolver: Callable[[str], Optional[str]], - shared_install_function: Callable[[str, FunctionBuildDefinition], bool], + prepare_shared_project_function: Callable[[str, FunctionBuildDefinition], bool], ) -> None: """ Parameters @@ -735,7 +735,7 @@ def __init__( project_root_resolver: Given a function's resolved code directory, returns npm's project root for it, or None when it cannot be resolved (npm missing, too-old aws-lambda-builders, non-npm project). - shared_install_function: + prepare_shared_project_function: Runs the shared install for (project_root, one grouped build definition); returns True on success. Failures must be handled inside and reported with False. """ @@ -743,7 +743,7 @@ def __init__( self._delegate_build_strategy = delegate_build_strategy self._base_dir = base_dir self._project_root_resolver = project_root_resolver - self._shared_install_function = shared_install_function + self._prepare_shared_project_function = prepare_shared_project_function def build(self) -> Dict[str, str]: with self._delegate_build_strategy: @@ -753,7 +753,7 @@ def _build_layers(self, build_graph: BuildGraph) -> Dict[str, str]: return self._delegate_build_strategy._build_layers(build_graph) def _build_functions(self, build_graph: BuildGraph) -> Dict[str, str]: - self._install_shared_dependencies_for_workspace_groups(build_graph) + self._prepare_shared_project_for_workspace_groups(build_graph) return self._delegate_build_strategy._build_functions(build_graph) def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]: @@ -762,7 +762,7 @@ def build_single_function_definition(self, build_definition: FunctionBuildDefini def build_single_layer_definition(self, layer_definition: LayerBuildDefinition) -> Dict[str, str]: return self._delegate_build_strategy.build_single_layer_definition(layer_definition) - def _install_shared_dependencies_for_workspace_groups(self, build_graph: BuildGraph) -> None: + def _prepare_shared_project_for_workspace_groups(self, build_graph: BuildGraph) -> None: groups: Dict[str, List[FunctionBuildDefinition]] = {} for build_definition in build_graph.get_function_build_definitions(): project_root = self._workspace_root_for(build_definition) @@ -775,7 +775,7 @@ def _install_shared_dependencies_for_workspace_groups(self, build_graph: BuildGr project_root, ", ".join(definition.get_resource_full_paths() for definition in build_definitions), ) - if self._shared_install_function(project_root, build_definitions[0]): + if self._prepare_shared_project_function(project_root, build_definitions[0]): for build_definition in build_definitions: build_definition.download_dependencies = False diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 96be719f26f..2e697774299 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -490,7 +490,7 @@ def test_build_in_source_run_should_wrap_with_workspace_aware_strategy( mock_default_build_strategy, "basedir", builder._resolve_npm_project_root, - builder._install_workspace_shared_dependencies, + builder._prepare_workspace_shared_project, ) mock_workspace_aware_build_strategy.build.assert_called_once() self.assertEqual(result, mock_workspace_aware_build_strategy.build()) @@ -807,6 +807,68 @@ def test_must_not_use_dep_layer_for_non_cached(self, mock_get_validated_client): ) +class TestApplicationBuilder_prepare_workspace_shared_project(TestCase): + """ + What this hands to aws-lambda-builders, which nothing else pins. The builders hook refuses + without experimentalNodejsMonorepo, and a caller that forgets the flag gets a caught + LambdaBuilderError and a silent fall back to per-function installs - correct artifacts by the + slow path, so no test downstream of here can see the mistake. + """ + + @patch("samcli.lib.build.app_builder.get_validated_container_client") + def setUp(self, _mock_client): + self.builder = ApplicationBuilder( + MagicMock(), "builddir", "basedir", "cachedir", stream_writer=StreamWriter(sys.stderr) + ) + self.definition = Mock(codeuri="functions/orders", runtime="nodejs22.x", metadata={}) + + @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags", return_value=["experimentalNodejsMonorepo"]) + @patch("samcli.lib.build.app_builder.LambdaBuilder") + @patch("samcli.lib.build.app_builder.get_workflow_config") + def test_passes_the_enabled_experimental_flags_to_the_builders_hook(self, _config, mock_builder_class, _flags): + builder_instance = mock_builder_class.return_value + + self.assertTrue(self.builder._prepare_workspace_shared_project("/base/mono", self.definition)) + + builder_instance.prepare_shared_project.assert_called_once_with( + "/base/mono", experimental_flags=["experimentalNodejsMonorepo"] + ) + + @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags", return_value=[]) + @patch("samcli.lib.build.app_builder.LambdaBuilder") + @patch("samcli.lib.build.app_builder.get_workflow_config") + def test_forwards_an_empty_flag_list_rather_than_omitting_the_argument(self, _config, mock_builder_class, _flags): + # the builders hook decides what an absent flag means, so the argument must always reach it + builder_instance = mock_builder_class.return_value + + self.builder._prepare_workspace_shared_project("/base/mono", self.definition) + + builder_instance.prepare_shared_project.assert_called_once_with("/base/mono", experimental_flags=[]) + + @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags", return_value=[]) + @patch("samcli.lib.build.app_builder.LambdaBuilder") + @patch("samcli.lib.build.app_builder.get_workflow_config") + def test_declines_an_aws_lambda_builders_without_the_hook(self, _config, mock_builder_class, _flags): + # the pinned aws-lambda-builders predates the hook, so grouping must not engage + builder_instance = mock_builder_class.return_value + del builder_instance.prepare_shared_project + + self.assertFalse(self.builder._prepare_workspace_shared_project("/base/mono", self.definition)) + + @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags", return_value=[]) + @patch("samcli.lib.build.app_builder.LambdaBuilder") + @patch("samcli.lib.build.app_builder.get_workflow_config") + def test_a_refused_or_failed_preparation_leaves_per_function_installs(self, _config, mock_builder_class, _flags): + mock_builder_class.return_value.prepare_shared_project.side_effect = LambdaBuilderError() + + self.assertFalse(self.builder._prepare_workspace_shared_project("/base/mono", self.definition)) + + @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags", return_value=[]) + @patch("samcli.lib.build.app_builder.get_workflow_config", side_effect=UnsupportedRuntimeException()) + def test_an_unsupported_runtime_leaves_per_function_installs(self, _config, _flags): + self.assertFalse(self.builder._prepare_workspace_shared_project("/base/mono", self.definition)) + + class PathValidator: def __init__(self, path): self._path = path diff --git a/tests/unit/lib/build_module/test_build_strategy.py b/tests/unit/lib/build_module/test_build_strategy.py index 9d42c6cfcef..3f99f97e0fb 100644 --- a/tests/unit/lib/build_module/test_build_strategy.py +++ b/tests/unit/lib/build_module/test_build_strategy.py @@ -926,7 +926,7 @@ def test_functions_sharing_a_workspace_root_install_once_and_skip_their_own_inst self.delegate._build_functions.assert_called_once_with(build_graph) self.assertEqual(result, self.delegate._build_functions.return_value) - def test_a_failed_shared_install_leaves_per_function_installs(self, _read, _write): + def test_a_failed_shared_preparation_leaves_per_function_installs(self, _read, _write): definition = self._definition() build_graph = self._graph(definition) self.resolver.return_value = "/base/mono" From ef2105a6237224076c52890476098e7bb223d35b Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 19 Oct 2026 22:11:02 +0000 Subject: [PATCH 8/9] test: annotate the JSON bodies the monorepo integration tests read mypy's no-any-return flagged both helpers: json.loads returns Any against a declared -> dict. Annotating the local satisfies it without a cast or a new import, and keeps the return type the reader wants. Caught by the repository's own lint target, which type-checks tests/ as well as samcli/ - I had run mypy over the two source modules I changed and missed it. --- tests/integration/buildcmd/test_build_in_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/buildcmd/test_build_in_source.py b/tests/integration/buildcmd/test_build_in_source.py index 12ac96eac0a..ecce069efb2 100644 --- a/tests/integration/buildcmd/test_build_in_source.py +++ b/tests/integration/buildcmd/test_build_in_source.py @@ -235,7 +235,8 @@ def run_built_handler(self, logical_id) -> dict: cwd=str(Path(self.default_build_dir, logical_id)), ) self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) - return json.loads(result.stdout.decode("utf-8").strip()) + body: dict = json.loads(result.stdout.decode("utf-8").strip()) + return body def build_workspace(self, parallel=False, beta_features=True): # the grouping is behind SAM_CLI_BETA_NODEJS_MONOREPO, which --beta-features turns on @@ -374,7 +375,8 @@ def run_bundle(self, logical_id) -> dict: cwd=str(self.artifact_dir(logical_id)), ) self.assertEqual(result.process.returncode, 0, result.stderr.decode("utf-8")) - return json.loads(result.stdout.decode("utf-8").strip()) + body: dict = json.loads(result.stdout.decode("utf-8").strip()) + return body @pytest.mark.flaky(reruns=3) def test_installs_once_then_bundles_each_function(self): From 8f5876409e8c8f64c3d4168468a09a65fac581ea Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 26 Oct 2026 18:55:20 +0000 Subject: [PATCH 9/9] docs: describe the monorepo workspace-root install on --build-in-source The --build-in-source help said only which workflows support building in source, so nothing told a user that an npm workspaces monorepo now installs once at the workspace root, that each function's artifacts are narrowed to its own dependencies, or that any of it needs a beta flag. Scoped to `sam build`: the option is shared with `sam sync`, whose build flows pass cached=True, and the grouping requires `not self._cached`. The help names the exclusions (--use-container, --cached) so the sync case reads correctly from the same text. schema/samcli.json is regenerated from that help by `make schema`, and the build command's rich help gains one example. The buildcmd core test pins the exact list of example sections, so it names the new one. --- samcli/commands/_utils/options.py | 12 +++++++++++- samcli/commands/build/core/command.py | 12 ++++++++++++ schema/samcli.json | 8 ++++---- tests/unit/commands/buildcmd/core/test_command.py | 3 +++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 01de507b117..a8377ca7b19 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -1053,7 +1053,17 @@ def build_in_source_click_option(): required=False, is_flag=True, help="Opts in to build project in the source folder. The following workflows support " - f"building in source: {SUPPORTED_BUILD_IN_SOURCE_WORKFLOWS}", + f"building in source: {SUPPORTED_BUILD_IN_SOURCE_WORKFLOWS}\n" + "\n" + "For `sam build` on an npm workspaces monorepo, npm installs once at the workspace root " + "instead of once per function, and each function's artifacts are narrowed to the " + "dependencies it resolves. The install runs in the workspace root npm itself reports, " + "which is inside your project but above the function's CodeUri.\n" + "\n" + "This grouping is in beta: enable it with --beta-features or " + "SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in " + "earlier releases. It is also skipped for --use-container and --cached builds, so " + "`sam sync` does not use it.", cls=ClickMutex, incompatible_params=["use_container", "hook_name"], ) diff --git a/samcli/commands/build/core/command.py b/samcli/commands/build/core/command.py index 8405a8881ff..a8f09c97928 100644 --- a/samcli/commands/build/core/command.py +++ b/samcli/commands/build/core/command.py @@ -74,6 +74,18 @@ def format_examples(ctx: Context, formatter: CommandHelpTextFormatter): ), ] ) + with formatter.indented_section( + name="Build an npm workspaces monorepo, installing once at the workspace root (beta)", + extra_indents=1, + ): + formatter.write_text_rows( + [ + RowDefinition( + name=style(f"$ {ctx.command_path} --build-in-source --beta-features"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) with formatter.indented_section(name="Build sam project and deploy", extra_indents=1): formatter.write_text_rows( [ diff --git a/schema/samcli.json b/schema/samcli.json index fb48d4346fb..0288ffa3f36 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -310,7 +310,7 @@ "properties": { "parameters": { "title": "Parameters for the build command", - "description": "Available parameters for the build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* use_buildkit:\nEnable buildkit for container image builds. Requires Docker with buildx plugin or Finch CLI.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* output:\nOutput the results from the command in a given output format. Supported formats: text (default), json.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* use_buildkit:\nEnable buildkit for container image builds. Requires Docker with buildx plugin or Finch CLI.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it.\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* output:\nOutput the results from the command in a given output format. Supported formats: text (default), json.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_project_root_path": { @@ -341,7 +341,7 @@ "build_in_source": { "title": "build_in_source", "type": "boolean", - "description": "Opts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']" + "description": "Opts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it." }, "language_extensions": { "title": "language_extensions", @@ -1838,7 +1838,7 @@ "properties": { "parameters": { "title": "Parameters for the sync command", - "description": "Available parameters for the sync command:\n* template_file:\nAWS SAM template file.\n* code:\nSync ONLY code resources. This includes Lambda Functions, API Gateway, and Step Functions.\n* watch:\nWatch local files and automatically sync with cloud.\n* resource_id:\nSync code for all the resources with the ID. To sync a resource within a nested stack, use the following pattern {ChildStack}/{logicalId}.\n* resource:\nSync code for all resources of the given resource type. Accepted values are ['AWS::Serverless::Function', 'AWS::Lambda::Function', 'AWS::Serverless::LayerVersion', 'AWS::Lambda::LayerVersion', 'AWS::Serverless::Api', 'AWS::ApiGateway::RestApi', 'AWS::Serverless::HttpApi', 'AWS::ApiGatewayV2::Api', 'AWS::Serverless::StateMachine', 'AWS::StepFunctions::StateMachine']\n* dependency_layer:\nSeparate dependencies of individual function into a Lambda layer for improved performance.\n* skip_deploy_sync:\nThis option will skip the initial infrastructure deployment if it is not required by comparing the local template with the template deployed in cloud.\n* express:\nUse CloudFormation Express mode to speed up infrastructure deployments by completing once resource configuration is applied, without waiting for full stabilization.\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* watch_exclude:\nExcludes a file or folder from being observed for file changes. Files and folders that are excluded will not trigger a sync workflow. This option can be provided multiple times.\n\nExamples:\n\nHelloWorldFunction=package-lock.json\n\nChildStackA/FunctionName=database.sqlite3\n* stack_name:\nName of the AWS CloudFormation stack.\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* role_arn:\nARN of an IAM role that AWS Cloudformation assumes when executing a deployment change set.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* notification_arns:\nARNs of SNS topics that AWS Cloudformation associates with the stack.\n* tags:\nList of tags to associate with the stack.\n* capabilities:\nList of capabilities that one must specify before AWS Cloudformation can create certain stacks.\n\nAccepted Values: CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_RESOURCE_POLICY, CAPABILITY_AUTO_EXPAND.\n\nLearn more at: https://docs.aws.amazon.com/serverlessrepo/latest/devguide/acknowledging-application-capabilities.html\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the sync command:\n* template_file:\nAWS SAM template file.\n* code:\nSync ONLY code resources. This includes Lambda Functions, API Gateway, and Step Functions.\n* watch:\nWatch local files and automatically sync with cloud.\n* resource_id:\nSync code for all the resources with the ID. To sync a resource within a nested stack, use the following pattern {ChildStack}/{logicalId}.\n* resource:\nSync code for all resources of the given resource type. Accepted values are ['AWS::Serverless::Function', 'AWS::Lambda::Function', 'AWS::Serverless::LayerVersion', 'AWS::Lambda::LayerVersion', 'AWS::Serverless::Api', 'AWS::ApiGateway::RestApi', 'AWS::Serverless::HttpApi', 'AWS::ApiGatewayV2::Api', 'AWS::Serverless::StateMachine', 'AWS::StepFunctions::StateMachine']\n* dependency_layer:\nSeparate dependencies of individual function into a Lambda layer for improved performance.\n* skip_deploy_sync:\nThis option will skip the initial infrastructure deployment if it is not required by comparing the local template with the template deployed in cloud.\n* express:\nUse CloudFormation Express mode to speed up infrastructure deployments by completing once resource configuration is applied, without waiting for full stabilization.\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* watch_exclude:\nExcludes a file or folder from being observed for file changes. Files and folders that are excluded will not trigger a sync workflow. This option can be provided multiple times.\n\nExamples:\n\nHelloWorldFunction=package-lock.json\n\nChildStackA/FunctionName=database.sqlite3\n* stack_name:\nName of the AWS CloudFormation stack.\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* role_arn:\nARN of an IAM role that AWS Cloudformation assumes when executing a deployment change set.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* notification_arns:\nARNs of SNS topics that AWS Cloudformation associates with the stack.\n* tags:\nList of tags to associate with the stack.\n* capabilities:\nList of capabilities that one must specify before AWS Cloudformation can create certain stacks.\n\nAccepted Values: CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_RESOURCE_POLICY, CAPABILITY_AUTO_EXPAND.\n\nLearn more at: https://docs.aws.amazon.com/serverlessrepo/latest/devguide/acknowledging-application-capabilities.html\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "template_file": { @@ -1928,7 +1928,7 @@ "build_in_source": { "title": "build_in_source", "type": "boolean", - "description": "Opts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']" + "description": "Opts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n\nFor `sam build` on an npm workspaces monorepo, npm installs once at the workspace root instead of once per function, and each function's artifacts are narrowed to the dependencies it resolves. The install runs in the workspace root npm itself reports, which is inside your project but above the function's CodeUri.\n\nThis grouping is in beta: enable it with --beta-features or SAM_CLI_BETA_NODEJS_MONOREPO=1. Without it, every function installs for itself, as in earlier releases. It is also skipped for --use-container and --cached builds, so `sam sync` does not use it." }, "build_image": { "title": "build_image", diff --git a/tests/unit/commands/buildcmd/core/test_command.py b/tests/unit/commands/buildcmd/core/test_command.py index 6e6020ab4ef..1758aea5896 100644 --- a/tests/unit/commands/buildcmd/core/test_command.py +++ b/tests/unit/commands/buildcmd/core/test_command.py @@ -49,6 +49,9 @@ def test_get_options_build_command_text(self, mock_get_params): "Build sam project and invoke default function locally": [ ("$ sam build && sam local invoke\x1b[0m", ""), ], + "Build an npm workspaces monorepo, installing once at the workspace root (beta)": [ + ("$ sam build --build-in-source --beta-features\x1b[0m", ""), + ], "Build sam project and deploy": [("$ sam build && sam deploy\x1b[0m", "")], "Required Options": [("--template-file", "")], "Template Options": [("--parameter-overrides", "")],