From f67f0d29cb0ccf0f2d37f10e56f9a6f6d82e8f31 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 14 Aug 2026 04:40:48 +0700 Subject: [PATCH] fix(release): enforce Node client glibc toolchain Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 23 +++++- .../test_release_workflow_structure.py | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index c37e72434..de3ee950b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -575,13 +575,32 @@ jobs: napi_args=(--platform --release --target "${{ matrix.target }}") if [[ "${RUNNER_OS}" == Linux ]]; then # The published linux-*-gnu package names carry no distro floor. - # Link the addons with napi-rs' locked glibc 2.17 toolchain rather - # than inheriting the Ubuntu 24.04 runner's glibc 2.39 ABI. + # aws-lc-sys treats a same-architecture target as a native build, + # so route its host C and C++ compilers through napi-rs' locked + # glibc 2.17 toolchain too. Otherwise it inherits the Ubuntu 24.04 + # headers even though Rust links with the older toolchain. + export HOST_CC="${{ matrix.target }}-gcc" + export HOST_CXX="${{ matrix.target }}-g++" napi_args+=(--use-napi-cross) fi (cd "${client_dir}" && ./node_modules/.bin/napi build "${napi_args[@]}") addon="${client_dir}/${client}-client.${{ matrix.napi_platform }}.node" if [[ "${RUNNER_OS}" == Linux ]]; then + # The version table misses unversioned imports. Candidate + # packaging rejects strong ones except the Node-API imports that + # the host intentionally resolves; weak imports remain optional. + # Rebuild with the pinned compilers is recovery, until the build + # host itself enforces the ABI floor. + unversioned_imports="$( + readelf --wide --dyn-syms "${addon}" \ + | awk '$7 == "UND" && $5 != "WEAK" && $8 !~ /@/ && $8 !~ /^(napi_|node_api_)/ { print $8 }' \ + | sort -u + )" + if [[ -n "${unversioned_imports}" ]]; then + printf 'native addon has strong unversioned imports:\n%s\n' \ + "${unversioned_imports}" >&2 + exit 1 + fi highest_glibc="$( readelf --version-info "${addon}" \ | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \ diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 1e910c1a4..608583f94 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -563,6 +563,84 @@ def test_builds_and_smokes_stable_native_client_packages(self) -> None: node = step_run(document, "clients", "Build Node client packages") self.assertIn("--use-napi-cross", node) self.assertIn('--target "${{ matrix.target }}"', node) + self.assertIn( + 'export HOST_CC="${{ matrix.target }}-gcc"\n' + ' export HOST_CXX="${{ matrix.target }}-g++"\n' + " napi_args+=(--use-napi-cross)", + node, + ) + napi_build = ( + '(cd "${client_dir}" && ./node_modules/.bin/napi build ' + '"${napi_args[@]}")' + ) + self.assertLess(node.index("export HOST_CC="), node.index(napi_build)) + self.assertLess(node.index("export HOST_CXX="), node.index(napi_build)) + self.assertIn( + 'unversioned_imports="$(\n' + ' readelf --wide --dyn-syms "${addon}" \\\n' + " | awk '$7 == \"UND\" && $5 != \"WEAK\" && " + "$8 !~ /@/ && $8 !~ /^(napi_|node_api_)/ { print $8 }' \\\n" + " | sort -u\n" + " )\"", + node, + ) + self.assertIn( + 'if [[ -n "${unversioned_imports}" ]]; then\n' + " printf 'native addon has strong unversioned imports:" + "\\n%s\\n' \\\n" + ' "${unversioned_imports}" >&2\n' + " exit 1", + node, + ) + guard_start = node.index('unversioned_imports="$(') + self.assertLess(node.index(napi_build), guard_start) + self.assertLess( + guard_start, + node.index('(cd "${client_dir}" && npm pack'), + ) + predicate_marker = "| awk '" + predicate_start = node.index(predicate_marker, guard_start) + len( + predicate_marker + ) + predicate_end = node.index("' \\", predicate_start) + predicate = node[predicate_start:predicate_end] + dynsym_fixtures = { + "observed ISO C23 import": ( + " 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND " + "__isoc23_sscanf\n", + ["__isoc23_sscanf"], + ), + "generic strong unversioned import": ( + " 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND malloc\n", + ["malloc"], + ), + "intentional Node-API imports": ( + " 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND " + "napi_create_function\n" + " 4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND " + "node_api_get_module_file_name\n", + [], + ), + "versioned import": ( + " 5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND " + "malloc@GLIBC_2.2.5\n", + [], + ), + "weak import": ( + " 6: 0000000000000000 0 FUNC WEAK DEFAULT UND getrandom\n", + [], + ), + } + for fixture_name, (dynsym, expected) in dynsym_fixtures.items(): + with self.subTest(fixture=fixture_name): + guard = subprocess.run( + ["awk", predicate], + input=dynsym, + capture_output=True, + text=True, + check=True, + ) + self.assertEqual(guard.stdout.splitlines(), expected) self.assertIn("readelf --version-info", node) self.assertIn("GLIBC_2.17", node) self.assertIn(