release: v0.31.0 #118
Workflow file for this run
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: 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 |