name: Node Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version to assemble (without the leading v)"
required: true
default: "0.3.1"
type: string
publish:
description: "Enable npm publication (also requires NPM_PUBLISH_ENABLED=true)"
required: false
default: false
type: boolean
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
concurrency:
group: node-release-${{ github.ref }}
cancel-in-progress: false
jobs:
validate:
name: Validate Node release version
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Install latest Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
- name: Install Rust 1.85.0 for root metadata
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.85.0
- name: Validate all package versions
env:
RELEASE_VERSION_INPUT: ${{ env.RELEASE_VERSION }}
shell: bash
run: |
set -euo pipefail
expected="${RELEASE_VERSION_INPUT#v}"
[[ "$expected" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Release version must be a semantic X.Y.Z value: $expected" >&2
exit 1
}
root_version="$(
cargo metadata --locked --no-deps --format-version 1 |
node -e 'const fs = require("node:fs"); const m = JSON.parse(fs.readFileSync(0, "utf8")); console.log(m.packages.find((p) => p.name === "ripex").version)'
)"
binding_version="$(
cargo metadata --manifest-path bindings/node/Cargo.toml --locked --no-deps --format-version 1 |
node -e 'const fs = require("node:fs"); const m = JSON.parse(fs.readFileSync(0, "utf8")); console.log(m.packages.find((p) => p.name === "ripex-node").version)'
)"
npm_version="$(node -p "require('./bindings/node/package.json').version")"
for pair in \
"root ripex $root_version" \
"binding ripex-node $binding_version" \
"npm @astraive/ripex $npm_version"; do
read -r kind package version <<<"$pair"
test "$version" = "$expected" || {
echo "$kind package $package is $version, expected $expected" >&2
exit 1
}
done
build:
name: Build Node binding (${{ matrix.target }})
needs: validate
runs-on: ${{ matrix.os }}
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: ripex.linux-x64-gnu.node
cross: "0"
runtime: true
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
binary: ripex.linux-arm64-gnu.node
cross: "1"
runtime: false
- os: macos-14
target: x86_64-apple-darwin
binary: ripex.darwin-x64.node
cross: "0"
runtime: false
- os: macos-14
target: aarch64-apple-darwin
binary: ripex.darwin-arm64.node
cross: "0"
runtime: true
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: ripex.win32-x64-msvc.node
cross: "0"
runtime: true
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Install latest Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
- name: Install latest pnpm
run: npm install --global pnpm@10.28.2
- name: Install latest Bun
run: npm install --global bun@1.3.14
- name: Install Rust 1.85.0 and target
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.85.0
target: ${{ matrix.target }}
- name: Install Node binding dependencies
working-directory: bindings/node
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Install aarch64 cross-compiler
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build release binding
working-directory: bindings/node
env:
NAPI_TARGET: ${{ matrix.target }}
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: npm run build:release
- name: Verify target binary and package metadata
working-directory: bindings/node
env:
EXPECTED_BINARY: ${{ matrix.binary }}
EXPECTED_TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
node - "$EXPECTED_BINARY" "$EXPECTED_TARGET" <<'NODE'
const fs = require('node:fs');
const binary = process.argv[2];
const target = process.argv[3];
const binaries = fs.readdirSync('.')
.filter((entry) => entry.endsWith('.node'))
.filter((entry) => fs.statSync(entry).isFile())
.sort();
if (binaries.length !== 1 || binaries[0] !== binary) {
throw new Error(`expected exactly ${binary}, found ${binaries.join(', ')}`);
}
const metadata = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const targets = metadata.napi?.targets;
if (!Array.isArray(targets) || targets.length !== 5 || !targets.includes(target)) {
throw new Error(`package.json does not declare target ${target}`);
}
if (metadata.scripts?.postinstall) {
throw new Error('postinstall network/download hooks are forbidden');
}
NODE
- name: Run native Node binding tests
if: matrix.runtime == true
working-directory: bindings/node
run: npm test
- name: Run Bun native smoke test
if: matrix.runtime == true
working-directory: bindings/node
run: bun test test/index.bun.test.mjs
- name: Check public TypeScript declarations
if: matrix.runtime == true
working-directory: bindings/node
run: npm run test:types
- name: Check package contents
if: matrix.runtime == true
working-directory: bindings/node
run: npm run pack:dry
- name: Upload target binding artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: node-release-target-${{ matrix.target }}
path: |
bindings/node/${{ matrix.binary }}
bindings/node/index.cjs
bindings/node/index.js
bindings/node/index.d.ts
if-no-files-found: error
retention-days: 14
assemble:
name: Assemble npm platform packages
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Install latest Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
- name: Install latest pnpm
run: npm install --global pnpm@10.28.2
- name: Install Node binding dependencies
working-directory: bindings/node
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Download target artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: node-release-target-*
path: bindings/node/artifacts
merge-multiple: true
- name: Normalize and validate native artifact set
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
expected=(
ripex.linux-x64-gnu.node
ripex.linux-arm64-gnu.node
ripex.darwin-x64.node
ripex.darwin-arm64.node
ripex.win32-x64-msvc.node
)
mapfile -t found < <(find artifacts -type f -name '*.node' -print | sort)
test "${#found[@]}" -eq 5
declare -A seen=()
for file in "${found[@]}"; do
base="${file##*/}"
case " ${expected[*]} " in *" $base "*) ;; *) echo "unexpected native artifact: $base" >&2; exit 1 ;; esac
test -z "${seen[$base]+set}" || { echo "duplicate native artifact: $base" >&2; exit 1; }
seen[$base]=1
if [[ "$file" != "artifacts/$base" ]]; then
mv "$file" "artifacts/$base"
fi
done
for base in "${expected[@]}"; do test -f "artifacts/$base"; done
direct_count="$(node -e 'const fs = require("node:fs"); process.stdout.write(String(fs.readdirSync("artifacts", { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".node")).length))')"
test "$direct_count" -eq 5
- name: Create platform package directories
working-directory: bindings/node
run: pnpm exec napi create-npm-dir -t .
- name: Assemble binaries into platform packages
working-directory: bindings/node
run: pnpm exec napi artifacts
- name: Verify exactly five platform packages and binaries
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
declare -A expected=(
[linux-x64-gnu]=ripex.linux-x64-gnu.node
[linux-arm64-gnu]=ripex.linux-arm64-gnu.node
[darwin-x64]=ripex.darwin-x64.node
[darwin-arm64]=ripex.darwin-arm64.node
[win32-x64-msvc]=ripex.win32-x64-msvc.node
)
mapfile -t dirs < <(node -e "const fs = require('node:fs'); process.stdout.write(fs.readdirSync('npm', { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().join('\\n'))")
test "${#dirs[@]}" -eq 5
for dir in "${dirs[@]}"; do
test -n "${expected[$dir]+set}" || { echo "unexpected platform directory: $dir" >&2; exit 1; }
mapfile -t binaries < <(node -e "const fs = require('node:fs'); process.stdout.write(fs.readdirSync('npm/$dir', { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith('.node')).map((entry) => entry.name).sort().join('\\n'))")
test "${#binaries[@]}" -eq 1
test "${binaries[0]}" = "${expected[$dir]}"
done
all_count="$(node -e 'const fs = require("node:fs"); let count = 0; for (const dir of fs.readdirSync("npm", { withFileTypes: true }).filter((entry) => entry.isDirectory())) count += fs.readdirSync("npm/" + dir.name, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".node")).length; process.stdout.write(String(count))')"
test "$all_count" -eq 5
for dir in "${!expected[@]}"; do test -d "npm/$dir"; done
- name: Reject postinstall download hooks
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const manifests = ['package.json', ...fs.readdirSync('npm', { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join('npm', entry.name, 'package.json'))];
for (const file of manifests) {
const packageJson = JSON.parse(fs.readFileSync(file, 'utf8'));
if (packageJson.scripts?.postinstall) {
throw new Error(`postinstall hook is forbidden: ${file}`);
}
}
NODE
- name: Check assembled package contents
working-directory: bindings/node
run: npm run pack:dry
- name: Pack assembled package
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ripex-node-package"
rm -rf "$pack_dir"
mkdir -p "$pack_dir"
npm pack --ignore-scripts --pack-destination "$pack_dir"
test "$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' | wc -l)" -eq 1
- name: Clean install and load assembled package
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ripex-node-package"
smoke_dir="$RUNNER_TEMP/ripex-node-smoke"
tarball="$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
rm -rf "$smoke_dir"
mkdir -p "$smoke_dir"
cd "$smoke_dir"
npm init -y >/dev/null
# The non-publishing assembly intentionally leaves optionalDependencies untouched.
# Install the matching local platform package to exercise the packed root loader offline.
npm install --ignore-scripts --no-audit --no-fund \
"$tarball" \
"$GITHUB_WORKSPACE/bindings/node/npm/linux-x64-gnu" \
>/dev/null
node --input-type=commonjs -e "const p=require('@astraive/ripex'); const out=p.parseSync('export const x = 1',{language:'javascript'}); if(out.language!=='javascript') process.exit(1)"
node --input-type=module -e "import { parseSync } from '@astraive/ripex'; const out=parseSync('export const x = 1',{language:'javascript'}); if(out.language!=='javascript') process.exit(1)"
- name: Upload assembled tarball for review
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: node-release-tarball-${{ env.RELEASE_VERSION }}
path: ${{ runner.temp }}/ripex-node-package/*.tgz
if-no-files-found: error
retention-days: 14
publish:
name: Publish npm packages
needs: [validate, assemble]
if: >-
needs.assemble.result == 'success' &&
vars.NPM_PUBLISH_ENABLED == 'true' &&
(github.event_name == 'push' || inputs.publish == true)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Install latest Node.js with npm provenance
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Install latest pnpm
run: npm install --global pnpm@10.28.2
- name: Install Node binding dependencies
working-directory: bindings/node
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Download target artifacts for publication
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: node-release-target-*
path: bindings/node/artifacts
merge-multiple: true
- name: Normalize target artifacts
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
mapfile -t found < <(find artifacts -type f -name '*.node' -print | sort)
test "${#found[@]}" -eq 5
for file in "${found[@]}"; do
base="${file##*/}"
if [[ "$file" != "artifacts/$base" ]]; then mv "$file" "artifacts/$base"; fi
done
- name: Create npm platform packages
working-directory: bindings/node
run: pnpm exec napi create-npm-dir -t .
- name: Assemble platform binaries
working-directory: bindings/node
run: pnpm exec napi artifacts
- name: Publish platform packages before root package
working-directory: bindings/node
shell: bash
run: |
set -euo pipefail
# npm publication is non-atomic: napi prepublish publishes each optional
# platform package before the final root npm publish below.
pnpm exec napi prepublish -t npm
- name: Publish root package with provenance
working-directory: bindings/node
shell: bash
run: npm publish --provenance