Skip to content

fix: honour the lockfile when building nodejs dependencies in source - #931

Open
bnusunny wants to merge 1 commit into
aws:developfrom
bnusunny:fix/nodejs-build-in-source-honor-lockfile
Open

bnusunny wants to merge 1 commit into
aws:developfrom
bnusunny:fix/nodejs-build-in-source-honor-lockfile

Conversation

@bnusunny

@bnusunny bnusunny commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available: reported in aws/aws-sam-cli#6567

no linked issue: aws/aws-sam-cli#6567 asks for more than this fix, so this PR must not close it

Description of changes

Building nodejs dependencies in source ignored the project's lockfile: the install step ran npm update with --no-package-lock, so every build re-resolved the ranges in package.json and could upgrade dependencies in the developer's own source tree. On a workspaces project whose root package-lock.json pins lodash 4.17.20 (manifest ^4.17.20), one sam build --build-in-source left 4.18.1 installed.

Opt-in while it rolls out. is_nodejs_monorepo_support_enabled reads
experimentalNodejsMonorepo from the experimental_flags the workflow is constructed with - sam-cli's
ExperimentalFlag.NodejsMonorepo, whose config key crosses the boundary as that string, the way
experimentalBuildPerformance already does for python_pip. Without the flag every in-source build keeps
npm update --no-package-lock, and npm is not even asked where the lockfile is.

project today, and without the flag with the flag
has a lockfile npm update --no-audit --no-save --omit=dev --no-package-lock --install-links npm install -q --no-audit --no-save --omit=dev --install-links
has no lockfile npm update ... unchanged

Which directory holds the lockfile npm will use is npm's own decision, so NodejsNpmWorkflow.get_lockfile_path() asks it: npm prefix, run in the install directory, reports the monorepo root for a workspace package and the package itself for any other nested package, whose ancestors' lockfiles npm ignores. Identical on npm 8.19.4, 9.9.4, 10.9.9 and 11.19.0. NodejsNpmInstallAction takes back an install_links argument.

Nothing else moves. npm ci stays gated on a lockfile in the source directory itself — run from a workspace package it empties the workspace root's devDependencies on every npm version.

Relation to #579

This PR moves the lockfile case back onto npm install, and #579 deliberately moved build-in-source off npm install onto npm update — so it is worth being explicit that this does not undo that fix.

What #579 fixed: with --build-in-source, a dependency removed from package.json between two builds was left behind in the source tree's node_modules. Its fix introduced NodejsNpmUpdateAction.

The pruning #579 wanted comes from npm's reify step, which both commands run — not from the choice between update and install. What made the lockfile get ignored is the --no-package-lock flag that came along with npm update, and that flag is the only thing this PR drops:

  • has a lockfile → npm install ... --install-links, which reconciles node_modules against the manifest and the lockfile, pruning what no longer belongs.
  • has no lockfile → still NodejsNpmUpdateAction, fix: use npm update when building in source #579's own action, untouched.

Two integration tests pin this, one per path, both added here:

  • test_build_in_source_with_removed_dependencies_and_a_lockfile — builds, removes the dependency from the manifest, rebuilds, asserts it is gone from the source tree, and asserts the developer's lockfile is byte-identical afterwards, including once it is out of date with the manifest.
  • test_build_in_source_with_removed_dependencies — the same property on the lockfile-less npm update path, so fix: use npm update when building in source #579's behaviour keeps a test of its own rather than resting on assumption.

Both pass on every npm major listed below, and were re-confirmed on npm 10.9.9 and 11.19.0 specifically for this question (10 tests on each, across all supported runtimes). So a project with a lockfile gains the pinning and loses nothing #579 gave it.

Performance. npm update --no-package-lock re-resolves the whole workspace tree once per function; the lockfile install on an already-consistent tree is close to a no-op. On an npm-workspaces monorepo with disjoint per-function dependencies (~300 packages, esbuild functions, --build-in-source, median of 3 full builds):

functions before after
8 21.9s 6.5s
20 57.8s 16.7s

One pre-existing gap stays open: in a monorepo the plain workflow's artifact link finds no node_modules beside the function (npm hoists to the root) and skips silently — tracked as #933 and named in a comment at the link site. The esbuild workflow is unaffected, since it bundles dependencies into the output file.

The new manifests and lockfiles under the nodejs_npm/nodejs_npm_esbuild integration testdata/ directories are test fixture data, not a dependency change to this package.

Description of how you validated changes

