name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
defaults:
run:
shell: bash
jobs:
linux-x86_64:
name: linux x86_64 (static musl)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
run: |
# The workspace pins nightly via rust-toolchain.toml, but release
# binaries build on STABLE (scripts/release.sh policy: reproducible,
# matches the crates.io/MSRV story). Every cargo invocation below
# goes through scripts/build-stable.sh (`cargo +stable`), so only
# stable must be present — rustup will NOT download nightly.
rustup toolchain install stable --profile minimal --no-self-update
rustup target add --toolchain stable x86_64-unknown-linux-musl
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Install rpmbuild
run: |
# dpkg-deb (.deb) is preinstalled on ubuntu runners; rpmbuild is
# not. release.sh treats both as best-effort (skips with a warning
# when absent), so installing rpm here is what turns the .rpm from
# a manual-SOP artifact into a CI one. build-rpm.sh consumes the
# same host target/dist/ build the .deb uses.
# SCOPE the update to Ubuntu's own sources: the runner image also
# carries a Google Chrome apt repo, and dl.google.com's index can
# hash-sum-flake when Google republishes mid-day (observed for real:
# `apt-get update` died with exit 100 on that repo BEFORE `rpm` was
# ever installed, failing the whole job on a source we never install
# from). `Dir::Etc::sourceparts=-` drops every third-party source;
# List-Cleanup keeps the main-list entries so the install finds rpm.
sudo apt-get update -qq \
-o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" \
-o Dir::Etc::sourceparts="-" \
-o APT::Get::List-Cleanup="0"
sudo apt-get install -y -qq rpm
- name: Build (static musl tarball + .deb/.rpm)
run: |
# Diagnostic: show what the dirty-tree guard would see (tooling in
# the setup steps — e.g. the rust-cache restore — can leave the tree
# flagged even though the checkout itself is the exact pushed
# commit).
git status --porcelain || true
# --allow-dirty is release.sh's documented escape hatch for CI-style
# flows, and CI is precisely that: the checkout IS the commit being
# released, so the guard's threat model (uncommitted local edits
# sneaking into the artifacts) cannot apply. Residual dirt here is
# tooling noise, not source drift — first observed on a rerun where
# the identical job had passed cleanly without a cache restore.
./scripts/release.sh --allow-dirty
- name: Smoke-test the tarball
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/smoke-test.sh "dist/choreographr-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
- name: Daemon smoke-test the tarball
run: |
# Boot the shipped daemon hermetically (scratch socket + config dir,
# see scripts/daemon-smoke.sh's header) — smoke-test.sh above only
# exercises --version/--help, which never touches the listener.
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/daemon-smoke.sh "dist/choreographr-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
- uses: actions/upload-artifact@v7
with:
name: linux-x86_64-musl
path: dist/choreographr-*
if-no-files-found: error
linux-arm64:
name: linux aarch64 (static musl)
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
run: |
# Same release policy as the x86_64 job: stable only, never the
# toml-pinned nightly. release.sh detects Linux-aarch64 and takes the
# aarch64 musl branch, so the target here is the arm64 musl one.
rustup toolchain install stable --profile minimal --no-self-update
rustup target add --toolchain stable aarch64-unknown-linux-musl
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Install rpmbuild
run: |
# Same best-effort .deb/.rpm story as the x86_64 job (dpkg-deb is
# preinstalled; rpmbuild is apt-installed here). The apt update is
# scoped to Ubuntu's own sources for the same Google-Chrome-repo
# reason documented on the x86_64 job.
sudo apt-get update -qq \
-o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" \
-o Dir::Etc::sourceparts="-" \
-o APT::Get::List-Cleanup="0"
sudo apt-get install -y -qq rpm
- name: Build (static musl tarball + .deb/.rpm)
run: |
git status --porcelain || true
# Same --allow-dirty rationale as the x86_64 job: a CI checkout IS the
# pushed commit. On this arm64 runner release.sh builds the
# aarch64-unknown-linux-musl tarball and the arm64 .deb/.rpm.
./scripts/release.sh --allow-dirty
- name: Smoke-test the tarball
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/smoke-test.sh "dist/choreographr-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
- name: Daemon smoke-test the tarball
run: |
# Boots the shipped arm64 daemon hermetically — native execution on
# this arm64 runner, the same bring-up check the x86_64 job runs.
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/daemon-smoke.sh "dist/choreographr-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
- uses: actions/upload-artifact@v7
with:
name: linux-aarch64-musl
path: dist/choreographr-*
if-no-files-found: error
macos-arm64:
name: macOS aarch64 + x86_64 cross
runs-on: macos-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
run: |
# See the linux job: stable is the release policy; +stable builds
# never resolve the nightly pin.
rustup toolchain install stable --profile minimal --no-self-update
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Build (aarch64 + x86_64-apple-darwin tarballs)
run: |
# On a Darwin-arm64 host release.sh builds BOTH darwin tarballs in
# one pass: native aarch64-apple-darwin plus cross-built
# x86_64-apple-darwin (no musl/mimalloc, .deb/.rpm skipped). The
# x86_64 target is added here so the toolchain setup is visible in
# the job log (release.sh re-runs it idempotently). Same
# --allow-dirty rationale as the linux job: a CI checkout IS the
# pushed commit, so the guard's uncommitted-edits threat model
# cannot apply; --allow-dirty is its documented CI-style escape
# hatch (the BSD-sed run died inside the strips — never reached the
# build — so any tooling-dirt here has not surfaced yet; the
# diagnostic above the linux build would show it).
# --toolchain stable: see release.sh's cross-build comment — a
# bare target add lands on the workspace's NIGHTLY default, leaving
# stable (which build-stable.sh uses) without the std crates.
rustup target add x86_64-apple-darwin --toolchain stable
./scripts/release.sh --allow-dirty
- name: Smoke-test the aarch64 tarball
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/smoke-test.sh "dist/choreographr-${VERSION}-aarch64-apple-darwin.tar.gz"
- name: Daemon smoke-test the aarch64 tarball
run: |
# Boot the shipped daemon hermetically (scratch socket + config dir,
# see scripts/daemon-smoke.sh's header) — smoke-test.sh above only
# exercises --version/--help, which never touches the listener.
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/daemon-smoke.sh "dist/choreographr-${VERSION}-aarch64-apple-darwin.tar.gz"
- name: Smoke-test the x86_64 tarball under Rosetta
run: |
# No free x64 macOS runner exists, so the Intel slice is exercised
# interpretively here: Rosetta 2 (preinstalled on runners; the
# softwareupdate call is a belt-and-braces bootstrap) runs the
# x86_64 binaries through the SAME smoke suite. This is an
# approximation, not proof — Rosetta masks some real-hardware
# failure classes — so this is coverage, not a guarantee; what
# structurally protects the Intel channel is generation-side (both
# tarballs required by release.sh and update-homebrew-tap.sh),
# plus the digest-level checks downstream.
#
# softwareupdate is a belt-and-braces bootstrap (runners normally
# have Rosetta preinstalled) — package metadata for Apple's own
# RosettaUpdateAuto always prints harmless noise ("Package
# Authoring Error: ... missing installKBytes attribute"), which is
# Apple-side and unfixable, so the update is run ONLY when Rosetta
# is actually absent (oahd = the Rosetta AOT daemon; the
# RosettaUpdateAuto receipt is the authoritative receipt probe).
# This skips the noise AND the needless install on every run after
# image refresh; a failure at this step must not abort the smoke —
# the arch -x86_64 command below fails its own way if Rosetta is
# truly absent.
if ! /usr/bin/pgrep -qx oahd && \
! /usr/sbin/pkgutil --pkg-info com.apple.pkg.RosettaUpdateAuto >/dev/null 2>&1; then
softwareupdate --install-rosetta --agree-to-license || true
else
echo "Rosetta 2 already present — skipping install"
fi
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
arch -x86_64 ./scripts/smoke-test.sh "dist/choreographr-${VERSION}-x86_64-apple-darwin.tar.gz"
- name: Daemon smoke-test the x86_64 tarball under Rosetta
run: |
# Same daemon boot as the aarch64 step above, translated (Rosetta
# caveat as in the previous step's comment).
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
arch -x86_64 ./scripts/daemon-smoke.sh "dist/choreographr-${VERSION}-x86_64-apple-darwin.tar.gz"
- uses: actions/upload-artifact@v7
with:
name: macos-aarch64
path: dist/choreographr-*.tar.gz
if-no-files-found: error
windows-msvc:
name: windows x86_64 (MSVC)
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
run: |
rustup toolchain install stable --profile minimal --no-self-update
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Build
run: |
# Same build line as release.sh's macOS/host path: stable, --locked,
# --profile dist (the workspace's shipped-artifact profile — matches
# what release.sh/build-android.sh pass; output lands in target/dist),
# metrics + blockchain features, system allocator (mimalloc is a
# static-musl-only property of the tarball build). The daemon and the
# TUI live in two SEPARATE packages (binary-split refactor), so both
# are selected explicitly and features use package-scoped syntax
# (`pkg/feature` — see release.sh's rationale comment). Package-scoped
# syntax is identical to the musl build minus the mimalloc bits.
# build-stable.sh strips the nightly-only profile-rustflags bits that
# hard-block stable Cargo — it is plain bash + sed, so it runs fine
# under Git Bash on the Windows runner.
#
# CPU floor: x86-64-v2 — same reasoning as release.sh's musl build
# (see the comment there): env rustflags, not profile rustflags
# (target-blind + nightly-only), so a developer's ~/.cargo
# target-cpu=native can never leak into shipped Windows binaries, and
# a future per-CPU-level Windows artifact reuses this mechanism with
# a different value.
export RUSTFLAGS="-C target-cpu=x86-64-v2"
./scripts/build-stable.sh build --locked --profile dist -p choreographr -p choreo-tui --features choreographr/metrics,choreographr/blockchain
- name: Stage, smoke-test, and zip
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
STAGE="$(mktemp -d)"
for b in choreographr choreo-tui; do
cp "target/dist/$b.exe" "$STAGE/$b.exe"
done
# Smoke (mirrors scripts/smoke-test.sh): the TUI client
# cannot run fully headless, but --version must print the release
# version and exit 0 — never hang or crash.
for b in choreographr choreo-tui; do
"$STAGE/$b.exe" --version
done
mkdir -p dist
# Git Bash's /usr/bin/tar is GNU tar, which cannot write zip; the
# System32 tar.exe is bsdtar, and -a picks zip up from the suffix.
/c/Windows/System32/tar.exe -a -cf \
"dist/choreographr-${VERSION}-x86_64-pc-windows-msvc.zip" \
-C "$STAGE" .
- name: Daemon smoke-test the zip
run: |
# Boot the shipped daemon hermetically (scratch socket + DB path,
# see scripts/daemon-smoke.sh's header) — the inline --version loop
# above never touches the listener. On Windows the daemon binds via
# uds_windows (a file-path Unix-socket emulation, NOT a named pipe),
# the DB path is pinned via CHOREOGRAPHR_DB_PATH (dirs::data_dir()
# ignores env redirects there — first CI run failed with "could not
# determine data directory"), and the forced shutdown's exit code is
# not asserted (see the script's shutdown comment for why).
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
./scripts/daemon-smoke.sh "dist/choreographr-${VERSION}-x86_64-pc-windows-msvc.zip"
- uses: actions/upload-artifact@v7
with:
name: windows-x86_64-msvc
path: dist/choreographr-*.zip
if-no-files-found: error
android-termux:
name: android aarch64 (Termux)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain + Android rust target
run: |
# build-android.sh forces the stable toolchain (RUSTUP_TOOLCHAIN)
# and checks the rustup targets against it, so the target must be
# added to STABLE, not the toml-pinned nightly.
rustup toolchain install stable --profile minimal --no-self-update
rustup target add --toolchain stable aarch64-linux-android
- uses: taiki-e/install-action@v2
with:
tool: cargo-ndk
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Build Termux binaries
run: |
# Same features as the desktop release binaries (release.sh passes
# metrics,blockchain) so the Termux suite behaves identically.
./scripts/build-android.sh --features metrics,blockchain
- name: Package + sanity-check (tarball)
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
# A wrong-target Android build still "works" on the runner and only
# fails on-device, so verify the artifacts are ARM aarch64 ELF here.
# target/android/<abi>/ is build-android.sh's staging tree (under
# cargo's target/ — dist/ is reserved for final publishable
# artifacts); packaging consumes it without rebuilding.
file target/android/arm64-v8a/*
file target/android/arm64-v8a/* | grep -q 'ARM aarch64'
# The other jobs create dist/ when they build into it; this job
# only stages tarballs, so create it explicitly before packaging.
mkdir -p dist
tar czf "dist/choreographr-${VERSION}-aarch64-linux-android.tar.gz" \
-C target/android/arm64-v8a choreographr choreo-tui
- name: Package the Termux .deb (no rebuild)
run: |
# Consumes the binaries build-android.sh staged above — packaging
# never recompiles (single build of record per artifact kind).
# dpkg-deb is preinstalled on ubuntu runners. The script's own
# structural validation (control fields, contents, exec bits) runs
# inside; this step adds the smoke-test pass on top.
./scripts/build-deb-termux.sh
- name: Smoke-test the .deb (structural)
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
# Structural validation only — executing the packaged binaries is
# NOT possible on this bare runner; the next step unpacks a Termux
# aarch64 rootfs and runs the binaries under qemu-user instead.
# On-device install remains the final smoke test.
./scripts/smoke-test.sh "dist/choreographr-termux_${VERSION}_aarch64.deb"
- name: Execute the binaries in a Termux rootfs (qemu-user)
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
# The binaries are plain userspace programs (no Binder/Ashmem),
# bionic-linked and Termux-patched to find their libs under the
# Termux $PREFIX. This step executes them under qemu-user against
# the official Termux aarch64 rootfs — the closest thing to a
# on-device run without a device.
#
# No docker: a containerized attempt was abandoned after several CI
# iterations (the image's ENTRYPOINT drops privileges and resets
# env; bypassing it breaks emulation of the container's own bash).
# Instead: skopeo (no daemon) fetches the OCI image layers, which
# are unpacked into a scratch rootfs, and every binary is invoked
# through an EXPLICIT qemu-aarch64-static -L <rootfs> call — no
# binfmt registration, no entrypoint, no env surprises. The
# rootfs's own dpkg-deb (run under the same emulation) extracts the
# .deb, so Termux tooling still decodes the archive — including our
# xz members — into the real on-device $PREFIX layout. No root is
# needed: we own the scratch rootfs.
#
# Smoke-level by design: qemu-user emulation can't cover what the
# Android framework provides (no Binder/Ashmem, no GUI, no
# on-device timing), so this proves the shipped binaries LOAD,
# LINK, and answer --version/--help — the same clap surface
# smoke-test.sh checks on the desktop artifacts, which until now
# the Android builds had never had. Timing-sensitive behaviour and
# real device interaction remain on-device responsibilities.
sudo apt-get install -y -qq skopeo qemu-user-static
ROOTFS=/tmp/termux-rootfs
mkdir -p "$ROOTFS"
# Fetch the official Termux aarch64 rootfs as OCI layers (skopeo's
# dir transport writes the manifest and per-digest layer files).
IMG=/tmp/termux-img
skopeo copy --override-os linux --override-arch arm64 \
docker://termux/termux-docker:latest "dir:$IMG"
# Unpack the layers in manifest order (later layers overlay earlier
# ones). tar auto-detects the layer compression. The manifest file
# name differs between skopeo versions/transports (OCI layout writes
# "manifest", docker-schema2 writes "manifest.json" — verified
# locally with skopeo 1.24); layer files are named by the bare hex
# digest (colon stripped).
MANIFEST="$IMG/manifest"
[ -f "$MANIFEST" ] || MANIFEST="$IMG/manifest.json"
for digest in $(jq -r '.layers[].digest' "$MANIFEST"); do
tar -xf "$IMG/${digest#*:}" -C "$ROOTFS"
done
PREFIX="$ROOTFS/data/data/com.termux/files/usr"
# Sanity checks before anything else: the emulation scaffold (the
# bionic loader the binaries reference as /system/bin/linker64) and
# the Termux tooling must both exist in the unpacked rootfs, or
# everything below would fail for the wrong reason.
[ -e "$ROOTFS/system/bin/linker64" ] || {
echo "error: linker64 missing from the rootfs — cannot run bionic binaries" >&2
exit 1
}
[ -x "$PREFIX/bin/dpkg-deb" ] || {
echo "error: dpkg-deb missing from the rootfs" >&2
exit 1
}
# Extract the .deb with TERMUX'S OWN dpkg-deb, running under the
# same qemu — exercises Termux tooling decoding our archive (the
# xz members smoke-test.sh checks structurally) into the real
# on-device $PREFIX layout. Glob, not a VERSION-expanded name: no
# env must cross the emulation boundary.
DEB=$(echo dist/choreographr-termux_*_aarch64.deb)
[ -f "$DEB" ] || { echo "error: no Termux .deb in dist/" >&2; exit 1; }
qemu-aarch64-static -L "$ROOTFS" "$PREFIX/bin/dpkg-deb" -x "$DEB" "$ROOTFS"
# The shipped binaries must answer --version with the exact release
# version — proves the bionic ELF actually loads, links against the
# Termux $PREFIX libs, and runs (the core "never executed before
# release" check). timeout(1) is the HOST's coreutils — the guards
# work natively here, unlike inside a container.
timeout 60 qemu-aarch64-static -L "$ROOTFS" "$PREFIX/bin/choreographr" --version | grep -q "$VERSION"
timeout 60 qemu-aarch64-static -L "$ROOTFS" "$PREFIX/bin/choreo-tui" --version
# --help exercises the full clap parser construction (more
# code paths than --version) and must exit 0.
timeout 60 qemu-aarch64-static -L "$ROOTFS" "$PREFIX/bin/choreographr" --help >/dev/null
# Keep the workspace clean: the scratch rootfs is large and must
# never leak into the artifact upload.
rm -rf "$ROOTFS" "$IMG"
- uses: actions/upload-artifact@v7
with:
name: android-aarch64-termux
path: dist/choreographr-*
if-no-files-found: error
ios-build:
name: ios aarch64 (build + link)
runs-on: macos-latest
timeout-minutes: 60
env:
BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_ios_sim: --target=aarch64-apple-ios
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain + iOS rust targets
run: |
# STABLE ONLY: this job must never touch the workspace's pinned
# nightly. build-ios.sh with IOS_BUILD_STABLE=1 and the checks below
# route through build-stable.sh, which strips the manifest's
# nightly-only profile-rustflags keys per invocation (bare
# `cargo +stable` cannot even parse the manifest — observed in CI
# 1.98.1: "feature `profile-rustflags` is required").
rustup toolchain install stable --profile minimal --no-self-update
rustup target add --toolchain stable aarch64-apple-ios aarch64-apple-ios-sim
- uses: mlugg/setup-zig@v2
- uses: Swatinem/rust-cache@v2
- name: Compile check (both targets, rlib only)
run: |
./scripts/build-stable.sh check -p choreo-gui --lib --target aarch64-apple-ios
./scripts/build-stable.sh check -p choreo-gui --lib --target aarch64-apple-ios-sim
- name: Build + stage link inputs (device + simulator)
run: IOS_BUILD_STABLE=1 ./scripts/build-ios.sh
- name: Generate the Xcode project
run: |
brew install xcodegen
cd ios && xcodegen generate
- name: Link the app (simulator SDK, Debug)
run: |
set -o pipefail
DEST="$(xcrun simctl list devices available | grep -m1 -oE '[A-F0-9-]{36}')"
[ -n "$DEST" ] || { echo "error: no available simulator found" >&2; exit 1; }
echo "DEST=$DEST" >> "$GITHUB_ENV"
xcodebuild -project ios/Choreographr.xcodeproj -scheme Choreographr \
-configuration Debug -sdk iphonesimulator -destination "id=$DEST" \
build CODE_SIGNING_ALLOWED=NO
# The .app lands in DerivedData; locate it from the build settings
# (TARGET_BUILD_DIR is only dumped by -showBuildSettings, not by
# `build`). -showBuildSettings must use the same destination or it
# reports the default-simulator variant of the paths.
APP_DIR="$(xcodebuild -project ios/Choreographr.xcodeproj \
-scheme Choreographr -configuration Debug -sdk iphonesimulator \
-destination "id=$DEST" -showBuildSettings 2>/dev/null \
| grep -m1 ' TARGET_BUILD_DIR = ' | sed 's/.*TARGET_BUILD_DIR = //')"
[ -n "$APP_DIR" ] || { echo "error: could not resolve TARGET_BUILD_DIR" >&2; exit 1; }
echo "APP_DIR=$APP_DIR" >> "$GITHUB_ENV"
- name: "Smoke: boot the app in the simulator"
continue-on-error: true
run: |
set -uo pipefail
APP="$APP_DIR/choreo-gui.app"
[ -d "$APP" ] || { echo "error: built app not found at $APP" >&2; ls "$APP_DIR" >&2 || true; exit 1; }
xcrun simctl boot "$DEST" 2>/dev/null || true # already booted is fine
xcrun simctl bootstatus "$DEST" -b
xcrun simctl install "$DEST" "$APP"
# --console-pty mirrors the app's stdout/stderr into this job log:
# dioxus-logger's tracing output (including the TCP failure status)
# is visible here when diagnosing a hang.
xcrun simctl launch --console-pty "$DEST" com.choreographr.gui \
> /tmp/app-console.log 2>&1 &
LAUNCH_PID=$!
# Poll: the process must still exist inside the sim after 30s.
alive=0
for i in $(seq 1 30); do
sleep 1
if ! xcrun simctl spawn "$DEST" launchctl list 2>/dev/null \
| grep -q com.choreographr.gui; then
echo "app process disappeared from the simulator at t=${i}s" >&2
echo "--- app console so far: ---" >&2
cat /tmp/app-console.log >&2 || true
break
fi
if [ "$i" = 30 ]; then alive=1; fi
done
if [ "$alive" = 1 ]; then
echo "SMOKE PASS: app alive after 30s (stub expected: TCP connect failure shown in UI)"
echo "--- app console: ---"
cat /tmp/app-console.log || true
xcrun simctl terminate "$DEST" com.choreographr.gui 2>/dev/null || true
else
echo "SMOKE FAIL: app did not stay alive — phase 0b wiring needs a real Mac session to debug" >&2
exit 1
fi
release:
name: verify + (on tag) create GitHub release
needs: [linux-x86_64, linux-arm64, macos-arm64, android-termux]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
pattern: "{linux-x86_64-musl,linux-aarch64-musl,macos-aarch64,android-aarch64-termux}"
merge-multiple: true
path: dist
- name: Guard against unintended artifacts
run: |
if ls dist/*windows* >/dev/null 2>&1; then
echo "error: unexpected Windows artifact in dist/ — the Windows build is not shipping yet" >&2
ls dist >&2
exit 1
fi
- name: Combined SHA256SUMS
run: |
# One checksum file over ALL artifacts (the release.sh convention:
# install.sh and the tap/AUR update scripts verify against it).
# Regenerated here rather than reused per-job so it always covers
# the complete release, exactly like release.sh's dist/ glob.
( cd dist && sha256sum choreographr-* > SHA256SUMS && cat SHA256SUMS )
- name: Install release tools
uses: taiki-e/install-action@v2
with:
tool: cargo-deny,git-cliff
- name: Check the dependency supply chain
run: ./scripts/check-supply-chain.sh
- name: Generate the release notes
run: |
./scripts/release-notes.sh > /tmp/release-notes.md
# Require at least one bullet, not merely a non-empty file: with no
# commits in range git-cliff still writes the `## [X.Y.Z]` heading, so
# `[ -s ]` alone would let a silently-empty release body ship.
if ! grep -q '^- ' /tmp/release-notes.md; then
echo "error: scripts/release-notes.sh produced no bullets — check cliff.toml, the tag, and that the checkout has full history + tags" >&2
exit 1
fi
cat /tmp/release-notes.md
- name: Create the GitHub release
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
# Guard against a tag that disagrees with the manifest — the tag is
# what users download, the manifest is the version source of truth
# (RELEASE.md); a mismatch would ship a mislabeled release.
if [ "v${VERSION}" != "${GITHUB_REF_NAME}" ]; then
echo "error: tag ${GITHUB_REF_NAME} != manifest version v${VERSION}" >&2
exit 1
fi
# Assets are immutable once uploaded (RELEASE.md hotfix section): a
# re-run after the release exists fails on create — delete and
# re-create per the SOP rather than "fixing" this job.
# The body is the git-cliff-generated notes (above) — commit messages
# rendered as Keep a Changelog sections, already carrying the "Full
# Changelog" compare link, so no separate --generate-notes pass.
# Release name: read from the source-of-truth file that is ALSO
# compiled into the binaries (choreo-shared/release-name.txt). Patch
# releases have an empty file and get a bare title. Parentheses match
# the binaries' `--version` output (e.g. `choreographr 0.2.0 (Lindy)`).
CODENAME="$(head -n1 choreo-shared/release-name.txt 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' || true)"
TITLE="choreographr ${VERSION}"
if [ -n "$CODENAME" ]; then
TITLE="${TITLE} (${CODENAME})"
fi
gh release create "v${VERSION}" dist/* \
--title "$TITLE" \
--notes-file /tmp/release-notes.md