Skip to content

fix(agent): Make claudeModelsByID available on non-unix platforms #1081

fix(agent): Make claudeModelsByID available on non-unix platforms

fix(agent): Make claudeModelsByID available on non-unix platforms #1081

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Shrink the desktop/rust debug artifacts that `cargo test` (dev profile)
# bakes into the cached `target/` -- full DWARF debuginfo is the single
# largest slice of the Swatinem/rust-cache blob. `line-tables-only` keeps
# file/line numbers in panic backtraces (so test failures stay
# diagnosable) while dropping the variable/type info nobody reads in CI.
# Set here, not in desktop/rust/Cargo.toml, so local builds keep full debug
# for debuggers. MUST stay identical to desktop-artifacts.yaml: every
# CARGO_* var is hashed into the rust-cache key, so a mismatch would split
# the `shared-key: desktop` cache the two workflows are meant to share.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
linux:
# UBUNTU VERSION: the `ubuntu-24.04` literal below is the single source of
# truth for the Linux CI base image. Bump it here when moving to a newer
# Ubuntu release; no other occurrence exists in this workflow.
runs-on: ubuntu-24.04${{ matrix.runner_suffix }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner_suffix: ''
- arch: arm64
runner_suffix: '-arm'
steps:
- uses: actions/checkout@v5
- name: Derive cache epoch
run: echo "CACHE_WEEK=$(date -u +%G-W%V)" >> "$GITHUB_ENV"
- name: Load tool versions
run: grep -E '^[A-Z_][A-Z0-9_]*=' versions.env >> "$GITHUB_ENV"
- name: Cache APT packages
uses: actions/cache@v5
with:
path: .apt-cache
# Hash both workflow files so adding or removing a package in
# either ci.yaml or desktop-artifacts.yaml rotates the cache. The
# two workflows install identical package sets and share the
# cache scope; if only ci.yaml were hashed, a desktop-artifacts-
# only edit would silently re-download every run.
key: apt-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_WEEK }}-${{ hashFiles('.github/workflows/ci.yaml', '.github/workflows/desktop-artifacts.yaml') }}
restore-keys: apt-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_WEEK }}-
- name: Configure APT cache directories
run: |
mkdir -p "$GITHUB_WORKSPACE/.apt-cache/archives/partial"
mkdir -p "$GITHUB_WORKSPACE/.apt-cache/lists/partial"
sudo rm -f /etc/apt/apt.conf.d/docker-clean
cat <<EOF | sudo tee /etc/apt/apt.conf.d/99github-cache
Binary::apt::APT::Keep-Downloaded-Packages "true";
Dir::Cache::archives "$GITHUB_WORKSPACE/.apt-cache/archives";
Dir::State::lists "$GITHUB_WORKSPACE/.apt-cache/lists";
EOF
- name: Install Tauri system dependencies
# GStreamer dev + runtime plugins are required for AppImages built
# with `bundle.linux.appimage.bundleMediaFramework: true`. Without
# them the AppImage launches to a blank window and logs
# "GStreamer element autoaudiosink not found".
# See https://github.com/tauri-apps/tauri/issues/4642 and
# https://gstreamer.freedesktop.org/documentation/installing/on-linux.html.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libsoup-3.0-dev \
protobuf-compiler \
xdg-utils \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-bad1.0-dev \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
gstreamer1.0-libav \
gstreamer1.0-tools \
gstreamer1.0-x \
gstreamer1.0-alsa \
gstreamer1.0-gl \
gstreamer1.0-gtk3 \
gstreamer1.0-pulseaudio
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GOLANG_VERSION }}
cache-dependency-path: |
backend/go.sum
desktop/go/go.sum
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('frontend/bun.lock') }}
restore-keys: bun-${{ runner.os }}-${{ runner.arch }}-
- name: Cache Cargo dependencies
# Swatinem/rust-cache replaces a raw actions/cache for desktop/rust/
# target: it splits the key by rustc + target triple automatically,
# only stores fingerprinted artifacts that match the current
# Cargo.lock, and prunes stale outputs before save — so the saved
# blob stays tight and a `RUST_VERSION` bump rotates the key
# cleanly instead of restoring 1+ GB of artifacts cargo then
# discards. `shared-key: desktop` keeps ci.yaml and
# desktop-artifacts.yaml on the same key so push-to-main's
# desktop-artifacts run reuses the cache CI just populated.
uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/rust
shared-key: desktop
# Only push-to-main writes the cache. Otherwise every PR build saves
# its own ~1.3 GB blob under the shared `desktop` key, and they evict
# each other against GitHub's 10 GB per-repo cache limit. PRs still
# *restore* main's cache (the expensive dep compile is reused); they
# just don't pay to save a near-duplicate.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cache Tauri downloads
# The cached ~/.cache/tauri contents (linuxdeploy AppImage, the
# extracted AppRun, and the linuxdeploy-plugin-* scripts) are
# post-processed by desktop/rust/scripts/patch-linuxdeploy.sh,
# which also installs a compiled wrapper built from
# linuxdeploy-wrapper.c. The patch script is idempotent on a
# clean download, but on a cache hit it skips re-applying
# patches it has already left behind — so if either input file
# changes (e.g. a new sentinel string, an updated allowlist,
# or a wrapper rebuild), the cached file would silently retain
# the old patches until the weekly key rotation. Hashing both
# inputs into the cache key invalidates the cache exactly when
# the patch logic changes. Linux-only: macOS and Windows
# Tauri caches contain unrelated tooling not touched by these
# scripts.
uses: actions/cache@v5
with:
path: ~/.cache/tauri
key: tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_WEEK }}-${{ hashFiles('desktop/rust/scripts/patch-linuxdeploy.sh', 'desktop/rust/scripts/linuxdeploy-wrapper.c') }}
# Fall back to last week's cache at the Monday CACHE_WEEK rotation
# boundary so we don't redownload the linuxdeploy AppImage + GTK
# plugins every Monday.
restore-keys: tauri-${{ runner.os }}-${{ runner.arch }}-
- name: Cache awesome-claude-spinners
# Spinner JSON files are fetched by `task generate-spinners` with
# a fresh git clone every run. Caching the generated directory
# keyed on CACHE_WEEK lets us skip the clone on subsequent runs
# within the same ISO week while still refreshing weekly (matching
# the Tauri cache policy above). The content is pure JSON,
# identical across all OS/arch combinations, so the key omits
# runner.os/runner.arch — the first job in any run populates it
# for the rest.
uses: actions/cache@v5
with:
path: frontend/src/spinners
key: spinners-${{ env.CACHE_WEEK }}
- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
version: ${{ env.BUF_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP macOS runners.
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Task
uses: arduino/setup-task@v2
with:
version: ${{ env.TASK_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP runners — has been failing intermittently
# on Windows without it.
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate code (proto + sqlc)
run: task generate
- name: Install frontend dependencies
run: task prepare-frontend
- name: Build
run: task build
- name: Diagnose linuxdeploy (on failure)
# Tauri captures linuxdeploy's stdout/stderr and silently discards
# them on failure, leaving only "failed to run linuxdeploy" in the
# log. Re-run the wrapper and the extracted AppRun directly so
# their real output surfaces in the CI log. Runs only if a
# preceding step failed, so the success path stays quiet.
if: failure()
run: |
set +e
echo "=== ~/.cache/tauri contents ==="
ls -la ~/.cache/tauri/
echo
echo "=== Tauri's AppImage bundle dir ==="
ls -la desktop/rust/target/release/bundle/appimage/ 2>/dev/null
echo
echo "=== linuxdeploy wrapper --version (exit=?) ==="
~/.cache/tauri/linuxdeploy-$(uname -m).AppImage --version
echo "wrapper exit=$?"
echo
echo "=== extracted AppRun --list-plugins (exit=?) ==="
~/.cache/tauri/linuxdeploy-extracted/AppRun --list-plugins
echo "AppRun exit=$?"
echo
echo "=== Re-run linuxdeploy against Tauri's partial AppDir ==="
# Tauri suppresses linuxdeploy's stdout/stderr on failure; invoking
# it directly against the AppDir it already populated surfaces the
# actual error.
appdir="$(ls -d desktop/rust/target/release/bundle/appimage/*.AppDir 2>/dev/null | head -1)"
if [ -n "$appdir" ]; then
echo "appdir=$appdir"
~/.cache/tauri/linuxdeploy-$(uname -m).AppImage \
--appdir "$appdir" \
--output appimage \
--plugin gtk 2>&1 | tail -80
echo "direct linuxdeploy exit=${PIPESTATUS[0]}"
else
echo "No AppDir found — Tauri didn't reach the linuxdeploy step."
fi
echo
echo "=== Go sidecar linkage (source copy) ==="
src_sidecar="$(ls desktop/go/bin/leapmux-desktop-service-* 2>/dev/null | head -1)"
if [ -n "$src_sidecar" ]; then
echo "path=$src_sidecar"
file "$src_sidecar"
echo "--- readelf -l | grep INTERP ---"
readelf -l "$src_sidecar" 2>&1 | grep -E 'INTERP|interpreter' || echo "(no INTERP segment — static binary)"
echo "--- ldd output ---"
ldd "$src_sidecar" 2>&1
echo "ldd exit=$?"
else
echo "No sidecar found under desktop/go/bin/"
fi
echo
echo "=== Go sidecar linkage (AppDir copy) ==="
ad_sidecar="$(find "$appdir" -name 'leapmux-desktop-service-*' 2>/dev/null | head -1)"
if [ -n "$ad_sidecar" ]; then
echo "path=$ad_sidecar"
file "$ad_sidecar"
ldd "$ad_sidecar" 2>&1
echo "ldd exit=$?"
else
echo "No sidecar found inside AppDir."
fi
echo
echo "=== Toolchain sanity ==="
echo "gcc: $(command -v gcc) $(gcc --version 2>/dev/null | head -1)"
echo "CGO_ENABLED (go env): $(go env CGO_ENABLED)"
echo "/lib/ld-linux-aarch64.so.1: $(ls -la /lib/ld-linux-aarch64.so.1 2>&1 || true)"
- name: Test (full)
if: matrix.arch == 'amd64'
# `task test` lints and tests every module — amd64 is the canonical
# runner, so it exercises the full suite including the Docker-backed
# storage backends (postgres/mysql/cockroachdb/tidb/yugabytedb).
run: task test
- name: Test (no Docker-backed storage)
if: matrix.arch == 'arm64'
# testcontainers-backed tests for postgres/mysql/cockroachdb/tidb/
# yugabytedb exercise SQL-dialect glue, not the runner arch. Skip
# them on arm64 (and macOS/Windows) to save minutes and reduce
# flake surface; amd64 still runs the full suite every commit.
run: task test-no-docker
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- name: Derive cache epoch
run: echo "CACHE_WEEK=$(date -u +%G-W%V)" >> "$GITHUB_ENV"
- name: Load tool versions
run: grep -E '^[A-Z_][A-Z0-9_]*=' versions.env >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GOLANG_VERSION }}
cache-dependency-path: |
backend/go.sum
desktop/go/go.sum
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('frontend/bun.lock') }}
restore-keys: bun-${{ runner.os }}-${{ runner.arch }}-
- name: Cache Cargo dependencies
# See ci.yaml's linux job for why Swatinem/rust-cache + shared-key +
# save-if.
uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/rust
shared-key: desktop
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cache Tauri downloads
uses: actions/cache@v5
with:
path: ~/Library/Caches/tauri
key: tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_WEEK }}
restore-keys: tauri-${{ runner.os }}-${{ runner.arch }}-
- name: Cache awesome-claude-spinners
uses: actions/cache@v5
with:
path: frontend/src/spinners
key: spinners-${{ env.CACHE_WEEK }}
- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
version: ${{ env.BUF_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP macOS runners.
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup protoc
# Tauri/Rust's prost-build invokes protoc directly; the Linux job
# gets it from `apt install protobuf-compiler`.
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Task
uses: arduino/setup-task@v2
with:
version: ${{ env.TASK_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP runners — has been failing intermittently
# on Windows without it.
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate code (proto + sqlc)
run: task generate
- name: Install frontend dependencies
run: task prepare-frontend
- name: Test (no Docker-backed storage)
# `task test-no-docker` lints and tests frontend/backend/desktop
# with the Docker-backed storage backends excluded — amd64 Linux
# still runs the full suite on every commit.
run: task test-no-docker
- name: Build
# MACOS_CODESIGN_IDENTITY is intentionally unset here. The .app gets
# ad-hoc signed by Taskfile.yaml's build-desktop-darwin so the test
# job builds cleanly without secrets. Release artifacts (signed +
# notarized DMG) come from the desktop-artifacts reusable workflow,
# which the desktop-artifacts job below invokes only on push-to-main.
run: task build
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- name: Load tool versions
shell: bash
run: |
grep -E '^[A-Z_][A-Z0-9_]*=' versions.env >> "$GITHUB_ENV"
echo "CACHE_WEEK=$(date -u +%G-W%V)" >> "$GITHUB_ENV"
- name: Setup MSVC dev environment
# Puts link.exe, cl.exe, and the Windows SDK on PATH so Tauri's
# Rust build (and any CGO packages) can link against MSVC.
uses: ilammy/msvc-dev-cmd@v1
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GOLANG_VERSION }}
cache-dependency-path: |
backend/go.sum
desktop/go/go.sum
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy
- name: Cache Cargo dependencies
# See ci.yaml's linux job for why Swatinem/rust-cache + shared-key +
# save-if.
uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/rust
shared-key: desktop
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cache Tauri downloads
uses: actions/cache@v5
with:
path: ~/AppData/Local/tauri
key: tauri-${{ runner.os }}-${{ env.CACHE_WEEK }}
restore-keys: tauri-${{ runner.os }}-
- name: Cache awesome-claude-spinners
uses: actions/cache@v5
with:
path: frontend/src/spinners
key: spinners-${{ env.CACHE_WEEK }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('frontend/bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
version: ${{ env.BUF_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP macOS runners.
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup protoc
# Tauri/Rust's prost-build invokes protoc directly; the Linux job
# gets it from `apt install protobuf-compiler`.
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Task
uses: arduino/setup-task@v2
with:
version: ${{ env.TASK_VERSION }}
# Authenticate the action's GitHub API calls (listing releases,
# downloading assets) so it doesn't hit the 60/hr unauthenticated
# rate limit on shared-IP runners — has been failing intermittently
# on Windows without it.
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Test (no Docker-backed storage)
# Run under pwsh (not bash) for the same reason `task build` does:
# Git Bash prepends /usr/bin to PATH, and /usr/bin/link.exe
# shadows MSVC's link.exe, which breaks `cargo test` (invoked by
# `task test-desktop`). pwsh inherits MSVC's PATH cleanly.
shell: pwsh
run: task test-no-docker
- name: Build all modules
# Run under pwsh rather than bash: Git Bash prepends /usr/bin to
# PATH, and /usr/bin/link.exe (a Unix hard-link utility shipped
# with Git for Windows) shadows MSVC's link.exe, causing the
# Rust/Tauri build to fail with "/usr/bin/link: extra operand".
shell: pwsh
run: task build
docker:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Load tool versions and git metadata
run: |
grep -E '^[A-Z_][A-Z0-9_]*=' versions.env >> "$GITHUB_ENV"
echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
echo "COMMIT_TIME=$(git log -1 --format=%cI)" >> "$GITHUB_ENV"
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker build (dry run)
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
push: false
build-args: |
BASE_IMAGE=${{ env.ALPINE_IMAGE }}
BUF_VERSION=${{ env.BUF_VERSION }}
TASK_VERSION=${{ env.TASK_VERSION }}
S6_OVERLAY_VERSION=${{ env.S6_OVERLAY_VERSION }}
BUN_VERSION=${{ env.BUN_VERSION }}
NODE_VERSION=${{ env.NODE_VERSION }}
GOLANG_VERSION=${{ env.GOLANG_VERSION }}
VERSION=${{ env.VERSION }}
COMMIT_HASH=${{ env.COMMIT_HASH }}
COMMIT_TIME=${{ env.COMMIT_TIME }}
cache-from: type=gha,scope=alpine
cache-to: type=gha,mode=max,scope=ci-docker
desktop-artifacts:
# Build, sign, and notarize the binaries that get attached to the
# auto-generated prerelease. Runs only on push-to-main; PR runs do not
# trigger this and therefore never request the apple-signing environment
# secrets the macos-app-sign step needs.
#
# `secrets: inherit` is required so the macos-app-sign job in the called
# workflow can read the apple-signing environment secrets — without it,
# `${{ secrets.X }}` in the called workflow resolves to empty even for
# env-scoped secrets, and `security import` fails with errSecParam on
# an empty .p12. Env scoping still holds: only jobs that declare
# `environment: apple-signing` can read those secrets.
needs: [linux, macos, windows]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: ./.github/workflows/desktop-artifacts.yaml
with:
ref: ${{ github.sha }}
sign_macos: true
secrets: inherit
prerelease:
needs: desktop-artifacts
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
# Full history + tags so `git log <prev-prerelease>..HEAD` can
# build the changelog block in the release notes below.
fetch-depth: 0
- name: Download installers
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: desktop-*
merge-multiple: true
- name: Compute tag and write release notes
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# UTC timestamp (YYYYMMDD-HHMMSS) sorts lexicographically the same
# as chronologically, so GitHub's name-descending release list
# surfaces the newest pre-release at the top.
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
SHORT_SHA=${GITHUB_SHA::7}
. ./versions.env
TAG="prerelease-${TIMESTAMP}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
RELEASE_URL="${REPO_URL}/releases/download/${TAG}"
# Find the most recent auto-generated pre-release tag to bound
# the changelog. Match the same strict regex the prune step
# uses so stable `vX.Y.Z` releases are ignored.
PREV_TAG=$(gh release list --limit 100 \
--json tagName,isPrerelease,createdAt \
--jq '[.[] | select(.isPrerelease) | select(.tagName | startswith("prerelease-"))] | sort_by(.createdAt) | reverse | .[0].tagName // empty')
# Write the static part of the release notes. Unquoted heredoc
# expands ${VERSION}/${GITHUB_SHA}/${REPO_URL}/${RELEASE_URL};
# backticks are escaped so they stay literal in the markdown
# instead of being re-parsed. Each filename is wrapped in a
# markdown link to its download URL on this release.
cat > release-notes.md <<EOF
Pre-release from commit [\`${SHORT_SHA}\`](${REPO_URL}/commit/${GITHUB_SHA}).
## Artifacts
- LeapMux Desktop
- macOS
- [\`LeapMuxDesktop_${VERSION}_arm64.dmg\`](${RELEASE_URL}/LeapMuxDesktop_${VERSION}_arm64.dmg)
- Windows
- [\`LeapMuxDesktop_${VERSION}_x64.msi\`](${RELEASE_URL}/LeapMuxDesktop_${VERSION}_x64.msi)
- Linux
- [\`leapmux-desktop_${VERSION}_x86_64.AppImage\`](${RELEASE_URL}/leapmux-desktop_${VERSION}_x86_64.AppImage)
- [\`leapmux-desktop_${VERSION}_aarch64.AppImage\`](${RELEASE_URL}/leapmux-desktop_${VERSION}_aarch64.AppImage)
- [\`leapmux-desktop_${VERSION}_amd64.deb\`](${RELEASE_URL}/leapmux-desktop_${VERSION}_amd64.deb)
- [\`leapmux-desktop_${VERSION}_arm64.deb\`](${RELEASE_URL}/leapmux-desktop_${VERSION}_arm64.deb)
- LeapMux Server
- macOS
- [\`leapmux_${VERSION}_darwin_arm64.tar.gz\`](${RELEASE_URL}/leapmux_${VERSION}_darwin_arm64.tar.gz)
- Windows
- [\`leapmux_${VERSION}_windows_amd64.zip\`](${RELEASE_URL}/leapmux_${VERSION}_windows_amd64.zip)
- Linux
- [\`leapmux_${VERSION}_linux_amd64.tar.gz\`](${RELEASE_URL}/leapmux_${VERSION}_linux_amd64.tar.gz)
- [\`leapmux_${VERSION}_linux_arm64.tar.gz\`](${RELEASE_URL}/leapmux_${VERSION}_linux_arm64.tar.gz)
EOF
# Append the changelog. Write the commit list via `git log` output
# appended raw — avoids running commit subjects through another
# round of shell parsing, so subjects containing backticks or
# dollar signs stay intact in the rendered markdown. Each
# abbreviated hash is wrapped in a markdown link to the commit
# on this repo (using the full %H SHA in the URL).
{
echo
if [ -n "$PREV_TAG" ] && git rev-parse --verify "$PREV_TAG" >/dev/null 2>&1; then
echo "## Changes since \`${PREV_TAG}\`"
echo
git log --pretty=format:"- [\`%h\`](${REPO_URL}/commit/%H) %s" "${PREV_TAG}..HEAD"
echo
else
echo "## Recent changes"
echo
git log --pretty=format:"- [\`%h\`](${REPO_URL}/commit/%H) %s" -20
echo
fi
} >> release-notes.md
- name: Create pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.meta.outputs.tag }}" \
--prerelease \
--target "$GITHUB_SHA" \
--title "${{ steps.meta.outputs.tag }}" \
--notes-file release-notes.md \
artifacts/*.tar.gz \
artifacts/*.zip \
artifacts/*.AppImage \
artifacts/*.deb \
artifacts/*.dmg \
artifacts/*.msi
- name: Prune old pre-releases (keep latest 3)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Filter to auto-generated prereleases (isPrerelease=true AND tag
# starts with `prerelease-`), so manual releases from the
# `Release` workflow (tags like `v1.2.3`) are never touched.
# Sort newest first, drop the first 3, delete the rest
# (tag + release).
gh release list --limit 100 \
--json tagName,isPrerelease,createdAt \
--jq '[.[] | select(.isPrerelease) | select(.tagName | startswith("prerelease-"))] | sort_by(.createdAt) | reverse | .[3:] | .[].tagName' \
| while read -r tag; do
[ -n "$tag" ] || continue
echo "Deleting old pre-release: $tag"
gh release delete "$tag" --yes --cleanup-tag
done