Five new integration tests; four fail on develop with AssertionError: '1.5.0' != '1.3.0' (every fixture's lockfile pins minimal-request-promise 1.3.0 while its manifest allows ^1.3.0). They cover: the workspaces monorepo (root lockfile; the produced bundle is required with node, so the artifact is covered and not just the installed tree; the root's workspace link stays a link to the developer's packages/fn), removed-dependency pruning with and without a lockfile (see Relation to #579 above), a version 1 lockfile (npm 6 format), a file: local dependency under --install-links (lands as a real directory, not a symlink out of the artifacts), and devDependency pruning from a pre-populated node_modules. Four of them read the lockfile bytes before the build and assert they are unchanged after it, at lockfileVersion 1, 2 and 3 — dropping --no-save rewrites the version 1 fixture and fails.

The new tests construct the workflow with the flag, since they assert the behaviour it gates; test_without_the_flag_a_lockfile_is_ignored_and_the_update_runs is the other side - a project whose lockfile npm would read still takes the update, and npm prefix is never called.

New unit tests cover the lockfile lookup against a stubbed npm prefix (project root, workspaces root, shrinkwrap, absent, an ancestor's lockfile ignored for a nested non-workspace package, npm prefix failing), the install-directory semantics, and --install-links on the install action. The existing build-in-source wiring tests pin the no-lockfile case to npm update.

  • make pr equivalent: 958 passed, coverage 94.67%; ruff check and black --check clean.
  • nodejs + esbuild integration workflows: 290 passed on npm 11.19.0. Because pruning, lockfile writes and npm prefix are npm-version dependent, the lockfile subset was re-run on each npm major CI builds — 8.19.4, 9.9.4, 10.9.9, 11.19.0 — 20 passed on every one. Linux only locally; the Windows lanes are untested outside CI.
  • End to end with SAM CLI 1.166.2 on a two-function workspaces project: the build runs NpmInstall instead of NpmUpdate, root lodash stays at the locked 4.17.20 across repeat builds (4.18.1 before), the root esbuild devDependency survives, and the bundle still inlines the local workspace package.

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..e0d4fe9
Files: 10
Comments: 2

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
Comment thread tests/integration/workflows/nodejs_npm/test_nodejs_npm.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from e0d4fe9 to ce5004a Compare September 27, 2026 05:32

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..ce5004a
Files: 10
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from ce5004a to 9c5f169 Compare September 27, 2026 05:46

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..9c5f169
Files: 10
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 9c5f169 to 1ba2c01 Compare September 27, 2026 06:07

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..1ba2c01
Files: 15
Comments: 2


Comments on lines outside the diff:

[aws_lambda_builders/workflows/nodejs_npm/workflow.py:176] [GENERAL] Re-raising the artifact concern from the earlier review round — it was confirmed rather than dismissed, and the resolution was to move the test to the esbuild suite rather than address the behaviour.

Your own measurement of the workspaces-monorepo fixture through the non-esbuild NodejsNpmWorkflow produced artifacts/ = ['included.js', 'package.json'] with no node_modules, and require.resolve('minimal-request-promise') failing from the artifacts directory. The cause is that npm hoists to the monorepo root, so packages/fn/node_modules never exists, and LinkSinglePathAction then returns at LOG.debug level:

if not source_path.exists():
    # Source path doesn't exist, nothing to symlink
    LOG.debug("Source path %s does not exist, skipping generating symlink", source_path)
    return

The esbuild test added here passes because esbuild bundles the dependency into included.js, which masks the gap. For the plain npm workflow the result is a deployable package with no dependencies and no warning to the user — a failure that only surfaces at Lambda invocation time with MODULE_NOT_FOUND.

This is pre-existing rather than introduced here, but this PR makes workspaces monorepos a deliberately supported build-in-source path, so it is now in scope. At minimum, the missing node_modules in this configuration should be surfaced (a LOG.warning at the workflow level when building in source and the expected source node_modules is absent) rather than silently skipped. If you consider it out of scope, please say so explicitly and link a follow-up issue so it is not lost.

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 1ba2c01 to 40ed159 Compare September 27, 2026 06:17

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..40ed159
Files: 15
Comments: 2

Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py
Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..34e1339
Files: 15
Comments: 1


Comments on lines outside the diff:

[aws_lambda_builders/workflows/nodejs_npm/workflow.py:176] [GENERAL] Re-raising the artifact gap from the previous two rounds. It was confirmed by your own measurement rather than declared acceptable, and the resolution — moving the monorepo fixture into the esbuild suite — routes around the behaviour instead of recording it.

For a workspaces monorepo built in source, npm resolves its project root to the monorepo root (the same resolution get_lockfile_path now relies on), so it hoists dependencies into /node_modules and never creates packages/fn/node_modules. _actions_for_linking_source_dependencies_to_artifacts then links source_dir/node_modules, and LinkSinglePathAction.execute returns silently when the source path is absent:

if not source_path.exists():
   # Source path doesn't exist, nothing to symlink
   LOG.debug("Source path %s does not exist, skipping generating symlink", source_path)
   return

Through the plain nodejs_npm workflow that yields artifacts/ without node_modules, matching what you measured (['included.js', 'package.json'], require.resolve('minimal-request-promise') failing). The new test only exercises the esbuild workflow, where the bundler inlines the dependency and the missing link cannot be observed — the require(bundle) assertion passes for a bundled import regardless.

I accept this hoisting predates the PR (npm update --no-package-lock --install-links resolves the same project root). The concern is that the PR adds the first workspaces-monorepo fixture and asserts it works, while the one workflow where it silently produces an incomplete artifact stays untested. Either add a plain-nodejs_npm test over workspaces-monorepo asserting current artifact contents (an xfail-style record is fine), or note the limitation next to the linking property so the gap is discoverable rather than implied to be covered.

@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 34e1339 to 41f2ff4 Compare September 27, 2026 06:55
@bnusunny

Copy link
Copy Markdown
Contributor Author

Recorded in the code in 41f2ff4, which is the second of the two options you named.

_actions_for_linking_source_dependencies_to_artifacts now carries the gap where a reader meets the linking property:

# Known gap (aws/aws-lambda-builders#933): in an npm workspaces monorepo npm hoists to the
# monorepo root, so source_dir/node_modules is never created and LinkSinglePathAction skips
# silently, leaving the artifacts without dependencies. Pre-dates the lockfile lookup below and
# applies to this workflow only - the esbuild workflow bundles the dependency into the output.

#933 (filed before this round) carries the measurement, the mechanism, the code pointer and a repro. The PR description says the same thing in prose, so the limitation is discoverable from the code, the issue and the description rather than implied away by the new workspaces coverage.

I did not take the first option. An xfail-style test over workspaces-monorepo would assert artifacts/ == ['included.js', 'package.json'] and require.resolve failing — that pins the broken shape as the expected one, and #933's fix would then have to delete the test that recorded it. A comment plus an issue says the same thing without a test whose purpose is to be wrong.

Your reading of the esbuild assertion is right and worth stating plainly: require(bundle) passes because esbuild inlined the import, so it proves the dependency resolved at build time, not that the artifact carries node_modules. It is evidence for the lockfile fix (the bundle contains 1.3.0's code, which is why the test fails on develop with '1.5.0' != '1.3.0'), and it is not evidence about the linking property. Nothing in this PR claims the plain workflow's monorepo artifact is covered.

Gates on 41f2ff4: 958 unit+functional passed at 94.67% coverage, ruff and black --check clean, 275 nodejs + esbuild integration tests passed. The change is a comment, so no behaviour moved.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..41f2ff4
Files: 15
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 41f2ff4 to 08597d0 Compare September 27, 2026 07:10

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..08597d0
Files: 15
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 08597d0 to bda8848 Compare September 28, 2026 16:05

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..bda8848
Files: 19
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch 2 times, most recently from d42b637 to 73e9e1c Compare September 28, 2026 16:47

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..73e9e1c
Files: 19
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 73e9e1c to 27db041 Compare September 28, 2026 17:05

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..27db041
Files: 23
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 47f952e to 910de6d Compare September 30, 2026 02:06

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..910de6d
Files: 23
Comments: 1

@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 910de6d to 74d8888 Compare September 30, 2026 17:24

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..74d8888
Files: 25
Comments: 2

Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py
Comment thread aws_lambda_builders/workflows/nodejs_npm/utils.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 74d8888 to 2376a72 Compare September 30, 2026 18:00

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..2376a72
Files: 25 (12 test-data fixtures reviewed briefly; lockfile fixtures checked for consistency with their manifests)
Comments: 3

Comment thread tests/integration/workflows/nodejs_npm/test_nodejs_npm.py
Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py Outdated
Comment thread aws_lambda_builders/workflows/nodejs_npm/workflow.py Outdated
Building nodejs dependencies in source ignored the project's lockfile: the install
step ran `npm update` with `--no-package-lock`, so every build re-resolved the
ranges in `package.json` and could upgrade dependencies in the developer's own
source tree. On a workspaces project whose root `package-lock.json` pins `lodash`
4.17.20 (manifest `^4.17.20`), one `sam build --build-in-source` left 4.18.1
installed.

Behind an opt-in flag while it rolls out. `is_nodejs_monorepo_support_enabled`
reads `experimentalNodejsMonorepo` from the `experimental_flags` the workflow is
constructed with - sam-cli's `ExperimentalFlag.NodejsMonorepo`, whose config key
crosses the boundary as that string, the same way `experimentalBuildPerformance`
already does for python_pip. Without the flag every in-source build keeps
`npm update --no-package-lock`, and npm is not even asked where the lockfile is.

With it, and a lockfile npm will use:

| project | before | with the flag |
|---|---|---|
| has a lockfile | `npm update --no-audit --no-save --omit=dev --no-package-lock --install-links` | `npm install -q --no-audit --no-save --omit=dev --install-links` |
| has no lockfile | `npm update ...` | unchanged |

Which directory holds the lockfile npm will use is npm's own decision, so
`NodejsNpmWorkflow.get_lockfile_path()` asks it: `npm prefix`, run in the install
directory, reports the monorepo root for a workspace package and the package
itself for any other nested package, whose ancestors' lockfiles npm ignores.
Identical on npm 8.19.4, 9.9.4, 10.9.9 and 11.19.0. `NodejsNpmInstallAction`
takes back an `install_links` argument.

`npm ci` stays gated on a lockfile in the source directory itself - run from a
workspace package it empties the workspace root's devDependencies on every npm
version. Lockfile-less projects keep `npm update` and its pruning of dependencies
removed from the manifest (aws#579); the lockfile-respecting install prunes those as
well, so projects with a lockfile lose nothing.
@bnusunny
bnusunny force-pushed the fix/nodejs-build-in-source-honor-lockfile branch from 2376a72 to 90c3822 Compare September 30, 2026 21:04

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..90c3822
Files: 25
Comments: 3

# with no lockfile anywhere there are no locked versions to install, and `npm update` additionally
# prunes dependencies that were removed from the manifest since the previous build
can_use_links_mock.return_value = True
get_lockfile_path_mock.return_value = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] This test does not exercise the path its name and comment describe. The workflow is constructed without experimental_flags, so self.experimental_flags == [] and the gate short-circuits before get_lockfile_path is ever consulted:

if is_nodejs_monorepo_support_enabled(experimental_flags) and NodejsNpmWorkflow.get_lockfile_path(...):

The get_lockfile_path_mock.return_value = None is therefore inert — the assertion would still pass if the mock returned a lockfile path. What is actually being pinned is the flag-off path, which test_without_the_flag_a_lockfile_is_ignored_and_the_update_runs already covers (and covers more precisely, with run.assert_not_called()).

Add experimental_flags=["experimentalNodejsMonorepo"] to the constructor so the test fails if the flag-on/no-lockfile combination ever stops selecting NodejsNpmUpdateAction.

@patch("aws_lambda_builders.workflows.nodejs_npm.workflow.NodejsNpmWorkflow.can_use_install_links")
def test_build_in_source_without_lockfile(self, install_links_mock, get_lockfile_path_mock):
install_links_mock.return_value = True
get_lockfile_path_mock.return_value = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] Same inert-mock problem as the nodejs_npm counterpart: test_build_in_source_without_lockfile patches get_lockfile_path to return None but omits experimental_flags, so the monorepo gate short-circuits and the patched return value is never read. The test passes for the wrong reason and would keep passing if the flag-on/no-lockfile branch regressed to NodejsNpmInstallAction.

Pass experimental_flags=["experimentalNodejsMonorepo"].

LOG.debug("NODEJS could not resolve the npm project root of %s: %s", install_dir, ex)
return None

for lockfile_name in ("package-lock.json", "npm-shrinkwrap.json"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The lookup order contradicts npm's own precedence. npm prefers npm-shrinkwrap.json over package-lock.json when both exist in a package root, but this loop returns package-lock.json first:

for lockfile_name in ("package-lock.json", "npm-shrinkwrap.json"):

No live bug today because the caller only uses the result for truthiness, but the docstring promises "the lockfile npm would use" / "Path of the lockfile in npm's project root", and this is a public static method that the existing LOCKFILE_TYPES integration matrix already builds package-lock-and-shrinkwrap fixtures for. Swapping the tuple order makes the returned path match what the install will actually read:

for lockfile_name in ("npm-shrinkwrap.json", "package-lock.json"):

Notes on scope: lock files and the new JSON/JS fixtures were read for consistency only (the file:../npm-deps entry in with-local-dependency-and-lockfile/package-lock.json matches the real npm-deps manifest, and the workspaces fixture's v2 lockfile is internally consistent). I did not run any code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants