From 0c94019f24773ec343a1acd35112971aeb996aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20KUBLER?= Date: Fri, 31 Jul 2026 16:23:38 +0200 Subject: [PATCH 01/15] fix(pruning): run yarn heroku prune Instead of yarn scalingo prune --- lib/package_managers/yarn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/package_managers/yarn.sh b/lib/package_managers/yarn.sh index c339a690d..8eea9798f 100644 --- a/lib/package_managers/yarn.sh +++ b/lib/package_managers/yarn.sh @@ -376,7 +376,7 @@ function package_managers::yarn::prune_devdependencies() { cd "${build_dir}" || return echo "Running 'yarn scalingo prune'" export YARN_PLUGINS="${buildpack_dir}/yarn2-plugins/prune-dev-dependencies/bundles/@yarnpkg/plugin-prune-dev-dependencies.js" - monitor "prune_dev_dependencies" yarn scalingo prune + monitor "prune_dev_dependencies" yarn heroku prune # shellcheck disable=SC2310 # invoked in a condition so set -e is disabled inside; a false result just skips the cache cleanup if package_managers::yarn::_berry_node_modules_enabled "${build_dir}"; then echo "Removing local yarn cache to reduce slug size" From 8e3dd936b35acd569e92e0e41d87fa8e2cc0c5c3 Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Fri, 31 Jul 2026 11:30:26 -0300 Subject: [PATCH 02/15] Migrate pnpm install error handling to call-site classification (#1735) * Migrate pnpm install error handling to call-site classification The pnpm install path was the last dependency-install command still wrapped in the legacy `monitor` helper, routing all failures through the global ERR trap where pnpm output had no matcher at all. Move it onto the call-site failure-classification framework, matching the npm and yarn install paths already migrated. Classify ERR_PNPM_OUTDATED_LOCKFILE (frozen-lockfile drift) with a user-facing message and a `user` classification, gating on pnpm's stable error code rather than its version-drifting message text. The `monitor` wrapper is dropped; the install_dependencies_time metric is preserved and the peak-memory gauge retired along with the helper. W-23651645 * Drop install_dependencies_memory assertion from pnpm metadata test The memory gauge was recorded by the `monitor` wrapper, which the preceding commit removed from the pnpm install path. Mirrors the same removal already done for the npm and yarn metadata tests. W-23651645 --- CHANGELOG.md | 2 + lib/package_managers/pnpm.sh | 121 +++++++++++++++++- test/run-pnpm | 1 - test/unit | 57 +++++++++ test/unit-fixtures/pnpm-failures/clean.log | 10 ++ .../pnpm-failures/outdated-lockfile.log | 8 ++ 6 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 test/unit-fixtures/pnpm-failures/clean.log create mode 100644 test/unit-fixtures/pnpm-failures/outdated-lockfile.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 2250409ad..c240ce5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Migrated the pnpm lockfile-out-of-sync (`ERR_PNPM_OUTDATED_LOCKFILE`) build error onto the call-site failure-classification framework. ([#1735](https://github.com/heroku/heroku-buildpack-nodejs/pull/1735)) + ## [v360] - 2026-07-29 diff --git a/lib/package_managers/pnpm.sh b/lib/package_managers/pnpm.sh index f67a98a41..62f6ed401 100644 --- a/lib/package_managers/pnpm.sh +++ b/lib/package_managers/pnpm.sh @@ -32,7 +32,47 @@ package_managers::pnpm::install_dependencies() { esac fi - monitor "install_dependencies" pnpm "${pnpm_install_args[@]}" 2>&1 + local log_file + log_file=$(mktemp) + + local start + start=$(build_data::current_unix_realtime) + + # Run inside `if !` so errexit is suppressed and we can inspect the failure ourselves. + # pnpm writes progress and errors across stdout+stderr; merge them with `2>&1` and pass the + # merged stream through `tee` for classification. Indentation is applied by the enclosing + # `build_dependencies | output "$LOG_FILE"` pipe in bin/compile — do not re-indent here or + # every pnpm line would be indented twice. + # shellcheck disable=SC2310 # invoked in a condition so set -e is disabled inside + if ! { pnpm "${pnpm_install_args[@]}" 2>&1 | tee "${log_file}"; }; then + # Capture the full pipe status first (before any other command clobbers PIPESTATUS). + # The pipeline is `pnpm 2>&1 | tee`, so [0] is pnpm's exit code and [1] is tee's. + local pipe_status=("${PIPESTATUS[@]}") + local pnpm_exit="${pipe_status[0]}" + build_data::set_duration "install_dependencies_time" "${start}" + + local -A failure + # shellcheck disable=SC2310 # the elif calls a function in a condition, so set -e is disabled inside + if [[ "${pnpm_exit}" -eq 0 ]]; then + # pnpm succeeded but the pipeline failed (tee couldn't write the log — e.g. out of + # disk). Buildpack-side, so don't run it through the pnpm classifier. + package_managers::pnpm::_handle_install_pipefail "${pipe_status[*]}" + elif package_managers::pnpm::_handle_install_failure "${log_file}" failure; then + # The classifier fills `failure` by nameref and returns 0 on a match. It is invoked + # directly in the `elif` condition (not wrapped in `$(...)`) so its writes survive — a + # command substitution runs in a subshell where the nameref updates would be lost. + failure::emit failure + fi + + # No known failure mode recognised. Bubble up by returning pnpm's exit code: the pipeline + # that runs this install (`build_dependencies | output "$LOG_FILE"`) then fails under + # errexit/pipefail, the legacy ERR trap fires, and `log_other_failures` classifies the + # failure from $LOG_FILE — covering the pnpm codes not yet migrated here, instead of + # masking them with a generic message. + return "${pnpm_exit}" + fi + + build_data::set_duration "install_dependencies_time" "${start}" # prune the store when the counter reaches zero to clean up errant package versions which may have been upgraded/removed counter=$(load_pnpm_prune_store_counter "${cache_dir}") @@ -59,6 +99,85 @@ package_managers::pnpm::install_dependencies() { save_pnpm_prune_store_counter "${cache_dir}" "$((counter - 1))" } +# Emits the pnpm-install pipefail failure for the case where pnpm exited 0 but a downstream +# pipe stage (typically `tee` writing to the log) failed — for example the build ran out of +# disk space. Wraps `failure::handle_pipefail` with the pnpm-specific id and message so callers +# pass only the joined PIPESTATUS string. +function package_managers::pnpm::_handle_install_pipefail() { + local pipe_status_str="${1}" + local message + message=$( + cat <<-EOF + Error: Unable to capture the pnpm install log output. + + The dependency install ran, but writing its log to disk failed (for example, + the build ran out of disk space). This is not a problem with your + dependencies. Please try again. + EOF + ) + failure::handle_pipefail "pnpm-install-pipefail" "${pipe_status_str}" "${message}" +} + +# Pure classifier for pnpm dependency-install failures. +# +# Input: +# $1 path to a log file containing the captured output of the failed pnpm command +# $2 name of an associative array to fill (see failure::emit for its fields) +# Returns 0 and fills the array when a known failure mode is recognised; returns 1 and leaves +# the array untouched otherwise. Has no side effects: it does not write build data, print to +# the build log, or exit. Detail is set to the pnpm error code plus the first descriptive error +# line, giving observability a precise discriminator within each failure bucket. +function package_managers::pnpm::_handle_install_failure() { + local log_file="${1}" + # shellcheck disable=SC2178 # nameref alias to the caller's associative array, not a string + local -n __failure="${2}" + + # ERR_PNPM_OUTDATED_LOCKFILE — pnpm refuses to install under `--frozen-lockfile` when + # pnpm-lock.yaml has drifted from package.json (thrown from pnpm's install index.ts). Gate on + # the stable error code rather than the message, which has drifted across pnpm versions. The + # ERR_PNPM_ prefix is stamped by the PnpmError constructor and survives in non-TTY dynos even + # when chalk wraps it in ANSI color. + if grep -qi 'ERR_PNPM_OUTDATED_LOCKFILE' "${log_file}"; then + __failure["id"]="pnpm-lockfile-out-of-sync" + __failure["classification"]="user" + __failure["detail"]="ERR_PNPM_OUTDATED_LOCKFILE: $(package_managers::pnpm::_extract_error_detail "${log_file}")" + __failure["message"]=$( + cat <<-EOF + Error: pnpm lockfile is not in sync. + + This error occurs when the contents of \`package.json\` contains a different + set of dependencies than the contents of \`pnpm-lock.yaml\`. This can happen + when a package is added, modified, or removed but the lockfile was not updated. + + To fix this, run \`pnpm install\` locally in your app directory to regenerate the + lockfile, commit the changes to \`pnpm-lock.yaml\`, and redeploy. + EOF + ) + return 0 + fi + + # TODO: classify additional pnpm codes surfaced by pnpm's default reporter but not yet handled + # here, e.g. ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE (lockfile format-version mismatch), + # ERR_PNPM_NO_MATCHING_VERSION, ERR_PNPM_FETCH_401/403/404, ERR_PNPM_PEER_DEP_ISSUES, ELIFECYCLE. + # Add each as its own matcher above, verified against pnpm source. + + # No known failure mode recognised — signal no match so the caller can fall through. + return 1 +} + +# Returns the first descriptive pnpm error line for use as failure detail: pnpm's default +# reporter renders the summary as `[ERR_PNPM_] `, so grab that first line and +# strip the leading `[CODE] ` bracket prefix. `|| true` so a no-match never trips errexit. +# Internal helper to package_managers::pnpm::_handle_install_failure; not meant to be called +# directly. +function package_managers::pnpm::_extract_error_detail() { + local log_file="${1}" + grep -aE '^\[ERR_PNPM_[A-Z_]+\]' "${log_file}" \ + | head -n 1 \ + | sed -E 's/^\[[A-Z_]+\][[:space:]]*//' \ + || true +} + function package_managers::pnpm::prune_devdependencies() { local build_dir=${1:-} diff --git a/test/run-pnpm b/test/run-pnpm index e68df4a61..f0721d570 100755 --- a/test/run-pnpm +++ b/test/run-pnpm @@ -13,7 +13,6 @@ testPnpmBuildMetaData() { select(.has_cached_bower_components == false) select(.has_custom_cache_dirs == false) select(.has_procfile == false) - select(.install_dependencies_memory | type == "number") select(.install_dependencies_time | type == "number") select(.install_node_binary_time | type == "number") select(.install_pnpm_binary_time | type == "number") diff --git a/test/unit b/test/unit index b86f3e21e..3aad7b707 100755 --- a/test/unit +++ b/test/unit @@ -678,6 +678,63 @@ testHandleYarnInstallPipefail() { rm -f "$capture" } +testHandlePnpmInstallLockfileOutOfSync() { + local -A result + package_managers::pnpm::_handle_install_failure "$(pwd)/test/unit-fixtures/pnpm-failures/outdated-lockfile.log" result + assertEquals "classifier should return 0 on a match" "0" "$?" + assertEquals "pnpm-lockfile-out-of-sync" "${result[id]}" + assertEquals "user" "${result[classification]}" + # Detail carries the pnpm code plus the first descriptive error line. + assertContains "${result[detail]}" "ERR_PNPM_OUTDATED_LOCKFILE:" + assertContains "${result[detail]}" "is not up to date" + assertContains "${result[message]}" "pnpm lockfile is not in sync" + assertContains "${result[message]}" "pnpm install" +} + +testHandlePnpmInstallNoMatchReturnsNonZero() { + local -A result + # `clean.log` is a successful install; the classifier should not match and should leave the + # array empty. Guard the call so set -e (if active) doesn't abort on the expected non-zero. + if package_managers::pnpm::_handle_install_failure "$(pwd)/test/unit-fixtures/pnpm-failures/clean.log" result; then + fail "expected classifier to return non-zero for an unrecognised log" + fi + assertEquals "array should be left empty on no match" "" "${result[id]:-}" +} + +testHandlePnpmInstallPipefail() { + # The pnpm-specific pipefail wrapper owns the failure id and the user-facing message; + # verify both plus the buildpack classification and PIPESTATUS detail inherited from + # failure::handle_pipefail. + local out capture + capture=$(mktemp) + out=$( + fail() { exit 1; } + build_data::set_string() { echo "$1=$2" >> "$capture"; } + package_managers::pnpm::_handle_install_pipefail "0 1" 2>&1 || true + ) + + assertContains "$out" "Unable to capture the pnpm install log output" + assertContains "$out" "ran out of disk space" + assertContains "$(cat "$capture")" "failure=pnpm-install-pipefail" + assertContains "$(cat "$capture")" "failure_classification=buildpack" + assertContains "$(cat "$capture")" "failure_detail=PIPESTATUS=[0 1]" + rm -f "$capture" +} + +testPnpmExtractErrorDetailReturnsFirstDescriptiveLine() { + # Strips the leading `[ERR_PNPM_] ` bracket prefix from the rendered summary line. + local detail + detail=$(package_managers::pnpm::_extract_error_detail "$(pwd)/test/unit-fixtures/pnpm-failures/outdated-lockfile.log") + assertEquals "Cannot install with \"frozen-lockfile\" because pnpm-lock.yaml is not up to date with /package.json" "$detail" +} + +testPnpmExtractErrorDetailEmptyWhenNoDescriptiveLine() { + # A log with no `[ERR_PNPM_...]` summary line yields empty detail without erroring. + local detail + detail=$(package_managers::pnpm::_extract_error_detail "$(pwd)/test/unit-fixtures/pnpm-failures/clean.log") + assertEquals "" "$detail" +} + # npm 12 removed --unsafe-perm; the gate helper decides whether callers may pass it. Stub `npm` # in a subshell so `version_major` reads a controlled version without a real npm install. testSupportsUnsafePermForNpm11() { diff --git a/test/unit-fixtures/pnpm-failures/clean.log b/test/unit-fixtures/pnpm-failures/clean.log new file mode 100644 index 000000000..3f5515904 --- /dev/null +++ b/test/unit-fixtures/pnpm-failures/clean.log @@ -0,0 +1,10 @@ +Scope: all 3 workspace projects +Lockfile is up to date, resolution step is skipped +Packages: +142 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Progress: resolved 142, reused 142, downloaded 0, added 142, done + +dependencies: ++ lodash 4.17.21 + +Done in 3.2s diff --git a/test/unit-fixtures/pnpm-failures/outdated-lockfile.log b/test/unit-fixtures/pnpm-failures/outdated-lockfile.log new file mode 100644 index 000000000..f9e6fbe4c --- /dev/null +++ b/test/unit-fixtures/pnpm-failures/outdated-lockfile.log @@ -0,0 +1,8 @@ +Scope: all 3 workspace projects + WARN Ignoring not compatible lockfile at /tmp/build/pnpm-lock.yaml +[ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with /package.json + +Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile" + + Failure reason: + specifiers in the lockfile ({"lodash":"^4.17.20"}) don't match specs in package.json ({"lodash":"^4.17.21"}) From 8dc1cf3b1c2e769783fb58510d7e9530b1b90dad Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Fri, 31 Jul 2026 11:30:46 -0300 Subject: [PATCH 03/15] Migrate npm prune dev-dependency step to call-site failure classification (#1736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the legacy `monitor` wrapper around `npm prune` in package_managers::npm::prune_devdependencies and capture the command inside `if ! { npm prune … | tee log; }`, mirroring the npm install path. Add package_managers::npm::_handle_prune_pipefail to classify a downstream pipe (tee) failure as buildpack-side (npm-prune-pipefail); real npm-prune failures bubble to the legacy trap unchanged. Preserve the prune_dev_dependencies_time metric on both branches and drop the prune_dev_dependencies_memory metric (monitor's sampling is not carried forward), removing its now-stale test/run-npm assertion. --- lib/package_managers/npm.sh | 49 ++++++++++++++++++++++++++++++++++++- test/run-npm | 1 - test/unit | 20 +++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/package_managers/npm.sh b/lib/package_managers/npm.sh index 464dfe2eb..bfb45d584 100644 --- a/lib/package_managers/npm.sh +++ b/lib/package_managers/npm.sh @@ -592,11 +592,58 @@ function package_managers::npm::prune_devdependencies() { return 0 else cd "${build_dir}" || return - monitor "prune_dev_dependencies" npm prune --userconfig "${build_dir}/.npmrc" 2>&1 + + local log_file + log_file=$(mktemp) + + local start + start=$(build_data::current_unix_realtime) + + # Run inside `if !` so errexit is suppressed and we can inspect the failure ourselves. + # shellcheck disable=SC2310 # invoked in a condition so set -e is disabled inside + if ! { npm prune --userconfig "${build_dir}/.npmrc" 2>&1 | tee "${log_file}"; }; then + # Capture the full pipe status first (before any other command clobbers PIPESTATUS). + # The pipeline is `npm 2>&1 | tee`, so [0] is npm's exit code and [1] is tee's. + local pipe_status=("${PIPESTATUS[@]}") + local npm_exit="${pipe_status[0]}" + build_data::set_duration "prune_dev_dependencies_time" "${start}" + + if [[ "${npm_exit}" -eq 0 ]]; then + # npm succeeded but the pipeline failed (tee couldn't write the log — e.g. out of + # disk). Buildpack-side, so don't blame the app. + package_managers::npm::_handle_prune_pipefail "${pipe_status[*]}" + fi + + # No known failure mode recognised. Bubble up by returning npm's exit code: the pipeline + # that runs this prune (`prune_devdependencies | output "$LOG_FILE"`) then fails under + # errexit/pipefail, the legacy ERR trap fires, and `log_other_failures` classifies the + # failure — there is no migrated npm-prune tool-error classifier to add here yet. + return "${npm_exit}" + fi + + build_data::set_duration "prune_dev_dependencies_time" "${start}" build_data::set_raw "skipped_prune" "false" fi } +# Emits the npm-prune pipefail failure. `prune_devdependencies` runs +# `npm prune 2>&1 | tee log`, and a tee-side failure (typically the build ran out of disk +# space) classifies as buildpack-side rather than blaming the app's dependencies. +function package_managers::npm::_handle_prune_pipefail() { + local pipe_status_str="${1}" + local message + message=$( + cat <<-EOF + Error: Unable to capture the npm prune log output. + + The dependency prune ran, but writing its log to disk failed (for example, + the build ran out of disk space). This is not a problem with your + dependencies. Please try again. + EOF + ) + failure::handle_pipefail "npm-prune-pipefail" "${pipe_status_str}" "${message}" +} + # Runs a named lifecycle script with npm. Spells the npm-specific command (`npm run