Skip to content

Add Captain Marvel, Apex Avenger #19624

Add Captain Marvel, Apex Avenger

Add Captain Marvel, Apex Avenger #19624

Workflow file for this run

name: CI
on:
push:
branches: [main]
paths:
- 'crates/**'
- 'client/**'
- 'lobby-worker/**'
- 'scripts/**'
- 'Cargo.*'
- '.cargo/**'
- 'Dockerfile'
- '.dockerignore'
- 'docker/**'
- '.github/actions/**'
- '.github/workflows/ci.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/release.yml'
pull_request:
branches: [main]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
rust-lint:
name: Rust lint (fmt, clippy, parser gate)
runs-on: ubuntu-latest
# Clippy can consume the former 15-minute ceiling on a cold hosted cache;
# retain a bounded job while leaving room for the remaining lint gates.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
# Full history so the parser-combinator gate can diff against origin/main.
fetch-depth: 0
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug
- uses: mozilla-actions/sccache-action@v0.0.10
- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings
- name: Interaction bindings are current
run: ./scripts/check-interaction-bindings.sh --check
- name: Parser combinator gate
# Diff-based gate: new parser code must use nom combinators, not
# string-matching methods. See crates/engine/src/parser/oracle_nom/PATTERNS.md.
run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before }}"
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="origin/main"
fi
./scripts/check-parser-combinators.sh "$BASE"
- name: PreLowered ratchet (Plan 05b burn-down)
# Ceiling check on `OracleNodeIr::PreLowered*` occurrences per parser
# file. Counts may only decrease, and a parser file carrying PreLowered
# with no ledger entry fails, so a new producer cannot be added in a
# third file and escape the burn-down. Ledger:
# scripts/prelowered-ratchet.txt
run: ./scripts/check-prelowered-ratchet.sh
- name: Skill doc gate (oracle-parser SKILL.md)
# Asserts .claude/skills/oracle-parser/SKILL.md still matches the
# parser source tree: documented paths/symbols exist and the §3
# priority table mirrors the `// Priority` slots in oracle.rs.
run: ./scripts/check-skill-doc.sh
- name: Engine authority gate
# (A) Diff-based: new engine code must use single-authority helpers
# (keyword queries via game/keywords.rs) instead of raw state pokes.
# (B) Full-tree: gameplay zone changes must go through zone_pipeline,
# not the raw movers / zone containers / `GameObject::zone =`.
# Every classified site requires a nonempty `allow-raw-zone:`
# annotation explaining why it is not a replaceable zone event.
run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before }}"
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="origin/main"
fi
./scripts/check-engine-authorities.sh "$BASE"
- name: Resolution-frame boundary guard
# Full-tree guard: legacy resolution keys are reader/fixture-only, and
# ResolutionStack mutation remains top-only or adjacent-pair scoped.
run: ./scripts/check-resolution-frame-boundaries.sh
- name: Test card-data load gate
# Diff-based gate: new test code must not reparse the full ~90 MB
# client/public/card-data.json (tens of seconds/test under nextest, and
# it self-skips in CI). Use support::shared_card_db() (fixture-backed).
run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before }}"
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="origin/main"
fi
./scripts/check-test-card-data-load.sh "$BASE"
rust-test:
name: Rust tests (shard ${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- name: Free runner disk for Rust test linking
# The nextest count partition splits execution, but each shard still
# runs `cargo test --no-run` for the whole workspace. Keep the Rust
# caches intact and reclaim only unused hosted-image payloads before
# setup-rust-toolchain restores/saves Cargo state.
run: |
echo "Disk before cleanup:"
df -h /
sudo rm -rf \
/usr/local/lib/android \
/usr/share/dotnet \
/opt/ghc \
/usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL
if command -v docker >/dev/null 2>&1; then
docker system prune --all --force || true
fi
echo "Disk after cleanup:"
df -h /
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug
- uses: mozilla-actions/sccache-action@v0.0.10
- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: Run tests
# PROPTEST_CASES split: PRs run a smaller case count for fast feedback;
# pushes to main run the default (256) so any property regression is
# caught before it lands on the branch that feeds staging/preview.
env:
PROPTEST_CASES: ${{ github.event_name == 'pull_request' && '32' || '256' }}
run: cargo nextest run --profile ci --partition count:${{ matrix.shard }}/2 --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest --status-level fail --final-status-level fail
card-data-gate:
name: Card data (generate, validate, coverage)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
# Parse-diff reads both parents of GitHub's synthetic PR merge commit.
fetch-depth: 2
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug
- uses: mozilla-actions/sccache-action@v0.0.10
- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"
- name: Set cache keys
id: cache-keys
run: echo "week=$(date +%Y-W%V)" >> "$GITHUB_OUTPUT"
- name: Cache MTGJSON data
# Scoped to AtomicCards.json under its own `mtgjson-atomic-` namespace —
# this job needs nothing else from data/mtgjson. The per-content
# namespace stops draft-pools (which populates only SetList + sets/,
# never AtomicCards) from clobbering this job's key with a partial dir.
#
# Exact weekly key, NO restore-keys: a new week misses, so the download
# step re-fetches fresh MTGJSON data (the weekly refresh we want); a
# restore-keys fallback would pin us to last week's AtomicCards forever
# because the download is file-existence-gated. Same-week runs reuse the
# exact-key hit.
id: mtgjson-cache
uses: actions/cache@v4
with:
path: data/mtgjson/AtomicCards.json
key: mtgjson-atomic-${{ steps.cache-keys.outputs.week }}
- 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: Cache generated card data
# Output of `oracle-gen` is deterministic given (MTGJSON input + engine
# source). Hash both into the key; on hit, skip the ~30k-card pass.
# Exact-match only (no restore-keys) — a stale hit would feed wrong
# card-data into the validate/coverage gates downstream. Cargo.lock is
# included so dep bumps that affect parsing (e.g. nom) invalidate.
#
# `crates/engine/data/**` and `build.rs` are hashed because the engine
# lib compiles those files IN: oracle-subtypes.json via `include_str!`,
# known-tokens.toml via build.rs → OUT_DIR embed. A PR touching only
# those changes parser behaviour, so omitting them would serve stale
# card-data from this cache and (via gates-cache below) skip the
# validate/coverage gates that would have caught it. hashFiles evaluates
# at step start against the clean checkout, so oracle-gen's later
# write-if-changed rewrite of oracle-subtypes.json cannot feed back in.
#
# The AI gate's shared card-data-cache action intentionally uses the
# same `cardgen-` key expression, so main pushes can seed generated
# entries that AI PR gates reuse. Both paths download AtomicCards.json
# before evaluating the key so the gitignored MTGJSON input participates
# in `hashFiles`.
id: cardgen-cache
uses: actions/cache@v4
with:
path: |
data/card-data.json
data/card-names.json
key: cardgen-${{ hashFiles('data/mtgjson/AtomicCards.json', 'crates/engine/src/**/*.rs', 'crates/engine/data/**', 'crates/engine/build.rs', 'crates/engine/Cargo.toml', 'Cargo.lock') }}
- name: Cache card-data gate results
# Proof-of-passage cache for the two expensive steps that are pure
# functions of the same hashed inputs: `card-data-validate` and
# `coverage-report`. `actions/cache` writes its entry only when the job
# succeeds (`post-if: success()` on the action's post step), so an entry
# under this key is evidence that validate AND coverage passed for this
# exact input set — which is what licenses skipping them on a hit.
# Key expression must stay identical to `cardgen-cache`'s above.
#
# Deliberately a SEPARATE key from `cardgen-cache`, not a reuse of it.
# A cardgen entry proves only that generation succeeded; it is not
# evidence that anything validated it. ai-gate.yml intentionally shares
# `cardgen-` entries but never validates them. If this gate skipped
# validate/coverage on a shared cardgen hit, an engine change whose
# generated output fails `card-data-validate` could still seed a false
# green for the next push. This cache is written by card-data-gate alone,
# so a hit cannot lie about a gate.
id: gates-cache
uses: actions/cache@v4
with:
path: |
data/coverage-data.json
data/coverage-summary.json
key: cardgen-gates-v1-${{ hashFiles('data/mtgjson/AtomicCards.json', 'crates/engine/src/**/*.rs', 'crates/engine/data/**', 'crates/engine/build.rs', 'crates/engine/Cargo.toml', 'Cargo.lock') }}
- name: Generate card data
# All engine-backed tools in this job use the SAME (profile, features)
# pair — `[tool, cli]` — so the engine crate compiles exactly once and is
# reused as a cache hit by validate, coverage, and semantic-audit below.
# Mixing profiles/feature-sets (e.g. dev here, release elsewhere)
# re-fingerprints engine and forces a full recompile per variant.
if: steps.cardgen-cache.outputs.cache-hit != 'true'
run: |
cargo run --profile tool --features cli --bin oracle-gen -- data/ --stats --names-out data/card-names.json > data/card-data.json
- name: Card export must not mutate the tracked parser vocabulary
# Regression guard: the export is a PURE READ of the tracked tree.
#
# It used to rewrite crates/engine/data/oracle-subtypes.json on every run,
# from CardTypes.json ∪ the AtomicCards harvest — but this job downloads
# only AtomicCards.json (see the mtgjson cache above), so CardTypes.json
# was always absent and the regeneration silently fell back to the harvest
# alone. That deletes the 26 token-only creature subtypes (Army, Servo,
# Pentavite, Sculpture, Tentacle, …) which are printed on no card face and
# so live in CardTypes.json alone. Because the engine `include_str!`s that
# file, the rewrite bumped its mtime and rebuilt the engine right here —
# leaving validate, coverage, and semantic-audit below to run against a
# parser whose subtype vocabulary was missing all 26.
#
# The refresh now lives behind `--write-subtypes`, passed only by
# scripts/gen-card-data.sh (the one caller that fetches the sidecar, and
# which hard-fails without it). This asserts the export stays a pure read.
# Scoped to the same condition as the generate step so it is never
# vacuous: it runs exactly when an export ran.
if: steps.cardgen-cache.outputs.cache-hit != 'true'
run: git diff --exit-code -- crates/engine/data/oracle-subtypes.json
- name: Validate card-data against engine schema
# PR-time gate — the same parse the WASM does at runtime, run against
# the freshly produced card-data. If a parser/schema change makes the
# engine unable to read its own output, fail the build before merge.
# Skipped on a gates-cache hit (NOT a cardgen hit — see that step): a
# gates entry for this input hash is proof this exact check already
# passed. card-data.json is byte-identical either way, restored from
# cardgen or regenerated above.
# Same [tool, cli] fingerprint as card-gen above → engine cache hit. That
# hit is real because the export never writes to crates/engine/data: the
# engine lib pulls oracle-subtypes.json in via `include_str!`, so any
# write would bump its mtime, dirty the crate's dep-info fingerprint, and
# force a full engine recompile right here — against a vocabulary this job
# has no CardTypes.json to regenerate correctly. The step above pins that.
if: steps.gates-cache.outputs.cache-hit != 'true'
run: cargo run --profile tool --features cli --bin card-data-validate -- data/card-data.json
- name: Draw replacement corpus freeze (Plan 03 / CR 121.2)
# Every `ReplacementEvent::Draw` definition must declare whether it
# modifies the draw *instruction* count (CR 121.2a) or replaces one
# *individual* draw (CR 121.2). Scope is assigned at construction and is
# not recoverable afterwards, so the corpus that carries one — and the
# scope each row must get — is frozen against a committed baseline.
#
# This lives in the card-data job, not the Rust lint job, because it
# reads the generated (gitignored) export. It runs UNCONDITIONALLY, with
# no gates-cache guard: that cache is keyed on MTGJSON + engine sources
# and does NOT hash scripts/**, so a PR that edited only the baseline
# would hit the cache, skip the check, and report a false green. The
# export is present either way (generated above or restored by cardgen).
run: python3 scripts/draw_replacement_census.py --corpus --check
- name: Card support coverage report
# Skipped on a gates-cache hit — coverage-data.json and
# coverage-summary.json are restored from it, and both are deterministic
# in the same hashed inputs. Nothing downstream mutates either file, so
# the cached bytes are exactly this step's output regardless of which ref
# populated the entry (the main-only stamp writes a separate .stamped.json
# copy). The regression/parse-diff steps below still run unconditionally
# and consume the restored files (their R2 baselines move independently
# of this cache).
# Same [tool, cli] fingerprint as card-gen above → engine cache hit (see
# the oracle-subtypes note on the validate step).
if: steps.gates-cache.outputs.cache-hit != 'true'
run: |
cargo run --profile tool --features cli --bin coverage-report -- data/ --all > data/coverage-data.json
jq '{total_cards, supported_cards, coverage_pct, coverage_by_format}' data/coverage-data.json > data/coverage-summary.json
- name: Coverage regression check (engine vs parser-honesty)
# Fails the build if a previously-supported card loses support because
# the engine now lacks a handler (non-ParseWarning gap). Parser-honesty
# flips (new ParseWarning:*) and genuinely-gained cards are informational.
# Baseline is main's last-good coverage-data.json published to R2.
# On push-to-main the check is informational only: the merge has already
# happened, and failing here blocks the R2 baseline republish below,
# wedging main red until a manual baseline upload (the PR #2339 /
# PR #2802 ratchet deadlock). PR and merge_group runs remain blocking.
continue-on-error: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
run: |
./scripts/coverage-regression-check.sh \
"https://data.phase-rs.dev/preview/coverage-data.json" \
data/coverage-data.json \
--fail-on-engine
- name: Parse-detail diff vs base baseline
# PR-only review aid (NEVER blocks — continue-on-error): surfaces the
# field-level parse changes this PR introduces, for the reviewing LLM.
# Unlike the regression check above (supported flips only), this diffs
# the full parse_details tree.
#
# Baseline = the published coverage of the EXACT main commit this CI
# merge commit was built on (the merge commit's non-head parent),
# fetched by its engine-source hash; head = the coverage built above
# from that same merge commit. So head - baseline isolates EXACTLY this
# PR's parse changes, with no second coverage build and no cross-PR
# contamination — even when the PR is behind main, or has main merged
# into it (both sides move in lockstep because the base is read off the
# merge commit, not the frozen webhook base.sha). Path filter: identical
# engine-source hash => no parse
# change is possible => skip before the bin build. --features cli keeps
# the same [tool, cli] engine fingerprint as the steps above (lib cache
# hit; only the new bin links).
id: parsediff
if: github.event_name == 'pull_request'
continue-on-error: true
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
echo "$PR_NUMBER" > pr-number.txt
# Derive the baseline from the ACTUAL main commit this CI merge commit
# was built on — its non-head parent — NOT the webhook payload's
# base.sha. The payload value freezes at event-creation time, but the
# checked-out refs/pull/N/merge floats: GitHub recomputes it against
# main's tip whenever main advances or the contributor merges main into
# the branch. When the two diverge, head carries the newer main while a
# payload-base.sha baseline does not, so head - baseline leaks every
# intervening main parse change into this PR's diff (this contaminated
# #4303: a one-card autotap fix whose comment listed 25 unrelated
# cards). Reading the base off the merge commit's parent keeps both
# sides in lockstep no matter how stale (or main-merged) the PR is.
if ! BASE_SHA="$(./scripts/parse-diff-base.sh "$HEAD_SHA")"; then
# An unmergeable PR has no synthetic merge parent. A shallow or
# missing parent is never licensed to use the frozen webhook base:
# that would bill intervening main changes to this PR.
printf '<!-- coverage-parse-diff -->\n### Parse changes introduced by this PR\n\n_Baseline pending — no synthetic merge parent is available for PR head `%s`._\n' "$HEAD_SHA" > parse-diff.md
echo "produced=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch --no-tags --depth=1 origin "$BASE_SHA"
BASE_HASH="$(./scripts/engine-source-hash.sh "$BASE_SHA")"
HEAD_HASH="$(./scripts/engine-source-hash.sh HEAD)"
if [ "$BASE_HASH" = "$HEAD_HASH" ]; then
echo "Engine source unchanged vs base ($BASE_HASH) — no parse change possible; skipping."
echo "produced=false" >> "$GITHUB_OUTPUT"
exit 0
fi
URL="https://data.phase-rs.dev/parse-baselines/coverage-data-${BASE_HASH}.json"
if curl -fsSL --retry 3 --retry-delay 2 "$URL" -o coverage-base.json; then
cargo run --profile tool --features cli --bin coverage-parse-diff -- \
coverage-base.json data/coverage-data.json \
--base-sha "$BASE_SHA" \
--markdown parse-diff.md --json parse-diff.json
else
# Baseline not published yet (base.sha only just merged) or aged out
# of retention. NEVER fall back to the lagging preview/ snapshot —
# that reintroduces cross-PR contamination. Emit a pending marker so
# reviewers can distinguish "attempted but baseline unavailable"
# from "parse-diff never ran."
printf '<!-- coverage-parse-diff -->\n### Parse changes introduced by this PR\n\n_Baseline pending for `%s` — this populates once main publishes its coverage snapshot (a few minutes after that commit landed)._\n' "$BASE_SHA" > parse-diff.md
fi
echo "produced=true" >> "$GITHUB_OUTPUT"
- name: Upload parse-diff artifact
# Consumed by .github/workflows/coverage-parse-diff-comment.yml, which
# posts the sticky PR comment from the trusted workflow_run context.
if: github.event_name == 'pull_request' && steps.parsediff.outputs.produced == 'true'
uses: actions/upload-artifact@v4
with:
name: parse-diff
if-no-files-found: ignore
retention-days: 3
path: |
parse-diff.md
parse-diff.json
pr-number.txt
- name: Run semantic audit
# Produces data/semantic-audit.json with structured findings for cards
# that parse cleanly but disagree semantically with their Oracle text.
# Only runs on push-to-main because the only consumer is the R2 upload
# below (also main-only) — on a PR the JSON is written and discarded.
# Pass data/ explicitly — the binary's default lookup is
# `client/public/card-data.json` which doesn't exist on the ci.yml
# path (card-data is generated directly into data/ above).
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: cargo semantic-audit data/
- name: Stamp shared card_data_hash on coverage + audit JSONs
# Embed the same card-data hash in both files so downstream consumers
# can verify they're reading a consistent R2 snapshot. Gated to main
# because the only consumer is the R2 upload below.
#
# coverage-data.json is stamped into a SEPARATE .stamped.json copy, not
# rewritten in place: it is a `gates-cache` path, and this step runs
# before that cache's post-save. Stamping in place would make the cached
# bytes depend on github.ref (main-push entries jq-reserialized and
# stamped, PR entries raw), coupling a content-addressed cache to the ref
# that happened to populate it. semantic-audit.json is not cached, so it
# is still stamped in place. The uploads below publish the stamped copy,
# so R2 artifacts keep exactly their current shape.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
HASH=$(sha256sum data/card-data.json | awk '{print substr($1, 1, 16)}')
jq --arg h "$HASH" '. + {card_data_hash: $h}' data/semantic-audit.json > data/semantic-audit.json.tmp
mv data/semantic-audit.json.tmp data/semantic-audit.json
jq --arg h "$HASH" '. + {card_data_hash: $h}' data/coverage-data.json > data/coverage-data.stamped.json
- name: Upload data to R2 (preview)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler r2 object put phase-rs-data/preview/card-data.json --file data/card-data.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
npx wrangler r2 object put phase-rs-data/preview/card-names.json --file data/card-names.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
npx wrangler r2 object put phase-rs-data/preview/coverage-data.json --file data/coverage-data.stamped.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
npx wrangler r2 object put phase-rs-data/preview/coverage-summary.json --file data/coverage-summary.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
npx wrangler r2 object put phase-rs-data/preview/semantic-audit.json --file data/semantic-audit.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
# changelog{,-meta}.json are committed to client/public/ (not generated
# into data/ like the files above), so the preview snapshot tracks main
# the moment a regenerated changelog lands — ahead of the nightly that
# carries it to production.
npx wrangler r2 object put phase-rs-data/preview/changelog.json --file client/public/changelog.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
npx wrangler r2 object put phase-rs-data/preview/changelog-meta.json --file client/public/changelog-meta.json --remote --content-type application/json --cache-control "public, max-age=60, must-revalidate"
- name: Publish immutable parse-diff baseline to R2
# Content-addressed sibling of the mutable preview/coverage-data.json
# write above: an immutable copy keyed by THIS commit's engine-source
# hash. The PR-side "Parse-detail diff" step fetches the copy matching
# its CI merge commit's base parent's hash, so its baseline is the exact
# main that merge commit was built on — never the lagging preview/
# snapshot (which mixes in other PRs' changes). Identical-source commits
# dedupe to one object, so storage is
# bounded by distinct parser states, not commit count. best-effort
# (continue-on-error): a missed publish just defers a PR's comment to
# "baseline pending" until the next main push, never wedges main.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
HASH="$(./scripts/engine-source-hash.sh HEAD)"
npx wrangler r2 object put "phase-rs-data/parse-baselines/coverage-data-${HASH}.json" \
--file data/coverage-data.stamped.json --remote --content-type application/json \
--cache-control "public, max-age=31536000, immutable"
draft-pools:
# Split out of card-data-gate because draft-pool-gen lives in draft-core and
# cannot take `--features cli` — it always needs its own `[tool, default]`
# engine fingerprint, distinct from the `[tool, cli]` build every other
# card-data tool shares. Keeping it inline forced a second full engine
# compile onto card-data-gate's critical path; here it runs in parallel.
# Its output (draft-pools.json) is not consumed by ci.yml — this is a
# main-only smoke test that draft-pool-gen still compiles and runs.
name: Draft pools (smoke)
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
# No wasm32 target: draft-pool-gen is a native binary. The target list
# isn't part of the rust-cache key, so omitting it still shares the
# rust-debug cache with card-data-gate.
cache-shared-key: rust-debug
- name: Set cache keys
id: cache-keys
run: echo "week=$(date +%Y-W%V)" >> "$GITHUB_OUTPUT"
- name: Cache MTGJSON data
# Scoped to SetList.json + the per-set files this job populates, under
# its own `mtgjson-sets-` namespace so it can't collide with
# card-data-gate's `mtgjson-atomic-` key (this job never fetches
# AtomicCards). Exact weekly key, NO restore-keys: a new week misses and
# re-fetches fresh set data; same-week runs reuse the exact-key hit.
uses: actions/cache@v4
with:
path: |
data/mtgjson/SetList.json
data/mtgjson/sets
key: mtgjson-sets-${{ steps.cache-keys.outputs.week }}
- name: Download MTGJSON SetList data
# fetch-draft-sets.sh enumerates draftable sets from SetList.json.
run: |
mkdir -p data/mtgjson
if [ ! -f data/mtgjson/SetList.json ]; then
source scripts/lib/mtgjson-fetch.sh
mtgjson_download SetList.json data/mtgjson/SetList.json
fi
- name: Download draft set data
run: ./scripts/fetch-draft-sets.sh
- name: Generate draft pools
# Profile `tool`, not `release`: release is the WASM-size profile
# (lto + codegen-units=1), the slowest compile in the repo, for a
# one-shot JSON transform.
run: cargo run --profile tool --bin draft-pool-gen -- data/mtgjson/sets data/draft-pools.json
wasm-check:
name: WASM compile check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug
- name: WASM compile check
run: cargo check --package engine-wasm --target wasm32-unknown-unknown
tauri-check:
name: Tauri compile check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-tauri
cache-workspaces: |
. -> target
client/src-tauri -> target
- name: Install Linux Tauri build deps
# Required by phase-tauri's transitive dependencies (webkit2gtk, gtk).
# Without these, `cargo check -p phase-tauri` fails at the linker.
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- name: Stub bootstrap dist for tauri::generate_context!
# phase-tauri's build.rs runs `tauri::generate_context!()` which
# requires `frontendDist` (../bootstrap/dist per tauri.conf.json) to
# exist. An empty stub is enough for type-checking — we don't bundle
# bootstrap assets here.
run: |
mkdir -p client/bootstrap/dist
echo '<!doctype html><html></html>' > client/bootstrap/dist/index.html
- name: Tauri compile check
# phase-tauri is excluded from the workspace clippy and nextest runs
# above (no proptest support, no tests). Without this step, compile
# errors in the desktop crate only surface at shell release time.
# Catches them on every PR in ~3 min.
# Driven via --manifest-path because client/src-tauri is excluded
# from the root workspace, so `-p phase-tauri` cannot resolve it.
run: cargo check --locked --manifest-path client/src-tauri/Cargo.toml
rust-check:
name: Rust (fmt, clippy, test, coverage-gate)
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- rust-lint
- rust-test
- card-data-gate
- draft-pools
- wasm-check
- tauri-check
if: always()
steps:
- name: Check split Rust jobs
env:
RUST_LINT_RESULT: ${{ needs.rust-lint.result }}
RUST_TEST_RESULT: ${{ needs.rust-test.result }}
CARD_DATA_RESULT: ${{ needs.card-data-gate.result }}
DRAFT_POOLS_RESULT: ${{ needs.draft-pools.result }}
WASM_RESULT: ${{ needs.wasm-check.result }}
TAURI_RESULT: ${{ needs.tauri-check.result }}
run: |
results="$RUST_LINT_RESULT $RUST_TEST_RESULT $CARD_DATA_RESULT $DRAFT_POOLS_RESULT $WASM_RESULT $TAURI_RESULT"
# `skipped` is a pass: draft-pools is main-only and is skipped on PRs.
for result in $results; do
if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
echo "One or more split Rust jobs failed: $results"
exit 1
fi
done
lobby-worker-test:
name: Lobby worker (pnpm test)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: lobby-worker/package-lock.json
- name: Install and test
run: |
cd lobby-worker
npm ci
pnpm test
frontend:
name: Frontend (lint, type-check, test)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- 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 dependencies
run: cd client && pnpm install --frozen-lockfile
- name: Lint
run: cd client && pnpm run lint
- name: Type check
run: cd client && pnpm run type-check
- name: Run tests with coverage
run: cd client && pnpm test -- --run --coverage