From 815ac6492508986886110408fe14234181d65b89 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sat, 14 Nov 2026 15:36:14 -0800 Subject: [PATCH 1/3] build: add the Node 22 Dockerfiles from the v22.21.1 branch Unchanged from v22.21.1, so this build uses the same Ubuntu images, g++-10 and configure flags as the Node 22 builds LunaServer runs today. The FIPS build still links the Ubuntu 22.04 OpenSSL 3.0 headers with --shared-openssl. --- Dockerfile.Node22 | 50 +++++++++++++++++++++++++++++++++++++++++++ Dockerfile.Node22fips | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Dockerfile.Node22 create mode 100644 Dockerfile.Node22fips diff --git a/Dockerfile.Node22 b/Dockerfile.Node22 new file mode 100644 index 000000000000..bd59c3ffcc5e --- /dev/null +++ b/Dockerfile.Node22 @@ -0,0 +1,50 @@ +# Stage 1 +FROM ubuntu:20.04 AS base + +# Set non-interactive mode to avoid prompts during installation +ENV DEBIAN_FRONTEND=noninteractive + +# Combine update, PPA setup, and install into one robust retry loop +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + apt-get update --allow-releaseinfo-change && \ + apt-get install -y --no-install-recommends software-properties-common && \ + add-apt-repository -y ppa:ubuntu-toolchain-r/test && \ + apt-get update --allow-releaseinfo-change && \ + apt-get install -y --no-install-recommends --fix-missing \ + build-essential \ + python3 \ + python3-distutils \ + g++-10 \ + make \ + curl \ + git \ + pkg-config \ + libssl-dev \ + libffi-dev \ + libgmp-dev \ + libtool \ + autoconf \ + automake \ + cmake \ + wget \ + xz-utils \ + unzip \ + vim \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN rm -rf /var/lib/apt/lists/* + +# Set g++ 10 as the default +RUN update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-10 100 + +# Copy local Node.js source into the image +WORKDIR /usr/src/node +COPY . . + +RUN ./configure --experimental-enable-pointer-compression +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Node22fips b/Dockerfile.Node22fips new file mode 100644 index 000000000000..926a914fee09 --- /dev/null +++ b/Dockerfile.Node22fips @@ -0,0 +1,50 @@ +# Stage 1 +FROM ubuntu:22.04 AS base + +# Set non-interactive mode to avoid prompts during installation +ENV DEBIAN_FRONTEND=noninteractive + +# Combine update, PPA setup, and install into one robust retry loop +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + apt-get update --allow-releaseinfo-change && \ + apt-get install -y --no-install-recommends software-properties-common && \ + add-apt-repository -y ppa:ubuntu-toolchain-r/test && \ + apt-get update --allow-releaseinfo-change && \ + apt-get install -y --no-install-recommends --fix-missing \ + build-essential \ + python3 \ + python3-distutils \ + g++-10 \ + make \ + curl \ + git \ + pkg-config \ + libssl-dev \ + libffi-dev \ + libgmp-dev \ + libtool \ + autoconf \ + automake \ + cmake \ + wget \ + xz-utils \ + unzip \ + vim \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN rm -rf /var/lib/apt/lists/* + +# Set g++ 10 as the default +RUN update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-10 100 + +# Copy local Node.js source into the image +WORKDIR /usr/src/node +COPY . . + +RUN ./configure --shared-openssl --experimental-enable-pointer-compression --openssl-conf-name 'openssl_conf' +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] From 0483f5431f153c4fdc97a9e0a4034f663be790cf Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sat, 14 Nov 2026 15:36:14 -0800 Subject: [PATCH 2/3] build: add Asana Node 22 builds without the fibers patches Ports the build workflows, build verification and staging script from the Node 26 branch (jrsells/node-v26.13.1-rhel8), which already work without fibers. Compared with the v22.21.1 branch, this leaves out every fibers change: - the V8 Isolate thread-local storage patch, - the src/env.cc change that stopped creating the CppHeap, - the realm.js change that exposed internalBinding('util') through process.binding('util'), - the node-fibers workflow and Dockerfile, Dockerfile.Node20fips and build-local.sh. codez no longer uses node-fibers or fiber_elimination_utils.ts, the only reader of process.binding('util'), and its Node 26 builds already run without these patches. The smoke test expects Node 22's shared-cage pointer compression and Maglev disabled, matching the v22.21.1 builds. --- .github/workflows/build-node-openssl-fips.yml | 108 ++++++++++++ .github/workflows/build-node.yml | 98 +++++++++++ asana-smoke-test.js | 160 ++++++++++++++++++ asana-verify-build.sh | 98 +++++++++++ stage_for_s3.bash | 111 ++++++++++++ 5 files changed, 575 insertions(+) create mode 100644 .github/workflows/build-node-openssl-fips.yml create mode 100644 .github/workflows/build-node.yml create mode 100644 asana-smoke-test.js create mode 100755 asana-verify-build.sh create mode 100755 stage_for_s3.bash diff --git a/.github/workflows/build-node-openssl-fips.yml b/.github/workflows/build-node-openssl-fips.yml new file mode 100644 index 000000000000..e7532bf00520 --- /dev/null +++ b/.github/workflows/build-node-openssl-fips.yml @@ -0,0 +1,108 @@ +name: Build Node (FIPS) + +on: + workflow_dispatch: + inputs: + BUILD_REF: + description: 'Git ref to build Node.js from (defaults to the branch the workflow runs from)' + required: false + default: '' + type: string + DOCKER_FILE: + description: 'Dockerfile to use for building Node.js' + required: true + default: 'Dockerfile.Node22fips' + type: string + +permissions: + contents: write + +jobs: + build-node: + name: Build ${{ matrix.platform }}-${{ matrix.arch }} with FIPS OpenSSL + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout Node fork + uses: actions/checkout@v4 + with: + path: node + ref: ${{ inputs.BUILD_REF || github.ref_name }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' node/src/node_version.h | awk '{print $3}') + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Set build metadata + id: meta + working-directory: node + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M) + SHORT_SHA=$(git rev-parse --short HEAD) + echo "BUILD_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "build_id=${TIMESTAMP}-${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Execute the Dockerfile + working-directory: node + run: | + docker build -t node_fips_build -f ./${{ inputs.DOCKER_FILE }} . + + - name: Extract resources + run: | + docker create --name temp_node_extract node_fips_build + docker cp temp_node_extract:/usr/src/node/node-install $GITHUB_WORKSPACE/node-install + docker rm temp_node_extract + + - name: Verify build + run: | + bash node/asana-verify-build.sh node-install --fips + + - name: Archive Node + run: | + mkdir -p artifacts + FILENAME=node-${NODE_VERSION}-fips-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }}.tar.xz + FILENAME_LATEST=node-${NODE_VERSION}-fips-${{ matrix.platform }}-${{ matrix.arch }}-LATEST.tar.xz + tar -C node-install -cJf artifacts/$FILENAME . + cp artifacts/$FILENAME artifacts/$FILENAME_LATEST + echo "NODE_ARCHIVE=$FILENAME" >> $GITHUB_ENV + echo "NODE_ARCHIVE_LATEST=$FILENAME_LATEST" >> $GITHUB_ENV + + - name: Upload Node archive + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-fips-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }} + path: artifacts/${{ env.NODE_ARCHIVE }} + + - name: Upload Node archive latest + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-fips-${{ matrix.platform }}-${{ matrix.arch }}-LATEST + path: artifacts/${{ env.NODE_ARCHIVE_LATEST }} + + - name: Upload Node archive to release + run: | + TAG="node-${NODE_VERSION}-fips-release" + RELEASE_NAME="node-${NODE_VERSION}-fips-LATEST" + if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + gh release create "$TAG" --title "$RELEASE_NAME" --notes "" --target "$BUILD_SHA" --repo "$REPO" \ + || gh release view "$TAG" --repo "$REPO" >/dev/null + fi + gh release upload "$TAG" "artifacts/${NODE_ARCHIVE_LATEST}" --clobber --repo "$REPO" + gh release edit "$TAG" --title "$RELEASE_NAME" --repo "$REPO" diff --git a/.github/workflows/build-node.yml b/.github/workflows/build-node.yml new file mode 100644 index 000000000000..acaa5e310193 --- /dev/null +++ b/.github/workflows/build-node.yml @@ -0,0 +1,98 @@ +name: Build Node (Standard) + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-node: + name: Build ${{ matrix.platform }}-${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout Node fork + uses: actions/checkout@v4 + with: + path: node + ref: ${{ github.ref_name }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' node/src/node_version.h | awk '{print $3}') + echo "NODE_MAJOR_VERSION=${NODE_MAJOR_VERSION}" >> $GITHUB_ENV + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Set build metadata + id: meta + working-directory: node + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M) + SHORT_SHA=$(git rev-parse --short HEAD) + echo "BUILD_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "build_id=${TIMESTAMP}-${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Execute the Dockerfile + working-directory: node + run: | + docker build -t node_build -f ./Dockerfile.Node${NODE_MAJOR_VERSION} . + + - name: Extract resources + run: | + docker create --name temp_node_extract node_build + docker cp temp_node_extract:/usr/src/node/node-install $GITHUB_WORKSPACE/node-install + docker rm temp_node_extract + + - name: Verify build + run: | + bash node/asana-verify-build.sh node-install + + - name: Archive Node + run: | + mkdir -p artifacts + FILENAME=node-${NODE_VERSION}-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }}.tar.xz + FILENAME_LATEST=node-${NODE_VERSION}-${{ matrix.platform }}-${{ matrix.arch }}-LATEST.tar.xz + tar -C node-install -cJf artifacts/$FILENAME . + cp artifacts/$FILENAME artifacts/$FILENAME_LATEST + echo "NODE_ARCHIVE=$FILENAME" >> $GITHUB_ENV + echo "NODE_ARCHIVE_LATEST=$FILENAME_LATEST" >> $GITHUB_ENV + + - name: Upload Node archive + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }} + path: artifacts/${{ env.NODE_ARCHIVE }} + + - name: Upload Node archive latest + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-${{ matrix.platform }}-${{ matrix.arch }}-LATEST + path: artifacts/${{ env.NODE_ARCHIVE_LATEST }} + + - name: Upload Node archive to release + run: | + TAG="node-${NODE_VERSION}-release" + RELEASE_NAME="node-${NODE_VERSION}-LATEST" + if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + gh release create "$TAG" --title "$RELEASE_NAME" --notes "" --target "$BUILD_SHA" --repo "$REPO" \ + || gh release view "$TAG" --repo "$REPO" >/dev/null + fi + gh release upload "$TAG" "artifacts/${NODE_ARCHIVE_LATEST}" --clobber --repo "$REPO" + gh release edit "$TAG" --title "$RELEASE_NAME" --repo "$REPO" diff --git a/asana-smoke-test.js b/asana-smoke-test.js new file mode 100644 index 000000000000..f65b2eeb01e2 --- /dev/null +++ b/asana-smoke-test.js @@ -0,0 +1,160 @@ +'use strict'; + +// Smoke test for Asana's Node.js builds. Run with the built binary: +// +// node asana-smoke-test.js [--fips] +// +// Exits non-zero if any check fails. The checks cover properties LunaServer depends on +// that the upstream test suite does not assert for our configure flags. + +const assert = require('assert'); +const { AsyncLocalStorage } = require('async_hooks'); +const { spawnSync } = require('child_process'); +const crypto = require('crypto'); +const tls = require('tls'); +const v8 = require('v8'); +const { Worker } = require('worker_threads'); +const zlib = require('zlib'); + +const expectFips = process.argv.includes('--fips'); +const results = []; + +async function check(name, fn) { + try { + const detail = await fn(); + results.push({ name, ok: true, detail }); + } catch (err) { + results.push({ name, ok: false, detail: err?.stack ?? String(err) }); + } +} + +function heapLimitWithMaxOldSpace(mb) { + const out = spawnSync(process.execPath, [ + `--max-old-space-size=${mb}`, + '-p', + 'require("v8").getHeapStatistics().heap_size_limit', + ], { encoding: 'utf8' }); + assert.strictEqual(out.status, 0, out.stderr); + return Number(out.stdout.trim()); +} + +async function main() { + const vars = process.config.variables; + + await check('version matches the source tree', () => { + assert.strictEqual(process.versions.node.split('.')[0], process.env.EXPECTED_NODE_MAJOR); + return `${process.version} (modules ${process.versions.modules}, v8 ${process.versions.v8})`; + }); + + // Node 22's --experimental-enable-pointer-compression shares one cage across isolates, as in + // the v22.21.1 builds LunaServer ran; Node 26 builds use a cage per isolate instead. + await check('pointer compression enabled, V8 sandbox disabled', () => { + assert.strictEqual(vars.v8_enable_pointer_compression, 1); + assert.strictEqual(vars.v8_enable_31bit_smis_on_64bit_arch, 1); + assert.strictEqual(vars.v8_enable_sandbox, 0); + assert.strictEqual(vars.v8_enable_pointer_compression_shared_cage, 1); + return 'shared-cage pointer compression'; + }); + + await check('heap is capped by the 4GB pointer compression cage', () => { + const limit = heapLimitWithMaxOldSpace(8192); + assert(limit <= 4.5 * 1024 ** 3, `heap_size_limit ${limit} exceeds the 4GB cage`); + return `heap_size_limit with --max-old-space-size=8192: ${(limit / 1024 ** 3).toFixed(2)} GiB`; + }); + + await check('LunaServer startup flags are accepted', () => { + // Node exits on an option it doesn't know. Keep in sync with codez's setup_js_env.bashrc + // and node/launcher.sh, which set these for every LunaServer process. + const flags = ['--stack_trace_limit=1000', '--no-wasm-code-gc', '--expose-gc', + '--max-old-space-size=2000', '--max-semi-space-size=64', + '--no-experimental-global-navigator', '--no-experimental-webstorage']; + const env = { ...process.env, NODE_OPTIONS: '--experimental-abortcontroller' }; + const out = spawnSync(process.execPath, [...flags, '-e', 'gc()'], { encoding: 'utf8', env }); + assert.strictEqual(out.status, 0, out.stderr); + return `${flags.join(' ')}, NODE_OPTIONS=${env.NODE_OPTIONS}`; + }); + + await check('Temporal support matches the build', () => { + return `v8_enable_temporal_support=${vars.v8_enable_temporal_support}, ` + + `globalThis.Temporal: ${typeof globalThis.Temporal}`; + }); + + await check('Maglev is disabled, as in the v22.21.1 builds', () => { + assert.strictEqual(vars.v8_enable_maglev, 0); + }); + + await check('OpenSSL linkage matches the build flavor', () => { + assert.strictEqual(Boolean(vars.node_shared_openssl), expectFips); + return `openssl ${process.versions.openssl}, shared=${vars.node_shared_openssl}, ` + + `getFips()=${crypto.getFips()}`; + }); + + await check('brotli rejects invalid block switches (CVE-2026-111497)', () => { + const invalid = Buffer.from('200010414141fcffff07002228f00b60f7f41f000000', 'hex'); + assert.strictEqual(zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, -17); + assert.throws(() => zlib.brotliDecompressSync(invalid), + { code: 'ERR__ERROR_FORMAT_BLOCK_SWITCH' }); + }); + + await check('worker threads get their own heap', async () => { + const used = await new Promise((resolve, reject) => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const chunks = []; + for (let i = 0; i < 64; i++) chunks.push(new Array(256 * 1024).fill(i)); + parentPort.postMessage(require('v8').getHeapStatistics().used_heap_size); + `, { eval: true }); + worker.once('message', resolve); + worker.once('error', reject); + }); + return `worker used_heap_size ${(used / 1024 ** 2).toFixed(0)} MiB`; + }); + + await check('WebAssembly memory can be reserved', async () => { + // (module (memory (export "m") 1 65536)) + const bytes = Buffer.from('0061736d010000000506010101808004070501016d0200', 'hex'); + const { instance } = await WebAssembly.instantiate(bytes); + instance.exports.m.grow(16); + return `${instance.exports.m.buffer.byteLength} bytes`; + }); + + await check('full ICU is available', () => { + const formatted = new Intl.DateTimeFormat('ja-JP', { month: 'long' }).format(new Date(0)); + assert.match(formatted, /月/); + return formatted; + }); + + await check('AsyncLocalStorage propagates across await and timers', async () => { + const als = new AsyncLocalStorage(); + const seen = await als.run('ctx', async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + await null; + return new Promise((resolve) => setImmediate(() => resolve(als.getStore()))); + }); + assert.strictEqual(seen, 'ctx'); + }); + + await check('crypto and TLS basics', () => { + const digest = crypto.createHash('sha256').update('asana').digest('hex'); + assert.strictEqual(digest.length, 64); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const signature = crypto.sign('sha256', Buffer.from('x'), privateKey); + assert(crypto.verify('sha256', Buffer.from('x'), publicKey, signature)); + tls.createSecureContext({ minVersion: 'TLSv1.2' }); + }); + + await check('v8.serialize round-trips', () => { + const value = { a: [1, 2, 3], b: new Map([['k', 'v']]), c: 2n ** 70n }; + assert.deepStrictEqual(v8.deserialize(v8.serialize(value)), value); + }); + + let failed = 0; + for (const { name, ok, detail } of results) { + if (!ok) failed++; + console.log(`${ok ? 'PASS' : 'FAIL'}: ${name}${detail ? `\n ${detail}` : ''}`); + } + console.log(failed ? `\n${failed} check(s) failed` : '\nAll checks passed'); + process.exitCode = failed ? 1 : 0; +} + +main(); diff --git a/asana-verify-build.sh b/asana-verify-build.sh new file mode 100755 index 000000000000..8be5cb48ce50 --- /dev/null +++ b/asana-verify-build.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Verifies an Asana Node.js build before it is published: +# 1. The binary only needs glibc/libstdc++ symbol versions available on Ubuntu 22.04, +# the oldest platform that runs the LunaServer Node toolchain (sandboxes and EngFlow +# remote-execution workers). +# 2. asana-smoke-test.js passes on stock Ubuntu 22.04 and on Wolfi, the base of the +# LunaServer images, with every symbol resolved at load time (LD_BIND_NOW). +# +# Usage: asana-verify-build.sh [--fips] +# +# is the DESTDIR passed to `make install`, i.e. it contains usr/local/bin/node. +# Requires docker and binutils. + +set -euo pipefail + +INSTALL_DIR=$(cd "$1" && pwd) +FLAVOR=${2:-} +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +NODE_BIN="$INSTALL_DIR/usr/local/bin/node" +EXPECTED_NODE_MAJOR=$(grep '#define NODE_MAJOR_VERSION' "$SCRIPT_DIR/src/node_version.h" | awk '{print $3}') + +MAX_GLIBC=2.35 +MAX_GLIBCXX=3.4.30 +MAX_CXXABI=1.3.13 +MAX_OPENSSL=3.0.0 + +SMOKE_ARGS="" +UBUNTU_PACKAGES="" +WOLFI_PACKAGES="libstdc++ libgcc" +if [ "$FLAVOR" = "--fips" ]; then + SMOKE_ARGS="--fips" + UBUNTU_PACKAGES="libssl3" + WOLFI_PACKAGES="$WOLFI_PACKAGES libssl3 libcrypto3" +fi + +test -x "$NODE_BIN" || { echo "ERROR: $NODE_BIN not found" >&2; exit 1; } + +max_symbol_version() { + objdump -T "$NODE_BIN" | grep -o "$1_[0-9][0-9.]*" | sed "s/^$1_//" | sort -uV | tail -1 +} + +check_symbol_version() { + local prefix=$1 limit=$2 found + found=$(max_symbol_version "$prefix") + echo "Highest $prefix symbol version required: ${found:-none} (limit $limit)" + if [ -n "$found" ] && [ "$(printf '%s\n%s\n' "$found" "$limit" | sort -V | tail -1)" != "$limit" ]; then + echo "ERROR: node requires ${prefix}_$found, newer than ${prefix}_$limit on Ubuntu 22.04" >&2 + objdump -T "$NODE_BIN" | grep "${prefix}_$found" | head -20 >&2 + exit 1 + fi +} + +echo "=== Dynamic dependencies ===" +objdump -p "$NODE_BIN" | grep NEEDED + +echo "=== Symbol versions ===" +check_symbol_version GLIBC "$MAX_GLIBC" +check_symbol_version GLIBCXX "$MAX_GLIBCXX" +check_symbol_version CXXABI "$MAX_CXXABI" +if [ "$FLAVOR" = "--fips" ]; then + # Ubuntu 22.04 ships OpenSSL 3.0.2. + check_symbol_version OPENSSL "$MAX_OPENSSL" +fi + +echo "=== Position-independent executable ===" +if ! readelf -h "$NODE_BIN" | grep -q 'Type: *DYN'; then + echo "ERROR: node isn't a position-independent executable, so ASLR can't randomize its code" >&2 + readelf -h "$NODE_BIN" | grep 'Type:' >&2 + exit 1 +fi +echo "node is a position-independent executable" + +if objdump -p "$NODE_BIN" | grep NEEDED | grep -q libatomic; then + UBUNTU_PACKAGES="$UBUNTU_PACKAGES libatomic1" + WOLFI_PACKAGES="$WOLFI_PACKAGES libatomic" +fi + +run_smoke_test() { + local image=$1 setup=$2 + echo "=== Smoke test on $image ===" + docker run --rm \ + -v "$INSTALL_DIR:/opt/node:ro" \ + -v "$SCRIPT_DIR/asana-smoke-test.js:/opt/asana-smoke-test.js:ro" \ + -e LD_BIND_NOW=1 \ + -e EXPECTED_NODE_MAJOR="$EXPECTED_NODE_MAJOR" \ + "$image" \ + sh -c "set -e; $setup /opt/node/usr/local/bin/node /opt/asana-smoke-test.js $SMOKE_ARGS" +} + +UBUNTU_SETUP="" +if [ -n "${UBUNTU_PACKAGES// /}" ]; then + UBUNTU_SETUP="apt-get update -qq && apt-get install -y -qq --no-install-recommends $UBUNTU_PACKAGES >/dev/null;" +fi +run_smoke_test ubuntu:22.04 "$UBUNTU_SETUP" +run_smoke_test cgr.dev/chainguard/wolfi-base "apk add --no-cache -q $WOLFI_PACKAGES;" + +echo "=== Build verified ===" diff --git a/stage_for_s3.bash b/stage_for_s3.bash new file mode 100755 index 000000000000..39da948ba346 --- /dev/null +++ b/stage_for_s3.bash @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# +# Downloads this branch's release artifacts (standard + FIPS Node builds and the native +# packages) and stages them under a directory that mirrors their s3://asana-oss-cache keys. +# Every file gets a unique suffix so uploading never overwrites an existing object (the +# bucket is not versioned). Prints the codez Starlark to point at the new files. +# +# Usage: ./stage_for_s3.bash (run from the root of this repository) + +set -euo pipefail + +NODE_MAJOR=$(grep '#define NODE_MAJOR_VERSION' src/node_version.h | awk '{print $3}') +NODE_MINOR=$(grep '#define NODE_MINOR_VERSION' src/node_version.h | awk '{print $3}') +NODE_PATCH=$(grep '#define NODE_PATCH_VERSION' src/node_version.h | awk '{print $3}') +VERSION="${NODE_MAJOR}.${NODE_MINOR}.${NODE_PATCH}" +REPO=Asana/node + +DOWNLOAD_DIR=$(mktemp -d) +trap 'rm -rf "$DOWNLOAD_DIR"' EXIT + +gh release download -R "$REPO" "node-v${VERSION}-release" -D "$DOWNLOAD_DIR" -p "*.tar.xz" -p "packages_*.tar.gz" +gh release download -R "$REPO" "node-v${VERSION}-fips-release" -D "$DOWNLOAD_DIR" -p "*.tar.xz" + +ARCHIVE_HASH=$(cat "$DOWNLOAD_DIR"/*.tar.xz | shasum -a 1 | cut -c1-4) +UNIQUE="pc-$(date '+%Y%m%d.%H%M')-${ARCHIVE_HASH}" +STAGE="node-v${VERSION}-${UNIQUE}" +mkdir -p "$STAGE/node/fork/v${VERSION}" "$STAGE/node-fips/v${VERSION}" "$STAGE/node-gyp" + +# Repackage each build so the archive has a single top-level node-v-[fips-]linux- +# directory, the layout rules_nodejs expects, instead of the usr/local DESTDIR layout. +for flavor in "" "fips-"; do + key_prefix=${flavor:+fips_} + for arch in x64 arm64; do + base="node-v${VERSION}-${flavor}linux-${arch}" + src="$DOWNLOAD_DIR/${base}-LATEST.tar.xz" + test -f "$src" || { echo "ERROR: missing $(basename "$src") in the release" >&2; exit 1; } + + work=$(mktemp -d) + mkdir "$work/$base" + tar -xJf "$src" -C "$work/$base" + if [ -d "$work/$base/usr/local" ]; then + mv "$work/$base"/usr/local/* "$work/$base" + rm -rf "${work:?}/$base/usr" + fi + + if [ -z "$flavor" ]; then dest_dir="$STAGE/node/fork/v${VERSION}"; else dest_dir="$STAGE/node-fips/v${VERSION}"; fi + file="${base}-${UNIQUE}.tar.xz" + tar -C "$work" -cJf "$dest_dir/$file" "$base" + rm -rf "$work" + + printf -v "FILE_${key_prefix}${arch}" %s "$file" + printf -v "SHA_${key_prefix}${arch}" %s "$(shasum -a 256 "$dest_dir/$file" | cut -d' ' -f1)" + done +done + +for arch in x64 arm64; do + if [ "$arch" = x64 ]; then bazel_arch=amd64; else bazel_arch=arm64; fi + src="$DOWNLOAD_DIR/packages_${arch}.tar.gz" + if [ ! -f "$src" ]; then + echo "WARNING: packages_${arch}.tar.gz is not in the release; run build-node-packages.yml first" + continue + fi + sha=$(shasum -a 256 "$src" | cut -d' ' -f1) + file="packages_${bazel_arch}_node${NODE_MAJOR}-${sha:0:8}.tar.gz" + cp "$src" "$STAGE/node-gyp/$file" + printf -v "FILE_packages_${bazel_arch}" %s "$file" + printf -v "SHA_packages_${bazel_arch}" %s "$sha" +done + +echo "" +echo "Staged files (upload with: aws s3 cp --recursive $STAGE/ s3://asana-oss-cache/):" +(cd "$STAGE" && find . -type f | sort) + +# shellcheck disable=SC2154 # FILE_* and SHA_* are assigned with printf -v above. +cat < Date: Sat, 14 Nov 2026 16:07:41 -0800 Subject: [PATCH 3/3] deps: V8: fix the build without a shared read-only heap Since 22.22.2, AstRawString::AsArrayIndex() decodes cached array indices with HashSeed(ReadOnlyHeap::GetReadOnlyRoots()), which only exists with V8_SHARED_RO_HEAP. configure turns the shared read-only heap off for --experimental-enable-pointer-compression builds, so ours don't compile (nodejs/node#62631). The proposed upstream fix, nodejs/node#62664, calls a GetReadOnlyRoots() that V8 12.4 doesn't have. Without a shared read-only heap there's no process-wide hash seed to decode the cached index with, so parse the index from the string's digits, as the function already does for longer strings. For strings short enough to cache an index, the result is the same. --- deps/v8/src/ast/ast-value-factory.cc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deps/v8/src/ast/ast-value-factory.cc b/deps/v8/src/ast/ast-value-factory.cc index 8af2d51397b1..5322e828d60f 100644 --- a/deps/v8/src/ast/ast-value-factory.cc +++ b/deps/v8/src/ast/ast-value-factory.cc @@ -81,14 +81,17 @@ bool AstRawString::AsArrayIndex(uint32_t* index) const { // The StringHasher will set up the hash. Bail out early if we know it // can't be convertible to an array index. if (!IsIntegerIndex()) return false; +#ifdef V8_SHARED_RO_HEAP if (length() <= Name::kMaxCachedArrayIndexLength) { *index = StringHasher::DecodeArrayIndexFromHashField( raw_hash_field_, HashSeed(ReadOnlyHeap::GetReadOnlyRoots())); return true; } +#endif // V8_SHARED_RO_HEAP // Might be an index, but too big to cache it. Do the slow conversion. This // might fail if the string is outside uint32_t (but within "safe integer") - // range. + // range. Without a shared read-only heap there is no process-wide hash seed + // to decode a cached index with, so short indices take this path too. OneByteStringStream stream(literal_bytes_); return StringToIndex(&stream, index); }