From 83ef1308f7c71ab2cd718da5ed469ad78dc69e90 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sun, 25 Oct 2026 19:47:05 -0700 Subject: [PATCH 1/7] build: add Asana Node 26 builds with pointer compression Standard and FIPS builds configured with --experimental-enable-pointer-compression (multi-cage, V8 sandbox off) and Temporal support, with no Node or V8 source changes. Node 26 needs GCC >= 13.2, newer than Ubuntu 22.04's libstdc++ (the sandbox and remote-execution hosts). Like the official release binaries, these build on RHEL 8 (AlmaLinux 8) with gcc-toolset-14, which keeps the runtime requirements at glibc 2.28, GLIBCXX_3.4.25 and libatomic. The FIPS build compiles against OpenSSL 3.0 LTS built from source and links against the system libssl.so.3 at runtime. Each build is verified before it is published: symbol versions must be available on Ubuntu 22.04, and asana-smoke-test.js must pass with LD_BIND_NOW=1 on stock Ubuntu 22.04 and on Wolfi. Native packages are rebuilt with the same toolchain; @datadog/pprof moves to 5.20.0, the first line with Node 26 (ABI 147) support. Co-authored-by: Cursor --- .github/workflows/build-node-openssl-fips.yml | 108 ++++++++++++ .github/workflows/build-node-packages.yml | 151 +++++++++++++++++ .github/workflows/build-node.yml | 98 +++++++++++ Dockerfile.Node26 | 45 +++++ Dockerfile.Node26fips | 61 +++++++ Dockerfile.Packages | 100 ++++++++++++ asana-smoke-test.js | 154 ++++++++++++++++++ asana-verify-build.sh | 90 ++++++++++ stage_for_s3.bash | 111 +++++++++++++ 9 files changed, 918 insertions(+) create mode 100644 .github/workflows/build-node-openssl-fips.yml create mode 100644 .github/workflows/build-node-packages.yml create mode 100644 .github/workflows/build-node.yml create mode 100644 Dockerfile.Node26 create mode 100644 Dockerfile.Node26fips create mode 100644 Dockerfile.Packages 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..10f1e6858e37 --- /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: 'v26.12.1' + type: string + DOCKER_FILE: + description: 'Dockerfile to use for building Node.js' + required: true + default: 'Dockerfile.Node26fips' + 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-packages.yml b/.github/workflows/build-node-packages.yml new file mode 100644 index 000000000000..908ab576b2d9 --- /dev/null +++ b/.github/workflows/build-node-packages.yml @@ -0,0 +1,151 @@ +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: + - v26.12.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: | + # Lay each package out as its rules_js store directory: @. + version_of() { sed -n "s/^ARG $1=//p" Dockerfile.Packages; } + DIRS=() + for spec in "bcrypt BCRYPT_VERSION" "cld CLD_VERSION" "unix-dgram UNIX_DGRAM_VERSION" "@datadog/pprof PPROF_VERSION"; do + set -- $spec + name=$1 + version=$(version_of "$2") + dir="${name//\//+}@${version}" + mkdir -p "$dir/node_modules/$(dirname "$name")" + mv "node_modules/$name" "$dir/node_modules/$name" + DIRS+=("$dir") + done + tar --hard-dereference -cvzf "packages_${ARCH}.tar.gz" "${DIRS[@]}" + + - 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/.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.Node26 b/Dockerfile.Node26 new file mode 100644 index 000000000000..1e12624e4740 --- /dev/null +++ b/Dockerfile.Node26 @@ -0,0 +1,45 @@ +# Builds Asana's Node.js 26 binary: upstream Node with V8 pointer compression. +# +# Node 26 needs GCC >= 13.2, whose own libstdc++ is newer than Ubuntu 22.04's (the sandbox +# and EngFlow remote-execution hosts that run the LunaServer Node toolchain). Like the official +# Node.js release binaries, this builds on RHEL 8 (AlmaLinux 8) with gcc-toolset-14: the +# toolset links the parts of libstdc++ newer than the system's GCC 8 runtime statically, so +# the binary only needs glibc >= 2.28 and GLIBCXX_3.4.25, plus the libatomic runtime. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:/root/.cargo/bin:$PATH" +ENV CC=gcc CXX=g++ + +# Rust builds temporal_rs (vendored in deps/crates) for the Temporal API, as in official builds. +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain stable && \ + rustc --version && cargo --version + +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 --v8-enable-temporal-support +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Node26fips b/Dockerfile.Node26fips new file mode 100644 index 000000000000..8e34e6db6e5b --- /dev/null +++ b/Dockerfile.Node26fips @@ -0,0 +1,61 @@ +# Builds Asana's FIPS-capable Node.js 26 binary for FedRAMP: the same build as +# Dockerfile.Node26, but dynamically linked against OpenSSL 3 so that the FIPS provider +# installed in the LunaServer FIPS image is used at runtime. +# +# RHEL 8's system OpenSSL is 1.1.1, so this compiles against OpenSSL 3.0 LTS built from +# source. Its libraries export the OPENSSL_3.0.0 symbol version, so the binary links at +# runtime against any OpenSSL 3 libssl.so.3: Ubuntu 22.04's 3.0.2 and Wolfi's 3.x alike. +# +# See Dockerfile.Node26 for why this builds on RHEL 8 with gcc-toolset-14. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + perl \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:/root/.cargo/bin:$PATH" +ENV CC=gcc CXX=g++ + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain stable && \ + rustc --version && cargo --version + +ARG OPENSSL_VERSION=3.0.22 +ARG OPENSSL_SHA256=67ebca7e50d17383028045486653492195b83db95f8558709701bb47b5c1ef81 +WORKDIR /usr/src/openssl +RUN curl -sSfL -o openssl.tar.gz \ + "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" && \ + echo "${OPENSSL_SHA256} openssl.tar.gz" | sha256sum -c - && \ + tar -xzf openssl.tar.gz --strip-components=1 && \ + ./Configure --prefix=/opt/openssl --libdir=lib shared no-tests && \ + make -j4 && \ + make install_sw +# The build runs the freshly built node (e.g. to generate the startup snapshot). +ENV LD_LIBRARY_PATH=/opt/openssl/lib + +WORKDIR /usr/src/node +COPY . . + +RUN ./configure --shared-openssl \ + --shared-openssl-includes=/opt/openssl/include \ + --shared-openssl-libpath=/opt/openssl/lib \ + --experimental-enable-pointer-compression \ + --v8-enable-temporal-support \ + --openssl-conf-name 'openssl_conf' +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Packages b/Dockerfile.Packages new file mode 100644 index 000000000000..32357d849819 --- /dev/null +++ b/Dockerfile.Packages @@ -0,0 +1,100 @@ +# 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.Node26 for why this builds on RHEL 8 with gcc-toolset-14. Package versions must +# match codez's pnpm-lock.yaml, since the tarball is laid over its .aspect_rules_js store. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:$PATH" +ENV CC=gcc CXX=g++ PYTHON=python3.12 npm_config_python=python3.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" + +ARG BCRYPT_VERSION=5.1.0 +ARG CLD_VERSION=2.9.1 +ARG UNIX_DGRAM_VERSION=2.0.6 +ARG PPROF_VERSION=5.20.0 + +RUN npm i --nodedir="$NODE_DIR" bcrypt@${BCRYPT_VERSION} +RUN npm i --nodedir="$NODE_DIR" cld@${CLD_VERSION} +RUN npm i --nodedir="$NODE_DIR" unix-dgram@${UNIX_DGRAM_VERSION} + +# Build @datadog/pprof from source against Asana custom Node. +# The npm package ships with 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@${PPROF_VERSION} --ignore-scripts +RUN git clone --branch v${PPROF_VERSION} --depth 1 https://github.com/DataDog/pprof-nodejs.git /usr/src/pprof-source +WORKDIR /usr/src/pprof-source +RUN npm install --unsafe-perm --ignore-scripts +RUN npm install --unsafe-perm --no-save node-gyp +RUN node_modules/.bin/node-gyp rebuild --jobs=max --nodedir="$NODE_DIR" +# Install our binary where node-gyp-build looks first (build/Release), and also over the stock +# prebuild for this ABI so that a loader that skips build/Release cannot pick a stock binary. +WORKDIR /usr/src/node +RUN set -e; \ + ARCH=$(node -p "process.arch"); \ + MODULES_VER=$(node -p "process.versions.modules"); \ + PKG=node_modules/@datadog/pprof; \ + PROFILER_NODE=$(find /usr/src/pprof-source/build/Release -maxdepth 1 -name "*.node" | head -1); \ + echo "Using profiler binary: $PROFILER_NODE"; \ + test -f "$PROFILER_NODE"; \ + mkdir -p "$PKG/build/Release"; \ + cp "$PROFILER_NODE" "$PKG/build/Release/"; \ + REPLACED=0; \ + for f in $(find "$PKG/prebuilds" -path "*linux*${ARCH}*" -name "*.node" | grep -E "(node-|abi)${MODULES_VER}\.node$" | grep -v musl); do \ + echo "Replacing prebuild $f"; cp "$PROFILER_NODE" "$f"; REPLACED=1; \ + done; \ + test "$REPLACED" = 1 || echo "No stock prebuild for ABI ${MODULES_VER}; build/Release only" + +# 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/asana-smoke-test.js b/asana-smoke-test.js new file mode 100644 index 000000000000..6ee5472e7618 --- /dev/null +++ b/asana-smoke-test.js @@ -0,0 +1,154 @@ +'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})`; + }); + + 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('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('Temporal support matches the build', () => { + return `v8_enable_temporal_support=${vars.v8_enable_temporal_support}, ` + + `globalThis.Temporal: ${typeof globalThis.Temporal}`; + }); + + 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..1c018c82de15 --- /dev/null +++ b/asana-verify-build.sh @@ -0,0 +1,90 @@ +#!/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 + +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: Sun, 25 Oct 2026 22:21:08 -0700 Subject: [PATCH 2/7] build: pin @datadog/pprof 5.15.1 for pointer-compressed Node 26 Co-authored-by: Cursor --- Dockerfile.Packages | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile.Packages b/Dockerfile.Packages index 32357d849819..23ff5be79e5a 100644 --- a/Dockerfile.Packages +++ b/Dockerfile.Packages @@ -44,7 +44,10 @@ RUN node -v && node -p "process.arch" && node -p "process.versions.modules" && \ ARG BCRYPT_VERSION=5.1.0 ARG CLD_VERSION=2.9.1 ARG UNIX_DGRAM_VERSION=2.0.6 -ARG PPROF_VERSION=5.20.0 +# pprof 5.16.0+ adds otel-thread-ctx.cc, which static_asserts that V8 was built without pointer +# compression. 5.15.1 is the newest release that compiles here and still supports Node 26; codez +# must pin it with dd-trace 5.110.x-5.116.x or a pnpm override. +ARG PPROF_VERSION=5.15.1 RUN npm i --nodedir="$NODE_DIR" bcrypt@${BCRYPT_VERSION} RUN npm i --nodedir="$NODE_DIR" cld@${CLD_VERSION} From 4d7a6b2bca3a8f52c049fca49fd5e3990d2e142d Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Wed, 4 Nov 2026 17:26:49 -0800 Subject: [PATCH 3/7] build: build Asana Node 26 as a position-independent executable gcc-toolset on RHEL 8 doesn't build PIE by default, so the Node 26 binaries lost the ASLR coverage our Node 22 builds got from Ubuntu's GCC. asana-verify-build.sh now fails a non-PIE build. Co-authored-by: Cursor --- Dockerfile.Node26 | 5 +++++ Dockerfile.Node26fips | 4 ++++ asana-verify-build.sh | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/Dockerfile.Node26 b/Dockerfile.Node26 index 1e12624e4740..4e79cf23574c 100644 --- a/Dockerfile.Node26 +++ b/Dockerfile.Node26 @@ -35,6 +35,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ WORKDIR /usr/src/node COPY . . +# Build a position-independent executable, so ASLR also randomizes where node's own code is +# loaded. gcc-toolset on RHEL 8 doesn't do this by default (nor do official Node.js builds); +# our Node 22 builds got it from Ubuntu's GCC defaults. asana-verify-build.sh checks it. +ENV CFLAGS="-fPIE" CXXFLAGS="-fPIE" LDFLAGS="-pie" + # 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 diff --git a/Dockerfile.Node26fips b/Dockerfile.Node26fips index 8e34e6db6e5b..7f3d294ef8e5 100644 --- a/Dockerfile.Node26fips +++ b/Dockerfile.Node26fips @@ -50,6 +50,10 @@ ENV LD_LIBRARY_PATH=/opt/openssl/lib WORKDIR /usr/src/node COPY . . +# Build a position-independent executable, as in Dockerfile.Node26. Set only after the OpenSSL +# build above: -pie can't link its shared libraries. +ENV CFLAGS="-fPIE" CXXFLAGS="-fPIE" LDFLAGS="-pie" + RUN ./configure --shared-openssl \ --shared-openssl-includes=/opt/openssl/include \ --shared-openssl-libpath=/opt/openssl/lib \ diff --git a/asana-verify-build.sh b/asana-verify-build.sh index 1c018c82de15..8be5cb48ce50 100755 --- a/asana-verify-build.sh +++ b/asana-verify-build.sh @@ -63,6 +63,14 @@ if [ "$FLAVOR" = "--fips" ]; then 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" From c49a63bba3923dc27b5be746f555bba297b56bfd Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Wed, 4 Nov 2026 18:16:53 -0800 Subject: [PATCH 4/7] build: build the FIPS workflow's own branch by default BUILD_REF defaulted to the upstream v26.12.1 branch, so running the workflow from another branch silently built different code than the standard build. Leave it empty to build the branch the workflow runs from, as build-node.yml does. Co-authored-by: Cursor --- .github/workflows/build-node-openssl-fips.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-node-openssl-fips.yml b/.github/workflows/build-node-openssl-fips.yml index 10f1e6858e37..c009052971c3 100644 --- a/.github/workflows/build-node-openssl-fips.yml +++ b/.github/workflows/build-node-openssl-fips.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: BUILD_REF: - description: 'Git ref to build Node.js from' - required: true - default: 'v26.12.1' + 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' @@ -41,7 +41,7 @@ jobs: uses: actions/checkout@v4 with: path: node - ref: ${{ inputs.BUILD_REF }} + ref: ${{ inputs.BUILD_REF || github.ref_name }} - name: Extract Node Version run: | From faa44418f2dd467f4fbfe962d7056173ee8bb2e1 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Wed, 4 Nov 2026 20:43:55 -0800 Subject: [PATCH 5/7] build: pin Node 26's base image and Rust, and correct its requirements Pin almalinux:8 and Rust to the versions that built the published binaries and packages. The dnf packages, including gcc-toolset-14, still float. The binaries need GLIBCXX_3.4.21 at most and don't link libatomic. --- Dockerfile.Node26 | 7 ++++--- Dockerfile.Node26fips | 5 +++-- Dockerfile.Packages | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Dockerfile.Node26 b/Dockerfile.Node26 index 4e79cf23574c..973486850c03 100644 --- a/Dockerfile.Node26 +++ b/Dockerfile.Node26 @@ -4,8 +4,9 @@ # and EngFlow remote-execution hosts that run the LunaServer Node toolchain). Like the official # Node.js release binaries, this builds on RHEL 8 (AlmaLinux 8) with gcc-toolset-14: the # toolset links the parts of libstdc++ newer than the system's GCC 8 runtime statically, so -# the binary only needs glibc >= 2.28 and GLIBCXX_3.4.25, plus the libatomic runtime. -FROM almalinux:8 AS base +# the binary only needs glibc >= 2.28 and GLIBCXX_3.4.21. +# Pinned to the image that built the published binaries. +FROM almalinux:8@sha256:2d0f2eb2a83cc09dbc2eb92fade4dbe8f709628149e81aa00aec70630406603b AS base RUN set -e; \ for i in 1 2 3 4 5; do \ @@ -29,7 +30,7 @@ ENV CC=gcc CXX=g++ # Rust builds temporal_rs (vendored in deps/crates) for the Temporal API, as in official builds. RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain stable && \ + sh -s -- -y --profile minimal --default-toolchain 1.99.1 && \ rustc --version && cargo --version WORKDIR /usr/src/node diff --git a/Dockerfile.Node26fips b/Dockerfile.Node26fips index 7f3d294ef8e5..2e95b8e912c8 100644 --- a/Dockerfile.Node26fips +++ b/Dockerfile.Node26fips @@ -7,7 +7,8 @@ # runtime against any OpenSSL 3 libssl.so.3: Ubuntu 22.04's 3.0.2 and Wolfi's 3.x alike. # # See Dockerfile.Node26 for why this builds on RHEL 8 with gcc-toolset-14. -FROM almalinux:8 AS base +# Pinned to the image that built the published binaries. +FROM almalinux:8@sha256:2d0f2eb2a83cc09dbc2eb92fade4dbe8f709628149e81aa00aec70630406603b AS base RUN set -e; \ for i in 1 2 3 4 5; do \ @@ -31,7 +32,7 @@ ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:/root/.cargo/bin:$PATH" ENV CC=gcc CXX=g++ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain stable && \ + sh -s -- -y --profile minimal --default-toolchain 1.99.1 && \ rustc --version && cargo --version ARG OPENSSL_VERSION=3.0.22 diff --git a/Dockerfile.Packages b/Dockerfile.Packages index 23ff5be79e5a..c05bcdb9b226 100644 --- a/Dockerfile.Packages +++ b/Dockerfile.Packages @@ -8,7 +8,8 @@ # # See Dockerfile.Node26 for why this builds on RHEL 8 with gcc-toolset-14. Package versions must # match codez's pnpm-lock.yaml, since the tarball is laid over its .aspect_rules_js store. -FROM almalinux:8 AS base +# Pinned to the image that built the published packages. +FROM almalinux:8@sha256:8b469a3a78515e8a18ea8fc727e6a3679e1d0c5ba6f58d5b30be3d0d9b86cffe AS base RUN set -e; \ for i in 1 2 3 4 5; do \ From 5db69723e3181617a84733033fa5648fa3a340a6 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Wed, 4 Nov 2026 20:43:55 -0800 Subject: [PATCH 6/7] build: drop the packages workflow's workflow_run trigger GitHub only runs workflow_run triggers from the default branch's copy of a workflow, so this one never fired. Correct the S3 comment: main's copy builds Node 22's packages, so this branch's are uploaded by hand. Co-authored-by: Cursor --- .github/workflows/build-node-packages.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-node-packages.yml b/.github/workflows/build-node-packages.yml index 908ab576b2d9..466d56c5a683 100644 --- a/.github/workflows/build-node-packages.yml +++ b/.github/workflows/build-node-packages.yml @@ -8,16 +8,9 @@ on: required: false default: '' type: string - workflow_run: - workflows: ["Build Node (Standard)"] - types: - - completed - branches: - - v26.12.1 jobs: build-packages: - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} permissions: id-token: write contents: write @@ -50,7 +43,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ inputs.NODE_REF || github.event.workflow_run.head_branch || github.ref_name }} + ref: ${{ inputs.NODE_REF || github.ref_name }} - name: Extract Node Version run: | @@ -101,8 +94,9 @@ jobs: --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. + # (push_node_gyp_packages) trusts only refs/heads/main via OIDC, and main's copy of this + # workflow builds Node 22's packages. When running from this branch, stage the release's + # packages_*.tar.gz with stage_for_s3.bash and upload them by hand. - name: Configure AWS credentials if: github.ref == 'refs/heads/main' uses: aws-actions/configure-aws-credentials@v4 From 914bced9a6652f19a1baaaafdcf948323426efef Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Wed, 4 Nov 2026 20:43:56 -0800 Subject: [PATCH 7/7] build: check LunaServer's navigator, webstorage and NODE_OPTIONS flags Node exits on an option it doesn't know, so a release that drops one of the flags LunaServer passes should fail the smoke test, not LunaServer. Co-authored-by: Cursor --- asana-smoke-test.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/asana-smoke-test.js b/asana-smoke-test.js index 6ee5472e7618..0fbd08cfb390 100644 --- a/asana-smoke-test.js +++ b/asana-smoke-test.js @@ -61,11 +61,15 @@ async function main() { }); 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']; - const out = spawnSync(process.execPath, [...flags, '-e', 'gc()'], { encoding: 'utf8' }); + '--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(' '); + return `${flags.join(' ')}, NODE_OPTIONS=${env.NODE_OPTIONS}`; }); await check('Temporal support matches the build', () => {