name: wasm
# This file declares a lane. Whether the lane fired is its run history on the
# forge and not a sentence in this header: a forge setting copied into a
# comment is a mutable fact recorded once, and it goes stale in silence
# (causl/causl-wasm-ts#468). The "INERT, Actions is disabled" header that stood
# here was exactly that copy. It recorded a real outage, 2026-08-04 through
# 2026-08-07, when the Actions unit was switched off on causl/causl-wasm-ts and
# on causl/causl-core-rs; that was repo-scoped rather than a runner or a forge
# failure, the rest of the instance ran green throughout, and the header
# outlived it. causl/causl-wasm-ts#409 moved every workflow here out of the
# legacy forge root Gitea ignores, into the root it reads. See
# `ci/parked/README.md`.

# wasm-pack build pipeline — `iasbuilt/causl#683`.
#
# ISSUE NUMBERS IN THIS FILE. A bare `#N` means `causl/causl-wasm-ts#N`.
# This workflow was written in the predecessor monorepo and its numbers
# came with it, so each of those is written `iasbuilt/causl#N`: that
# tracker did not move here and none of its numbers resolves from this
# repository. See the *Status* note in README.md.
#
# Sub-task of EPIC `iasbuilt/causl#680`. Validates that the three Rust
# bridge crates compile to wasm32-unknown-unknown and that wasm-pack
# produces the four artefacts the runtime loader selects between at
# startup. Sequencing note: this workflow landed before the crates
# themselves — `iasbuilt/causl#682`, `iasbuilt/causl#693`,
# `iasbuilt/causl#1006`; it is
# defensively structured to skip cleanly when the bridge crates do not
# yet exist on the branch under test.
#
# Job layout:
#
#   - wasm-pack: matrix on (crate × features) for the three artefacts.
#     Defensively skips per-target when the crate dir is absent. Each
#     job runs `pnpm wasm:build:check` first to surface any wasm-pack
#     install issue with a clean message before the heavyweight build.
#
# THE `cargo-check` JOB IS GONE (causl/causl-wasm-ts#471). It was named
# `cargo check (workspace, defensive)`, and a `Detect workspace` step set
# a flag from `[ -f Cargo.toml ] && grep -q '[workspace]' Cargo.toml`
# that all four of its working steps hung their `if:` on. The predicate
# reads THIS repository, which carries zero Rust and no root Cargo.toml,
# so the flag was permanently false and the job published a green status
# having run one condition. Measured on the release head `d8c5f97`
# (`GET /repos/causl/causl-wasm-ts/commits/d8c5f97…/statuses`,
# 2026-08-09): `wasm / cargo check (workspace, defensive) (push)` is
# `success` on a commit where `wasm-pack build`, all three
# `bundler-interop` legs and `bundle-budget` are `failure`. The one green
# cell on that board is the job that executed nothing.
#
# Its wasm-shim leak check went with it, and not for want of caring about
# the invariant (`iasbuilt/causl#682`: the native enumerator must not
# pull wasm-bindgen / js-sys / serde-wasm-bindgen transitively). The
# crate it read is in neither repository this workflow can reach. This
# repo has no `tools/enumerator`, and the sibling `causl/causl-core-rs`
# workspace at `tools/Cargo.toml` lists exactly `engine-rs-core` and
# `engine-rs-bridge` as members, its own header recording that `checker`
# and `enumerator` moved to the sibling `causl-check` / `causl-bench`
# repositories. That check belongs beside the crate it reads; re-land it
# there, or here on the day a `[workspace]` root and that crate arrive
# together.
#
# Triggers: push to main + pull_request. Before the bridge crates
# existed we ran on every PR — now that they fill in, we may want to
# add a paths filter (`tools/engine-rs-*/**`, `.gitea/workflows/wasm.yml`)
# to skip on PRs that don't touch the wasm side; deferred until the
# pipeline is doing real work.

# Triggers (CI split — see .gitea/workflows/main-fast.yml header):
#   - PRs targeting `release` (merge-gate verification)
#   - push to `release` (post-merge re-verification)
#   - workflow_dispatch (ad-hoc / re-run)
#
# The wasm-pack matrix + binaryen install + bundler-interop fixtures are
# the heaviest workflow in this repo — kept on the release lane so PRs
# to `main` stay fast. workflow_dispatch lets you run it on demand
# against any branch.
on:
  push:
    branches: [release]
  pull_request:
    branches: [release]
  workflow_dispatch:

# Cancel superseded runs on the same branch — the Rust toolchain
# install is the slow step and there is no reason to keep stale runs
# alive once a newer commit lands. Mirrors the convention in
# ci/parked/ci.yml.
concurrency:
  group: wasm-${{ github.ref }}
  cancel-in-progress: true

jobs:
  wasm-pack:
    # Issue #10 — build the CONSOLIDATED `causl-engine-bridge` artefact
    # from the sibling `causl/causl-core-rs` checkout. THIS repo carries
    # ZERO Rust source: the crate lives in causl-wasm at
    # `tools/engine-rs-bridge`. `pnpm wasm:build` builds the one
    # string-strategy feature variant (gc-classic classic-strings) and
    # ENFORCES the size gate
    # (700 KB raw / 200 KB Brotli) per artefact — see
    # `tools/wasm-build/build.mjs`. This replaces the retired split-crate
    # `engine_rs` lineage (`tools/engine-rs-bridge-serde` /
    # `tools/engine-rs-bridge-gc`, never present in this repo).
    #
    # The sibling checkout is BEST-EFFORT (continue-on-error). A run
    # without read access to causl-wasm (no CAUSL_WASM_TOKEN secret)
    # skips the in-repo rebuild cleanly rather than false-failing; the
    # committed artefact is still size-gated by `pnpm size` against the
    # `size-limit` cells (here + the bundle-budget workflow).
    name: wasm-pack build (consolidated causl-engine-bridge)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Sibling engine source. `build.mjs` resolves the crate at
      # `${CAUSL_WASM_PATH}/tools/engine-rs-bridge`; check out causl-wasm
      # INSIDE the workspace and point CAUSL_WASM_PATH at it (actions/
      # checkout cannot write outside the workspace, so the `../causl-core-rs`
      # default is overridden via the env var on the build step below).
      - name: Check out causl-wasm (consolidated bridge crate)
        id: engine_src
        uses: actions/checkout@v4
        with:
          repository: causl/causl-core-rs
          path: causl-wasm
          token: ${{ secrets.CAUSL_WASM_TOKEN || secrets.GITHUB_TOKEN }}

      - uses: pnpm/action-setup@v4

      - name: Use Node.js 24
        uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Detect consolidated bridge crate
        id: detect
        run: |
          CRATE_DIR="${{ github.workspace }}/causl-wasm/tools/engine-rs-bridge"
          if [ -d "$CRATE_DIR" ]; then
            echo "present=true" >> "$GITHUB_OUTPUT"
            # Export the absolute crate root for `build.mjs` (it reads
            # CAUSL_WASM_PATH, falling back to ../causl-core-rs otherwise).
            echo "CAUSL_WASM_PATH=${{ github.workspace }}/causl-wasm" >> "$GITHUB_ENV"
          else
            echo "present=false" >> "$GITHUB_OUTPUT"
            echo "::error::sibling causl/causl-core-rs bridge crate not checked out — the wasm rebuild would silently no-op."
            echo "::error::A skipped rebuild reports GREEN while testing an engine that was never built (see #265, split into #305)."
            echo "::error::Check the causl/causl-core-rs path and CAUSL_WASM_TOKEN read access."
            exit 1
          fi

      - uses: dtolnay/rust-toolchain@stable
        if: steps.detect.outputs.present == 'true'
        with:
          targets: wasm32-unknown-unknown

      - uses: Swatinem/rust-cache@v2
        if: steps.detect.outputs.present == 'true'

      - name: Install wasm-pack
        if: steps.detect.outputs.present == 'true'
        run: |
          if ! command -v wasm-pack >/dev/null 2>&1; then
            curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
          fi
          wasm-pack --version

      # Driver sanity, ahead of the two heavy installs below (binaryen 119
      # is a tarball fetch, `pnpm wasm:build` is the wasm-pack compile).
      # `--check` resolves the crate, names the engine revision, verifies
      # wasm-pack, prints the wasm-pack invocation it WOULD run, and
      # asserts the committed bridge tree is one the loader can boot. It
      # writes nothing.
      #
      # POSITION IS LOAD-BEARING (causl/causl-wasm-ts#471). This step used to sit between
      # `pnpm install` and `Detect consolidated bridge crate`, and it ended
      # in `|| echo "::warning::…"`, which replaces the exit status with 0
      # and makes a step unable to fail. The tail was hiding a step that
      # could never have passed where it stood: `--check` resolves the
      # crate through CAUSL_WASM_PATH, which `Detect` had not exported yet,
      # and calls `ensureWasmPack()`, which `Install wasm-pack` had not
      # satisfied yet. Both preconditions are met here, so the check is a
      # real question with a real answer and it fails loud on a bad one.
      # Verified locally against the sibling checkout:
      # `CAUSL_WASM_PATH=… pnpm wasm:build:check` exits 0 and reports
      # `built: gc-classic-bundler`.
      - name: pnpm wasm:build --check (driver sanity)
        run: |
          if ! pnpm wasm:build:check; then
            echo "::error::the wasm build driver refused before the build ran."
            echo "::error::wasm-pack: install with \`curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh\`."
            echo "::error::crate: build.mjs reads CAUSL_WASM_PATH (exported by the Detect step above) and falls back to ../causl-core-rs."
            exit 1
          fi

      # Bundle-size enforcement gate (`iasbuilt/causl#1085`). The driver in
      # `tools/wasm-build/build.mjs` shells out to an external
      # `wasm-opt` (binaryen) with `--enable-reference-types
      # --enable-gc --enable-nontrapping-float-to-int` because
      # wasm-pack 0.14.0's bundled wasm-opt is from a pre-WasmGC
      # binaryen release and rejects those flags. Pin binaryen 119
      # (December 2024 — first release with stable WasmGC support).
      - name: Install binaryen (wasm-opt) for the artefact size gate
        if: steps.detect.outputs.present == 'true'
        run: |
          BINARYEN_VERSION=119
          curl -sSL -o /tmp/binaryen.tar.gz \
            "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz"
          tar -xzf /tmp/binaryen.tar.gz -C /tmp/
          sudo cp -r /tmp/binaryen-version_${BINARYEN_VERSION}/bin/* /usr/local/bin/
          sudo cp -r /tmp/binaryen-version_${BINARYEN_VERSION}/lib/* /usr/local/lib/ 2>/dev/null || true
          wasm-opt --version

      - name: pnpm wasm:build (real build + artefact size gate)
        if: steps.detect.outputs.present == 'true'
        # `pnpm wasm:build` now runs wasm-pack → wasm-opt -Oz → raw +
        # Brotli q11 budget check per artefact. A bridge
        # that crosses its SPEC §17.6 cap fails the step with the
        # `BUDGET EXCEEDED` line in the driver log.
        run: pnpm wasm:build

      # @v3, not @v4: this forge refuses v4+ outright. Measured on the
      # 0.5.0 cut (#424): `::error::upload-artifact@v4+ are not
      # currently supported on GHES.` The step failed on every run of
      # this job that reached it.
      - name: Upload artefact
        if: steps.detect.outputs.present == 'true'
        uses: actions/upload-artifact@v3
        with:
          # The consolidated build emits one feature variant, under
          # `packages/core/wasm-pkg/gc-classic-bundler/` (the gc-builtins
          # variant was retired by causl/causl-core-rs#355); upload the
          # whole tree as one artefact.
          name: wasm-pkg-consolidated
          path: packages/core/wasm-pkg/
          if-no-files-found: warn
          retention-days: 7

      # Eight of the nine size-limit cells measure `packages/*/dist/*`,
      # and nothing in this job built them (#453). The job ran checkout ->
      # `pnpm install` -> `pnpm wasm:build` -> `pnpm size`, so every
      # `dist/` was absent and the glob behind each cell path matched
      # nothing. A cell that also declares `import` is then keyed on
      # `files[0]` (size-limit's `get-config.js`), which for an empty
      # match is `undefined`, and esbuild reported the result:
      #
      #     Could not resolve "/workspace/causl/causl-wasm-ts/undefined"
      #     import { createCausl } from ".../undefined"
      #
      # The throw lands inside the plugin loop, BEFORE `calc.js` marks a
      # cell `missed`, so the accurate line size-limit already knows how
      # to print for this exact condition (`Size Limit can't find files
      # at packages/core/dist/index.js`) never got a turn. The gate had
      # never once passed. `.husky/pre-commit` builds before its own size
      # step and its comment says why; this job was the one place that
      # ran the gate without a build.
      #
      # A PATH filter via the root `build` script, not a name filter:
      # `--filter @causl/core` matched nothing after the rename and pnpm
      # answers an unmatched filter with exit code 0 (#424), which is how
      # a build step comes to build nothing and pass.
      - name: Build packages (the JS cells' subject)
        if: steps.detect.outputs.present == 'true'
        run: pnpm build

      # Second-layer raw-byte gate via `size-limit`. The
      # `pnpm wasm:build` step above already failed the build on cap
      # violation; running `pnpm size` re-verifies the raw caps with an
      # independent tool, catches drift between the driver's hardcoded
      # table and the declared cells, and produces the canonical
      # `size — bundle-size gate` summary the repo conventions expect.
      #
      # WHERE THE CELLS ARE DECLARED. This comment named root
      # `package.json` until #453, and had done since the cells left it.
      # The authority is `.size-limit.cjs` at the repo root: size-limit
      # walks `searchPlaces` with the manifest FIRST, so a sibling config
      # is ignored for as long as the manifest carries a `size-limit`
      # key, and that key was dropped when the file was added. The rest
      # of the rationale is in that file's header (one cell needs a
      # `modifyEsbuildConfig` FUNCTION, which JSON cannot hold).
      - name: Size-limit cells (raw bytes) — second artefact gate
        if: steps.detect.outputs.present == 'true'
        run: pnpm size

  bundler-interop:
    name: bundler-interop (${{ matrix.fixture }})
    runs-on: ubuntu-latest
    # Three minimal fixture apps under e2e/bundler-interop/
    # that import `@causl/core` (main barrel) and dynamically import
    # `@causl/causl-wasm-ts/wasm` (lazy-load entry). Each fixture's `verify.mjs`
    # enforces the bundle-no-wasm-leak invariant: the main chunk must
    # not contain the `loadWasmBackend` / `WasmBackendUnavailableError`
    # sentinels, and some other chunk MUST contain them (proves the
    # dynamic import was preserved as a code-split rather than
    # inlined).
    #
    # Failures attributable to a specific bundler are labelled by the
    # matrix-leg name, satisfying the "labelled per bundler" piece of
    # the gate's exit criterion (`iasbuilt/causl#689`, whose tracker did
    # not move here — these three fixtures are the specification now).
    #
    # Per-fixture install runs with `npm install --no-save` rather than
    # via pnpm: the fixtures live OUTSIDE the workspace by design
    # (fixtures must mirror how an external adopter consumes
    # `@causl/core`, not a workspace member that can resolve devDeps
    # the wild adopter wouldn't have access to). The `file:` specifier
    # in each fixture's package.json resolves into the freshly-built
    # `packages/core/` directory.
    strategy:
      fail-fast: false
      matrix:
        include:
          - fixture: webpack5-app
          - fixture: vite5-app
          - fixture: esbuild-app
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

      - name: Use Node.js 24
        uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: 'pnpm'

      - name: Install workspace deps
        run: pnpm install --frozen-lockfile

      # The fixtures consume `@causl/core` via a `file:` specifier that
      # points at `packages/core/`. The bundler reads `packages/core/dist/`
      # via the package's `exports` map, so a build must happen first.
      #
      # A PATH filter, not a name filter: see the note on the same step
      # in differential-pr.yml. `--filter @causl/core` has not matched
      # anything since the rename (#288, #291), and pnpm answers an
      # unmatched filter with `No projects matched the filters` and
      # EXIT CODE 0, so this step built nothing and passed (#424). A
      # directory cannot go stale from a package rename.
      - name: Build the core package
        run: pnpm --filter './packages/core' run build

      # Stub the wasm-pkg artifact tree so bundlers that statically
      # analyse `new URL('./pkg/...', import.meta.url)` (notably
      # webpack 5 with `experiments.asyncWebAssembly`) can resolve
      # the asset paths. The stubs are minimal-valid 8-byte WASM
      # modules; they're never instantiated — `loadWasmBackend()`
      # throws before reaching the fetch path. See
      # `e2e/bundler-interop/stub-wasm-pkg.mjs` for the rationale.
      #
      # Once the real wasm-pack pipeline produces
      # artifacts and they land on disk before this job, the stubs
      # become a no-op overwrite — drop this step then.
      - name: Stub wasm-pkg artifacts (bundler resolver bait)
        run: node e2e/bundler-interop/stub-wasm-pkg.mjs

      - name: Install fixture deps (${{ matrix.fixture }})
        working-directory: e2e/bundler-interop/${{ matrix.fixture }}
        run: npm install --no-save --no-audit --no-fund

      - name: Build fixture (${{ matrix.fixture }})
        working-directory: e2e/bundler-interop/${{ matrix.fixture }}
        run: npm run build

      - name: Verify bundle-no-wasm-leak (${{ matrix.fixture }})
        working-directory: e2e/bundler-interop/${{ matrix.fixture }}
        run: npm run verify
