Skip to content

Commit 43784fa

Browse files
authored
Classify Node.js resolve failures via the failure framework (#1687)
* feat: classify Node resolve failures via the failure framework * docs: changelog for Node.js resolve failure classification * fix: return 1 on Node download/checksum failures and correct invalid-node assertion Wrapping _install in `if ! { ... | tee }` disables errexit inside the function body, so the bare `false` at the download and checksum failure sites no longer aborted the install — _install fell through to tar/chmod and could exit 0 when a stale /tmp/node.tar.gz was present. Use explicit `return 1` so the failure propagates. Also fix the testInvalidNode stderr assertion, which still expected the reworded "Error:" prefix after the message was restored to its original verbatim text.
1 parent cf0d002 commit 43784fa

8 files changed

Lines changed: 154 additions & 114 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Migrated Node.js version-resolution build errors (unresolvable version, invalid semver requirement) onto the call-site failure-classification framework. The error messages are unchanged. ([#1687](https://github.com/heroku/heroku-buildpack-nodejs/pull/1687))
56

67
## [v356] - 2026-06-25
78

bin/compile

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,7 @@ handle_failure() {
109109
fail_using_yarn2_with_yarn_production_environment_variable_set "$LOG_FILE"
110110
fail_yarn_outdated "$LOG_FILE"
111111
fail_yarn_lockfile_outdated "$LOG_FILE"
112-
fail_node_install "$LOG_FILE" "$BUILD_DIR"
113112
fail_yarn_install "$LOG_FILE" "$BUILD_DIR"
114-
fail_invalid_semver "$LOG_FILE"
115113
log_other_failures "$LOG_FILE"
116114
warn_aws_proxy "$BUILD_DIR"
117115
warn_untracked_dependencies "$LOG_FILE"
@@ -315,12 +313,7 @@ install_bins() {
315313
local install_start
316314

317315
build_data::set_string "build_step" "install-nodejs"
318-
install_start=$(build_data::current_unix_realtime)
319316
runtimes::nodejs::install "$node_engine" "$BUILD_DIR/.heroku/node"
320-
build_data::set_duration "install_node_binary_time" "$install_start"
321-
build_data::set_string "node_version" "$(node --version)"
322-
build_data::set_raw "node_version_major" "$(get_node_major_version)"
323-
build_data::set_string "bundled_npm_version" "$(npm --version)"
324317

325318
build_data::set_string "build_step" "install-npm"
326319
install_npm "$npm_engine" "$BUILD_DIR/.heroku/node" "$NPM_LOCK"

lib/_failures.sh

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -254,64 +254,6 @@ fail_yarn_lockfile_outdated() {
254254
fi
255255
}
256256

257-
fail_bin_install() {
258-
local error
259-
local version="$1"
260-
261-
# Allow the subcommand to fail without trapping the error so we can
262-
# get the failing message output
263-
set +e
264-
265-
# re-request the result, saving off the reason for the failure this time
266-
error=$($RESOLVE "$BP_DIR/inventory/node.toml" "$version" 2>&1)
267-
268-
# re-enable trapping
269-
set -e
270-
271-
if [[ $error = "No result" ]]; then
272-
echo "Could not find Node version corresponding to version requirement: $version"
273-
elif [[ $error == "Could not parse"* ]] || [[ $error == "Could not get"* ]]; then
274-
echo "Error: Invalid semantic version \"$version\""
275-
else
276-
echo "Error: Unknown error installing \"$version\" of node"
277-
fi
278-
279-
return 1
280-
}
281-
282-
fail_node_install() {
283-
local node_engine
284-
local log_file="$1"
285-
local build_dir="$2"
286-
287-
if grep -qi 'Could not find Node version corresponding to version requirement' "$log_file"; then
288-
node_engine=$(read_json "$build_dir/package.json" ".engines.node")
289-
build_data::set_string "failure" "invalid-node-version"
290-
echo ""
291-
warn "No matching version found for Node: $node_engine
292-
293-
Heroku supports the latest Stable version of Node.js as well as all
294-
active LTS (Long-Term-Support) versions, however you have specified
295-
a version in package.json ($node_engine) that does not correspond to
296-
any published version of Node.js.
297-
298-
You should always specify a Node.js version that matches the runtime
299-
you’re developing and testing with. To find your version locally:
300-
301-
$ node --version
302-
v6.11.1
303-
304-
Use the engines section of your package.json to specify the version of
305-
Node.js to use on Heroku. Drop the ‘v’ to save only the version number:
306-
307-
\"engines\": {
308-
\"node\": \"6.11.1\"
309-
}
310-
" https://help.heroku.com/6235QYN4/
311-
fail
312-
fi
313-
}
314-
315257
fail_yarn_install() {
316258
local yarn_engine
317259
local log_file="$1"
@@ -347,25 +289,6 @@ fail_yarn_install() {
347289
fi
348290
}
349291

350-
fail_invalid_semver() {
351-
local log_file="$1"
352-
if grep -qi 'Error: Invalid semantic version' "$log_file"; then
353-
build_data::set_string "failure" "invalid-semver-requirement"
354-
echo ""
355-
warn "Invalid semver requirement
356-
357-
Node, Yarn, and npm adhere to semver, the semantic versioning convention
358-
popularized by GitHub.
359-
360-
http://semver.org/
361-
362-
However you have specified a version requirement that is not a valid
363-
semantic version.
364-
" https://help.heroku.com/0ZIOF3ST
365-
fail
366-
fi
367-
}
368-
369292
# Yarn 2 failures
370293

371294
fail_using_yarn2_with_yarn_production_environment_variable_set() {

lib/runtimes/nodejs.sh

Lines changed: 120 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,49 @@ RESOLVE="${BP_DIR}/lib/vendor/resolve-version-$(get_os)"
1414
function runtimes::nodejs::install() {
1515
local requested_version="${1:-}"
1616
local dir="${2:?}"
17+
18+
local log_file
19+
log_file=$(mktemp)
20+
21+
local start
22+
start=$(build_data::current_unix_realtime)
23+
24+
# Run inside `if !` so errexit is suppressed and we can inspect the failure ourselves.
25+
# Capture only stdout into the log: `_install` echoes its failure discriminators to stdout
26+
# for the classifier, while user-facing warnings/errors go to stderr (via output::*) and must
27+
# pass straight through to the real stderr — so we deliberately do NOT merge `2>&1` here.
28+
# `tee` passes stdout through, so normal install output still reaches the caller's pipe.
29+
# shellcheck disable=SC2310 # invoked in a condition so set -e is disabled inside
30+
if ! { runtimes::nodejs::_install "${requested_version}" "${dir}" | tee "${log_file}"; }; then
31+
# Capture the full pipe status before any other command clobbers PIPESTATUS.
32+
local install_exit="${PIPESTATUS[0]}"
33+
build_data::set_duration "install_node_binary_time" "${start}"
34+
35+
local -A failure
36+
# shellcheck disable=SC2310 # the classifier fills `failure` by nameref; invoked directly so its writes survive
37+
if runtimes::nodejs::_handle_install_failure "${log_file}" "${requested_version}" failure; then
38+
failure::emit failure
39+
fi
40+
41+
# No known failure mode recognised. Bubble up so the legacy ERR trap classifies it
42+
# (generic fallback) while matchers not yet migrated here still get handled.
43+
return "${install_exit}"
44+
fi
45+
46+
build_data::set_duration "install_node_binary_time" "${start}"
47+
48+
local node_version node_version_major bundled_npm_version
49+
node_version=$(node --version)
50+
node_version_major=$(get_node_major_version)
51+
bundled_npm_version=$(npm --version)
52+
build_data::set_string "node_version" "${node_version}"
53+
build_data::set_raw "node_version_major" "${node_version_major}"
54+
build_data::set_string "bundled_npm_version" "${bundled_npm_version}"
55+
}
56+
57+
function runtimes::nodejs::_install() {
58+
local requested_version="${1:-}"
59+
local dir="${2:?}"
1760
local resolve_result
1861

1962
if [[ -n "${NODE_BINARY_URL}" ]]; then
@@ -26,11 +69,22 @@ function runtimes::nodejs::install() {
2669
echo "Resolving node version ${requested_version}..."
2770
fi
2871

29-
# shellcheck disable=SC2310 # || disables errexit, but that's intentional for this pattern
30-
resolve_result=$(runtimes::nodejs::_resolve "${requested_version}" || echo "failed")
72+
# The resolver prints "No result" on stdout (exit 0) for an unknown version, or
73+
# "Could not parse"/"Could not get …" for a malformed requirement; capture stderr too so
74+
# either surfaces in a single invocation.
75+
local resolve_result resolve_exit_code
76+
resolve_result=$("${RESOLVE}" "${BP_DIR}/inventory/node.toml" "${requested_version}" 2>&1) && resolve_exit_code=0 || resolve_exit_code=$?
3177

32-
if [[ "${resolve_result}" == "failed" ]]; then
33-
fail_bin_install "${requested_version}"
78+
if [[ "${resolve_exit_code}" -ne 0 || "${resolve_result}" == "No result" ]]; then
79+
# Print the canonical discriminating line so _handle_install_failure can classify it.
80+
if [[ "${resolve_result}" == "No result" ]]; then
81+
echo "Could not find Node version corresponding to version requirement: ${requested_version}"
82+
elif [[ "${resolve_result}" == "Could not parse"* ]] || [[ "${resolve_result}" == "Could not get"* ]]; then
83+
echo "Error: Invalid semantic version \"${requested_version}\""
84+
else
85+
echo "Error: Unknown error installing \"${requested_version}\" of node"
86+
fi
87+
return 1
3488
fi
3589

3690
version=$(echo "${resolve_result}" | jq -r .version)
@@ -71,7 +125,7 @@ function runtimes::nodejs::install() {
71125
If that doesn't help, check the Node.js status page:
72126
https://status.nodejs.org/
73127
EOF
74-
false
128+
return 1
75129
fi
76130

77131
if [[ -z "${NODE_BINARY_URL}" ]]; then
@@ -83,15 +137,15 @@ function runtimes::nodejs::install() {
83137
output::error <<-EOF
84138
Checksum validation failed for Node.js ${version} - ${checksum_type}:${checksum_value}
85139
EOF
86-
false
140+
return 1
87141
fi
88142
;;
89143
*)
90144
build_data::set_string "failure" "unsupported-checksum"
91145
output::error <<-EOF
92146
Unsupported checksum for Node.js ${version} - ${checksum_type}:${checksum_value}
93147
EOF
94-
false
148+
return 1
95149
;;
96150
esac
97151
fi
@@ -101,21 +155,6 @@ function runtimes::nodejs::install() {
101155
chmod +x "${dir}"/bin/*
102156
}
103157

104-
function runtimes::nodejs::_resolve() {
105-
local node_version="$1"
106-
local output
107-
108-
if output=$("${RESOLVE}" "${BP_DIR}/inventory/node.toml" "${node_version}"); then
109-
if [[ ${output} = "No result" ]]; then
110-
return 1
111-
else
112-
echo "${output}"
113-
return 0
114-
fi
115-
fi
116-
return 1
117-
}
118-
119158
function runtimes::nodejs::_warn_wide_range() {
120159
local requested_version="$1"
121160
local lts_version="$2"
@@ -164,13 +203,70 @@ function runtimes::nodejs::_warn_known_bad_release() {
164203

165204
# Pure classifier for Node.js install failures.
166205
#
167-
# Input: $1 path to the captured Node install log; $2 name of an associative array to fill.
206+
# Input: $1 path to the captured Node install log; $2 the requested version requirement;
207+
# $3 name of an associative array to fill.
168208
# Returns 0 + fills the array on a known failure; returns 1 untouched otherwise. No side effects.
169209
function runtimes::nodejs::_handle_install_failure() {
170210
local log_file="${1}"
211+
local requested_version="${2}"
171212
# shellcheck disable=SC2178 # nameref alias to the caller's associative array, not a string
172-
local -n __failure="${2}"
213+
local -n __failure="${3}"
214+
215+
if grep -qi 'Could not find Node version corresponding to version requirement' "${log_file}"; then
216+
__failure["id"]="invalid-node-version"
217+
__failure["classification"]="user"
218+
__failure["detail"]="${requested_version}"
219+
__failure["message"]=$(
220+
cat <<-EOF
221+
No matching version found for Node: ${requested_version}
222+
223+
Heroku supports the latest Stable version of Node.js as well as all
224+
active LTS (Long-Term-Support) versions, however you have specified
225+
a version in package.json (${requested_version}) that does not correspond to
226+
any published version of Node.js.
227+
228+
You should always specify a Node.js version that matches the runtime
229+
you're developing and testing with. To find your version locally:
230+
231+
$ node --version
232+
v6.11.1
233+
234+
Use the engines section of your package.json to specify the version of
235+
Node.js to use on Heroku. Drop the 'v' to save only the version number:
236+
237+
"engines": {
238+
"node": "6.11.1"
239+
}
240+
241+
https://help.heroku.com/6235QYN4/
242+
EOF
243+
)
244+
return 0
245+
fi
246+
247+
if grep -qi 'Error: Invalid semantic version' "${log_file}"; then
248+
__failure["id"]="invalid-semver-requirement"
249+
__failure["classification"]="user"
250+
__failure["detail"]="${requested_version}"
251+
__failure["message"]=$(
252+
cat <<-EOF
253+
Invalid semver requirement
254+
255+
Node, Yarn, and npm adhere to semver, the semantic versioning convention
256+
popularized by GitHub.
257+
258+
http://semver.org/
259+
260+
However you have specified a version requirement that is not a valid
261+
semantic version.
262+
263+
https://help.heroku.com/0ZIOF3ST
264+
EOF
265+
)
266+
return 0
267+
fi
173268

269+
# No known failure mode recognised — let the caller fall through.
174270
return 1
175271
}
176272

test/run-general

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -389,21 +389,25 @@ testConcurrencyTooHigh() {
389389

390390

391391
testInvalidNode() {
392-
compile "invalid-node"
392+
cache_dir=$(mktmpdir)
393+
compile "invalid-node" "${cache_dir}"
393394
assertCaptured "Resolving node version 0.11.333"
394395
assertCaptured "Could not find Node version corresponding to version requirement: 0.11.333"
395-
assertCaptured "No matching version found for Node: 0.11.333"
396-
assertCaptured "https://help.heroku.com/6235QYN4"
396+
assertCapturedStderr "No matching version found for Node: 0.11.333"
397+
assertCapturedStderr "https://help.heroku.com/6235QYN4"
397398
assertCapturedError
399+
assertMetricEqualsString "${cache_dir}" "failure" "invalid-node-version"
398400
}
399401

400402

401403
testInvalidNodeSemver() {
402-
compile "invalid-node-semver"
404+
cache_dir=$(mktmpdir)
405+
compile "invalid-node-semver" "${cache_dir}"
403406
assertCaptured "Resolving node version stable"
404407
assertCaptured "Error: Invalid semantic version \"stable\""
405-
assertCaptured "Invalid semver requirement"
408+
assertCapturedStderr "Invalid semver requirement"
406409
assertCapturedError
410+
assertMetricEqualsString "${cache_dir}" "failure" "invalid-semver-requirement"
407411
}
408412

409413

test/unit

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,12 +703,31 @@ testHandleNpmInstallNoMatchReturnsNonZero() {
703703
testHandleNodeInstallScaffoldNoMatch() {
704704
local -A result
705705
# Scaffold classifier recognises nothing yet; it must return non-zero and leave the array empty.
706-
if runtimes::nodejs::_handle_install_failure "$(_unit_fixture node-failures/no-result.log)" result; then
706+
if runtimes::nodejs::_handle_install_failure "$(_unit_fixture node-failures/no-result.log)" "16.0.0" result; then
707707
fail "expected scaffold classifier to return non-zero"
708708
fi
709709
assertEquals "array should be left empty on no match" "" "${result[id]:-}"
710710
}
711711

712+
testHandleNodeInstallInvalidVersion() {
713+
local -A result
714+
runtimes::nodejs::_handle_install_failure "$(_unit_fixture node-failures/invalid-version.log)" "0.11.333" result
715+
assertEquals "classifier should return 0 on a match" "0" "$?"
716+
assertEquals "invalid-node-version" "${result[id]}"
717+
assertEquals "user" "${result[classification]}"
718+
assertContains "${result[message]}" "No matching version found for Node: 0.11.333"
719+
assertContains "${result[detail]}" "0.11.333"
720+
}
721+
722+
testHandleNodeInstallInvalidSemver() {
723+
local -A result
724+
runtimes::nodejs::_handle_install_failure "$(_unit_fixture node-failures/invalid-semver.log)" "stable" result
725+
assertEquals "invalid-semver-requirement" "${result[id]}"
726+
assertEquals "user" "${result[classification]}"
727+
assertContains "${result[message]}" "Invalid semver requirement"
728+
assertEquals "stable" "${result[detail]}"
729+
}
730+
712731
testExtractErrorDetailReturnsFirstDescriptiveLine() {
713732
# Skips the bare `code <CODE>` line and the "complete log" noise, strips the
714733
# `npm error `/`npm ERR! ` prefix (but keeps any keyword like `notsup` that follows).
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Resolving node version stable...
2+
Error: Invalid semantic version "stable"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Resolving node version 0.11.333...
2+
Could not find Node version corresponding to version requirement: 0.11.333

0 commit comments

Comments
 (0)