Skip to content

release: v0.31.0

release: v0.31.0 #118

Workflow file for this run

name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Release tag to recover, e.g. v0.1.32
required: true
type: string
permissions:
contents: write
# Serialize per release tag, so a tag-push and a recovery dispatch for the same tag can
# never build and publish it concurrently. Both triggers resolve to the same group: a push
# has no `tag` input and carries the tag in ref_name; a dispatch passes it explicitly.
# Never cancel-in-progress — a half-cancelled release is what leaves a tag stuck.
concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
resolve-release-ref:
name: Resolve Release Ref
# Every downstream job needs this one, so guarding the root skips the entire
# release graph in forks (which lack the deploy/release secrets anyway).
if: github.repository == 'phase-rs/phase'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
release_tag: ${{ steps.resolve.outputs.release_tag }}
release_sha: ${{ steps.resolve.outputs.release_sha }}
is_calver: ${{ steps.resolve.outputs.is_calver }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve tag and commit
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
PUSH_TAG: ${{ github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_TAG"
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::workflow_dispatch tag must be a release tag like v0.1.32"
exit 1
fi
else
TAG="$PUSH_TAG"
fi
git fetch --force --tags origin "refs/tags/$TAG:refs/tags/$TAG"
SHA=$(git rev-list -n 1 "$TAG")
if [[ "$TAG" =~ ^v[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}$ ]]; then
IS_CALVER=true
else
IS_CALVER=false
fi
echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_sha=$SHA" >> "$GITHUB_OUTPUT"
echo "is_calver=$IS_CALVER" >> "$GITHUB_OUTPUT"
tauri-preflight:
name: Tauri Preflight
needs: [resolve-release-ref]
# Fast-fail sanity check before paying for the full release matrix. Catches
# the structural class of failures that historically only surfaced 30-45
# minutes into the build-tauri job: malformed JSON, version drift between
# workspace Cargo.toml / tauri.conf.json / package.json (which
# cargo-release-local should keep in sync but won't if the release commit
# was hand-edited), and Rust-side compile errors in phase-tauri (excluded
# from regular CI's clippy/test runs in ci.yml). Linux-only — caught
# 99% of failures historically come from cross-platform-agnostic config or
# generic Rust mistakes, not from platform-specific Tauri internals.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- name: Verify tag matches workspace + Tauri + package versions
run: |
set -euo pipefail
TAG="$RELEASE_TAG"
EXPECTED="${TAG#v}"
WORKSPACE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
TAURI_CONF_VERSION=$(jq -r '.version' client/src-tauri/tauri.conf.json)
PACKAGE_JSON_VERSION=$(jq -r '.version' client/package.json)
fail=0
if [ "$WORKSPACE_VERSION" != "$EXPECTED" ]; then
echo "::error::Workspace Cargo.toml version $WORKSPACE_VERSION does not match tag $TAG"; fail=1
fi
if [ "$TAURI_CONF_VERSION" != "$EXPECTED" ]; then
echo "::error::tauri.conf.json version $TAURI_CONF_VERSION does not match tag $TAG"; fail=1
fi
if [ "$PACKAGE_JSON_VERSION" != "$EXPECTED" ]; then
echo "::error::client/package.json version $PACKAGE_JSON_VERSION does not match tag $TAG"; fail=1
fi
[ "$fail" = "0" ]
- name: Validate Tauri JSON configs
run: |
set -euo pipefail
jq empty client/src-tauri/tauri.conf.json
for f in client/src-tauri/capabilities/*.json; do
jq empty "$f"
done
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-tauri-preflight
- name: Install Linux Tauri build deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- name: Stub frontend dist for tauri::generate_context!
# `cargo check -p phase-tauri` runs `tauri::generate_context!()` which
# needs `frontendDist` (../dist per tauri.conf.json) to exist. We don't
# need the real frontend bundle for type-checking — a stub is enough.
run: |
mkdir -p client/dist
echo '<!doctype html><html></html>' > client/dist/index.html
- name: Stub phase-server sidecar + bundled resources for tauri-build
# tauri.conf.json declares `externalBin: ["binaries/phase-server"]`
# and bundled data resources, so tauri-build's resource resolver
# requires those paths to exist
# at check time. The real sidecar / card-data are produced by the
# build-tauri matrix; for the preflight we only need the paths to
# exist (tauri-build validates existence, not content). Empty
# placeholders are sufficient.
run: |
mkdir -p client/src-tauri/binaries client/public
touch client/src-tauri/binaries/phase-server-x86_64-unknown-linux-gnu
touch client/public/card-data.json
touch client/public/draft-pools.json
- name: cargo check phase-tauri
# phase-tauri is excluded from the root workspace (client/src-tauri),
# so `-p phase-tauri` from the repo root cannot resolve it. Drive the
# check via the crate's own manifest instead.
run: cargo check --locked --manifest-path client/src-tauri/Cargo.toml
build-wasm:
name: Build WASM + Frontend
needs: [resolve-release-ref, tauri-preflight]
runs-on: ubuntu-latest
# Card-data generation grows with the card/deck corpus: this job hit 28m for
# v0.4.0 and crossed the prior 30m ceiling for v0.5.0 (killed mid-frontend
# build, stranding the release). Raised to 60m for headroom — the ceiling
# only bills the failure case, so a normal ~30m run still ends at ~30m.
timeout-minutes: 60
env:
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-wasm
- uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.121
- name: Cache binaryen
id: binaryen-cache
uses: actions/cache@v4
with:
path: binaryen-version_123
key: binaryen-123-x86_64-linux
- name: Install binaryen
if: steps.binaryen-cache.outputs.cache-hit != 'true'
run: curl -L https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-linux.tar.gz | tar xz
- name: Add binaryen to PATH
run: echo "$PWD/binaryen-version_123/bin" >> $GITHUB_PATH
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: client/pnpm-lock.yaml
- name: Set cache keys
id: cache-keys
run: |
echo "week=$(date +%Y-W%V)" >> "$GITHUB_OUTPUT"
echo "day=$(date +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- name: Cache MTGJSON data
# Full data/mtgjson dir under the `mtgjson-full-` namespace (shared with
# deploy.yml — both run gen-card-data.sh and populate every file). Kept
# distinct from ci.yml's partial `mtgjson-atomic-`/`mtgjson-sets-`
# caches; all stay under the `mtgjson-` prefix clear-caches.yml deletes.
#
# Exact weekly key, NO restore-keys: a new week misses so gen-card-data.sh
# re-fetches fresh MTGJSON data; a restore-keys fallback would pin the
# file-existence-gated fetches to last week's data forever.
id: mtgjson-cache
uses: actions/cache@v4
with:
path: data/mtgjson
key: mtgjson-full-${{ steps.cache-keys.outputs.week }}
# Daily key with NO restore-keys: the gen-scryfall-*.sh scripts skip the
# download when the file already exists, so a stale restore-key hit would
# pin them to old data forever. Exact-key-only means a new day misses,
# the scripts re-fetch, and same-day deploys reuse the fetch — cutting
# Scryfall API load (and the Cloudflare-throttle flakiness it causes).
- name: Cache Scryfall bulk data
uses: actions/cache@v4
with:
path: data/scryfall
key: scryfall-${{ steps.cache-keys.outputs.day }}
- name: Download MTGJSON data
# Gate on file existence, not cache-hit: a restore-keys (non-exact) hit
# leaves cache-hit=false even though AtomicCards.json is already present,
# so the old `if: cache-hit != true` re-downloaded it every new week.
# File-existence gating fetches only when genuinely missing and lets a
# poisoned cache self-heal (matches ci.yml / deploy.yml).
run: |
if [ ! -f data/mtgjson/AtomicCards.json ]; then
mkdir -p data/mtgjson
source scripts/lib/mtgjson-fetch.sh
mtgjson_download AtomicCards.json data/mtgjson/AtomicCards.json
fi
- name: Generate card data and coverage report
# Single source of truth: gen-card-data.sh produces all 7 public JSON
# files. Deploy and release must use the same generator the local
# workflow does — no YAML reimplementation.
run: |
mkdir -p data
./scripts/gen-card-data.sh
# gen-card-data.sh mirrors card-data.json + coverage-data.json into
# data/ itself, so downstream steps can read from data/ uniformly.
- name: Download draft set data
# fetch-draft-sets.sh needs SetList.json (produced by gen-card-data.sh).
# Per-set files land in data/mtgjson/sets/ which is covered by the
# mtgjson cache — subsequent runs skip already-downloaded sets.
run: ./scripts/fetch-draft-sets.sh
- name: Generate draft pools
# Reads data/mtgjson/sets/*.json → writes client/public/draft-pools.json.
# Included in data-files.json manifest so it's uploaded to R2 and
# stripped from the Pages dist automatically.
# Profile `tool`, not `release`: this is a one-shot JSON transform, not a
# shipped binary. `release` is the WASM-size profile (lto + codegen-units=1)
# — the slowest compile in the repo. draft-pool-gen lives in draft-core
# and can't take `--features cli`, so it gets its own `[tool,default]`
# engine fingerprint regardless; `tool` just makes that compile cheap.
run: cargo run --profile tool --bin draft-pool-gen
- name: Validate card-data against engine schema
# Deploy gate — refuse to ship card-data the current engine cannot parse.
# `--profile tool --features cli` matches gen-card-data.sh's build above,
# so the engine + this bin are already compiled — a cache hit, not a
# second (release+LTO) engine compile.
run: cargo run --profile tool --features cli --bin card-data-validate -- client/public/card-data.json
- name: Compute content-addressed card-data filename
id: card-data-hash
# Pin this WASM bundle to its own card-data forever via an immutable
# `card-data-<hash>.json` URL baked into the JS bundle. Old browser
# caches keep resolving their old hash even after a new release.
run: |
HASH=$(sha256sum client/public/card-data.json | awk '{print substr($1, 1, 16)}')
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "filename=card-data-$HASH.json" >> "$GITHUB_OUTPUT"
cp client/public/card-data.json "client/public/card-data-$HASH.json"
- name: Generate Scryfall data
run: |
./scripts/gen-scryfall-images.sh
./scripts/gen-scryfall-token-images.sh
./scripts/gen-scryfall-sets.sh
./scripts/gen-scryfall-printings.sh
- name: Run semantic audit
# Produces data/semantic-audit.json with structured findings for cards
# that parse without `Unimplemented` markers but disagree semantically
# with their Oracle text. Release uploads use data-files.json as the
# single source of truth, so every manifest entry must exist before R2
# upload begins.
# Pass data/ explicitly — gen-card-data.sh mirrors card-data.json into
# data/ so this matches deploy.yml and avoids relying on client/public.
run: cargo semantic-audit data/
- name: Stamp shared card_data_hash on coverage + audit JSONs
# Both files are uploaded to R2 as separate PUTs with no transactional
# guarantee. Embedding the same card_data_hash in both lets downstream
# consumers verify they're reading a consistent snapshot.
env:
CARD_DATA_HASH: ${{ steps.card-data-hash.outputs.hash }}
run: |
jq --arg h "$CARD_DATA_HASH" '. + {card_data_hash: $h}' data/semantic-audit.json > client/public/semantic-audit.json
jq --arg h "$CARD_DATA_HASH" '. + {card_data_hash: $h}' client/public/coverage-data.json > client/public/coverage-data.json.tmp
mv client/public/coverage-data.json.tmp client/public/coverage-data.json
- name: Stage frontend data files artifact
run: |
rm -rf release-data
mkdir -p release-data
while IFS= read -r f; do
if [ ! -f "client/public/$f" ]; then
echo "::error::Expected data file client/public/$f was not generated"
exit 1
fi
cp "client/public/$f" "release-data/$f"
done < <(jq -r '.[]' data-files.json)
- name: Upload frontend data files artifact
uses: actions/upload-artifact@v4
with:
name: frontend-data-files
path: release-data/*
if-no-files-found: error
retention-days: 1
- name: Build WASM
run: ./scripts/build-wasm.sh release
- name: Build frontend
env:
# Standardized data URL config — see vite.config.ts. DATA_BASE_URL is
# the directory holding every shared JSON. CARD_DATA_URL pins the
# WASM bundle to its content-addressed card-data forever.
DATA_BASE_URL: "https://data.phase-rs.dev"
CARD_DATA_URL: "https://data.phase-rs.dev/${{ steps.card-data-hash.outputs.filename }}"
AUDIO_BASE_URL: "https://data.phase-rs.dev/audio"
# Tagged production release: surfaces the "try the preview build" CTA
# on the menu (see __IS_RELEASE_BUILD__ in vite.config.ts). The staging
# deploy (deploy.yml) deliberately omits this so it never self-links.
RELEASE_BUILD: "true"
# Cloud-sync config. The anon/publishable key is client-safe (RLS is the
# access control), so it's baked into the bundle. Empty (secret unset) →
# cloud sync stays disabled and the app falls back to file backup.
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
# First-party telemetry ingest (lobby Worker → Analytics Engine, see
# docs/telemetry-proposal.md). Unset would compile telemetry to a no-op.
TELEMETRY_URL: "https://lobby.phase-rs.dev/telemetry"
run: |
cd client
pnpm install --frozen-lockfile
pnpm build
- name: Upload data to R2
# Single source of truth: data-files.json at the repo root drives this
# loop, the verify step below, and vite.config.ts URL defines.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CARD_DATA_FILENAME: ${{ steps.card-data-hash.outputs.filename }}
run: |
# The r2.dev public endpoint does NOT compress on the fly, so these
# JSONs were served raw. Pre-compress with brotli and store
# Content-Encoding: br — every consumer fetches via browser/Bun
# fetch(), which decodes br transparently. q9: ~90% reduction at a
# fraction of q11's CPU.
#
# Compress into a temp dir, never client/public: card-data.json /
# draft-pools.json are re-read after this step and baked UNCOMPRESSED
# into the server image (loaded from disk).
command -v brotli >/dev/null || { sudo apt-get update && sudo apt-get install -y brotli; }
BRDIR="$(mktemp -d)"
# Content-addressed card-data: immutable, year-long cache. The hash is
# over the uncompressed file, so the URL stays a valid content key.
brotli -q 9 -c "client/public/$CARD_DATA_FILENAME" > "$BRDIR/card-data.br"
npx wrangler r2 object put "phase-rs-data/$CARD_DATA_FILENAME" --file "$BRDIR/card-data.br" --remote --content-type application/json --content-encoding br --cache-control "public, max-age=31536000, immutable"
# Mutable shared JSONs. Loop over the manifest.
while IFS= read -r f; do
if [ ! -f "client/public/$f" ]; then
echo "::error::Expected data file client/public/$f was not generated"
exit 1
fi
brotli -q 9 -c "client/public/$f" > "$BRDIR/$f.br"
npx wrangler r2 object put "phase-rs-data/$f" --file "$BRDIR/$f.br" --remote --content-type application/json --content-encoding br --cache-control "public, max-age=60, must-revalidate"
done < <(jq -r '.[]' data-files.json)
- name: Verify R2 uploads landed
env:
CARD_DATA_FILENAME: ${{ steps.card-data-hash.outputs.filename }}
run: |
BASE="https://data.phase-rs.dev"
fail=0
check() {
local f="$1"
local status
status=$(curl -sS -I -o /dev/null -w "%{http_code}" \
--connect-timeout 5 \
--max-time 15 \
--retry 3 \
--retry-all-errors \
"$BASE/$f" || true)
if [ "$status" != "200" ]; then
echo "::error::R2 upload verification failed: $BASE/$f returned $status"
fail=1
else
echo "Verified $BASE/$f ($status)"
fi
}
check "$CARD_DATA_FILENAME"
while IFS= read -r f; do check "$f"; done < <(jq -r '.[]' data-files.json)
# Prove the brotli round-trip end-to-end on one small file: --compressed
# requests + decodes br, jq confirms it decoded to valid JSON.
curl -fsS --compressed --connect-timeout 5 --max-time 30 --retry 3 --retry-all-errors "$BASE/scryfall-sets.json" | jq empty \
|| { echo "::error::brotli round-trip failed for scryfall-sets.json"; fail=1; }
[ "$fail" = "0" ]
- name: Remove R2-hosted data files from dist (Pages ships only the shell)
# Single source of truth: data-files.json lists every JSON the frontend
# fetches from R2. The same manifest drives the R2 upload loop above —
# this step is its inverse, stripping those files from the Pages
# bundle so we never double-ship bytes. Adding a new data file is
# now one line in data-files.json and nothing else.
#
# Separately handled:
# - card-data.json (non-manifest; kept in public/ for Tauri + server
# bundles + local dev, but never deployed to Pages)
# - card-data-<16hex>.json[.br] (content-addressed, lives on R2;
# glob matches only 16-hex suffixes, cannot match the 4-char
# `meta` suffix). This replaces the previous `find -regex` which
# used GNU find's default "emacs" regex flavor where `?` is a
# literal — so `\(\.br\)?` matched nothing and the 80 MiB hashed
# file slipped through, exceeding Pages' 25 MiB per-file cap.
shell: bash
run: |
shopt -s nullglob
while IFS= read -r f; do
rm -f "client/dist/$f" "client/dist/$f.br"
done < <(jq -r '.[]' data-files.json)
rm -f client/dist/card-data.json client/dist/card-data.json.br \
client/dist/card-data-????????????????.json \
client/dist/card-data-????????????????.json.br
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: client/dist
retention-days: 90
- name: Upload card data artifact
uses: actions/upload-artifact@v4
with:
name: card-data
path: client/public/card-data.json
retention-days: 1
- name: Upload draft pools artifact
uses: actions/upload-artifact@v4
with:
name: draft-pools
path: client/public/draft-pools.json
retention-days: 1
# ── Server binary compile (linux musl, shared) ──────────────────────────────
# Compiles the static musl binary ONCE per release run. Consumed by both
# build-server-image (prebuilt Docker image) and build-server's linux leg
# (release archive), so neither pays a redundant ~10min compile. Needs nothing
# the data pipeline produces, so it starts immediately.
build-server-binary:
name: Build Server Binary (linux)
needs: [resolve-release-ref]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-server-linux
- name: Install musl tools and target
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
rustup target add x86_64-unknown-linux-musl
- name: Build phase-server (static musl)
# No chmod here: upload-artifact strips the executable bit anyway; both
# consumers (image build, archive packaging) re-apply it.
run: |
cargo build --profile server-release --bin phase-server --target x86_64-unknown-linux-musl
cp target/x86_64-unknown-linux-musl/server-release/phase-server ./phase-server
- name: Upload server binary artifact
uses: actions/upload-artifact@v4
with:
name: server-binary-linux
path: phase-server
if-no-files-found: error
retention-days: 1
build-server-image:
name: Build Server Image
needs: [resolve-release-ref, build-wasm, build-server-binary]
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/phase-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- name: Download card data
uses: actions/download-artifact@v4
with:
name: card-data
path: data
- name: Download draft pools
uses: actions/download-artifact@v4
with:
name: draft-pools
path: data
- name: Download server binary
uses: actions/download-artifact@v4
with:
name: server-binary-linux
path: .
- name: Verify build context
run: |
test -s phase-server
chmod +x phase-server
test -s data/card-data.json
test -s data/draft-pools.json
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push release image
# BINARY_STAGE=prebuilt: copy the musl binary from build-server-binary
# instead of compiling in-container — the build is just apt + COPY.
uses: docker/build-push-action@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: false
with:
context: .
push: true
build-args: |
BINARY_STAGE=prebuilt
tags: |
${{ env.IMAGE_NAME }}:${{ env.RELEASE_TAG }}
${{ env.IMAGE_NAME }}:latest
deploy-production:
name: Deploy Production (Cloudflare Pages)
needs: [build-wasm, release]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Need the repo checked out so we can inject the Cloudflare-only
# _headers file alongside the prebuilt frontend artifact. The file
# is held outside client/public/ so the GitHub Pages preview deploy
# (deploy.yml) never serves it at /_headers.
- name: Checkout repository
uses: actions/checkout@v4
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist
- name: Inject Cloudflare Pages _headers
run: cp client/deploy/cloudflare-pages/_headers dist/_headers
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=phase-rs --branch=main --commit-dirty=true
deploy-lobby-worker:
# Builds the Rust lobby-broker -> WASM and deploys the Cloudflare Worker +
# Durable Object (the matchmaking broker). Independent of the Pages deploy —
# the DO is reachable via its own endpoint and is not yet the client's
# DEFAULT_SERVER. Redeploys are idempotent (the [[migrations]] tag is only
# applied once). The wasm build is driven by wrangler's [build] command
# (scripts/build-broker-wasm.sh), so the rust/wasm toolchain must be on PATH.
name: Deploy Lobby Worker (Cloudflare)
needs: [resolve-release-ref]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release-ref.outputs.release_sha }}
- name: Detect lobby Worker source
id: lobby-worker
run: |
if [ -d lobby-worker ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "lobby-worker/ is not present in this release commit; skipping lobby Worker deploy."
fi
- uses: actions-rust-lang/setup-rust-toolchain@v1
if: steps.lobby-worker.outputs.exists == 'true'
with:
targets: wasm32-unknown-unknown
cache-shared-key: rust-wasm
- uses: taiki-e/install-action@v2
if: steps.lobby-worker.outputs.exists == 'true'
with:
# Must match the pinned wasm-bindgen crate version in
# lobby-worker/broker-wasm/Cargo.toml (schema versions must be exact).
tool: wasm-bindgen-cli@0.2.121
- name: Cache binaryen
id: binaryen-cache
if: steps.lobby-worker.outputs.exists == 'true'
uses: actions/cache@v4
with:
path: binaryen-version_123
key: binaryen-123-x86_64-linux
- name: Install binaryen
if: steps.lobby-worker.outputs.exists == 'true' && steps.binaryen-cache.outputs.cache-hit != 'true'
run: curl -L https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-linux.tar.gz | tar xz
- name: Add binaryen to PATH
if: steps.lobby-worker.outputs.exists == 'true'
run: echo "$PWD/binaryen-version_123/bin" >> "$GITHUB_PATH"
- name: Deploy lobby Worker to Cloudflare
if: steps.lobby-worker.outputs.exists == 'true'
# `wrangler deploy` runs the [build] command in lobby-worker/wrangler.toml
# (scripts/build-broker-wasm.sh release): cargo build -> wasm32,
# wasm-bindgen, wasm-opt. The toolchain installed above is inherited.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4.94.0"
workingDirectory: lobby-worker
command: deploy
build-server:
name: Build Server (${{ matrix.os }})
needs: [resolve-release-ref, tauri-preflight, build-wasm, build-server-binary]
runs-on: ${{ matrix.runner }}
# The Windows leg ran ~33-34 min warm against a 35-min ceiling, so any cache
# miss tripped the timeout and cancelled the release (a timed-out job reports
# as `cancelled`, which fails the publish job's `build-server == success`
# gate). The server-release profile change cuts this well below 35; the
# headroom here guards against a cold cache or a slow runner.
timeout-minutes: 55
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
runner: ubuntu-latest
triple: x86_64-unknown-linux-musl
artifact: phase-server-linux-x86_64
archive_ext: tar.gz
- os: macos
runner: macos-latest
triple: aarch64-apple-darwin
artifact: phase-server-macos-arm64
archive_ext: tar.gz
- os: windows
runner: windows-latest
triple: x86_64-pc-windows-msvc
artifact: phase-server-windows-x86_64
archive_ext: zip
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-server-${{ matrix.os }}
- name: Build phase-server (macOS/Windows)
# Linux musl is compiled once in build-server-binary and reused below;
# only the non-musl targets compile here.
if: matrix.triple != 'x86_64-unknown-linux-musl'
run: cargo build --profile server-release --bin phase-server --target ${{ matrix.triple }}
- name: Download prebuilt linux binary
# Reuse the single musl build from build-server-binary, placed at the
# canonical target path so the packaging step is identical across OSes.
if: matrix.triple == 'x86_64-unknown-linux-musl'
uses: actions/download-artifact@v4
with:
name: server-binary-linux
path: target/x86_64-unknown-linux-musl/server-release
- name: Download card data
uses: actions/download-artifact@v4
with:
name: card-data
path: staging/data
- name: Download draft pools
uses: actions/download-artifact@v4
with:
name: draft-pools
path: staging/data
- name: Package server archive (Unix)
if: matrix.os != 'windows'
run: |
TAG="$RELEASE_TAG"
mkdir -p staging
cp "target/${{ matrix.triple }}/server-release/phase-server" staging/
chmod +x staging/phase-server
cd staging
tar czf ../${{ matrix.artifact }}.tar.gz phase-server data/card-data.json data/draft-pools.json
shell: bash
- name: Package server archive (Windows)
if: matrix.os == 'windows'
run: |
$Tag = $env:RELEASE_TAG
Copy-Item "target/${{ matrix.triple }}/server-release/phase-server.exe" "staging/"
Compress-Archive -Path "staging/phase-server.exe","staging/data/card-data.json","staging/data/draft-pools.json" -DestinationPath "${{ matrix.artifact }}.zip"
shell: pwsh
- name: Generate checksum (Unix)
if: matrix.os != 'windows'
run: shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256
- name: Generate checksum (Windows)
if: matrix.os == 'windows'
run: |
$hash = (Get-FileHash "${{ matrix.artifact }}.zip" -Algorithm SHA256).Hash.ToLower()
"$hash ${{ matrix.artifact }}.zip" | Out-File -Encoding utf8 "${{ matrix.artifact }}.zip.sha256"
shell: pwsh
- name: Upload server archive
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: |
${{ matrix.artifact }}.${{ matrix.archive_ext }}
${{ matrix.artifact }}.${{ matrix.archive_ext }}.sha256
retention-days: 90
build-tauri:
name: Build Tauri (${{ matrix.os }})
needs: [resolve-release-ref, tauri-preflight, build-wasm]
runs-on: ${{ matrix.runner }}
env:
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
runner: ubuntu-latest
artifact: phase-tauri-linux
sidecar_triple: x86_64-unknown-linux-gnu
- os: macos
runner: macos-latest
artifact: phase-tauri-macos
sidecar_triple: aarch64-apple-darwin
- os: windows
runner: windows-latest
artifact: phase-tauri-windows
sidecar_triple: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-tauri-${{ matrix.os }}
- uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.121
- name: Install binaryen (Linux)
if: matrix.os == 'linux'
run: |
curl -L https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-linux.tar.gz | tar xz
echo "$PWD/binaryen-version_123/bin" >> $GITHUB_PATH
- name: Install binaryen (macOS)
if: matrix.os == 'macos'
run: brew install binaryen
- name: Install binaryen (Windows)
if: matrix.os == 'windows'
run: |
curl -L -o binaryen.tar.gz https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-windows.tar.gz
tar xzf binaryen.tar.gz
echo "$PWD\binaryen-version_123\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
shell: pwsh
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: client/pnpm-lock.yaml
- name: Install system dependencies (Linux)
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf musl-tools
rustup target add x86_64-unknown-linux-musl
- name: Download card data
uses: actions/download-artifact@v4
with:
name: card-data
path: client/public
- name: Download frontend data files
uses: actions/download-artifact@v4
with:
name: frontend-data-files
path: client/public
- name: Verify Tauri bundled data files
run: |
test -s client/public/card-data.json
node - <<'NODE'
const fs = require("fs");
const files = JSON.parse(fs.readFileSync("data-files.json", "utf8"));
for (const file of files) {
const path = `client/public/${file}`;
const stat = fs.statSync(path);
if (stat.size === 0) throw new Error(`${path} is empty`);
}
NODE
shell: bash
- name: Build phase-server sidecar
run: |
if [ "${{ matrix.os }}" = "linux" ]; then
cargo build --profile server-release --bin phase-server --target x86_64-unknown-linux-musl
else
cargo build --profile server-release --bin phase-server
fi
mkdir -p client/src-tauri/binaries
shell: bash
- name: Place sidecar binary (Unix)
if: matrix.os != 'windows'
run: |
if [ "${{ matrix.os }}" = "linux" ]; then
cp target/x86_64-unknown-linux-musl/server-release/phase-server client/src-tauri/binaries/phase-server-${{ matrix.sidecar_triple }}
else
cp target/server-release/phase-server client/src-tauri/binaries/phase-server-${{ matrix.sidecar_triple }}
fi
- name: Place sidecar binary (Windows)
if: matrix.os == 'windows'
run: Copy-Item "target/server-release/phase-server.exe" "client/src-tauri/binaries/phase-server-${{ matrix.sidecar_triple }}.exe"
shell: pwsh
- name: Build WASM for Tauri
run: ./scripts/build-wasm.sh release
shell: bash
- name: Install frontend dependencies
run: cd client && pnpm install --frozen-lockfile
# Tauri selects its macOS signing path by the PRESENCE of these vars, not their
# value (tauri-bundler macos/sign.rs keys off `var_os("APPLE_CERTIFICATE")`), and a
# workflow `env:` key backed by an undefined secret is still DEFINED -- as "". So
# listing the Apple secrets directly on the build step made Tauri take the
# import-certificate branch and run `security import` on an empty certificate,
# failing the whole macOS bundle. $GITHUB_ENV only defines what it is handed, so
# export the signing vars only when a certificate actually exists. With no
# certificate, ad-hoc self-sign: identity "-" is passed straight to `codesign -s -`
# and needs no keychain (tauri-macos-sign Keychain::with_signing_identity).
- name: Configure macOS signing
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
shell: bash
run: |
set -euo pipefail
if [ -z "${APPLE_CERTIFICATE:-}" ]; then
echo "No APPLE_CERTIFICATE secret; ad-hoc self-signing the macOS bundle."
echo "APPLE_SIGNING_IDENTITY=-" >> "$GITHUB_ENV"
exit 0
fi
echo "APPLE_CERTIFICATE found; signing with the Developer ID certificate."
{
echo "APPLE_CERTIFICATE<<__EOF__"
echo "$APPLE_CERTIFICATE"
echo "__EOF__"
echo "APPLE_CERTIFICATE_PASSWORD=$APPLE_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
# Each of these is independently optional: an identity is inferred from the
# certificate when unset, and notarization is skipped unless all three Apple ID
# vars are present. Only forward the ones that actually carry a value, so an
# empty secret never reaches Tauri as a defined-but-blank variable.
for var in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
if [ -n "${!var:-}" ]; then
echo "$var=${!var}" >> "$GITHUB_ENV"
fi
done
- name: Build Tauri
uses: tauri-apps/tauri-action@v0
env:
# tauri-action runs beforeBuildCommand (pnpm build) as a child process,
# so this reaches vite.config.ts. Surfaces the preview-build CTA in the
# desktop app too (it routes through openExternal → shell open).
RELEASE_BUILD: "true"
# Cloud-sync config reaches vite.config.ts via the same beforeBuildCommand
# path; without these the desktop build ships with cloud sync disabled.
# Anon key is client-safe (RLS gates access).
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
# Telemetry ingest define reaches vite.config.ts via the same
# beforeBuildCommand path as the Supabase vars above.
TELEMETRY_URL: "https://lobby.phase-rs.dev/telemetry"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# The APPLE_* signing vars are deliberately absent here -- listing them would
# define them as "" whenever the secret is unset, which is the failure the
# "Configure macOS signing" step above exists to avoid. That step exports them
# through $GITHUB_ENV only when they carry a value.
with:
projectPath: client
- name: Stage Tauri artifacts
run: |
mkdir -p staging
find client/src-tauri/target/release/bundle \
-maxdepth 2 \
\( -name '*.deb' -o -name '*.AppImage' -o -name '*.AppImage.sig' \
-o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' \
-o -name '*.exe' -o -name '*.exe.sig' \) \
-exec cp {} staging/ \;
ls -la staging/
shell: bash
- name: Upload Tauri artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: staging/*
retention-days: 90
deploy-server:
name: Deploy Server (beebs, disabled)
needs: [resolve-release-ref, build-server-image, release]
runs-on: ubuntu-latest
timeout-minutes: 5
if: ${{ false }}
permissions:
contents: read
packages: read
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/phase-server
steps:
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p 29292 -H ${{ secrets.DEPLOY_HOST_BEEBS }} >> ~/.ssh/known_hosts
- name: Deploy to beebs
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SSH="ssh -p 29292 -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST_BEEBS }}"
IMAGE="${IMAGE_NAME}:${RELEASE_TAG}"
$SSH "set -euo pipefail \
&& (sudo systemctl stop phase-server || true) \
&& (sudo systemctl disable phase-server || true) \
&& (sudo systemctl mask phase-server || true) \
&& echo '$GHCR_TOKEN' | sudo docker login ghcr.io -u '${{ github.actor }}' --password-stdin \
&& sudo docker pull '$IMAGE' \
&& (sudo docker stop --time 30 phase-server || true) \
&& (sudo docker rm phase-server || true) \
&& sudo docker volume create phase-server-data >/dev/null \
&& sudo docker run -d \
--name phase-server \
--restart unless-stopped \
-p 127.0.0.1:9374:9374 \
-v phase-server-data:/var/lib/phase-server \
-e PHASE_LOBBY_ONLY=true \
-e PHASE_CORS_ORIGIN='*' \
-e RUST_LOG=info \
'$IMAGE'"
echo "Deployed $IMAGE to beebs"
- name: Verify deployment
run: |
SSH="ssh -p 29292 -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST_BEEBS }}"
$SSH "set -euo pipefail \
&& for attempt in \$(seq 1 30); do \
if curl -fsS http://127.0.0.1:9374/health; then \
sudo docker ps --filter name=phase-server --filter status=running --format '{{.Names}}' | grep -qx phase-server; \
exit 0; \
fi; \
sleep 1; \
done; \
sudo docker logs --tail 50 phase-server || true; \
exit 1"
release:
name: Create GitHub Release
needs: [resolve-release-ref, build-wasm, build-server, build-server-image, build-tauri]
# CalVer production releases require full Tauri updater output so the
# stable desktop updater never points at a partial nightly. Legacy v0.*
# releases keep the older permissive behavior.
if: >-
always() &&
needs.build-wasm.result == 'success' &&
needs.build-server.result == 'success' &&
needs.build-server-image.result == 'success' &&
(
needs.build-tauri.result == 'success' ||
(needs.resolve-release-ref.outputs.is_calver != 'true' && needs.build-tauri.result == 'failure')
)
runs-on: ubuntu-latest
timeout-minutes: 10
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
IS_CALVER: ${{ needs.resolve-release-ref.outputs.is_calver }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate updater manifest
run: |
set -euo pipefail
TAG="$RELEASE_TAG"
REPO="${GITHUB_REPOSITORY}"
shopt -s nullglob
first_match() {
local pattern="$1"
local match
match=$(compgen -G "$pattern" | sort | head -1 || true)
printf '%s' "$match"
}
artifact_url() {
local path="$1"
local name
name=$(basename "$path")
printf 'https://github.com/%s/releases/download/%s/%s' \
"$REPO" "$TAG" "$(jq -rn --arg value "$name" '$value | @uri')"
}
# Read signatures for available platforms (skip missing ones).
# Tauri v2 v3-compatible updater format: the raw installer is the
# updater payload (`.AppImage`, `-setup.exe`, `.app.tar.gz`); each
# has a sibling `.sig`. Set in tauri.conf.json: createUpdaterArtifacts: true.
LINUX_PAYLOAD=$(first_match "artifacts/phase-tauri-linux/*.AppImage")
MACOS_PAYLOAD=$(first_match "artifacts/phase-tauri-macos/*.app.tar.gz")
WINDOWS_PAYLOAD=$(first_match "artifacts/phase-tauri-windows/*-setup.exe")
LINUX_SIG=""
MACOS_SIG=""
WINDOWS_SIG=""
if [ -n "$LINUX_PAYLOAD" ] && [ -f "$LINUX_PAYLOAD.sig" ]; then LINUX_SIG=$(cat "$LINUX_PAYLOAD.sig"); fi
if [ -n "$MACOS_PAYLOAD" ] && [ -f "$MACOS_PAYLOAD.sig" ]; then MACOS_SIG=$(cat "$MACOS_PAYLOAD.sig"); fi
if [ -n "$WINDOWS_PAYLOAD" ] && [ -f "$WINDOWS_PAYLOAD.sig" ]; then WINDOWS_SIG=$(cat "$WINDOWS_PAYLOAD.sig"); fi
if [ "$IS_CALVER" = "true" ]; then
missing=0
if [ -z "$LINUX_PAYLOAD" ] || [ -z "$LINUX_SIG" ]; then echo "::error::Missing Linux updater payload/signature for CalVer release"; missing=1; fi
if [ -z "$MACOS_PAYLOAD" ] || [ -z "$MACOS_SIG" ]; then echo "::error::Missing macOS updater payload/signature for CalVer release"; missing=1; fi
if [ -z "$WINDOWS_PAYLOAD" ] || [ -z "$WINDOWS_SIG" ]; then echo "::error::Missing Windows updater payload/signature for CalVer release"; missing=1; fi
[ "$missing" = "0" ]
fi
# Build platforms object with only available platforms
PLATFORMS="{"
SEP=""
if [ -n "$LINUX_PAYLOAD" ] && [ -n "$LINUX_SIG" ]; then
LINUX_URL=$(artifact_url "$LINUX_PAYLOAD")
PLATFORMS="${PLATFORMS}${SEP}\"linux-x86_64\":{\"signature\":\"${LINUX_SIG}\",\"url\":\"${LINUX_URL}\"}"
SEP=","
fi
if [ -n "$MACOS_PAYLOAD" ] && [ -n "$MACOS_SIG" ]; then
MACOS_URL=$(artifact_url "$MACOS_PAYLOAD")
PLATFORMS="${PLATFORMS}${SEP}\"darwin-aarch64\":{\"signature\":\"${MACOS_SIG}\",\"url\":\"${MACOS_URL}\"}"
SEP=","
fi
if [ -n "$WINDOWS_PAYLOAD" ] && [ -n "$WINDOWS_SIG" ]; then
WINDOWS_URL=$(artifact_url "$WINDOWS_PAYLOAD")
PLATFORMS="${PLATFORMS}${SEP}\"windows-x86_64\":{\"signature\":\"${WINDOWS_SIG}\",\"url\":\"${WINDOWS_URL}\"}"
fi
PLATFORMS="${PLATFORMS}}"
if [ "$PLATFORMS" = "{}" ]; then
echo "::warning::No signed updater artifacts found — skipping update.json"
else
# Tauri's plugin-updater parses `version` through Rust's `semver`
# crate, which rejects a leading `v`. Strip it (URL paths above
# already do this with `${TAG#v}`).
VERSION="${TAG#v}"
echo "{\"version\":\"${VERSION}\",\"notes\":\"Release ${TAG}\",\"pub_date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"platforms\":${PLATFORMS}}" \
| jq . > artifacts/update.json
fi
if [ "$IS_CALVER" = "true" ]; then
test -s artifacts/update.json
fi
- name: Generate changelog
run: |
TAG="$RELEASE_TAG"
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${TAG}"
else
RANGE="${TAG}"
fi
cat > changelog.md <<'EOF'
## Downloads
| Platform | File |
|----------|------|
| **Windows** | `Phase_*_x64-setup.exe` |
| **macOS (Apple Silicon)** | `Phase_*_aarch64.dmg` |
| **Linux** | `Phase_*_amd64.AppImage` or `.deb` |
> **macOS note:** If you see "this app is damaged", open Terminal and run: `xattr -cr /Applications/Phase.app`
## Changes
EOF
sed -i 's/^ //' changelog.md
git log --format="- %s" "$RANGE" -- ':!.planning' \
| { grep -v "^- release:" || true; } \
| { grep -v "^- chore: update coverage" || true; } \
>> changelog.md
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
body_path: changelog.md
fail_on_unmatched_files: ${{ env.IS_CALVER == 'true' }}
files: |
artifacts/phase-server-linux-x86_64/*.tar.gz
artifacts/phase-server-linux-x86_64/*.sha256
artifacts/phase-server-macos-arm64/*.tar.gz
artifacts/phase-server-macos-arm64/*.sha256
artifacts/phase-server-windows-x86_64/*.zip
artifacts/phase-server-windows-x86_64/*.sha256
artifacts/phase-tauri-linux/*.deb
artifacts/phase-tauri-linux/*.AppImage
artifacts/phase-tauri-macos/*.dmg
artifacts/phase-tauri-macos/*.app.tar.gz
artifacts/phase-tauri-windows/*.exe
artifacts/update.json