From ef724c594743a45f444fc2baaf5a77a236cbd67e Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sun, 25 Oct 2026 17:42:16 -0700 Subject: [PATCH 1/4] build: add Asana Node 24 builds with pointer compression Standard and FIPS (shared OpenSSL) builds on Ubuntu 22.04 with its g++-12, configured with --experimental-enable-pointer-compression (multi-cage, V8 sandbox off). No fiber patches: LunaServer no longer uses node-fibers, so Maglev and CppHeap stay at upstream defaults. Each build is verified before it is published: the binary may only require glibc/libstdc++ symbol versions shipped by Ubuntu 22.04 (the sandbox and remote-execution hosts), and asana-smoke-test.js must pass on stock Ubuntu 22.04 and on Wolfi (the LunaServer image base). Co-authored-by: Cursor --- .github/workflows/build-node-openssl-fips.yml | 108 ++++++++++++++ .github/workflows/build-node.yml | 98 ++++++++++++ Dockerfile.Node24 | 46 ++++++ Dockerfile.Node24fips | 41 +++++ asana-smoke-test.js | 141 ++++++++++++++++++ asana-verify-build.sh | 77 ++++++++++ 6 files changed, 511 insertions(+) create mode 100644 .github/workflows/build-node-openssl-fips.yml create mode 100644 .github/workflows/build-node.yml create mode 100644 Dockerfile.Node24 create mode 100644 Dockerfile.Node24fips create mode 100644 asana-smoke-test.js create mode 100755 asana-verify-build.sh diff --git a/.github/workflows/build-node-openssl-fips.yml b/.github/workflows/build-node-openssl-fips.yml new file mode 100644 index 000000000000..19bcb4965258 --- /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' + required: true + default: 'v24.22.1' + type: string + DOCKER_FILE: + description: 'Dockerfile to use for building Node.js' + required: true + default: 'Dockerfile.Node24fips' + 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 }} + + - 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/Dockerfile.Node24 b/Dockerfile.Node24 new file mode 100644 index 000000000000..fbc0edba4d39 --- /dev/null +++ b/Dockerfile.Node24 @@ -0,0 +1,46 @@ +# Builds Asana's Node.js 24 binary: upstream Node with V8 pointer compression. +# +# The binary has to run on every host that executes the LunaServer Node toolchain: +# the Wolfi-based LunaServer images, Ubuntu 22.04 sandboxes, and Ubuntu 22.04 EngFlow +# remote-execution workers. Building on Ubuntu 22.04 with its own g++-12 (12.3) keeps the +# glibc (<= 2.35) and libstdc++ (<= GLIBCXX_3.4.30) requirements within what 22.04 ships, +# while meeting Node 24's GCC >= 12.2 requirement. A newer GCC (e.g. 13 from the +# toolchain PPA) produces binaries that need GLIBCXX_3.4.31+ and fail to start there. +FROM ubuntu:22.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive + +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 --fix-missing \ + ca-certificates \ + g++-12 \ + gcc-12 \ + git \ + make \ + python3 \ + xz-utils \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100 && \ + update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-12 100 +ENV CC=gcc-12 CXX=g++-12 + +WORKDIR /usr/src/node +COPY . . + +# Plain --experimental-enable-pointer-compression gives the multi-cage configuration: every +# isolate (main thread and each worker) gets its own 4GB pointer cage, and the V8 sandbox +# stays disabled. Do not add --experimental-pointer-compression-shared-cage: it enables the V8 +# sandbox, which rejects external ArrayBuffer backing stores that native addons create. +RUN ./configure --experimental-enable-pointer-compression +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Node24fips b/Dockerfile.Node24fips new file mode 100644 index 000000000000..f19b620be26f --- /dev/null +++ b/Dockerfile.Node24fips @@ -0,0 +1,41 @@ +# Builds Asana's FIPS-capable Node.js 24 binary for FedRAMP: the same build as +# Dockerfile.Node24, but dynamically linked against the system OpenSSL 3 so that the +# FIPS provider installed in the LunaServer FIPS image is used at runtime. +# +# See Dockerfile.Node24 for why this builds on Ubuntu 22.04 with g++-12. +FROM ubuntu:22.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive + +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 --fix-missing \ + ca-certificates \ + g++-12 \ + gcc-12 \ + git \ + libssl-dev \ + make \ + pkg-config \ + python3 \ + xz-utils \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100 && \ + update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-12 100 +ENV CC=gcc-12 CXX=g++-12 + +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"] diff --git a/asana-smoke-test.js b/asana-smoke-test.js new file mode 100644 index 000000000000..e5d255218dac --- /dev/null +++ b/asana-smoke-test.js @@ -0,0 +1,141 @@ +'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 is Node 24', () => { + assert.match(process.version, /^v24\./); + return `${process.version} (modules ${process.versions.modules}, v8 ${process.versions.v8})`; + }); + + 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, 0); + return 'multi-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('Maglev is enabled', () => { + assert.strictEqual(vars.v8_enable_maglev, 1); + }); + + 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..a70643a37b70 --- /dev/null +++ b/asana-verify-build.sh @@ -0,0 +1,77 @@ +#!/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. +# +# 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" + +MAX_GLIBC=2.35 +MAX_GLIBCXX=3.4.30 +MAX_CXXABI=1.3.13 + +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" + +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" \ + "$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 ===" From 7191dc31f2c3d6b5d05cc2d801e3fd92340c4504 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sun, 25 Oct 2026 17:42:16 -0700 Subject: [PATCH 2/4] build: build native packages and stage S3 uploads for Node 24 Dockerfile.Packages rebuilds bcrypt, cld, unix-dgram and @datadog/pprof against this branch's Node and loads each one as a smoke test. build-node-packages.yml reads the Node version from the checked-out tree and accepts a NODE_REF input, so it also works when dispatched from main for the S3 upload. stage_for_s3.bash stages the release artifacts under their asana-oss-cache key layout with unique names and prints the codez Starlark for node_toolchains_bzlmod.bzl and tools_repositories.bzl. Co-authored-by: Cursor --- .github/workflows/build-node-packages.yml | 147 ++++++++++++++++++++++ Dockerfile.Packages | 93 ++++++++++++++ stage_for_s3.bash | 111 ++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 .github/workflows/build-node-packages.yml create mode 100644 Dockerfile.Packages create mode 100755 stage_for_s3.bash diff --git a/.github/workflows/build-node-packages.yml b/.github/workflows/build-node-packages.yml new file mode 100644 index 000000000000..648576885182 --- /dev/null +++ b/.github/workflows/build-node-packages.yml @@ -0,0 +1,147 @@ +name: Build Node-Packages + +on: + workflow_dispatch: + inputs: + NODE_REF: + description: 'Branch with the Node source and Dockerfile.Packages (defaults to the ref the workflow runs from)' + required: false + default: '' + type: string + workflow_run: + workflows: ["Build Node (Standard)"] + types: + - completed + branches: + - v24.22.1 + +jobs: + build-packages: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + permissions: + id-token: write + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + bazel_arch: amd64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + bazel_arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + PLATFORM: ${{ matrix.platform }} + ARCH: ${{ matrix.arch }} + BAZEL_ARCH: ${{ matrix.bazel_arch }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + # The workflow YAML runs from whichever ref triggered it (main for a workflow_dispatch + # that should upload to S3); that's what the OIDC subject claim binds to, and it's how + # the IAM role's ref_patterns gate works. `ref:` here only controls which tree gets + # checked out into $GITHUB_WORKSPACE. + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.NODE_REF || github.event.workflow_run.head_branch || github.ref_name }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' src/node_version.h | awk '{print $3}') + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Download Node archive + run: | + ASSET="node-${NODE_VERSION}-${PLATFORM}-${ARCH}-LATEST.tar.xz" + gh release download "node-${NODE_VERSION}-release" \ + --repo "$REPO" \ + --pattern "$ASSET" + mv "$ASSET" node.tar.xz + + - name: Execute the Dockerfile + run: | + docker build -t node_packages_build -f Dockerfile.Packages . + + - name: Extract resources + run: | + docker create --name temp_node_packages_extract node_packages_build + docker cp temp_node_packages_extract:/usr/src/node/node_modules $GITHUB_WORKSPACE/node_modules + docker rm temp_node_packages_extract + + - name: Tar node-packages + run: | + mkdir -p ./bcrypt@5.1.0/node_modules + mkdir -p ./cld@2.9.1/node_modules + mkdir -p ./unix-dgram@2.0.6/node_modules + mkdir -p "./@datadog+pprof@5.8.0/node_modules/@datadog" + mv node_modules/bcrypt ./bcrypt@5.1.0/node_modules/ + mv node_modules/cld ./cld@2.9.1/node_modules/ + mv node_modules/unix-dgram ./unix-dgram@2.0.6/node_modules/ + mv "node_modules/@datadog/pprof" "./@datadog+pprof@5.8.0/node_modules/@datadog/" + tar --hard-dereference -cvzf "packages_${ARCH}.tar.gz" bcrypt@5.1.0 cld@2.9.1 unix-dgram@2.0.6 "@datadog+pprof@5.8.0" + + - name: Upload archive to release + run: | + gh release upload "node-${NODE_VERSION}-release" \ + "packages_${ARCH}.tar.gz" \ + --clobber \ + --repo "$REPO" + + # S3 upload is restricted to the protected main branch only. The IAM role + # (push_node_gyp_packages) trusts only refs/heads/main via OIDC. To upload + # packages to S3 after a Node upgrade, trigger workflow_dispatch from main with NODE_REF. + - name: Configure AWS credentials + if: github.ref == 'refs/heads/main' + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: us-east-1 + role-to-assume: arn:aws:iam::403483446840:role/autogen_github_actions_beta_push_node_gyp_packages + + - name: Upload packages to S3 + if: github.ref == 'refs/heads/main' + run: | + # No --acl public-read: the bucket has BucketOwnerEnforced + # (disable_confusing_acls = true), which disables ACLs entirely. + # Reads come via CloudFront OAC. + NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/^v//' | cut -d. -f1) + SHA256=$(sha256sum "packages_${ARCH}.tar.gz" | awk '{print $1}') + SHORT_HASH=${SHA256:0:8} + S3_KEY="node-gyp/packages_${BAZEL_ARCH}_node${NODE_MAJOR}-${SHORT_HASH}.tar.gz" + echo "Uploading packages_${ARCH}.tar.gz to s3://asana-oss-cache/$S3_KEY" + aws s3 cp "packages_${ARCH}.tar.gz" "s3://asana-oss-cache/$S3_KEY" + { + echo "S3_KEY=$S3_KEY" + echo "SHA256=$SHA256" + echo "NODE_MAJOR=$NODE_MAJOR" + } >> "$GITHUB_ENV" + + - name: Verify upload is reachable via CloudFront + if: github.ref == 'refs/heads/main' + run: | + # Mac Bazel builds rewrite asana-oss-cache.s3.us-east-1.amazonaws.com/* + # to asana-oss-cache.asana.biz/* (CloudFront). If the S3 key prefix isn't + # allowlisted in CloudFront's path_patterns, Bazel fetches will 403. + URL="https://asana-oss-cache.asana.biz/$S3_KEY" + echo "Checking $URL" + if ! curl -fsSI "$URL"; then + echo "CloudFront returned an error for $URL. Check path_patterns in system_packages.tf." + exit 1 + fi + + - name: Print tools_repositories.bzl stanza + if: github.ref == 'refs/heads/main' + run: | + echo "" + echo "=== Update tools_repositories.bzl in codez ===" + echo " name = \"node_gyp_packages_${BAZEL_ARCH}_node${NODE_MAJOR}\"," + echo " urls = [\"https://asana-oss-cache.s3.us-east-1.amazonaws.com/$S3_KEY\"]," + echo " sha256 = \"$SHA256\"," diff --git a/Dockerfile.Packages b/Dockerfile.Packages new file mode 100644 index 000000000000..cdc32c2c325b --- /dev/null +++ b/Dockerfile.Packages @@ -0,0 +1,93 @@ +# Builds the native npm packages that LunaServer loads from node/gyp in codez against +# Asana's pointer-compressed Node build (node.tar.xz, downloaded from this branch's release). +# +# Addons built on the V8/NAN API must be compiled against our headers: prebuilt binaries for +# stock Node have the same NODE_MODULE_VERSION but a different V8 object layout, so they load +# and then crash. N-API addons (bcrypt, cld) are layout-independent but are built here too so +# the tarball stays self-contained. +# +# See Dockerfile.Node24 for why this builds on Ubuntu 22.04 with g++-12. +FROM ubuntu:22.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive + +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 --fix-missing \ + ca-certificates \ + curl \ + g++-12 \ + gcc-12 \ + git \ + make \ + python3 \ + xz-utils \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100 && \ + update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-12 100 && \ + update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-12 100 +ENV CC=gcc-12 CXX=g++-12 + +WORKDIR /usr/src/node +COPY node.tar.xz . + +RUN mkdir -p node-install && tar -C node-install -xJf node.tar.xz + +ENV PATH="/usr/src/node/node-install/usr/local/bin:$PATH" +ENV NODE_DIR="/usr/src/node/node-install/usr/local" + +RUN node -v && node -p "process.arch" && node -p "process.versions.modules" && \ + node -p "process.config.variables.v8_enable_pointer_compression" + +RUN npm i --nodedir="$NODE_DIR" bcrypt@5.1.0 +RUN npm i --nodedir="$NODE_DIR" cld@2.9.1 +RUN npm i --nodedir="$NODE_DIR" unix-dgram@2.0.6 + +# Build @datadog/pprof from source against Asana custom Node. +# The npm package ships with "install: exit 0" and no C++ source (only prebuilts for stock +# Node), so we clone from GitHub to build the native binding against our pointer-compressed V8. +RUN npm i @datadog/pprof@5.8.0 --ignore-scripts +RUN git clone --branch v5.8.0 --depth 1 https://github.com/DataDog/pprof-nodejs.git /usr/src/pprof-source +WORKDIR /usr/src/pprof-source +RUN npm install --unsafe-perm --build-from-source +RUN npm install --unsafe-perm node-gyp +RUN node_modules/.bin/node-gyp rebuild --jobs=max --nodedir="$NODE_DIR" +# Replace the linux prebuilt with our pointer-compression-compatible binary. +# NODE_MODULE_VERSION and arch are read dynamically from the custom Node binary. +WORKDIR /usr/src/node +RUN ARCH=$(node -e "process.stdout.write(process.arch)") && \ + MODULES_VER=$(node -e "process.stdout.write(process.versions.modules)") && \ + PROFILER_NODE=$(find /usr/src/pprof-source/build/Release -name "*.node" | head -1) && \ + echo "Using profiler binary: $PROFILER_NODE" && \ + test -f "$PROFILER_NODE" && \ + cp "$PROFILER_NODE" \ + node_modules/@datadog/pprof/prebuilds/linuxglibc-${ARCH}/node-${MODULES_VER}.node + +# Load every addon with the custom Node to catch ABI or missing-symbol problems here rather +# than in LunaServer. +RUN node -e " \ + const assert = require('assert'); \ + const bcrypt = require('bcrypt'); \ + assert(bcrypt.compareSync('x', bcrypt.hashSync('x', 4))); \ + assert.strictEqual(typeof require('cld').detect, 'function'); \ + require('unix-dgram').createSocket('unix_dgram').close(); \ + const pprof = require('@datadog/pprof'); \ + pprof.heap.start(512 * 1024, 64); \ + assert(Array.isArray(pprof.heap.profile().sample)); \ + pprof.heap.stop(); \ + pprof.time.start({ durationMillis: 1000 }); \ + const end = Date.now() + 200; while (Date.now() < end) {} \ + assert(Array.isArray(pprof.time.stop().sample)); \ + console.log('All native packages load'); \ +" + +RUN ls node_modules + +CMD ["bash"] 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: Sun, 25 Oct 2026 17:52:44 -0700 Subject: [PATCH 3/4] deps: define EVP_PKEY_PRIVATE_KEY for early OpenSSL 3.0 headers The FIPS build links against Ubuntu 22.04's shared OpenSSL 3.0.2, whose headers predate EVP_PKEY_PRIVATE_KEY. Define it with the same value later OpenSSL releases use. Co-authored-by: Cursor --- deps/ncrypto/ncrypto.cc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 88898735c5f7..5dbbd73c067e 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -42,6 +42,11 @@ (qbits), \ nullptr) #endif +// Early OpenSSL 3.0.x headers (e.g. 3.0.2 on Ubuntu 22.04) lack EVP_PKEY_PRIVATE_KEY. +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(EVP_PKEY_PRIVATE_KEY) +#define EVP_PKEY_PRIVATE_KEY \ + (EVP_PKEY_KEY_PARAMETERS | OSSL_KEYMGMT_SELECT_PRIVATE_KEY) +#endif namespace ncrypto { namespace { From 6d7154fdc320886f3f215692b5034281a67789c6 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sun, 25 Oct 2026 17:53:27 -0700 Subject: [PATCH 4/4] build: check LunaServer's V8 startup flags in the smoke test Co-authored-by: Cursor --- asana-smoke-test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/asana-smoke-test.js b/asana-smoke-test.js index e5d255218dac..d3d071c31a87 100644 --- a/asana-smoke-test.js +++ b/asana-smoke-test.js @@ -60,6 +60,14 @@ async function main() { return `heap_size_limit with --max-old-space-size=8192: ${(limit / 1024 ** 3).toFixed(2)} GiB`; }); + await check('LunaServer startup flags are accepted', () => { + const flags = ['--stack_trace_limit=1000', '--no-wasm-code-gc', '--expose-gc', + '--max-old-space-size=2000', '--max-semi-space-size=64']; + const out = spawnSync(process.execPath, [...flags, '-e', 'gc()'], { encoding: 'utf8' }); + assert.strictEqual(out.status, 0, out.stderr); + return flags.join(' '); + }); + await check('Maglev is enabled', () => { assert.strictEqual(vars.v8_enable_maglev, 1); });