Skip to content

feat(i18n): add bloom_get_language() OS-locale FFI #14

feat(i18n): add bloom_get_language() OS-locale FFI

feat(i18n): add bloom_get_language() OS-locale FFI #14

Workflow file for this run

name: Release
# Fires on a version tag push (e.g. `v0.3.2`). Gates on the Tests workflow
# passing for the exact same commit, then creates/updates the GitHub Release
# and publishes the package to npm as @bloomengine/engine.
#
# The /release Claude Code skill in .claude/skills/release/ drives this end
# to end: it bumps the version in package.json, commits, tags, pushes, and
# waits for this workflow to go green.
#
# Authentication: npm trusted publishing. Both packages — @bloomengine/engine
# (job: publish-npm) and @bloomengine/jolt-prebuilt (job: publish-jolt-prebuilt)
# — must be configured on npmjs.com with this workflow as a trusted publisher.
# `id-token: write` on each publish job is enough — `npm publish` exchanges
# the GitHub OIDC token for a short-lived publish credential, no NPM_TOKEN
# secret needed. Provenance attestation is automatic under this flow.
#
# Job order:
# await-tests — gate on Tests workflow passing for the tag SHA
# github-release — create/keep the GH Release object
# build-jolt-prebuilt — matrix: build libJolt + libbloom_jolt for every
# (os, arch) variant on its native runner
# publish-jolt-prebuilt — assemble the artifacts into npm/jolt-prebuilt/lib/
# and publish @bloomengine/jolt-prebuilt
# publish-npm — publish @bloomengine/engine, which depends on the
# jolt-prebuilt version just published
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: "Existing tag to (re-)publish a release for (e.g. v0.3.2)"
required: true
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false # never cancel a release mid-flight
permissions:
contents: write
jobs:
# ---------------------------------------------------------------------------
# Gate: wait for `Tests` to pass on this commit before we create the GitHub
# Release. Same pattern as Perry's release-packages.yml — query the API by
# workflow filename + head_sha and poll until it completes. Fails loud if
# Tests failed so we don't ship a known-broken tag.
# ---------------------------------------------------------------------------
await-tests:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Wait for Tests workflow to pass
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
echo "workflow_dispatch — bypassing test gate (manual republish)."
exit 0
fi
echo "Gating release on commit $SHA"
deadline=$(( SECONDS + 45 * 60 ))
retry_on_error=1
while :; do
set +e
api_out=$(gh api \
"/repos/$REPO/actions/workflows/test.yml/runs?head_sha=$SHA&per_page=1" \
2>&1)
rc=$?
set -e
if [ $rc -ne 0 ]; then
echo " gh api failed (rc=$rc):"
echo "$api_out" | awk '{print " " $0}'
if [ $retry_on_error -gt 0 ]; then
retry_on_error=0
echo " retrying once after 10s"
sleep 10
continue
fi
echo " gh api still failing after retry — failing gate."
exit 1
fi
retry_on_error=1
status=$(echo "$api_out" | jq -r '.workflow_runs[0].status // empty')
conclusion=$(echo "$api_out" | jq -r '.workflow_runs[0].conclusion // empty')
url=$(echo "$api_out" | jq -r '.workflow_runs[0].html_url // empty')
if [ -z "$status" ]; then
echo " no Tests run found yet for $SHA — waiting"
elif [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo " OK Tests passed: $url"
break
else
echo " FAIL Tests $conclusion: $url"
exit 1
fi
else
echo " Tests: $status ($url) — waiting"
fi
if [ $SECONDS -ge $deadline ]; then
echo " timed out waiting for Tests"
exit 1
fi
sleep 30
done
echo "Gate passed — proceeding to publish."
# ---------------------------------------------------------------------------
# Create (or update) the GitHub Release for this tag. The /release skill
# usually creates the release *body* from the CLI after pushing the tag;
# this job is a safety net: if the tag exists but no release does yet, it
# creates one with auto-generated notes.
# ---------------------------------------------------------------------------
github-release:
needs: await-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve tag
id: tag
env:
DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
if [ -n "$DISPATCH_TAG" ]; then
TAG="$DISPATCH_TAG"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release (if missing)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists — leaving body alone."
exit 0
fi
echo "Creating release $TAG with auto-generated notes."
gh release create "$TAG" \
--title "$TAG" \
--generate-notes
- name: Verify package.json version matches tag
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$PKG_VERSION" != "$VERSION" ]; then
echo "WARNING package.json version ($PKG_VERSION) does not match tag ($VERSION)."
echo " This is normally a bug — the /release skill should bump package.json"
echo " and commit before tagging. Continuing, but investigate."
else
echo "OK package.json version matches tag ($VERSION)"
fi
# ---------------------------------------------------------------------------
# Build the JoltPhysics + bloom_jolt static libraries for every (os, arch)
# combination Bloom supports. Each matrix entry runs on its native runner
# (Apple cross-compiles on macos-14, Android NDK on Linux, etc.) and uploads
# its archives as a per-target artifact for the publish step below to
# assemble. fail-fast: false so a broken slice (typically a platform-specific
# cmake quirk) doesn't cancel the other 16 builds — the publish job won't
# run anyway, but the failing job's logs land alongside the green ones for
# easier triage.
#
# The 17 variants here mirror the directory layout documented in
# npm/jolt-prebuilt/README.md. Adding a new target = new matrix entry +
# README update + corresponding arch mapping in native/shared/build.rs.
# ---------------------------------------------------------------------------
build-jolt-prebuilt:
needs: await-tests
strategy:
fail-fast: false
matrix:
include:
# Apple — built on macos-14 (Apple Silicon). Apple cross-compiles
# use the Xcode generator because the make/ninja generators don't
# reliably pick up iOS/tvOS/watchOS SDK toolchain quirks.
- target: macos-arm64
runner: macos-14
cmake_flags: ""
- target: macos-x64
runner: macos-14
cmake_flags: "-DCMAKE_OSX_ARCHITECTURES=x86_64"
- target: ios-arm64
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphoneos"
- target: ios-arm64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphonesimulator"
- target: ios-x64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_SYSROOT=iphonesimulator"
- target: tvos-arm64
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=tvOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=appletvos"
- target: tvos-arm64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=tvOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=appletvsimulator"
- target: tvos-x64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=tvOS -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_SYSROOT=appletvsimulator"
- target: watchos-arm64
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=watchOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=watchos"
- target: watchos-arm64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=watchOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=watchsimulator"
- target: watchos-x64-sim
runner: macos-14
cmake_flags: "-G Xcode -DCMAKE_SYSTEM_NAME=watchOS -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_SYSROOT=watchsimulator"
# Linux — native runners (no cross-compile). GitHub provides arm64
# Linux runners as of late 2025; if the arm runner becomes flaky,
# fall back to a cross-compile from ubuntu-22.04 with
# `aarch64-linux-gnu-gcc`.
- target: linux-x64
runner: ubuntu-22.04
cmake_flags: ""
- target: linux-arm64
runner: ubuntu-22.04-arm
cmake_flags: ""
# Windows — MSVC via the default Visual Studio generator.
- target: win32-x64
runner: windows-latest
cmake_flags: "-A x64"
# Android — cross-compiled from Linux via the NDK's cmake
# toolchain. ANDROID_NDK_HOME is set by the github-hosted ubuntu
# image (currently NDK 26+; check if a future release pins a
# specific NDK version).
- target: android-arm64
runner: ubuntu-22.04
cmake_flags: "-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-21"
- target: android-armv7
runner: ubuntu-22.04
cmake_flags: "-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake -DANDROID_ABI=armeabi-v7a -DANDROID_PLATFORM=android-21"
- target: android-x64
runner: ubuntu-22.04
cmake_flags: "-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake -DANDROID_ABI=x86_64 -DANDROID_PLATFORM=android-21"
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Configure cmake
shell: bash
run: |
cd native/third_party/bloom_jolt
mkdir -p build
cd build
cmake .. -DCMAKE_BUILD_TYPE=Release ${{ matrix.cmake_flags }}
- name: Build
shell: bash
run: cmake --build native/third_party/bloom_jolt/build --config Release --parallel
- name: Stage prebuilt libs
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts/${{ matrix.target }}
# Generator differences (Xcode → Release/, Make/Ninja → ./ or lib/,
# MSBuild → Release/x64/) mean the libs aren't at one fixed path.
# Find them by name instead.
find native/third_party/bloom_jolt/build -type f \
\( -name 'libJolt.a' -o -name 'Jolt.lib' \
-o -name 'libbloom_jolt.a' -o -name 'bloom_jolt.lib' \) \
-exec cp {} artifacts/${{ matrix.target }}/ \;
ls -la artifacts/${{ matrix.target }}/
# Sanity: both archives must be present, else the upload below
# silently ships a half-broken slice that bites consumers later.
if [ "$(ls artifacts/${{ matrix.target }}/ | wc -l)" -lt 2 ]; then
echo "::error::expected libJolt + libbloom_jolt for ${{ matrix.target }}, got:"
ls -la artifacts/${{ matrix.target }}/
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: jolt-prebuilt-${{ matrix.target }}
path: artifacts/${{ matrix.target }}/
if-no-files-found: error
retention-days: 7
# ---------------------------------------------------------------------------
# Assemble all per-target artifacts into npm/jolt-prebuilt/lib/<target>/ and
# publish @bloomengine/jolt-prebuilt via OIDC trusted publishing. Idempotent:
# the "already published" check short-circuits re-runs (workflow_dispatch on
# an old tag won't republish the same version, won't fail either).
# ---------------------------------------------------------------------------
publish-jolt-prebuilt:
needs: build-jolt-prebuilt
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v5
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Download all jolt-prebuilt artifacts
uses: actions/download-artifact@v4
with:
path: npm/jolt-prebuilt/lib
pattern: jolt-prebuilt-*
- name: Strip the jolt-prebuilt- artifact-name prefix from each subdir
shell: bash
run: |
set -euo pipefail
cd npm/jolt-prebuilt/lib
for d in jolt-prebuilt-*; do
mv "$d" "${d#jolt-prebuilt-}"
done
ls -la
- name: Check if version already published
id: check
run: |
PKG_NAME=$(node -p "require('./npm/jolt-prebuilt/package.json').name")
PKG_VERSION=$(node -p "require('./npm/jolt-prebuilt/package.json').version")
if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then
echo "$PKG_NAME@$PKG_VERSION is already on the registry — skipping publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "$PKG_NAME@$PKG_VERSION not yet published — will publish."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to npm
if: steps.check.outputs.skip == 'false'
working-directory: npm/jolt-prebuilt
run: npm publish --provenance --access public
# ---------------------------------------------------------------------------
# Publish the package to npm as @bloomengine/engine. Runs after the GitHub
# Release so a failure here doesn't leave a dangling release-but-no-package
# state. Also waits on publish-jolt-prebuilt — the engine declares
# @bloomengine/jolt-prebuilt as a dependency, so the prebuilt package must
# be on the registry first or `npm publish` of the engine will fail
# resolution. Skips cleanly if the version is already on the registry,
# which keeps re-runs idempotent (workflow_dispatch on an existing tag
# won't double-publish or fail).
#
# We check out submodules recursively because scripts/prepack.sh refuses
# to ship a tarball without the JoltPhysics sources materialised — the
# package vendors them rather than relying on a postinstall git clone.
# ---------------------------------------------------------------------------
publish-npm:
needs: [github-release, publish-jolt-prebuilt]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for npm provenance attestations
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v5
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Resolve tag
id: tag
env:
DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
if [ -n "$DISPATCH_TAG" ]; then
TAG="$DISPATCH_TAG"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Verify package.json version matches tag
env:
VERSION: ${{ steps.tag.outputs.version }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$PKG_VERSION" != "$VERSION" ]; then
echo "::error::Tag $TAG ($VERSION) does not match package.json ($PKG_VERSION) — refusing to publish."
exit 1
fi
- name: Check if version already published
id: check
run: |
PKG_NAME=$(node -p "require('./package.json').name")
PKG_VERSION=$(node -p "require('./package.json').version")
if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then
echo "$PKG_NAME@$PKG_VERSION is already on the registry — skipping publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "$PKG_NAME@$PKG_VERSION not yet published — will publish."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to npm
if: steps.check.outputs.skip == 'false'
run: npm publish --provenance --access public