Skip to content

v0.5.1166

v0.5.1166 #176

name: Release Packages
on:
release:
types: [published]
workflow_dispatch:
inputs:
publish_npm:
description: "Publish npm packages (use for the first run / to bypass release trigger)"
type: boolean
default: false
permissions:
contents: write
actions: read
jobs:
# ---------------------------------------------------------------------------
# Gate: wait for Tests + Simulator Tests to pass on this commit before we
# publish anything downstream (Homebrew bottle, apt .deb, npm tarballs).
# Both workflows are triggered by the same `push: tags: ['v*']` event that
# the /release skill does, so they run in parallel with this job. We poll
# `gh run list --commit <sha>` until each reports a conclusion. Fails fast
# if either workflow failed — npm publish is not reversible.
#
# Skipped on workflow_dispatch so the "first-run / bypass" lever still works.
# ---------------------------------------------------------------------------
await-tests:
runs-on: ubuntu-latest
# Job-level timeout must match the bash `deadline=$(( SECONDS + N * 60 ))`
# below. Both must move together.
# - v0.5.876: 60 → 90 (Tests had grown past 60 min wall)
# - v0.5.883: 90 → 120 (macOS-14 runners queue 30-60+ min during
# busy hours; Tests itself takes ~30 min, but Release Packages
# starts at the same time as Tests and runs out of budget before
# Tests gets dequeued + executes. 120 absorbs typical runner
# queue + Tests wall-time).
timeout-minutes: 120
steps:
- name: Wait for Tests + Simulator Tests (iOS) 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 publish)."
exit 0
fi
echo "Gating release on commit $SHA"
# Query by workflow *filename* via `gh api`, not by display name via
# `gh run list --workflow`. v0.5.155 shipped the latter and watched
# both gate workflows succeed while the poller logged "no run found
# yet" for 45 straight minutes — the `gh run list` name→id lookup
# silently returned [] in the release-event context, and the
# `2>/dev/null || echo '[]'` fallback made it look identical to an
# empty page. `gh api /repos/{repo}/actions/workflows/<file>/runs`
# skips the lookup, and we no longer swallow gh's stderr — a real
# API error now gets one retry then fails the gate loudly instead
# of burning the 45-min budget.
for wf_file in "test.yml" "simctl-tests.yml"; do
echo "::group::Waiting for $wf_file"
# 120-min budget per workflow.
# - v0.5.876: 60 → 90 (Tests wall-time grew past 60).
# - v0.5.883: 90 → 120. v0.5.882 hit 90-min wall because
# macOS-14 runner queue delayed doc-tests start by an
# hour. Tests itself runs ~30 min, but Release Packages
# starts at tag-time and runs out of budget waiting for
# queue + execution. 120 absorbs typical busy-hour queue.
deadline=$(( SECONDS + 120 * 60 ))
retry_on_error=1
while :; do
set +e
# `per_page=20` (was 1) — return the LAST 20 runs on this SHA
# ordered by created_at desc, then `select(.conclusion ==
# "success")` to find the first successful one. Pre-fix
# per_page=1 returned only the newest run on the SHA, which
# might be in_progress while an older run on the SAME SHA was
# already green — the gate hung until timeout. Happened twice
# during v0.5.386's release-packages cycle (the main-branch
# Tests run was green, but a tag-triggered Tests run on the
# same SHA was newer + still running, locking the gate).
api_out=$(gh api \
"/repos/$REPO/actions/workflows/$wf_file/runs?head_sha=$SHA&per_page=20" \
2>&1)
rc=$?
set -e
if [ $rc -ne 0 ]; then
echo " gh api failed (rc=$rc):"
echo "$api_out" | sed 's/^/ /'
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
# Look for ANY successful run on this SHA (order-independent).
success_url=$(echo "$api_out" | jq -r '.workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .html_url' | head -1)
# Look for any failed/cancelled/timed-out completed run as a
# FAIL signal — but only if there's NO successful run for
# this SHA. This way a flaky run that someone reran to green
# doesn't block the release.
fail_url=$(echo "$api_out" | jq -r '.workflow_runs[] | select(.status == "completed" and .conclusion != "success") | .html_url' | head -1)
# Newest run for the "still running" status line.
latest_status=$(echo "$api_out" | jq -r '.workflow_runs[0].status // empty')
latest_url=$(echo "$api_out" | jq -r '.workflow_runs[0].html_url // empty')
if [ -n "$success_url" ]; then
echo " OK $wf_file passed: $success_url"
break
elif [ -z "$latest_status" ]; then
echo " no run found yet for $wf_file on $SHA — waiting"
elif [ "$latest_status" = "completed" ] && [ -n "$fail_url" ]; then
echo " FAIL $wf_file: every completed run on this SHA failed"
echo " most-recent: $fail_url"
exit 1
else
echo " $wf_file: latest=$latest_status ($latest_url) — waiting"
fi
if [ $SECONDS -ge $deadline ]; then
echo " timed out waiting for $wf_file"
exit 1
fi
sleep 30
done
echo "::endgroup::"
done
echo "All gate workflows passed — proceeding to build/publish."
# ---------------------------------------------------------------------------
# Build release binaries for all platforms
# ---------------------------------------------------------------------------
build:
needs: await-tests
strategy:
# Don't let one platform's build failure cancel the others (mirrors
# build-cross). A broken cross-host target should not strand the
# binaries that did build — partial publish beats no publish.
fail-fast: false
matrix:
include:
- os: macos-14
target: aarch64-apple-darwin
artifact: perry-macos-aarch64
- os: macos-15
target: x86_64-apple-darwin
artifact: perry-macos-x86_64
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
artifact: perry-linux-x86_64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact: perry-linux-aarch64
- os: ubuntu-24.04
target: x86_64-unknown-linux-musl
artifact: perry-linux-x86_64-musl
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
artifact: perry-linux-aarch64-musl
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: perry-windows-x86_64
runs-on: ${{ matrix.os }}
env:
# Pin the macOS deployment target so bottle object files carry a
# stable LC_VERSION_MIN / LC_BUILD_VERSION tag regardless of which
# GitHub runner OS built them. Otherwise users linking against the
# bottle on macOS 14 see `ld: warning: ... was built for newer 'macOS'
# version (15.x) than being linked (14.x)` across every stdlib .o.
MACOSX_DEPLOYMENT_TARGET: "13.0"
steps:
- uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Cache cargo registry + target/ per (matrix.target, Cargo.lock).
# First run on a target is still a cold build, but every subsequent
# release-tag on the same Cargo.lock reuses the compiled crates —
# webkit6/gstreamer/swc/etc. dominate cold cargo and recompile takes
# 10-15 min per matrix entry. Cache hit drops that to ~30s.
- uses: Swatinem/rust-cache@v2
with:
shared-key: "release-${{ matrix.target }}"
# #4856 — evict workspace-crate fingerprints from the restored cache.
# rust-cache's own workspace cleanup misses cross-target dirs, so a
# restored target/ can make cargo treat perry-runtime & co. as
# up-to-date and silently reuse a staticlib built from older sources
# (v0.5.1150 shipped Apple cross runtimes missing
# perry_macos_bundle_chdir that way). Deleting the .fingerprint
# entries forces every workspace crate to rebuild from the checkout
# while keeping the expensive external-dependency precompiles cached.
# Note `cargo clean -p` is NOT equivalent: it only covers the host
# layout, not target/<triple>/ dirs (verified on cargo 1.8x).
- name: Evict workspace crates from restored cache (#4856)
shell: bash
run: |
set -euo pipefail
while IFS= read -r pkg; do
rm -rf target/release/.fingerprint/"$pkg"-* \
target/*/release/.fingerprint/"$pkg"-*
done < <(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name')
# Closes #394: Homebrew bottle was missing libperry_ui_ios.a so
# `perry compile foo.ts -o app --target ios[-simulator]` failed at link
# with `perry/ui imported but libperry_ui_ios.a not found`. Both macOS
# legs of the matrix now also cross-compile perry-runtime / perry-stdlib
# / perry-ui-ios for BOTH the iOS device and simulator triples and ship
# the resulting .a files alongside the host artifacts. Device libs use
# the canonical _ios suffix (`libperry_ui_ios.a`); simulator libs use
# `_ios_sim` (`libperry_ui_ios_sim.a`) so they don't collide in the
# bottle's flat lib dir. library_search.rs's cross-compile branch
# picks the right variant per --target. tier-3 tvOS/visionOS/watchOS
# are still a follow-up (need nightly + -Zbuild-std).
- name: Install iOS Rust targets (macOS)
if: runner.os == 'macOS'
run: rustup target add aarch64-apple-ios aarch64-apple-ios-sim
# Closes #396 / #397 / #398: tvOS, visionOS, and watchOS are Rust Tier-3
# targets — no prebuilt std, so we install nightly + rust-src and use
# `-Zbuild-std=core,std,panic_abort` below to compile the standard library
# from source for each (device, sim) triple. Stable stays the default
# toolchain; we only invoke nightly explicitly via `cargo +nightly` for
# the Tier-3 cross-builds.
- name: Install Rust nightly + rust-src for Tier-3 Apple targets (macOS)
if: runner.os == 'macOS'
run: rustup toolchain install nightly --component rust-src --profile minimal
- name: Install GTK4 development libraries (Linux glibc)
if: runner.os == 'Linux' && !endsWith(matrix.target, '-musl')
# libgstreamer1.0-dev added in v0.5.456 — perry-ui-gtk4 pulls in
# gstreamer-sys via the perry/media (#351, v0.5.440) GTK4 backend
# for streaming audio playback. Mirrors the doc-tests-gtk4 fix
# from v0.5.441 (commit a535a984).
# libunwind-dev added in v0.5.461 — on ubuntu-22.04 (jammy)
# libgstreamer1.0-dev hard-depends on libunwind-dev which is held
# back in the runner image, so apt-get install fails with "Unable
# to correct problems" unless libunwind-dev is named explicitly.
# ubuntu-24.04-arm doesn't have this issue but the explicit name
# is harmless there.
# libwebkitgtk-6.0-dev + libshumate-dev added v0.5.855 — perry-ui-gtk4
# builds (via the workflow's `-p perry-ui-gtk4` step) pull in webkit6
# (which transitively needs javascriptcoregtk-6.0.pc + webkitgtk-6.0.pc
# + libsoup-3.0.pc) and libshumate-sys (#517 MapView). Mirrors the
# apt step in test.yml's doc-tests-gtk4 job.
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libunwind-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libwebkitgtk-6.0-dev libshumate-dev
- name: Install musl tools (Linux musl)
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build perry
# Build only the CLI crate; the runtime libraries are built in
# the next step. (Perry is V8-free — the former perry-jsruntime
# crate, which downloaded a prebuilt V8 that 404'd on musl, was
# removed.)
run: cargo build --release --target ${{ matrix.target }} -p perry
- name: Build runtime libraries
run: |
cargo build --release --target ${{ matrix.target }} -p perry-runtime
cargo build --release --target ${{ matrix.target }} -p perry-stdlib
- name: Build panic=abort runtime variant (Unix)
# Out-of-tree installs can't rebuild the runtime, so ship the
# panic=abort profile prebuilt: `perry compile` links it for
# runtime-only apps with no catch_unwind callers (games, CLIs),
# dropping unwind tables/landing pads (~12-18% of the binary).
# See optimized_libs.rs (find_runtime_abort_library selection).
# Separate CARGO_TARGET_DIR so the profile override doesn't
# invalidate the main build's incremental cache.
if: runner.os != 'Windows'
run: |
CARGO_TARGET_DIR=target-abort CARGO_PROFILE_RELEASE_PANIC=abort \
cargo build --release --target ${{ matrix.target }} -p perry-runtime
cp "target-abort/${{ matrix.target }}/release/libperry_runtime.a" \
"target/${{ matrix.target }}/release/libperry_runtime_abort.a"
- name: Build native ext libraries (Unix)
# #2532 — the perry-ext-* wrapper crates ship the host functions for
# node:http (server), ws, net, zlib, fastify, the db drivers, etc.
# They are NOT dependencies of perry-stdlib, so `cargo build -p
# perry-stdlib` above does not produce them. An out-of-tree install
# needs the prebuilt `libperry_ext_*.a` sitting next to
# libperry_runtime.a so `perry compile` can link them without a
# workspace checkout (see optimized_libs.rs::resolve_prebuilt_ext_libs).
# Best-effort per crate: a wrapper that can't build on this host must
# not fail the whole release — the packaging glob below ships
# whatever was produced.
if: runner.os != 'Windows'
run: |
for d in crates/perry-ext-*; do
[ -d "$d" ] || continue
name=$(basename "$d")
echo "::group::build $name"
cargo build --release --target ${{ matrix.target }} -p "$name" \
|| echo " (skipped $name — failed to build on this host)"
echo "::endgroup::"
done
- name: Build UI library (macOS)
if: runner.os == 'macOS'
run: cargo build --release --target ${{ matrix.target }} -p perry-ui-macos
# Closes #394: cross-compile the runtime + stdlib + UI for both the
# iOS device and simulator triples (both Tier-2 stable). Library
# filenames are disambiguated in the staging step — device libs keep
# the canonical `_ios` name, simulator libs get a `_sim` variant
# suffix so the bottle can ship both side-by-side. tvOS/visionOS/
# watchOS are Tier-3 (need nightly + -Zbuild-std) and out of scope.
- name: Build iOS cross-compile libraries (macOS)
# Only on the arm64 mac job. These iOS/Apple cross-libs are aarch64 and
# identical regardless of host, so building them on the x86_64 mac runner
# is redundant — and the macos-15 x86_64 runner fails `libsqlite3-sys`
# bindgen on the iOS target (libclang/SDK env). The macOS staging step
# copies these libs only `if [ -f ]`, so the x86_64 bottle degrades
# gracefully; the arm64 bottle + the build-cross tarballs still ship them.
if: runner.os == 'macOS' && matrix.target == 'aarch64-apple-darwin'
run: |
for triple in aarch64-apple-ios aarch64-apple-ios-sim; do
cargo build --release --target "$triple" -p perry-runtime
cargo build --release --target "$triple" -p perry-stdlib
cargo build --release --target "$triple" -p perry-ui-ios
done
# Closes #396 / #397 / #398: cross-compile runtime + stdlib + UI for the
# Tier-3 Apple platforms (tvOS, visionOS, watchOS). Both device and
# simulator triples per platform; nightly + `-Zbuild-std=...` builds std
# from source. Note watchOS device uses the unusual `arm64_32` triple
# (32-bit pointers, 64-bit registers) — the only non-`aarch64-*` device
# triple in this set, so we keep the device/sim pairs explicit instead
# of deriving them. Library filenames are disambiguated in the staging
# step (device → `_<plat>.a`, sim → `_<plat>_sim.a`); library_search.rs's
# `apple_class_lib_name` already maps each `--target` to the correct
# variant (covered by the `handles_other_class_suffixes` unit test).
- name: Build tvOS/visionOS/watchOS cross-compile libraries (macOS)
# arm64 mac job only — same rationale as the iOS step above.
if: runner.os == 'macOS' && matrix.target == 'aarch64-apple-darwin'
run: |
set -e
# Format: "<platform>:<device-triple>:<sim-triple>"
# watchOS dropped v0.5.888: ring 0.17.14 has a pointer-size
# assertion that fails for `arm64_32-apple-watchos` (32-bit
# pointers with 64-bit regs). Sim target (aarch64-apple-watchos-sim)
# also dropped to keep the matrix simple — re-add both when
# ring publishes a fix or we pin to a working pre-0.17.14
# version.
for entry in \
"tvos:aarch64-apple-tvos:aarch64-apple-tvos-sim" \
"visionos:aarch64-apple-visionos:aarch64-apple-visionos-sim"; do
plat="${entry%%:*}"
rest="${entry#*:}"
dev="${rest%%:*}"
sim="${rest#*:}"
for triple in "$dev" "$sim"; do
echo "::group::$plat $triple"
cargo +nightly build --release \
-Z build-std=core,std,panic_abort \
--target "$triple" \
-p perry-runtime -p perry-stdlib "-p" "perry-ui-${plat}"
echo "::endgroup::"
done
done
- name: Build UI library (Linux glibc)
if: runner.os == 'Linux' && !endsWith(matrix.target, '-musl')
run: cargo build --release --target ${{ matrix.target }} -p perry-ui-gtk4
- name: Build UI library (Windows)
if: runner.os == 'Windows'
run: cargo build --release --target ${{ matrix.target }} -p perry-ui-windows
# Closes #872: cross-compile the Android runtime libs on the Windows
# leg so the WinGet zip includes `libperry_runtime.a` /
# `libperry_stdlib.a` for `aarch64-linux-android`. Without these,
# `perry compile --target android` from a WinGet-installed perry
# bails at link time with "Could not find libperry_runtime.a".
# GitHub-hosted Windows runners pre-install the Android NDK at
# `%ANDROID_NDK_HOME%`; we point cargo at its `aarch64-linux-android-*`
# clang as the cross-linker.
- name: Install aarch64-linux-android Rust target (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: rustup target add aarch64-linux-android
- name: Build Android cross-compile libraries (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
# API level 24 matches every other Android leg we cross-build for
# elsewhere (perry-ui-android's build.rs assumes the same NDK
# surface).
ANDROID_API_LEVEL: "24"
run: |
if (-not $env:ANDROID_NDK_HOME) {
$env:ANDROID_NDK_HOME = $env:ANDROID_NDK_LATEST_HOME
}
if (-not $env:ANDROID_NDK_HOME) {
Write-Error "ANDROID_NDK_HOME not set on this Windows runner — needed for #872 android cross-build."
exit 1
}
$toolchain = Join-Path $env:ANDROID_NDK_HOME "toolchains/llvm/prebuilt/windows-x86_64/bin"
$linker = Join-Path $toolchain "aarch64-linux-android$($env:ANDROID_API_LEVEL)-clang.cmd"
if (-not (Test-Path $linker)) {
$linker = Join-Path $toolchain "aarch64-linux-android$($env:ANDROID_API_LEVEL)-clang.exe"
}
if (-not (Test-Path $linker)) {
Write-Error "Android NDK linker not found at $linker — NDK layout drifted; update the version-suffixed path."
exit 1
}
$env:CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER = $linker
$env:CC_aarch64_linux_android = $linker
$env:AR_aarch64_linux_android = Join-Path $toolchain "llvm-ar.exe"
cargo build --release --target aarch64-linux-android -p perry-runtime
cargo build --release --target aarch64-linux-android -p perry-stdlib
# #4856 — defense in depth behind the cache eviction above: assert
# every runtime archive this leg built (host triple + any Apple/
# Android cross triples) defines the sentinel #[no_mangle] symbols.
# A stale cached archive fails the release here instead of breaking
# consumer links with `undefined symbol: _perry_macos_bundle_chdir`.
- name: Verify runtime archives are fresh (#4856)
shell: bash
run: |
set -euo pipefail
libs=$(find target -path "*/release/libperry_runtime.a" -o -path "*/release/perry_runtime.lib")
if [ -z "$libs" ]; then
echo "::error::no built runtime archives found under target/ — nothing to verify"
exit 1
fi
# shellcheck disable=SC2086
./scripts/check_runtime_symbols.sh $libs
- name: Package release archive (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p staging
cp target/${{ matrix.target }}/release/perry staging/
# Include static libraries for linking (runtime, stdlib, UI)
for lib in libperry_runtime.a libperry_runtime_abort.a libperry_stdlib.a libperry_ui_macos.a libperry_ui_gtk4.a; do
if [ -f "target/${{ matrix.target }}/release/$lib" ]; then
cp "target/${{ matrix.target }}/release/$lib" staging/
fi
done
# #2532 — ship the perry-ext-* staticlibs so an out-of-tree install
# can link node:http server / ws / net / zlib / fastify / db
# drivers without the workspace source. `perry compile` finds them
# via the same exe-dir / PERRY_LIB_DIR search as the runtime/stdlib
# libs (optimized_libs.rs::resolve_prebuilt_ext_libs).
for lib in target/${{ matrix.target }}/release/libperry_ext_*.a; do
[ -f "$lib" ] && cp "$lib" staging/
done
# Closes #394: macOS legs also ship iOS-cross-compiled libs.
# Device variant uses the canonical `_ios` suffix; simulator
# variant gets a `_sim` suffix so both can coexist in the
# bottle's flat lib dir. library_search.rs's cross-compile
# branch composes the right name based on --target.
if [ "${{ runner.os }}" = "macOS" ]; then
dev_dir="target/aarch64-apple-ios/release"
if [ -f "$dev_dir/libperry_runtime.a" ]; then
cp "$dev_dir/libperry_runtime.a" "staging/libperry_runtime_ios.a"
fi
if [ -f "$dev_dir/libperry_stdlib.a" ]; then
cp "$dev_dir/libperry_stdlib.a" "staging/libperry_stdlib_ios.a"
fi
if [ -f "$dev_dir/libperry_ui_ios.a" ]; then
cp "$dev_dir/libperry_ui_ios.a" "staging/libperry_ui_ios.a"
fi
sim_dir="target/aarch64-apple-ios-sim/release"
if [ -f "$sim_dir/libperry_runtime.a" ]; then
cp "$sim_dir/libperry_runtime.a" "staging/libperry_runtime_ios_sim.a"
fi
if [ -f "$sim_dir/libperry_stdlib.a" ]; then
cp "$sim_dir/libperry_stdlib.a" "staging/libperry_stdlib_ios_sim.a"
fi
if [ -f "$sim_dir/libperry_ui_ios.a" ]; then
cp "$sim_dir/libperry_ui_ios.a" "staging/libperry_ui_ios_sim.a"
fi
# Closes #396 / #397 / #398: same pattern as iOS for the Tier-3
# Apple platforms. Device triple varies (watchOS = arm64_32) so
# we drive the staging copy from the explicit triple list above.
# watchOS dropped — see the build step above.
for entry in \
"tvos:aarch64-apple-tvos:aarch64-apple-tvos-sim" \
"visionos:aarch64-apple-visionos:aarch64-apple-visionos-sim"; do
plat="${entry%%:*}"
rest="${entry#*:}"
dev_triple="${rest%%:*}"
sim_triple="${rest#*:}"
dev_dir="target/${dev_triple}/release"
sim_dir="target/${sim_triple}/release"
if [ -f "$dev_dir/libperry_runtime.a" ]; then
cp "$dev_dir/libperry_runtime.a" "staging/libperry_runtime_${plat}.a"
fi
if [ -f "$dev_dir/libperry_stdlib.a" ]; then
cp "$dev_dir/libperry_stdlib.a" "staging/libperry_stdlib_${plat}.a"
fi
if [ -f "$dev_dir/libperry_ui_${plat}.a" ]; then
cp "$dev_dir/libperry_ui_${plat}.a" "staging/libperry_ui_${plat}.a"
fi
if [ -f "$sim_dir/libperry_runtime.a" ]; then
cp "$sim_dir/libperry_runtime.a" "staging/libperry_runtime_${plat}_sim.a"
fi
if [ -f "$sim_dir/libperry_stdlib.a" ]; then
cp "$sim_dir/libperry_stdlib.a" "staging/libperry_stdlib_${plat}_sim.a"
fi
if [ -f "$sim_dir/libperry_ui_${plat}.a" ]; then
cp "$sim_dir/libperry_ui_${plat}.a" "staging/libperry_ui_${plat}_sim.a"
fi
done
fi
cd staging
tar czf ../${{ matrix.artifact }}.tar.gz *
# Bundle xwin.exe so `perry setup windows` can download the MS CRT + Windows
# SDK without requiring users to have Rust installed. xwin is pinned here to
# control updates — bump the version when switching to a newer xwin release.
# See crates/perry/src/commands/setup.rs::windows_wizard for the consumer.
- name: Build xwin.exe for bundling (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cargo install xwin --locked --version 0.9.0 --root xwin-install --target ${{ matrix.target }}
- name: Package release archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path staging
Copy-Item "target/${{ matrix.target }}/release/perry.exe" staging/
Copy-Item "xwin-install/bin/xwin.exe" staging/
# Include static libraries for linking (runtime, stdlib, UI)
$libs = @("perry_runtime.lib", "perry_stdlib.lib", "perry_ui_windows.lib")
foreach ($lib in $libs) {
$path = "target/${{ matrix.target }}/release/$lib"
if (Test-Path $path) {
Copy-Item $path staging/
}
}
# #872: stage the Android cross-compiled archives next to the
# Windows libs. perry's library_search.rs walks
# `<install-dir>/<triple>/release/<libname>.a` so we mirror that
# layout inside the zip — the `aarch64-linux-android/release/`
# subdir lives alongside `perry.exe`.
$androidLibDir = "staging/aarch64-linux-android/release"
New-Item -ItemType Directory -Force -Path $androidLibDir | Out-Null
$androidLibs = @("libperry_runtime.a", "libperry_stdlib.a")
foreach ($lib in $androidLibs) {
$src = "target/aarch64-linux-android/release/$lib"
if (Test-Path $src) {
Copy-Item $src $androidLibDir
}
}
Compress-Archive -Path staging/* -DestinationPath "${{ matrix.artifact }}.zip"
# SHA256 sidecars uploaded next to each archive so package-manager
# manifests (Scoop autoupdate, future winget/chocolatey/Homebrew bumps)
# can resolve hashes by URL without re-downloading the archive on the
# bumper machine. The sidecar format is `<hash> <filename>` (two
# spaces — same as `sha256sum`'s default), which Scoop's hash.url
# mode parses out of the box.
- name: Compute SHA256 sidecar (Unix)
if: runner.os != 'Windows'
run: |
# Linux ships `sha256sum` (coreutils); macOS ships `shasum -a 256`
# but no `sha256sum`. Both produce the same `<hash> <filename>`
# output format that Scoop / Homebrew / dpkg parse from the first
# whitespace-separated field. Pre-fix the macOS legs of this matrix
# bombed at v0.5.386's release-packages.yml run with
# `sha256sum: command not found` (exit 127), cancelling the
# x86_64-apple-darwin sibling via fail-fast and shipping a release
# without macOS binaries.
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.tar.gz.sha256"
else
shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.tar.gz.sha256"
fi
cat "${{ matrix.artifact }}.tar.gz.sha256"
- name: Compute SHA256 sidecar (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$hash = (Get-FileHash -Algorithm SHA256 "${{ matrix.artifact }}.zip").Hash.ToLower()
# Match `sha256sum` output exactly (two-space separator) for cross-platform
# tooling that grep-cuts the first field.
"$hash ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii "${{ matrix.artifact }}.zip.sha256"
Get-Content "${{ matrix.artifact }}.zip.sha256"
- name: Upload release asset (Unix)
if: runner.os != 'Windows' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# workflow_dispatch fallback: `github.event.release.tag_name` is
# only set for `release` events, so when triggered manually we
# look up the latest published release tag and upload to that.
# Pre-fix the v0.5.386 release shipped with NO assets uploaded
# because the original `release` run failed at await-tests, my
# workflow_dispatch bypass succeeded the build but skipped this
# upload step entirely.
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: uploading to latest release tag $TAG"
fi
gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz" --clobber
gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz.sha256" --clobber
- name: Upload release asset (Windows)
if: runner.os == 'Windows' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = "${{ github.event.release.tag_name }}"
if (-not $tag) {
$tag = (gh release list --limit 1 --json tagName --jq '.[0].tagName')
Write-Host "workflow_dispatch fallback: uploading to latest release tag $tag"
}
gh release upload "$tag" "${{ matrix.artifact }}.zip" --clobber
gh release upload "$tag" "${{ matrix.artifact }}.zip.sha256" --clobber
- name: Upload build artifact (Unix, for deb + npm-publish jobs)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: staging/
- name: Upload build artifact (Windows, for npm-publish job)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: staging/
# ---------------------------------------------------------------------------
# Build per-target cross-compile staticlib bundles (issue #1083)
#
# The host bundles emitted by `build:` above ship only host-triple .a's, so
# every consumer build worker that cross-compiles to a non-host target has
# to re-derive the cross toolchain env on each `perry update` (Apple
# sysroot vars, NDK clang paths, MSVC `lib.exe` shims, alsa/audio dev
# headers, aws-lc-sys jitterentropy disable, etc.) and pay a 5-25 min
# cargo rebuild per target. We already build these libs correctly in
# release CI's `build:` matrix — this job just packages them into
# standalone tarballs so the worker can `tar xzf` instead of rebuild.
#
# Each `perry-cross-<triple>.tar.gz` ships:
# libperry_runtime.a (the cross-compiled runtime)
# libperry_stdlib.a (the cross-compiled stdlib)
# libperry_ui_<plat>.a (the cross-compiled UI backend, when applicable)
# manifest.json (perry_version + target_triple + sha256+size per file)
#
# Each matrix entry runs on the natural host runner for its toolchain:
# - Apple targets: macos-14 (real Xcode + SDKs)
# - Android : ubuntu-24.04 + pre-installed NDK
# - Windows MSVC : windows-latest (clang-cl + lib.exe)
# — so no Linux→Apple sysroot or Linux→Windows VM dance is needed inside
# this workflow.
# ---------------------------------------------------------------------------
build-cross:
needs: await-tests
strategy:
# Each leg is independent; let one Tier-3 failure not cancel the
# other targets so we still ship the bundles that did build.
fail-fast: false
matrix:
include:
# --- Apple, Tier-2 (stable) ---------------------------------------
- os: macos-14
target: aarch64-apple-darwin
ui_crate: perry-ui-macos
ui_lib: libperry_ui_macos.a
tier3: false
- os: macos-15
target: x86_64-apple-darwin
ui_crate: perry-ui-macos
ui_lib: libperry_ui_macos.a
tier3: false
- os: macos-14
target: aarch64-apple-ios
ui_crate: perry-ui-ios
ui_lib: libperry_ui_ios.a
tier3: false
- os: macos-14
target: aarch64-apple-ios-sim
ui_crate: perry-ui-ios
ui_lib: libperry_ui_ios.a
tier3: false
# --- Apple, Tier-3 (nightly + build-std) --------------------------
# tvOS / visionOS are Rust Tier-3: no prebuilt std, so we install
# nightly + rust-src and pass `-Zbuild-std=core,std,panic_abort`
# in the build step below. Mirrors the existing `build:` job's
# tvos/visionos handling. watchOS is currently dropped — see the
# comment in `build:` (ring 0.17.14 pointer-size assertion fails
# for `arm64_32-apple-watchos`).
- os: macos-14
target: aarch64-apple-tvos
ui_crate: perry-ui-tvos
ui_lib: libperry_ui_tvos.a
tier3: true
- os: macos-14
target: aarch64-apple-tvos-sim
ui_crate: perry-ui-tvos
ui_lib: libperry_ui_tvos.a
tier3: true
- os: macos-14
target: aarch64-apple-visionos
ui_crate: perry-ui-visionos
ui_lib: libperry_ui_visionos.a
tier3: true
- os: macos-14
target: aarch64-apple-visionos-sim
ui_crate: perry-ui-visionos
ui_lib: libperry_ui_visionos.a
tier3: true
# --- Android (NDK on Linux runner) --------------------------------
# perry-ui-android requires the NDK clang++ wrapper and JNI; the
# existing test.yml leg already cross-compiles it on ubuntu-24.04,
# so we mirror that env here. x86_64 included so emulator builds
# also have prebuilt libs.
- os: ubuntu-24.04
target: aarch64-linux-android
ui_crate: perry-ui-android
ui_lib: libperry_ui_android.a
tier3: false
- os: ubuntu-24.04
target: x86_64-linux-android
ui_crate: perry-ui-android
ui_lib: libperry_ui_android.a
tier3: false
# --- Windows MSVC (native runner) ---------------------------------
# Only x86_64 today. perry's compile.rs maps `--target windows`
# to `x86_64-pc-windows-msvc` (compile.rs:1009); ARM64 Windows
# support is a separate follow-up — re-add `aarch64-pc-windows-msvc`
# here once the CLI knows about it.
- os: windows-latest
target: x86_64-pc-windows-msvc
ui_crate: perry-ui-windows
ui_lib: perry_ui_windows.lib
tier3: false
runs-on: ${{ matrix.os }}
env:
# Match `build:` for macOS object-file version tags so bottle-linked
# .a's don't emit "built for newer 'macOS' version" warnings against
# users on macOS 13.
MACOSX_DEPLOYMENT_TARGET: "13.0"
steps:
- uses: actions/checkout@v6
- name: Install Rust stable + cross target
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Rust nightly + rust-src (Tier-3 only)
if: matrix.tier3
run: rustup toolchain install nightly --component rust-src --profile minimal
# Same cache shape as `build:` so the cross legs share crate compiles
# across release cycles. Keyed on (target, Cargo.lock) — first run on a
# new target is cold, but every subsequent release-tag reuses the
# SWC/llvm-sys/webkit etc. precompile from the prior cycle.
- uses: Swatinem/rust-cache@v2
with:
shared-key: "release-cross-${{ matrix.target }}"
# #4856 — evict workspace-crate fingerprints from the restored cache
# so perry-runtime/stdlib/UI always rebuild from the checkout. The
# v0.5.1150 Apple cross bundles shipped a stale pre-#4833
# libperry_runtime.a (missing perry_macos_bundle_chdir) because the
# restored cache let cargo skip the rebuild. Mirrors the eviction
# step in `build:` — see the comment there for why `cargo clean -p`
# doesn't work (host layout only, misses target/<triple>/).
- name: Evict workspace crates from restored cache (#4856)
shell: bash
run: |
set -euo pipefail
while IFS= read -r pkg; do
rm -rf target/release/.fingerprint/"$pkg"-* \
target/*/release/.fingerprint/"$pkg"-*
done < <(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name')
# --- Per-runner toolchain wiring --------------------------------------
# Each of these blocks installs what the cross target needs that's NOT
# already on the runner image. Apple runners get Xcode for free; Linux
# runners get GTK/audio dev headers for the host build, but we don't
# need those here because we're cross-compiling.
- name: Wire Android NDK (Linux runner, android targets)
if: startsWith(matrix.target, 'aarch64-linux-android') || startsWith(matrix.target, 'x86_64-linux-android')
run: |
set -euo pipefail
# Reuse test.yml's NDK discovery — same precedence chain.
NDK=""
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
NDK="$ANDROID_NDK_HOME"
elif [ -d "${ANDROID_HOME:-}/ndk-bundle" ]; then
NDK="$ANDROID_HOME/ndk-bundle"
elif [ -d "${ANDROID_HOME:-}/ndk" ]; then
NDK=$(ls -1d "$ANDROID_HOME/ndk/"*/ 2>/dev/null | sort -V | tail -1)
NDK="${NDK%/}"
fi
if [ -z "$NDK" ]; then
echo "::error::No Android NDK found on runner — cannot cross-build ${{ matrix.target }}"
exit 1
fi
echo "ANDROID_NDK_HOME=$NDK" >> "$GITHUB_ENV"
HOST_TAG=linux-x86_64
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin"
TRIPLE="${{ matrix.target }}"
# NDK clang wrappers are named `<triple><api>-clang` (e.g.
# `aarch64-linux-android24-clang`); the API suffix is mandatory
# — `<triple>-clang` (no API) doesn't exist. We glob the highest
# API present on the runner image, matching test.yml's logic.
CLANG=$(ls "$TOOLCHAIN"/${TRIPLE}*-clang 2>/dev/null | sort -V | tail -1)
CLANGXX=$(ls "$TOOLCHAIN"/${TRIPLE}*-clang++ 2>/dev/null | sort -V | tail -1)
if [ -z "$CLANG" ]; then
echo "::error::Could not locate NDK clang under $TOOLCHAIN for $TRIPLE"
exit 1
fi
# cc-rs lookup vars: `CC_<triple>` / `CXX_<triple>` / `AR_<triple>`
# with `-` → `_`. The cargo-target linker var uses uppercase + `_`.
TRIPLE_UNDER=$(echo "$TRIPLE" | tr '-' '_')
TRIPLE_UPPER=$(echo "$TRIPLE_UNDER" | tr '[:lower:]' '[:upper:]')
{
echo "CC_${TRIPLE_UNDER}=$CLANG"
echo "CXX_${TRIPLE_UNDER}=$CLANGXX"
echo "AR_${TRIPLE_UNDER}=$TOOLCHAIN/llvm-ar"
echo "CARGO_TARGET_${TRIPLE_UPPER}_LINKER=$CLANG"
} >> "$GITHUB_ENV"
echo "NDK wired for $TRIPLE: $CLANG"
# The aarch64 MSVC target is Tier-2 but Rust still requires the cross
# std component to be installed by name (the matrix `targets:` field
# above handles that). Nothing else needed — windows-latest already
# ships clang-cl + lib.exe through Visual Studio.
# --- Build the three (or two, when UI is N/A) static libs ------------
- name: Build runtime + stdlib + UI (stable, Tier-2)
if: ${{ !matrix.tier3 }}
shell: bash
run: |
set -euo pipefail
cargo build --release --target ${{ matrix.target }} -p perry-runtime
cargo build --release --target ${{ matrix.target }} -p perry-stdlib
# UI crate is matrix-driven. perry-ui-android needs the NDK env
# we wired above; perry-ui-windows builds against the MSVC SDK
# already on windows-latest. Both go through plain `cargo build`.
cargo build --release --target ${{ matrix.target }} -p ${{ matrix.ui_crate }}
- name: Build runtime + stdlib + UI (nightly + build-std, Tier-3)
if: matrix.tier3
shell: bash
run: |
set -euo pipefail
cargo +nightly build --release \
-Z build-std=core,std,panic_abort \
--target ${{ matrix.target }} \
-p perry-runtime -p perry-stdlib -p ${{ matrix.ui_crate }}
# #4856 — defense in depth behind the cache eviction above: a stale
# cached runtime archive fails the release here instead of breaking
# every consumer executable link on the build workers.
- name: Verify runtime archive is fresh (#4856)
shell: bash
run: |
set -euo pipefail
lib="target/${{ matrix.target }}/release/libperry_runtime.a"
# Windows MSVC emits perry_runtime.lib (no `lib` prefix).
[ -f "$lib" ] || lib="target/${{ matrix.target }}/release/perry_runtime.lib"
./scripts/check_runtime_symbols.sh "$lib"
# --- Package + manifest ----------------------------------------------
# Manifest schema (per issue #1083):
# { "perry_version": "<x.y.z>", "target_triple": "<triple>",
# "files": [{"path": "...", "sha256": "...", "size": N}, ...] }
# Computed on the runner (sha256sum on Linux, shasum on macOS,
# PowerShell Get-FileHash on Windows). We write the manifest with a
# plain shell here-doc + jq concatenation; jq is on every GHA runner
# so we don't pull in a separate scripting dep.
- name: Stage cross artifacts + manifest (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
TARGET="${{ matrix.target }}"
UI_LIB="${{ matrix.ui_lib }}"
PERRY_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
STAGING="cross-staging"
rm -rf "$STAGING"
mkdir -p "$STAGING"
# Required runtime + stdlib (always present on a successful build).
for lib in libperry_runtime.a libperry_stdlib.a; do
src="target/${TARGET}/release/${lib}"
if [ ! -f "$src" ]; then
echo "::error::Expected $src after build but it's missing — aborting bundle."
exit 1
fi
cp "$src" "$STAGING/$lib"
done
# UI lib (optional — perry-ui-android failures are non-fatal in
# principle, but we built it above so this should exist; we hard-fail
# if it's missing so the worker doesn't see a silently-degraded
# bundle).
UI_SRC="target/${TARGET}/release/${UI_LIB}"
if [ ! -f "$UI_SRC" ]; then
echo "::error::UI lib $UI_SRC missing after build — aborting bundle."
exit 1
fi
cp "$UI_SRC" "$STAGING/$UI_LIB"
# SHA256 + size for each staged file. macOS ships `shasum -a 256`,
# Linux ships `sha256sum` — wrap both behind one alias.
if command -v sha256sum >/dev/null 2>&1; then
sha() { sha256sum "$1" | cut -d' ' -f1; }
else
sha() { shasum -a 256 "$1" | cut -d' ' -f1; }
fi
# `stat -c %s` (GNU) / `stat -f %z` (BSD) — choose at runtime.
if stat -c %s "$STAGING/libperry_runtime.a" >/dev/null 2>&1; then
sz() { stat -c %s "$1"; }
else
sz() { stat -f %z "$1"; }
fi
# Build the manifest with jq so we get correct JSON escaping
# (paths and triples are safe ASCII today, but the schema is the
# contract — let jq own it).
files_json=$(jq -n '[]')
for f in libperry_runtime.a libperry_stdlib.a "$UI_LIB"; do
files_json=$(jq -n --argjson acc "$files_json" \
--arg path "$f" \
--arg sha "$(sha "$STAGING/$f")" \
--argjson size "$(sz "$STAGING/$f")" \
'$acc + [{path: $path, sha256: $sha, size: $size}]')
done
jq -n --arg v "$PERRY_VERSION" --arg t "$TARGET" --argjson files "$files_json" \
'{perry_version: $v, target_triple: $t, files: $files}' \
> "$STAGING/manifest.json"
cat "$STAGING/manifest.json"
tar czf "perry-cross-${TARGET}.tar.gz" -C "$STAGING" .
- name: Stage cross artifacts + manifest (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$target = "${{ matrix.target }}"
$uiLib = "${{ matrix.ui_lib }}"
$perryVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' |
Select-Object -First 1).Matches[0].Groups[1].Value
$staging = "cross-staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Path $staging | Out-Null
# Windows MSVC builds emit `perry_runtime.lib` / `perry_stdlib.lib`
# (no `lib` prefix, `.lib` extension) — match the host-build
# behavior at line ~448 above.
$libs = @("perry_runtime.lib", "perry_stdlib.lib", $uiLib)
foreach ($lib in $libs) {
$src = "target/$target/release/$lib"
if (-not (Test-Path $src)) {
Write-Error "Expected $src after build but it's missing — aborting bundle."
exit 1
}
Copy-Item $src (Join-Path $staging $lib)
}
# Build manifest with sha256 + size for each file.
$entries = @()
foreach ($lib in $libs) {
$path = Join-Path $staging $lib
$hash = (Get-FileHash -Algorithm SHA256 $path).Hash.ToLower()
$size = (Get-Item $path).Length
$entries += [pscustomobject]@{ path = $lib; sha256 = $hash; size = $size }
}
$manifest = [pscustomobject]@{
perry_version = $perryVersion
target_triple = $target
files = $entries
}
$manifest | ConvertTo-Json -Depth 5 | Out-File -Encoding utf8 (Join-Path $staging "manifest.json")
Get-Content (Join-Path $staging "manifest.json")
# tar is available on windows-latest (BSD tar 3.x preinstalled by
# MS since 2018); produce the same .tar.gz extension as Unix so
# consumers only have one filename pattern to handle.
tar czf "perry-cross-$target.tar.gz" -C $staging .
- name: Compute SHA256 sidecar (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
ARCHIVE="perry-cross-${{ matrix.target }}.tar.gz"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ARCHIVE" > "$ARCHIVE.sha256"
else
shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256"
fi
cat "$ARCHIVE.sha256"
- name: Compute SHA256 sidecar (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$archive = "perry-cross-${{ matrix.target }}.tar.gz"
$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
"$hash $archive" | Out-File -Encoding ascii "$archive.sha256"
Get-Content "$archive.sha256"
- name: Upload cross release asset (Unix)
if: runner.os != 'Windows' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: uploading to latest release tag $TAG"
fi
ARCHIVE="perry-cross-${{ matrix.target }}.tar.gz"
gh release upload "$TAG" "$ARCHIVE" --clobber
gh release upload "$TAG" "$ARCHIVE.sha256" --clobber
- name: Upload cross release asset (Windows)
if: runner.os == 'Windows' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = "${{ github.event.release.tag_name }}"
if (-not $tag) {
$tag = (gh release list --limit 1 --json tagName --jq '.[0].tagName')
Write-Host "workflow_dispatch fallback: uploading to latest release tag $tag"
}
$archive = "perry-cross-${{ matrix.target }}.tar.gz"
gh release upload "$tag" "$archive" --clobber
gh release upload "$tag" "$archive.sha256" --clobber
# Always upload as a workflow artifact too — lets a maintainer
# pull the bundle off a workflow_dispatch run that didn't target an
# existing release (e.g. a smoke-test on a branch).
- name: Upload cross build artifact (for inspection)
uses: actions/upload-artifact@v7
with:
name: perry-cross-${{ matrix.target }}
path: |
perry-cross-${{ matrix.target }}.tar.gz
perry-cross-${{ matrix.target }}.tar.gz.sha256
# ---------------------------------------------------------------------------
# Update Homebrew tap
# ---------------------------------------------------------------------------
homebrew:
needs: build
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Get release info
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# workflow_dispatch fallback: github.event.release.tag_name is
# only set for `release` events. When triggered manually, fall
# back to the latest published release tag — same pattern the
# upload step uses (see v0.5.392 for the publish-jobs sweep).
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: using latest release tag $TAG"
fi
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download source tarball and compute SHA256
id: sha
run: |
curl -sL "https://github.com/${{ github.repository }}/archive/refs/tags/${{ steps.release.outputs.tag }}.tar.gz" -o source.tar.gz
SHA256=$(sha256sum source.tar.gz | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
- name: Download macOS ARM64 bottle and compute SHA256
id: bottle_arm64
run: |
curl -sL "https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.tag }}/perry-macos-aarch64.tar.gz" -o perry-macos-aarch64.tar.gz
SHA256=$(sha256sum perry-macos-aarch64.tar.gz | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
- name: Download macOS x86_64 bottle and compute SHA256
id: bottle_x64
run: |
curl -sL "https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.tag }}/perry-macos-x86_64.tar.gz" -o perry-macos-x86_64.tar.gz
SHA256=$(sha256sum perry-macos-x86_64.tar.gz | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
- name: Update Homebrew tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
git clone "https://x-access-token:${TAP_TOKEN}@github.com/PerryTS/homebrew-perry.git" tap
cd tap
mkdir -p Formula
cat > Formula/perry.rb << 'FORMULA_EOF'
class Perry < Formula
desc "Native TypeScript compiler — compiles TypeScript to native executables"
homepage "https://github.com/PerryTS/perry"
version "${{ steps.release.outputs.version }}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/PerryTS/perry/releases/download/${{ steps.release.outputs.tag }}/perry-macos-aarch64.tar.gz"
sha256 "${{ steps.bottle_arm64.outputs.sha256 }}"
else
url "https://github.com/PerryTS/perry/releases/download/${{ steps.release.outputs.tag }}/perry-macos-x86_64.tar.gz"
sha256 "${{ steps.bottle_x64.outputs.sha256 }}"
end
end
on_linux do
url "https://github.com/PerryTS/perry/archive/refs/tags/${{ steps.release.outputs.tag }}.tar.gz"
sha256 "${{ steps.sha.outputs.sha256 }}"
depends_on "rust" => :build
end
def install
if OS.mac?
bin.install "perry"
lib.install Dir["libperry_*.a"]
else
system "cargo", "build", "--release"
system "cargo", "build", "--release", "-p", "perry-runtime", "-p", "perry-stdlib"
bin.install "target/release/perry"
lib.install Dir["target/release/libperry_*.a"]
end
end
def caveats
<<~EOS
Perry requires a C linker to link compiled executables.
macOS: Xcode Command Line Tools (xcode-select --install)
Linux: GCC or Clang (sudo apt install build-essential)
Quick start:
echo 'console.log("hello")' > hello.ts
perry hello.ts -o hello && ./hello
EOS
end
test do
assert_match "perry", shell_output("#{bin}/perry --version")
(testpath/"test.ts").write('console.log("works");')
system bin/"perry", testpath/"test.ts", "-o", testpath/"test"
assert_equal "works\n", shell_output(testpath/"test")
end
end
FORMULA_EOF
# Remove leading whitespace from heredoc indentation
sed -i 's/^ //' Formula/perry.rb
git config user.name "perry-bot"
git config user.email "bot@perryts.com"
git add Formula/perry.rb
git commit -m "perry ${VERSION}" || echo "No changes"
git push
# ---------------------------------------------------------------------------
# Build .deb packages and update APT repository
# ---------------------------------------------------------------------------
apt:
needs: build
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
strategy:
matrix:
include:
- artifact: perry-linux-x86_64
arch: amd64
- artifact: perry-linux-aarch64
arch: arm64
steps:
- name: Get release info
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: using latest release tag $TAG"
fi
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: ${{ matrix.artifact }}
path: staging/
- name: Build .deb packages
run: |
VERSION="${{ steps.release.outputs.version }}"
ARCH="${{ matrix.arch }}"
umask 022
require_staging_file() {
local path="staging/$1"
if [ ! -f "$path" ]; then
echo "Missing required release artifact: $path" >&2
exit 1
fi
}
write_control() {
local pkg_dir="$1"
local package="$2"
local depends="$3"
local description="$4"
local details="$5"
{
echo "Package: ${package}"
echo "Version: ${VERSION}"
echo "Section: devel"
echo "Priority: optional"
echo "Architecture: ${ARCH}"
if [ -n "$depends" ]; then
echo "Depends: ${depends}"
fi
echo "Maintainer: Perry Team <hello@perryts.com>"
echo "Homepage: https://github.com/PerryTS/perry"
echo "Description: ${description}"
echo " ${details}"
} > "${pkg_dir}/DEBIAN/control"
}
init_package() {
local pkg_dir="$1"
local package="$2"
local depends="$3"
local description="$4"
local details="$5"
rm -rf "$pkg_dir"
mkdir -p "${pkg_dir}/DEBIAN"
chmod 755 "$pkg_dir" "${pkg_dir}/DEBIAN"
write_control "$pkg_dir" "$package" "$depends" "$description" "$details"
}
copy_static_archive() {
local lib="$1"
local pkg_dir="$2"
require_staging_file "$lib"
mkdir -p "${pkg_dir}/usr/lib/perry"
cp "staging/$lib" "${pkg_dir}/usr/lib/perry/"
# Strip DWARF debug info but keep the symbol table. Perry's
# codegen linker still needs the #[no_mangle] symbols, while
# release users do not need archive debug sections.
strip --strip-debug "${pkg_dir}/usr/lib/perry/$lib" || true
chmod 644 "${pkg_dir}/usr/lib/perry/$lib"
}
build_deb() {
local pkg_dir="$1"
# `-Zxz -z9` overrides dpkg-deb's default (zstd on noble) with
# xz level 9. xz is consistently smaller on the libperry_*.a
# archive bodies, and every dpkg version Perry targets supports it.
dpkg-deb --build -Zxz -z9 "$pkg_dir"
}
check_deb_sizes() {
local max_git_blob_bytes=99000000
local failed=0
shopt -s nullglob
local debs=( *.deb )
if [ "${#debs[@]}" -eq 0 ]; then
echo "No .deb packages were produced" >&2
exit 1
fi
for deb in "${debs[@]}"; do
local size
size=$(stat -c %s "$deb")
if [ "$size" -gt "$max_git_blob_bytes" ]; then
echo "$deb is $size bytes; GitHub rejects git blobs near 100 MB" >&2
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
exit 1
fi
}
require_staging_file perry
require_staging_file libperry_runtime.a
require_staging_file libperry_stdlib.a
MAIN_DEPENDS="gcc | clang, perry-runtime (= ${VERSION}), perry-stdlib (= ${VERSION})"
if [ -f staging/libperry_ui_gtk4.a ]; then
MAIN_DEPENDS="${MAIN_DEPENDS}, perry-ui-gtk4 (= ${VERSION})"
fi
MAIN_PKG="perry_${VERSION}_${ARCH}"
init_package "$MAIN_PKG" \
"perry" \
"$MAIN_DEPENDS" \
"Native TypeScript compiler" \
"Perry compiles TypeScript source code directly to native executables using Cranelift for code generation."
mkdir -p "${MAIN_PKG}/usr/bin" "${MAIN_PKG}/etc/perry"
install -m 755 staging/perry "${MAIN_PKG}/usr/bin/perry"
echo "/usr/lib/perry" > "${MAIN_PKG}/etc/perry/lib-path"
build_deb "$MAIN_PKG"
RUNTIME_PKG="perry-runtime_${VERSION}_${ARCH}"
init_package "$RUNTIME_PKG" \
"perry-runtime" \
"" \
"Perry runtime static library" \
"Static runtime archive required by the Perry compiler when linking generated executables."
copy_static_archive libperry_runtime.a "$RUNTIME_PKG"
build_deb "$RUNTIME_PKG"
STDLIB_PKG="perry-stdlib_${VERSION}_${ARCH}"
init_package "$STDLIB_PKG" \
"perry-stdlib" \
"" \
"Perry standard library static archive" \
"Static standard library archive required by the Perry compiler when linking generated executables."
copy_static_archive libperry_stdlib.a "$STDLIB_PKG"
build_deb "$STDLIB_PKG"
if [ -f staging/libperry_ui_gtk4.a ]; then
UI_PKG="perry-ui-gtk4_${VERSION}_${ARCH}"
init_package "$UI_PKG" \
"perry-ui-gtk4" \
"" \
"Perry GTK4 UI static archive" \
"Static GTK4 UI archive used by Perry programs that import the Linux UI backend."
copy_static_archive libperry_ui_gtk4.a "$UI_PKG"
build_deb "$UI_PKG"
fi
check_deb_sizes
- name: Upload .deb release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.release.outputs.tag }}"
shopt -s nullglob
debs=( *.deb )
if [ "${#debs[@]}" -eq 0 ]; then
echo "No .deb packages were produced" >&2
exit 1
fi
for deb in "${debs[@]}"; do
gh release upload "$TAG" "$deb" \
--repo "${{ github.repository }}" --clobber
done
- name: Upload .deb artifacts (for apt-repo job)
uses: actions/upload-artifact@v7
with:
name: deb-${{ matrix.arch }}
path: "*.deb"
# ---------------------------------------------------------------------------
# Update APT repository (GitHub Pages)
# ---------------------------------------------------------------------------
apt-repo:
needs: apt
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Get release info
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: using latest release tag $TAG"
fi
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download .deb packages
uses: actions/download-artifact@v8
with:
pattern: deb-*
merge-multiple: true
path: debs/
- name: Clone APT repo
env:
APT_TOKEN: ${{ secrets.APT_REPO_TOKEN }}
run: git clone "https://x-access-token:${APT_TOKEN}@github.com/PerryTS/perry-apt.git" apt-repo
- name: Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }}
run: echo "$GPG_PRIVATE_KEY" | gpg --batch --import
- name: Update repository
env:
GPG_KEY_ID: ${{ secrets.APT_GPG_KEY_ID }}
run: |
cd apt-repo
max_git_blob_bytes=99000000
oversized=0
for deb in ../debs/*.deb; do
size=$(stat -c %s "$deb")
if [ "$size" -gt "$max_git_blob_bytes" ]; then
echo "$deb is $size bytes; GitHub rejects git blobs near 100 MB" >&2
oversized=1
fi
done
if [ "$oversized" -ne 0 ]; then
exit 1
fi
# Copy new .deb files into pool
mkdir -p pool/main/p/perry
cp ../debs/*.deb pool/main/p/perry/
# Generate Packages index for each architecture
for ARCH in amd64 arm64; do
mkdir -p dists/stable/main/binary-${ARCH}
dpkg-scanpackages --arch ${ARCH} pool/ > dists/stable/main/binary-${ARCH}/Packages
gzip -9c dists/stable/main/binary-${ARCH}/Packages > dists/stable/main/binary-${ARCH}/Packages.gz
done
# Generate Release file
cd dists/stable
cat > Release << EOF
Origin: PerryTS
Label: Perry
Suite: stable
Codename: stable
Architectures: amd64 arm64
Components: main
Description: Perry native TypeScript compiler
EOF
sed -i 's/^ //' Release
# Add checksums
{
echo "MD5Sum:"
find main/ -type f | while read f; do
echo " $(md5sum "$f" | cut -d' ' -f1) $(wc -c < "$f") $f"
done
echo "SHA256:"
find main/ -type f | while read f; do
echo " $(sha256sum "$f" | cut -d' ' -f1) $(wc -c < "$f") $f"
done
} >> Release
# Sign
gpg --batch --yes --default-key "${GPG_KEY_ID}" -abs -o Release.gpg Release
gpg --batch --yes --default-key "${GPG_KEY_ID}" --clearsign -o InRelease Release
cd ../..
# Export public key for users
gpg --armor --export "${GPG_KEY_ID}" > perry.gpg.pub
git config user.name "perry-bot"
git config user.email "bot@perryts.com"
git add -A
git commit -m "perry ${{ steps.release.outputs.version }}"
git push
# ---------------------------------------------------------------------------
# Publish to winget (Windows Package Manager)
# ---------------------------------------------------------------------------
winget:
needs: build
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- name: Get release info
id: release
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$tag = "${{ github.event.release.tag_name }}"
if (-not $tag) {
$tag = (gh release list --limit 1 --json tagName --jq '.[0].tagName')
Write-Host "workflow_dispatch fallback: using latest release tag $tag"
}
$version = $tag -replace '^v', ''
echo "version=$version" >> $env:GITHUB_OUTPUT
echo "tag=$tag" >> $env:GITHUB_OUTPUT
- name: Download wingetcreate
shell: pwsh
run: |
Invoke-WebRequest "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe
- name: Sync WINGET_PAT-user's winget-pkgs fork with upstream
shell: pwsh
env:
GH_TOKEN: ${{ secrets.WINGET_PAT }}
# wingetcreate --submit uses the AUTHENTICATED USER'S fork of
# microsoft/winget-pkgs, not the org's (PerryTS/winget-pkgs was a
# red herring left over from a misread of how wingetcreate works).
# Query the token's current user, resolve `<user>/winget-pkgs`,
# then merge-upstream. microsoft/winget-pkgs gets thousands of
# commits per day — any fork untouched for a week is already behind
# enough that wingetcreate's internal SyncFork call will throw the
# opaque "forked repository could not be synced" error we saw on
# the v0.5.158 first-real-release run.
run: |
$user = (gh api /user --jq '.login').Trim()
Write-Host "Authenticated user: $user"
Write-Host "Syncing $user/winget-pkgs from microsoft/winget-pkgs"
gh repo sync "$user/winget-pkgs" --source microsoft/winget-pkgs --force
- name: Update winget manifest
shell: pwsh
env:
WINGET_PAT: ${{ secrets.WINGET_PAT }}
run: |
$url = "https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.tag }}/perry-windows-x86_64.zip"
./wingetcreate.exe update PerryTS.Perry `
--urls $url `
--version "${{ steps.release.outputs.version }}" `
--token $env:WINGET_PAT `
--submit
# ---------------------------------------------------------------------------
# Notify build workers to update perry
# ---------------------------------------------------------------------------
update-workers:
needs: build
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Get release version
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
echo "workflow_dispatch fallback: using latest release tag $TAG"
fi
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Trigger worker perry update
env:
HUB_ADMIN_SECRET: ${{ secrets.HUB_ADMIN_SECRET }}
run: |
echo "Triggering perry update on build workers (expected: ${{ steps.release.outputs.version }})..."
RESPONSE=$(curl -sf -X POST "https://hub.perryts.com/api/v1/admin/update-perry" \
-H "Authorization: Bearer ${HUB_ADMIN_SECRET}" \
-H "Content-Type: application/json" \
-d "{\"expected_version\": \"${{ steps.release.outputs.version }}\"}" 2>&1) || true
echo "Hub response: ${RESPONSE}"
# ---------------------------------------------------------------------------
# Publish npm packages (@perryts/perry + 7 per-platform packages)
#
# Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). Each of the 8
# package names must be registered on npmjs.com with this repo + workflow
# as a Trusted Publisher. See npm/README.md for the one-time setup.
# ---------------------------------------------------------------------------
npm-publish:
needs: build
if: >
github.event_name == 'release' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_npm == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # REQUIRED for OIDC + npm --provenance
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm (OIDC Trusted Publisher requires >= 11.5.1)
# Node 20 ships npm 10.x; versions below 11.5.1 silently fail the OIDC
# handshake and the registry returns a misleading 404 ("'pkg@ver' is
# not in this registry") on PUT. See npm/cli#9088.
run: npm install -g npm@latest
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: release-artifacts/
- name: Stage npm packages
run: ./scripts/stage-npm.sh release-artifacts/
- name: Sanity-check staged packages
run: |
for dir in npm/perry npm/perry-*; do
echo "=== $dir ==="
ls -la "$dir"
if [ -f "$dir/package.json" ]; then
node -e "const p=require('./$dir/package.json'); console.log(p.name, p.version)"
else
echo "MISSING package.json in $dir" >&2
exit 1
fi
done
- name: Publish platform packages
# `./` prefix is required — `npm publish npm/foo` parses `npm/foo` as
# a GitHub shorthand (user/repo) and tries ssh://git@github.com/npm/foo.git.
# See run 24632227482 npm-publish job for the symptom.
#
# Skip versions already on the registry so the job is idempotent: at
# v0.5.1151 a transient artifact digest-mismatch killed the first run
# after the two darwin packages had published, and every re-run then
# died on the first package with "You cannot publish over the
# previously published versions" — the remaining six packages could
# never go out (run 27239815088).
run: |
set -e
for pkg in ./npm/perry-*; do
name=$(node -p "require('$pkg/package.json').name")
ver=$(node -p "require('$pkg/package.json').version")
if npm view "$name@$ver" version >/dev/null 2>&1; then
echo "=== $name@$ver already published — skipping ==="
continue
fi
echo "=== publishing $pkg ==="
npm publish "$pkg" --access public --provenance
done
- name: Publish wrapper (@perryts/perry) last
run: |
set -e
name=$(node -p "require('./npm/perry/package.json').name")
ver=$(node -p "require('./npm/perry/package.json').version")
if npm view "$name@$ver" version >/dev/null 2>&1; then
echo "$name@$ver already published — skipping"
else
npm publish ./npm/perry --access public --provenance
fi