-
-
Notifications
You must be signed in to change notification settings - Fork 148
800 lines (728 loc) · 38.3 KB
/
Copy pathdeploy.yml
File metadata and controls
800 lines (728 loc) · 38.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
name: Deploy Staging (GitHub Pages)
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-pages
cancel-in-progress: true
permissions:
contents: read
# The exact commit to deploy. Shared by every job so they all check out the
# same tree (the head of the CI run that triggered this deploy, or HEAD on a
# manual dispatch). Workflow-level env may reference the github context.
env:
DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/phase-server
jobs:
# ── Preview inputs ─────────────────────────────────────────────────────────
# Generates the immutable card-data/draft-pool pair and derives the engine
# fingerprint shared by the preview server and frontend. The publication work
# follows in `card-data`, so native preview builds can start immediately after
# these inputs are ready rather than waiting on ancillary data generation.
preview-inputs:
name: Prepare preview inputs
if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
# Sized for the MTGJSON-publish day, not the steady state. When MTGJSON
# ships a new version the key rolls, and the first run to see it pays the
# whole cold path: ~6m30s of token-set downloads, two back-to-back engine
# compiles (~5m35s each — tokens-gen regenerates known-tokens.toml, which
# build.rs embeds, forcing a rebuild), ~2m20s of draft-set downloads and a
# ~5m30s draft-core compile. Observed 29-30m; run 30114030288 was killed at
# 30m12s mid-R2-upload.
#
# That kill is self-perpetuating and is why the ceiling matters more than
# it looks: actions/cache declares `post-if: success()`, so a timed-out run
# saves NOTHING. The next run recomputes the same missing key and pays the
# same cold path again. The budget has to clear the cold path, or the cache
# can never be seeded at all.
timeout-minutes: 50
permissions:
actions: write
contents: read
outputs:
card_data_filename: ${{ steps.card-data-hash.outputs.filename }}
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 }}
engine_fingerprint: ${{ steps.engine-fingerprint.outputs.fingerprint }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-tool
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Derive MTGJSON cache key
id: mtgjson-key
uses: ./.github/actions/mtgjson-cache-key
# ── Cache restores ─────────────────────────────────────────────────────
# Every cache in this job is restore-only, with an explicit save placed
# at the step where its data is complete — NOT the combined
# `actions/cache` action.
#
# The combined action registers its save as a post step declared
# `post-if: success()`, so a job that dies anywhere downstream saves
# NOTHING. That turned a routine MTGJSON publish into a self-sustaining
# outage on 2026-07-24: the key rolled, the run paid ~28m of cold
# downloads, got killed by the job timeout mid-R2-upload, saved nothing,
# and every following run recomputed the same missing key and paid the
# same 28m again (run 30114030288). Saving the instant the data is whole
# makes that cost one-time no matter what fails afterwards.
- name: Restore MTGJSON data
# Full data/mtgjson dir under the `mtgjson-full-` namespace: this job
# runs gen-card-data.sh, which populates every file (AtomicCards, Meta,
# SetList, token sets, decks). Kept distinct from ci.yml's partial
# `mtgjson-atomic-`/`mtgjson-sets-` caches; all stay under the
# `mtgjson-` prefix that 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 (its `[ ! -f ]` guards download whatever the empty dir is
# missing); 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 }}
- name: Download MTGJSON data
# Gate on file existence, not cache-hit: actions/cache restore-keys can
# match a poisoned/partial entry (and CACHE_ON_FAILURE saves caches from
# failed runs), setting cache-hit=true while AtomicCards.json is absent.
# Gating the download on cache-hit then permanently skips it, breaking
# oracle-gen with "AtomicCards.json not found". Always run; fetch only
# when the file is missing so a poisoned cache self-heals.
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 (from source)
# Single source of truth: scripts/gen-card-data.sh produces all public
# JSON files. The deploy must use the same generator the local dev
# workflow does — no YAML reimplementation. Mirrors card-data.json +
# coverage-data.json into data/ so downstream steps read from data/.
run: |
mkdir -p data
./scripts/gen-card-data.sh
- 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/, covered by the mtgjson cache
# (a hit downloads nothing — 244 skipped in ~2s). 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 one flaky set can't kill a deploy. That makes the reported
# failure count load-bearing for the pool cache below: pools built from
# a short sets/ dir must not be frozen under an immutable key. The
# count is republished as a step output; the summary line is a stable
# contract of a script in this repo.
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 (gen-card-data.sh fetches AtomicCards, Meta,
# SetList, token sets and decks; this adds the draft sets). Saving here
# rather than at job end means a failure in pool generation, the
# semantic audit or the R2 upload never re-costs the ~9m of downloads.
#
# Not earlier: a save mid-population would freeze a partial sets/ dir
# under an immutable key, and the later fetches would be skipped by
# their own file-existence guards forever.
#
# Deliberately NOT `always()`. As an inline step this runs in sequence,
# so a later failure cannot cancel it — it has already banked the
# download. `always()` would only add the case where an *earlier* step
# failed, i.e. exactly when the directory is incomplete and must not be
# frozen under an immutable key. Default `success()` is the correct
# "the data is whole" gate. cache-hit gating skips a redundant save,
# which would otherwise just warn that the key already exists.
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.
#
# 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
# ~2s of extraction behind a ~5m30s compile of engine + draft-core:
# draft-pool-gen can't take --features cli, so it never reuses the
# units gen-card-data.sh built 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 deploy with no pools at all.
# Absent file → regenerate, so the cache self-heals.
#
# Profile `tool`, not `release`: a one-shot JSON transform. draft-pool-gen
# lives in draft-core and can't take --features cli, so it gets its own
# [tool,default] engine fingerprint; tool keeps 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 short sets/ dir yields a pool
# missing those sets, and the pool key is derived from the MTGJSON data
# version — so caching it would pin the short pool for the whole key
# window. Not saving preserves the existing self-heal: sets/ is
# file-existence-gated, so the next run fetches only the sets that are
# still missing and regenerates a complete pool.
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 — parse the freshly produced card-data through the same
# CardDatabase::from_export path the WASM uses at runtime, before R2 sees
# it. --profile tool --features cli matches gen-card-data.sh → cache hit.
run: cargo run --profile tool --features cli --bin card-data-validate -- data/card-data.json
- name: Compute content-addressed card-data filename
id: card-data-hash
# Pin each WASM bundle to the exact card-data it was built against via an
# immutable card-data-<hash>.json URL baked into the JS bundle. New
# deploys publish a new hash; old browser caches keep resolving theirs.
run: |
CARD_DATA_SHA256=$(sha256sum data/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 data/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: Compute engine fingerprint
id: engine-fingerprint
# The fingerprint is computed exactly once, after both generated data
# files exist. It keys preview native artifacts and the frontend define.
run: |
FINGERPRINT=$(./scripts/engine-fingerprint.sh \
"$DEPLOY_SHA" \
data/card-data.json \
client/public/draft-pools.json)
echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT"
echo "Engine fingerprint: $FINGERPRINT"
- name: Upload preview inputs
# The publication job runs on a fresh runner. Transfer only its
# generated inputs so the preview-server matrix can overlap Scryfall,
# audit, compression, and R2 work instead of re-generating card data.
uses: actions/upload-artifact@v4
with:
name: preview-inputs
path: |
data/card-data.json
client/public/card-data-*.json
client/public/card-data.*.json
!client/public/card-data.json
client/public/card-names.json
client/public/draft-pools.json
client/public/coverage-data.json
client/public/coverage-summary.json
client/public/decks.json
client/public/set-list.json
if-no-files-found: error
retention-days: 1
# ── Ancillary data + R2 publication ───────────────────────────────────────
# The preview inputs above are already immutable and fingerprinted. This job
# publishes the remaining frontend data while the signed preview-server
# matrix builds. Pages still depends on this job, so no browser sees a shell
# pointing at data that has not reached R2.
card-data:
name: Publish card data & R2
needs: [preview-inputs]
if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-tool
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Set cache keys
id: cache-keys
run: |
echo "day=$(date +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- name: Restore Scryfall bulk data
# Exact-key-only: the generators skip downloads for existing files, so
# a stale restore-key hit would pin the staging payload to old data.
id: scryfall-cache
uses: actions/cache/restore@v4
with:
path: data/scryfall
key: scryfall-${{ steps.cache-keys.outputs.day }}
- name: Download preview inputs
uses: actions/download-artifact@v4
with:
name: preview-inputs
- 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: 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: Generate coverage badge endpoints
# --profile tool --features cli matches gen-card-data.sh → cache hit.
run: |
cargo run --profile tool --features cli --bin coverage-report -- data/ --write-stats data/coverage-stats.json > /dev/null
./scripts/gen-badge-endpoints.sh data/coverage-stats.json data/badges
- name: Run semantic audit
# Structured findings for cards that parse cleanly but disagree
# semantically with their Oracle text. Consumed by AI-CONTRIBUTOR.md's
# pre-flight gate. Pass data/ explicitly (binary defaults to client/public).
run: cargo semantic-audit data/
- name: Stamp shared card_data_hash on coverage + audit JSONs
# Both upload to R2 as separate PUTs with no transactional guarantee.
# The shared hash lets consumers verify a consistent snapshot.
env:
CARD_DATA_HASH: ${{ needs.preview-inputs.outputs.card_data_hash16 }}
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: Upload data to R2 (staging)
# data-files.json is the single source of truth for every shared JSON the
# frontend fetches — it drives this loop, the verify step, and the dist
# strip in build-pages.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CARD_DATA_FILENAME: ${{ needs.preview-inputs.outputs.card_data_filename }}
DRAFT_POOLS_FILENAME: draft-pools-${{ needs.preview-inputs.outputs.draft_pools_hash16 }}.json
run: |
# The r2.dev public endpoint does NOT compress on the fly, so these
# JSONs were served raw (~77 MB of scryfall data per cold game load).
# Pre-compress with brotli and store Content-Encoding: br. Every
# consumer fetches via browser/Bun fetch(), which decodes br
# transparently (R2 serves the stored bytes+header unconditionally —
# safe because 100% of these consumers are br-capable). q9: ~90%
# reduction at a fraction of q11's CPU, which matters on an
# every-merge path.
#
# Compress into a temp dir, never client/public: content hashes and
# native 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"
npx wrangler r2 object put "phase-rs-data/staging/$CARD_DATA_FILENAME" --file "$BRDIR/card-data.br" --remote --content-type application/json --content-encoding br --cache-control "public, max-age=31536000, immutable"
# Draft pools are a second immutable input to native/server data
# manifests. Keep the mutable draft-pools.json below for web clients.
brotli -q 9 -c client/public/draft-pools.json > "$BRDIR/draft-pools.br"
npx wrangler r2 object put "phase-rs-data/staging/$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"
npx wrangler r2 object put "phase-rs-data/staging/$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)
# Badges are not in the manifest and their consumer is unaudited here;
# left uncompressed (tiny files) to keep this change scoped.
for f in data/badges/*.json; do
name=$(basename "$f")
npx wrangler r2 object put "phase-rs-data/badges/$name" --file "$f" --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
done
- name: Verify R2 uploads landed
# Wrangler v4 silently writes to a local emulator unless --remote is
# passed. Fail the deploy if any file the frontend will fetch is not
# actually reachable. Reads from data-files.json — same source as upload.
env:
CARD_DATA_FILENAME: ${{ needs.preview-inputs.outputs.card_data_filename }}
DRAFT_POOLS_FILENAME: draft-pools-${{ needs.preview-inputs.outputs.draft_pools_hash16 }}.json
run: |
BASE="https://data.phase-rs.dev/staging"
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"
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. The 200
# checks above pass regardless of encoding; this is the real proof.
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" ]
# ── WASM build (parallel with card-data) ────────────────────────────────────
# Compiles the engine + draft WASM bundles. Depends on nothing the data
# pipeline produces — card-data is fetched at runtime — so it runs fully in
# parallel. The output feeds the frontend build via artifact.
build-wasm:
name: Build WASM
if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
targets: wasm32-unknown-unknown
cache-shared-key: rust-wasm
# An env RUSTFLAGS (this action's default is "-D warnings") overrides
# ALL .cargo/config.toml rustflags, silently dropping the
# [target.wasm32-unknown-unknown] 16 MiB shadow-stack link-arg that
# build-wasm.sh's assert_wasm_stack guard requires. Keep config.toml
# authoritative.
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
# build-wasm.sh's assert_wasm_stack guard parses the shipped wasm's memory
# section with node; declare the dependency explicitly (release.yml already
# sets up node for its build-wasm job).
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Build WASM
run: ./scripts/build-wasm.sh release
- name: Upload WASM artifact
uses: actions/upload-artifact@v4
with:
name: wasm
path: client/src/wasm/*
if-no-files-found: error
retention-days: 1
# ── Signed preview server (parallel with data publication) ──────────────────
# The shell uses ENGINE_FINGERPRINT to fetch this exact binary. Keep the
# server publication in the same workflow graph so Pages can never publish a
# fingerprint whose signed server artifact is missing from the manifest.
preview-server:
name: Publish Preview Server
needs: [preview-inputs]
if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
uses: ./.github/workflows/preview-server.yml
with:
fingerprint: ${{ needs.preview-inputs.outputs.engine_fingerprint }}
commit: ${{ github.event.workflow_run.head_sha || github.sha }}
card_data_sha256: ${{ needs.preview-inputs.outputs.card_data_sha256 }}
card_data_hash16: ${{ needs.preview-inputs.outputs.card_data_hash16 }}
draft_pools_sha256: ${{ needs.preview-inputs.outputs.draft_pools_sha256 }}
draft_pools_hash16: ${{ needs.preview-inputs.outputs.draft_pools_hash16 }}
secrets:
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 }}
# ── Frontend + Pages deploy (needs published data + server artifact + WASM) ─
build-pages:
name: Build & Deploy Pages
needs: [preview-inputs, card-data, build-wasm, preview-server]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- name: Download WASM artifact
uses: actions/download-artifact@v4
with:
name: wasm
path: client/src/wasm
- 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: 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 (staging)
# Keep the binary raw: wasm-bindgen uses streaming instantiation, which
# requires R2 to serve the object as application/wasm. The immutable
# content-addressed URL is baked into the frontend build below.
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: |
npx wrangler r2 object put "phase-rs-data/staging/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: Use preview icons
run: |
cp client/public/preview-icons/favicon.ico client/public/favicon.ico
cp client/public/preview-icons/favicon-16x16.png client/public/favicon-16x16.png
cp client/public/preview-icons/favicon-32x32.png client/public/favicon-32x32.png
cp client/public/preview-icons/logo.png client/public/logo.png
cp client/public/preview-icons/icons/icon-192.png client/public/icons/icon-192.png
cp client/public/preview-icons/icons/icon-512.png client/public/icons/icon-512.png
cp client/public/preview-icons/icons/apple-touch-icon.png client/public/icons/apple-touch-icon.png
- 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 is pinned to
# the content-addressed copy so the WASM bundle is locked to the exact
# card-data it was built against (filename from preview-inputs).
DATA_BASE_URL: "https://data.phase-rs.dev/staging"
CARD_DATA_URL: "https://data.phase-rs.dev/staging/${{ needs.preview-inputs.outputs.card_data_filename }}"
ENGINE_WASM_URL: "https://data.phase-rs.dev/staging/wasm/${{ steps.engine-wasm-hash.outputs.filename }}"
ENGINE_FINGERPRINT: ${{ needs.preview-inputs.outputs.engine_fingerprint }}
AUDIO_BASE_URL: "https://data.phase-rs.dev/audio"
# 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: Remove R2-hosted data files from dist (Pages ships only the shell)
# data-files.json lists every JSON the frontend fetches from R2; this is
# the inverse of the R2 upload loop, stripping those files from the Pages
# bundle so we never double-ship bytes.
#
# Separately handled:
# - card-data.json (non-manifest; kept in public/ for Tauri + server
# bundles + local dev, 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)
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: Guard Cloudflare Pages asset size limit
# Preview publishes to GitHub Pages today, but production Cloudflare
# Pages has a hard 25 MiB per-file ceiling. Keep the preview artifact
# within that ceiling so release cannot fail only at Pages deployment.
shell: bash
run: |
oversized=$(find client/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: SPA fallback for GitHub Pages
run: cp client/dist/index.html client/dist/404.html
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: client/dist
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@v4
# ── Server binary compile (parallel with card-data) ─────────────────────────
# phase-server is compiled NATIVELY as a static musl binary, then uploaded as
# an artifact for push-server-image. The compile needs nothing the data
# pipeline produces, so it deliberately has no `needs:` and overlaps card-data's
# ~10min run instead of serializing behind it.
#
# NOTE: this is a cold ~10min compile every run. Swatinem's rust-cache (via
# setup-rust-toolchain) caches registry/git deps but prunes the workspace
# crates' own artifacts, so the optimized server-release build of the engine
# crate recompiles each time. The win here is parallelism, not incrementality;
# warming the workspace compile would need sccache or similar.
build-server-binary:
name: Build server binary
if: ${{ github.repository == 'phase-rs/phase' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-server
- name: Install musl tools and target
# The musl target must be added explicitly with `rustup target add`, not
# via setup-rust-toolchain's `targets:` input: rust-toolchain.toml pins
# the toolchain and its own `targets` list (wasm32 only), which overrides
# the action input. Mirrors release.yml's Linux server build.
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
rustup target add x86_64-unknown-linux-musl
- name: Build phase-server (static musl)
# Static musl binary: no glibc dependency, so it runs on the slim debian
# runtime regardless of this runner's glibc. Mirrors release.yml's Linux
# server build.
# No chmod here: upload-artifact strips the executable bit anyway;
# push-server-image re-applies it (and the Dockerfile chmods in-image).
run: |
# -p scopes feature unification to phase-server's own graph: an
# unscoped workspace build unifies feed-scraper's native-tls reqwest
# features into phase-server, dragging openssl-sys into the musl
# cross-compile (which has no OpenSSL and fails).
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
path: phase-server
if-no-files-found: error
retention-days: 1
# ── Server image push (needs binary) ────────────────────────────────────────
# Pure packaging step: pulls the prebuilt musl binary, then BINARY_STAGE=prebuilt
# makes Docker just apt + COPY (no compile).
#
# NOTE: this binary is compiled WITHOUT a PHASE_CHANNEL identity (it builds in
# parallel with card-data, so the fingerprint cannot exist yet), which means
# the image cannot self-bootstrap its data: booting it against an empty
# PHASE_DATA_DIR is a clear fatal error. Any consumer must either
# pre-provision the data volume or set PHASE_DATA_MANIFEST_URL to a signed
# release-shaped data manifest. No in-repo deployment currently runs this
# image (beebs deploy-server is disabled); see GH #6287 for publishing a
# signed staging data manifest before re-enabling one.
push-server-image:
name: Build & push server image
needs: [build-server-binary]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_SHA }}
- name: Download server binary artifact
uses: actions/download-artifact@v4
with:
name: server-binary
path: .
- name: Verify build context
run: |
test -s phase-server
chmod +x phase-server
- name: Compute image tags
id: image
run: echo "short_sha=${DEPLOY_SHA::12}" >> "$GITHUB_OUTPUT"
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push preview image
# BINARY_STAGE=prebuilt makes Docker copy the prebuilt musl binary
# instead of compiling in-container — the build is just apt + COPY.
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
BINARY_STAGE=prebuilt
tags: |
${{ env.IMAGE_NAME }}:preview
${{ env.IMAGE_NAME }}:sha-${{ steps.image.outputs.short_sha }}