release: v0.49.0 #143
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 }} | |
| 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 = "|"/, "") | |
| 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 }} |