Skip to content

fix(release): build musl in Alpine per step, not as the job container #3

fix(release): build musl in Alpine per step, not as the job container

fix(release): build musl in Alpine per step, not as the job container #3

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
dry_run:
description: 'Build and verify without publishing'
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
NODE_DIR: src/bindings/nodejs
jobs:
# Both of these are cheap, and both catch a mistake that cannot be undone
# afterwards: npm refuses to republish a version, and a tag that has already
# been consumed by a release is awkward to retract.
guard:
name: Release preconditions
runs-on: ubuntu-latest
permissions:
contents: read
checks: read
steps:
- uses: actions/checkout@v7
# The tag is the only input this workflow takes, but `npm publish` ships
# whatever version package.json declares and never looks at it. When the
# two disagree the tag names one release and the registry gets another.
# Both published packages are checked, because one tag releases both and
# @retrigger/daemon peer-depends on the exact @retrigger/core line this
# workflow is about to publish.
- name: Tag must match the versions that will be published
shell: bash
run: |
if [ "$GITHUB_REF_TYPE" != tag ]; then
echo "Not a tag build, so there is no tag to disagree with."
exit 0
fi
tagged="${GITHUB_REF_NAME#v}"
status=0
for manifest in "$NODE_DIR/package.json" src/daemon/package.json; do
declared=$(node -p "require('./$manifest').version")
name=$(node -p "require('./$manifest').name")
echo "tag: $tagged / $name: $declared"
if [ "$tagged" != "$declared" ]; then
echo "::error::tag $GITHUB_REF_NAME would publish $name@$declared"
status=1
fi
done
exit $status
# @retrigger/daemon declares the core line it works with. A release that
# moved core's major without moving that range would publish a pair that
# npm refuses to install together -- which is exactly what shipping core
# 2.0.0 against the 1.0.4 daemon would have done.
- name: The daemon must accept the core version being published
shell: bash
run: |
node -e '
const core = require(`./${process.env.NODE_DIR}/package.json`).version;
const range = require("./src/daemon/package.json").peerDependencies["@retrigger/core"];
const wanted = core.split(".")[0];
// Deliberately permissive about range syntax and strict about the
// only thing that has ever been wrong here: the major.
const named = [...range.matchAll(/(\d+)\.\d+\.\d+/g)].map((m) => m[1]);
if (!named.includes(wanted)) {
console.error(`::error::daemon peer range "${range}" does not admit core ${core}`);
process.exit(1);
}
console.log(`daemon accepts core ${core} via "${range}"`);
'
# Releases are cut from tags, and CI runs on branches and pull requests --
# so nothing in this repository previously established that the commit
# being published had ever passed its own test suite. Rather than re-run
# the matrix here, require the result that already exists for this exact
# commit.
- name: The commit being released must have passed CI
if: github.event_name == 'push'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs" \
--jq '.check_runs[] | select(.name == "CI passed") | .conclusion' | head -n1)
if [ "$conclusion" = success ]; then
echo "CI passed on $GITHUB_SHA."
exit 0
fi
echo "::error::no successful 'CI passed' check on $GITHUB_SHA (found: ${conclusion:-none}). Push the commit to a branch, let CI finish, then tag it."
exit 1
# Every target is built on a runner of that same OS and architecture rather
# than cross-compiled. That costs a few more runners and buys two things:
# the C hash engine needs no cross toolchain, and — more importantly — each
# artifact can be executed on its real platform before it is published.
# Publishing a binary nobody ever ran is how "works on my machine" ships.
#
# This matrix plus `build-musl` must cover exactly package.json#napi.targets.
# `napi artifacts` in CLI v3 fails on a configured target with no binary
# rather than skipping it the way v2 did, so a target added in one place and
# not the other breaks the release instead of silently shipping nothing.
# api-contract.test.mjs holds napi.targets and optionalDependencies together
# from the other side, so the three lists cannot drift apart unnoticed.
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.host }}
needs: guard
strategy:
fail-fast: false
matrix:
include:
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu, native: true }
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, native: true }
# macos-15-intel is the last x86_64 macOS image Actions offers; the
# macos-13 labels were retired on 2025-12-04 and a job requesting one
# queues forever rather than failing, which would hang the release
# with nothing anywhere saying why.
- { host: macos-15-intel, target: x86_64-apple-darwin, native: true }
- { host: macos-latest, target: aarch64-apple-darwin, native: true }
- { host: windows-latest, target: x86_64-pc-windows-msvc, native: true }
# No aarch64 Windows runner exists yet, so this one is cross-built
# and therefore cannot be smoke-tested here. It is flagged as such.
- { host: windows-latest, target: aarch64-pc-windows-msvc, native: false }
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: src/bindings/nodejs/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install clang (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y clang
- name: Install dependencies
run: npm ci --no-audit --no-fund
working-directory: ${{ env.NODE_DIR }}
# Note the `--`: without it npm swallows the flag and silently builds for
# the host. That bug shipped host binaries under foreign platform names.
- name: Build native addon
run: npm run build -- --target ${{ matrix.target }}
working-directory: ${{ env.NODE_DIR }}
- name: Verify the artifact loads and computes
if: matrix.native
shell: bash
run: node scripts/verify-artifact.js
working-directory: ${{ env.NODE_DIR }}
- name: Note unverified cross build
if: '!matrix.native'
run: echo "::warning::${{ matrix.target }} was cross-built and could not be executed on this runner."
- uses: actions/upload-artifact@v7
with:
name: bindings-${{ matrix.target }}
path: ${{ env.NODE_DIR }}/*.node
if-no-files-found: error
# musl gets its own job because it is built *inside* Alpine rather than
# cross-compiled: the addon is then linked against the same libc it will be
# loaded with, and — the reason this is worth a separate job at all — it can
# be executed on the spot, so a musl binary is proven before it is published
# exactly like every other native target.
#
# Two details are load-bearing and were each found the hard way:
#
# * `-C target-feature=-crt-static`. Rust's musl targets default to static
# linking, which is right for an executable and fatal for a `cdylib`: the
# addon has to resolve symbols from the Node process that dlopens it.
# Without this the build succeeds and produces a `.node` that cannot load.
# * No `--target` flag. The container is already musl, so this is a native
# build and `napi build --platform` names the artifact `*-musl.node` on
# its own. Passing the triple instead asks Alpine's system rustc for a
# std it does not ship.
build-musl:
name: ${{ matrix.target }}
runs-on: ${{ matrix.host }}
needs: guard
strategy:
fail-fast: false
matrix:
include:
- { host: ubuntu-latest, target: x86_64-unknown-linux-musl }
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl }
# Alpine is entered per step rather than declared as the job's `container:`.
# GitHub runs its JavaScript actions -- checkout and upload-artifact among
# them -- inside the job container, and refuses to do that for an Alpine
# image on an arm64 runner: "JavaScript Actions in Alpine containers are
# only supported on x64 Linux runners". Keeping the actions on the host and
# putting only the build in Alpine is what lets one job serve both
# architectures, and both remain native builds on native hardware.
steps:
- uses: actions/checkout@v7
- name: Build and verify inside Alpine
run: |
cat > "$RUNNER_TEMP/build-musl.sh" <<'SCRIPT'
set -eu
apk add --no-cache build-base clang clang-dev llvm-dev rust cargo make bash python3 git
cd "/work/$NODE_DIR"
npm ci --no-audit --no-fund
cd /work
make build-core BUILD_TYPE=release
cd "/work/$NODE_DIR"
npm run build
# The whole point of building in Alpine instead of cross-compiling.
node scripts/verify-artifact.js
# A native build is named by the host, so this is also the check that
# the runner really was musl and not a glibc image standing in.
ls *.node
test -f "retrigger-nodejs-bindings.$(node -p "process.arch === 'x64' ? 'linux-x64-musl' : 'linux-arm64-musl'").node"
SCRIPT
docker run --rm \
-v "$PWD:/work" \
-v "$RUNNER_TEMP/build-musl.sh:/build-musl.sh:ro" \
-w /work \
-e NODE_DIR="$NODE_DIR" \
-e RUSTFLAGS='-C target-feature=-crt-static' \
node:22-alpine sh /build-musl.sh
- uses: actions/upload-artifact@v7
with:
name: bindings-${{ matrix.target }}
path: ${{ env.NODE_DIR }}/*.node
if-no-files-found: error
publish:
name: Publish
runs-on: ubuntu-latest
needs: [build, build-musl]
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
cache: npm
cache-dependency-path: src/bindings/nodejs/package-lock.json
- name: Install dependencies
run: npm ci --no-audit --no-fund
working-directory: ${{ env.NODE_DIR }}
- uses: actions/download-artifact@v8
with:
path: ${{ env.NODE_DIR }}/artifacts
# `napi artifacts` copies each .node into its per-platform npm/<platform>/
# package directory, and it fails on a binary with no matching package
# rather than dropping it. That makes `create-npm-dirs` a prerequisite,
# not a convenience: npm/ is generated from napi.targets and is not
# committed. An earlier workflow copied the binaries to the package root
# and skipped both steps, so the platform packages it published were empty.
- name: Create the platform package directories
run: npx napi create-npm-dirs
working-directory: ${{ env.NODE_DIR }}
- name: Distribute artifacts into platform packages
run: npx napi artifacts --output-dir artifacts
working-directory: ${{ env.NODE_DIR }}
# `prepublish` does two separable things: it rewrites each platform
# package's version, and — unless `--skip-optional-publish` — it npm
# publishes them. Both halves matter here.
#
# The publishing half needs a registry token of its own. Without one the
# eight platform packages never reach npm, and the root package below
# still ships optionalDependencies naming them: every install then
# resolves no native package and degrades to the JavaScript engine while
# looking like a clean install. That is exactly how 1.0.4 shipped.
#
# The publishing half must also not run on a dry run. Publishing is the
# one step in this workflow that cannot be undone — npm refuses to reuse
# a version — so a dry run that published the platform packages would
# burn the version it was meant to rehearse.
- name: Prepare and publish the platform packages
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PUBLISHING: ${{ github.event_name == 'push' || inputs.dry_run == false }}
run: |
if [ "$PUBLISHING" = true ]; then
npx napi prepublish -t npm --no-gh-release
else
npx napi prepublish -t npm --no-gh-release --skip-optional-publish
fi
working-directory: ${{ env.NODE_DIR }}
- name: Show what would be published
run: |
echo "--- root package ---"
npm pack --dry-run
echo "--- platform packages ---"
ls -R npm 2>/dev/null || echo "(no npm/ directory)"
working-directory: ${{ env.NODE_DIR }}
# Last, and only after the platform packages are on the registry: the
# root package is the one that points at them, so publishing it first
# would leave a window where installs resolve nothing native.
- name: Publish
if: github.event_name == 'push' || inputs.dry_run == false
run: npm publish --access public --provenance
working-directory: ${{ env.NODE_DIR }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# @retrigger/daemon is a pure-JavaScript shim -- it has no artifact of its own
# to build here, because the per-platform Rust binaries it looks for are not
# published yet and it is written to degrade politely when they are absent.
# It still has to be released in step with core: it peer-depends on the core
# line, so leaving it a major version behind makes `npm install` of the two
# together fail outright with ERESOLVE.
#
# It goes after core rather than beside it, so the peer it names already
# exists on the registry by the time anyone can install it.
publish-daemon:
name: Publish the daemon package
runs-on: ubuntu-latest
needs: publish
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# The shim, the shipped config, and the documented no-binary degradation.
# Cheap, and it is the whole package.
- name: Smoke test the shim
run: node scripts/test-daemon.js
working-directory: src/daemon
- name: Show what would be published
run: npm pack --dry-run
working-directory: src/daemon
- name: Publish
if: github.event_name == 'push' || inputs.dry_run == false
run: npm publish --access public --provenance
working-directory: src/daemon
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Installs the just-published package from the real registry on every OS and
# confirms it works there. Until this passes, a release is not proven; it is
# only uploaded.
verify-published:
name: Verify install / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
needs: publish
if: github.event_name == 'push'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-15-intel, windows-latest]
steps:
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Wait for registry propagation
shell: bash
run: sleep 45
- name: Install from the registry into a clean project
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/verify" && cd "$RUNNER_TEMP/verify"
npm init -y >/dev/null
npm install "@retrigger/core@${GITHUB_REF_NAME#v}"
- name: Require it and exercise both engines
shell: bash
run: |
cd "$RUNNER_TEMP/verify"
node -e "
const r = require('@retrigger/core');
const info = r.getEngineInfo();
console.log('engine:', JSON.stringify(info));
if (!r.hashBytesSync(Buffer.from('retrigger'))) throw new Error('hash failed');
if (info.engine !== 'native') {
throw new Error('expected the native engine on this platform, got ' + info.engine);
}
console.log('ok');
"
# The same proof as above on musl, which is the platform this release exists
# to fix: before the musl packages were built, an Alpine install resolved to
# no native package and degraded to the JavaScript engine without complaining.
# Asserting `engine === 'native'` here is what makes that regression loud.
verify-published-musl:
name: Verify install / alpine-${{ matrix.arch }}
runs-on: ${{ matrix.host }}
container: node:22-alpine
needs: publish
if: github.event_name == 'push'
strategy:
fail-fast: false
matrix:
include:
- { host: ubuntu-latest, arch: x64 }
- { host: ubuntu-24.04-arm, arch: arm64 }
steps:
- name: Wait for registry propagation
run: sleep 45
- name: Install from the registry and require it
shell: sh
run: |
mkdir -p /tmp/verify && cd /tmp/verify
npm init -y >/dev/null
npm install "@retrigger/core@${GITHUB_REF_NAME#v}"
node -e "
const r = require('@retrigger/core');
const info = r.getEngineInfo();
console.log('engine:', JSON.stringify(info));
if (!r.hashBytesSync(Buffer.from('retrigger'))) throw new Error('hash failed');
if (info.engine !== 'native') {
throw new Error('expected the native engine on musl, got ' + info.engine);
}
console.log('ok');
"
# The two packages are released together because npm resolves them together.
# A default `npm install` of both is the exact command that fails with
# ERESOLVE when the daemon's peer range trails core's major, so it is the
# command that proves the pair -- no --force, no --legacy-peer-deps.
verify-published-pair:
name: Verify install / core + daemon
runs-on: ubuntu-latest
needs: publish-daemon
if: github.event_name == 'push'
steps:
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Wait for registry propagation
run: sleep 45
- name: Install both from the registry
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/pair" && cd "$RUNNER_TEMP/pair"
npm init -y >/dev/null
version="${GITHUB_REF_NAME#v}"
npm install "@retrigger/core@$version" "@retrigger/daemon@$version"
node -e "
const core = require('@retrigger/core');
require('@retrigger/daemon');
if (core.getEngineInfo().engine !== 'native') {
throw new Error('expected the native engine alongside the daemon');
}
console.log('ok');
"
github-release:
name: GitHub release
runs-on: ubuntu-latest
needs: [build, build-musl, publish, publish-daemon]
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with: { path: artifacts }
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: artifacts/**/*.node