Skip to content

release: v0.49.0

release: v0.49.0 #143

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 }}
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")
echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_sha=$SHA" >> "$GITHUB_OUTPUT"
build-wasm:
name: Build WASM + Frontend
needs: [resolve-release-ref]
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
outputs:
card_data_sha256: ${{ steps.card-data-hash.outputs.card_data_sha256 }}
card_data_hash16: ${{ steps.card-data-hash.outputs.hash }}
draft_pools_sha256: ${{ steps.card-data-hash.outputs.draft_pools_sha256 }}
draft_pools_hash16: ${{ steps.card-data-hash.outputs.draft_pools_hash16 }}
env:
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
WRANGLER_VERSION: "4.113.0"
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
# Keep .cargo/config.toml's [target.wasm32-unknown-unknown] 16 MiB
# shadow-stack rustflags authoritative: an env RUSTFLAGS (this
# action's default "-D warnings") would override them and trip
# build-wasm.sh's assert_wasm_stack guard.
rustflags: ""
- 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
# Pin the R2 client. Bare `npx wrangler` follows npm's latest tag, which
# can briefly point at a version whose tarball is not available yet.
- name: Install pinned Wrangler
run: npm install --global "wrangler@$WRANGLER_VERSION"
- name: Set cache keys
id: cache-keys
run: |
echo "day=$(date +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- name: Derive MTGJSON cache key
id: mtgjson-key
uses: ./.github/actions/mtgjson-cache-key
# ── Cache restores ─────────────────────────────────────────────────────
# Restore-only, with explicit saves placed where each cache's data is
# complete — NOT the combined `actions/cache` action, whose save is a
# post step declared `post-if: success()` and is therefore skipped
# entirely when the job fails or times out. See deploy.yml's copy of this
# note for the 2026-07-24 incident that motivated the split.
- name: Restore 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 published-version key (week-based fallback when the probe
# fails — see the mtgjson-cache-key action), NO restore-keys: the key
# misses exactly when MTGJSON publishes, so gen-card-data.sh re-fetches
# fresh data; a restore-keys fallback would pin the file-existence-gated
# fetches to stale data forever.
id: mtgjson-cache
uses: actions/cache/restore@v4
with:
path: data/mtgjson
key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }}
# 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: Restore Scryfall bulk data
id: scryfall-cache
uses: actions/cache/restore@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
id: draft-sets
# 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. Left
# unconditional even when the pool cache below hits: this is what keeps
# the mtgjson entry whole, and it is the only step that repopulates
# sets/ after an eviction.
#
# The script treats a failed set download as non-fatal (warn, count,
# exit 0), so the reported count is load-bearing for the pool cache
# below — see deploy.yml's copy of this note.
run: |
set -o pipefail
./scripts/fetch-draft-sets.sh | tee draft-sets.log
failed=$(sed -n 's/^Summary: .*failed \([0-9][0-9]*\)$/\1/p' draft-sets.log | tail -1)
echo "failed=${failed:-0}" >> "$GITHUB_OUTPUT"
echo "set downloads failed: ${failed:-0}"
- name: Save MTGJSON data
# THE earliest point data/mtgjson is complete: fetch-draft-sets.sh is
# the last writer into it. Saving here rather than at job end means a
# later failure never re-costs the ~9m of downloads.
#
# Not `always()`: as an inline step a later failure cannot cancel it,
# so the only case `always()` would add is an *earlier* failure — i.e.
# exactly when the directory is incomplete and must not be frozen under
# an immutable key.
if: ${{ steps.mtgjson-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: data/mtgjson
key: mtgjson-full-${{ steps.mtgjson-key.outputs.suffix }}
- name: Restore draft pools
# Paired with the MTGJSON cache: same `mtgjson-` prefix (so
# /clear-caches mtgjson sweeps both) and a suffix built from the same
# published-data version — see the mtgjson-cache-key action, which is
# the single authority for both keys, shared with deploy.yml.
#
# A separate entry rather than folding draft-pools.json into the
# `data/mtgjson` path above, because actions/cache writes only on a key
# miss: a pool-affecting draft-core change must roll the key, and the
# data-version key alone cannot see source changes.
#
# NO restore-keys: every input to the output is in the key, so an
# inexact hit is by construction a stale pool.
id: draft-pools-cache
uses: actions/cache/restore@v4
with:
path: client/public/draft-pools.json
key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }}
- 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.
#
# ~2s of extraction behind a ~5m30s compile of engine + draft-core:
# draft-pool-gen never reuses the units gen-card-data.sh built (see the
# feature note below) and pays a full cold compile every run. Skipping
# that on a cache hit is the whole point of the step above.
#
# Gate on file existence, not `cache-hit` (same reasoning as "Download
# MTGJSON data"): a poisoned or partial entry reports a hit with no
# file, and skipping on that would ship a release with no pools at all.
# Absent file → regenerate, so the cache self-heals.
#
# 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: |
if [ -f client/public/draft-pools.json ]; then
echo "draft-pools.json restored from cache — skipping draft-pool-gen"
else
cargo run --profile tool --bin draft-pool-gen
fi
- name: Save draft pools
# Banked the moment the file exists, so the ~5m30s compile is never
# paid twice for the same inputs even if the rest of the job fails.
#
# Skipped when any set download failed: a pool built from a short
# sets/ dir must not be frozen under a key that only rolls when MTGJSON
# publishes. See deploy.yml for the full reasoning.
if: ${{ steps.draft-pools-cache.outputs.cache-hit != 'true' && steps.draft-sets.outputs.failed == '0' }}
uses: actions/cache/save@v4
with:
path: client/public/draft-pools.json
key: mtgjson-draft-pools-${{ steps.mtgjson-key.outputs.draft_pools_suffix }}
- 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: |
CARD_DATA_SHA256=$(sha256sum client/public/card-data.json | awk '{print $1}')
CARD_DATA_HASH16=${CARD_DATA_SHA256:0:16}
DRAFT_POOLS_SHA256=$(sha256sum client/public/draft-pools.json | awk '{print $1}')
DRAFT_POOLS_HASH16=${DRAFT_POOLS_SHA256:0:16}
echo "card_data_sha256=$CARD_DATA_SHA256" >> "$GITHUB_OUTPUT"
echo "hash=$CARD_DATA_HASH16" >> "$GITHUB_OUTPUT"
echo "filename=card-data-$CARD_DATA_HASH16.json" >> "$GITHUB_OUTPUT"
echo "draft_pools_sha256=$DRAFT_POOLS_SHA256" >> "$GITHUB_OUTPUT"
echo "draft_pools_hash16=$DRAFT_POOLS_HASH16" >> "$GITHUB_OUTPUT"
echo "draft_pools_filename=draft-pools-$DRAFT_POOLS_HASH16.json" >> "$GITHUB_OUTPUT"
cp client/public/card-data.json "client/public/card-data-$CARD_DATA_HASH16.json"
echo "Content-addressed data: card-data-$CARD_DATA_HASH16.json, draft-pools-$DRAFT_POOLS_HASH16.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: Restore locale card-art maps
# Caches the OUTPUT (five small JSON maps), not the ~169 MB AllSetFiles
# input the generator downloads to build them. Folding that input into
# the `data/mtgjson` entry is not an option: that cache is saved far
# earlier (it is complete before this step runs) and GitHub caches are
# immutable, so a later hit could never contain it — and the extracted
# set files would add ~1 GB to an entry shared with deploy.yml.
#
# Same `mtgjson-` prefix as the draft-pools pair (so /clear-caches
# mtgjson sweeps it) and the same published-data suffix. The generator's
# script hash joins the key because LOCALE_MAP lives in the script: a
# locale added there must rebuild, and the data-version suffix alone
# cannot see a source change.
#
# NO restore-keys: every input to the output is in the key, so an
# inexact hit is by construction a stale map.
id: locale-images-cache
uses: actions/cache/restore@v4
with:
path: client/public/scryfall-images.*.json
key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
# Separate from the Scryfall step: this reads MTGJSON set files, not the
# Scryfall bulk exports, so it is not covered by the data/scryfall cache.
#
# The generator no-ops when all five maps are already present, so a cache
# hit skips the AllSetFiles download entirely. It gates on the files
# themselves rather than `cache-hit` (same reasoning as "Generate draft
# pools"): a poisoned or partial entry reports a hit with nothing on disk,
# and regenerating on an absent file lets the cache self-heal.
- name: Generate locale card-art maps
run: ./scripts/gen-scryfall-locale-images.sh
- name: Save locale card-art maps
# Banked the moment the maps exist, so the AllSetFiles download is never
# paid twice for the same inputs even if a later step fails.
if: ${{ steps.locale-images-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: client/public/scryfall-images.*.json
key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
- name: Save Scryfall bulk data
# data/scryfall is complete once the four generators have run. Banking
# it here keeps a later failure from re-hitting the Scryfall API on the
# next run — the throttle flakiness this cache exists to avoid.
if: ${{ steps.scryfall-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: data/scryfall
key: scryfall-${{ steps.cache-keys.outputs.day }}
- 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: Build WASM
run: ./scripts/build-wasm.sh release
- name: Compute content-addressed engine WASM filename
id: engine-wasm-hash
run: |
ENGINE_WASM_SHA256=$(sha256sum client/src/wasm/engine_wasm_bg.wasm | awk '{print $1}')
ENGINE_WASM_HASH16=${ENGINE_WASM_SHA256:0:16}
echo "sha256=$ENGINE_WASM_SHA256" >> "$GITHUB_OUTPUT"
echo "hash=$ENGINE_WASM_HASH16" >> "$GITHUB_OUTPUT"
echo "filename=engine_wasm_bg-$ENGINE_WASM_HASH16.wasm" >> "$GITHUB_OUTPUT"
- name: Upload engine WASM to R2
# The engine WASM is intentionally uncompressed: wasm-bindgen's
# cross-origin streaming initialization needs application/wasm, and the
# immutable content-addressed URL below is its cache key.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ENGINE_WASM_FILENAME: ${{ steps.engine-wasm-hash.outputs.filename }}
run: |
wrangler r2 object put "phase-rs-data/wasm/$ENGINE_WASM_FILENAME" \
--file client/src/wasm/engine_wasm_bg.wasm \
--remote \
--content-type application/wasm \
--cache-control "public, max-age=31536000, immutable"
- 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 }}"
ENGINE_WASM_URL: "https://data.phase-rs.dev/wasm/${{ steps.engine-wasm-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 }}
DRAFT_POOLS_FILENAME: draft-pools-${{ steps.card-data-hash.outputs.draft_pools_hash16 }}.json
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: content hashes and
# release data manifests are over the original uncompressed bytes.
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"
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"
# Native/server manifests must pin draft pools to immutable content,
# while the mutable draft-pools.json remains in the shared-file loop.
brotli -q 9 -c client/public/draft-pools.json > "$BRDIR/draft-pools.br"
wrangler r2 object put "phase-rs-data/$DRAFT_POOLS_FILENAME" --file "$BRDIR/draft-pools.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"
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 }}
DRAFT_POOLS_FILENAME: draft-pools-${{ steps.card-data-hash.outputs.draft_pools_hash16 }}.json
ENGINE_WASM_FILENAME: ${{ steps.engine-wasm-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"
check "$DRAFT_POOLS_FILENAME"
check "wasm/$ENGINE_WASM_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 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
# ── 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
# (slim server asset), 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 }}
PHASE_CHANNEL: release
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
# CI owns the warning-as-error gate. Release profiles compile out
# debug-only assertions, which can legitimately leave their inputs
# unused in an otherwise CI-clean tagged source tree.
rustflags: ""
- 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, slim-asset packaging) re-apply it.
run: |
# -p scopes feature unification to phase-server's own graph — an
# unscoped workspace build unifies feed-scraper's native-tls reqwest
# features in, dragging openssl-sys into the musl cross-compile.
cargo build -p phase-server --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-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 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
- 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: Guard Cloudflare Pages asset size limit
shell: bash
run: |
oversized=$(find dist -type f -size +25M -print -quit)
if [ -n "$oversized" ]; then
size=$(du -h "$oversized" | awk '{print $1}')
echo "::error::Cloudflare Pages 25 MiB file limit exceeded: $oversized ($size)"
exit 1
fi
- 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
# Same as the wasm job: broker-wasm needs the config.toml
# [target.wasm32-unknown-unknown] shadow-stack rustflags, which an
# env RUSTFLAGS would override.
rustflags: ""
- 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.113.0"
workingDirectory: lobby-worker
command: deploy
build-server:
name: Build Server (${{ matrix.os }})
needs: [resolve-release-ref, 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 }}
PHASE_CHANNEL: release
strategy:
fail-fast: false
matrix:
include:
- os: linux
runner: ubuntu-latest
triple: x86_64-unknown-linux-musl
extension: ""
- os: macos
runner: macos-latest
triple: aarch64-apple-darwin
extension: ""
- os: windows
runner: windows-latest
triple: x86_64-pc-windows-msvc
extension: .exe
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 }}
# Keep this artifact build consistent with the primary Linux build:
# CI, rather than a release-only cfg shape, enforces warnings.
rustflags: ""
- 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'
# -p scopes feature unification (see the Linux leg's note).
run: cargo build -p phase-server --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: Prepare slim server binary (Unix)
if: matrix.os != 'windows'
run: |
cp "target/${{ matrix.triple }}/server-release/phase-server" "phase-server-slim-${{ matrix.triple }}"
chmod +x "phase-server-slim-${{ matrix.triple }}"
shell: bash
- name: Prepare slim server binary (Windows)
if: matrix.os == 'windows'
run: |
Copy-Item "target/${{ matrix.triple }}/server-release/phase-server.exe" "phase-server-slim-${{ matrix.triple }}.exe"
shell: pwsh
- name: Upload slim server binary
uses: actions/upload-artifact@v4
with:
name: phase-server-slim-${{ matrix.triple }}
path: phase-server-slim-${{ matrix.triple }}${{ matrix.extension }}
if-no-files-found: error
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]
if: >-
always() &&
needs.build-wasm.result == 'success' &&
needs.build-server.result == 'success' &&
needs.build-server-image.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
RELEASE_TAG: ${{ needs.resolve-release-ref.outputs.release_tag }}
RELEASE_SHA: ${{ needs.resolve-release-ref.outputs.release_sha }}
CARD_DATA_SHA256: ${{ needs.build-wasm.outputs.card_data_sha256 }}
CARD_DATA_HASH16: ${{ needs.build-wasm.outputs.card_data_hash16 }}
DRAFT_POOLS_SHA256: ${{ needs.build-wasm.outputs.draft_pools_sha256 }}
DRAFT_POOLS_HASH16: ${{ needs.build-wasm.outputs.draft_pools_hash16 }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY: ${{ secrets.SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY }}
WRANGLER_VERSION: "4.113.0"
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
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pinned Wrangler
run: npm install --global "wrangler@$WRANGLER_VERSION"
- name: Sign slim server artifacts and publish release data manifest
# Sign in this Ubuntu finisher so all three OS binaries use one trusted
# signing path. The versioned manifest is immutable; its final PUT is
# performed after its detached signature exists.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y minisign
WORKSPACE_VERSION=$(awk '
/^\[workspace\.package\]/ { in_workspace_package = 1; next }
/^\[/ { in_workspace_package = 0 }
in_workspace_package && /^version = "/ {
gsub(/version = "|"/, "")
print
exit
}
' Cargo.toml)
test -n "$WORKSPACE_VERSION"
test "$RELEASE_TAG" = "v$WORKSPACE_VERSION"
umask 077
key_file=$(mktemp)
trap 'rm -f "$key_file"' EXIT
printf '%s' "$SERVER_ARTIFACT_MINISIGN_PRIVATE_KEY" > "$key_file"
sign() {
printf '\n' | minisign -S -s "$key_file" -m "$1" -x "$1.minisig"
}
for triple in \
x86_64-unknown-linux-musl \
aarch64-apple-darwin \
x86_64-pc-windows-msvc; do
binary="artifacts/phase-server-slim-$triple/phase-server-slim-$triple"
if [ "$triple" = x86_64-pc-windows-msvc ]; then
binary="$binary.exe"
fi
test -s "$binary"
sign "$binary"
done
manifest="release-server-v$WORKSPACE_VERSION.json"
generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# sha256 is over UNCOMPRESSED bytes; R2 serves these objects with Content-Encoding: br unconditionally — consumers MUST brotli-decode before hashing.
jq -n \
--arg version "$WORKSPACE_VERSION" \
--arg generated_at "$generated_at" \
--arg card_data_sha256 "$CARD_DATA_SHA256" \
--arg card_data_url "https://data.phase-rs.dev/card-data-$CARD_DATA_HASH16.json" \
--arg draft_pools_sha256 "$DRAFT_POOLS_SHA256" \
--arg draft_pools_url "https://data.phase-rs.dev/draft-pools-$DRAFT_POOLS_HASH16.json" \
'{
schema: 1,
channel: "release",
version: $version,
generated_at: $generated_at,
data: [
{name: "card-data.json", sha256: $card_data_sha256, url: $card_data_url},
{name: "draft-pools.json", sha256: $draft_pools_sha256, url: $draft_pools_url}
]
}' > "$manifest"
sign "$manifest"
wrangler r2 object put "phase-rs-data/desktop/$manifest.minisig" --file "$manifest.minisig" --remote --content-type application/octet-stream --cache-control "public, max-age=31536000, immutable"
wrangler r2 object put "phase-rs-data/desktop/$manifest" --file "$manifest" --remote --content-type application/json --cache-control "public, max-age=31536000, immutable"
- name: Download shell updater migration bridge
id: shell-updater-bridge
run: |
set -euo pipefail
bridge=$(mktemp)
status=$(curl -sS -L -o "$bridge" -w '%{http_code}' \
--connect-timeout 5 \
--max-time 30 \
https://data.phase-rs.dev/desktop/update.json || true)
if [ "$status" = "200" ]; then
mv "$bridge" artifacts/update.json
echo "available=true" >> "$GITHUB_OUTPUT"
echo "Attached the current shell updater manifest for legacy desktop clients."
else
rm -f "$bridge"
echo "available=false" >> "$GITHUB_OUTPUT"
echo "Shell updater manifest unavailable (HTTP ${status:-curl failure}); publishing without the migration bridge."
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: Build release asset list
id: release-assets
env:
GH_TOKEN: ${{ github.token }}
SHELL_UPDATER_BRIDGE_AVAILABLE: ${{ steps.shell-updater-bridge.outputs.available }}
run: |
set -euo pipefail
# Immutable releases cannot accept a late asset upload. Include the
# optional bridge in the initial create/publish request, but skip it
# for recovery runs targeting a release that is already published.
release_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft' 2>/dev/null || echo missing)
include_bridge=false
if [ "$SHELL_UPDATER_BRIDGE_AVAILABLE" = true ] && [ "$release_draft" != false ]; then
include_bridge=true
elif [ "$SHELL_UPDATER_BRIDGE_AVAILABLE" = true ]; then
echo "Release $RELEASE_TAG is already published; skipping optional shell updater migration bridge."
fi
{
echo "files<<EOF"
cat <<'EOF'
artifacts/phase-server-slim-x86_64-unknown-linux-musl/phase-server-slim-x86_64-unknown-linux-musl
artifacts/phase-server-slim-x86_64-unknown-linux-musl/phase-server-slim-x86_64-unknown-linux-musl.minisig
artifacts/phase-server-slim-aarch64-apple-darwin/phase-server-slim-aarch64-apple-darwin
artifacts/phase-server-slim-aarch64-apple-darwin/phase-server-slim-aarch64-apple-darwin.minisig
artifacts/phase-server-slim-x86_64-pc-windows-msvc/phase-server-slim-x86_64-pc-windows-msvc.exe
artifacts/phase-server-slim-x86_64-pc-windows-msvc/phase-server-slim-x86_64-pc-windows-msvc.exe.minisig
release-server-${{ needs.resolve-release-ref.outputs.release_tag }}.json
release-server-${{ needs.resolve-release-ref.outputs.release_tag }}.json.minisig
EOF
if [ "$include_bridge" = true ]; then
echo "artifacts/update.json"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
body_path: changelog.md
fail_on_unmatched_files: true
# Never the "Latest" release. Every asset here is a machine artifact
# the desktop shell provisions for itself; the web app ships by
# deploy, not by download. These fire far more often than shell
# releases, so leaving the badge to default sends anyone visiting
# /releases/latest to a page of server binaries with no installer.
# The desktop release claims the badge instead (shell-release.yml).
make_latest: "false"
files: ${{ steps.release-assets.outputs.files }}