fix(agent): Make claudeModelsByID available on non-unix platforms #1081
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |