name: CI on: # `push` unrestricted plus `pull_request` ran every job TWICE on any branch # with an open PR — visible as the (push) / (pull_request) pairs in the checks # list. Restricting push to main means a PR branch is covered once by # pull_request, and main once by push, which is also what pages-deploy needs. push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always jobs: # --------------------------------------------------------------------------- # The one place wasm-pack runs (improvise CI dedup). # # Before this job there were two: `pages-build` ran `web/build.sh`, and the # browser harness ran `harness/build.sh`, which CALLS `web/build.sh`. The two # wasm-pack builds were therefore done twice per run, in parallel jobs, with # separate rust-cache keys (`add-job-id-key` is on by default, so `browser` # and `pages-build` never shared a target dir). Measured on run 35535225637 # (push to main, caches warm): 68s inside `pages-build`, and the same 68s # inside the browser job's 110s build step. # # Everything downstream now DOWNLOADS. The invariant that buys is artifact # identity: the harness and Pages are served bytes from one wasm-pack run, # so "the harness runs against the tree Pages publishes" (improvise-vvl3) is # true by construction rather than because two scripts agree. # # The native binary is deliberately NOT built here. Nothing downstream # consumes one — `cargo test --workspace` in the `test` job builds its own, # for its own target dir and its own cache key, and a `needs:` on this job # would serialise a 109s job behind a 126s one for no artifact at all. build: name: Build all bundles runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # First, because it needs nothing and takes a second: the gate that stops # an unstamped bundle shipping was deleted by injection on PR #69 and the # assembly still exited 0 (improvise-w8ih). It guards the assemble step # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to run: ./scripts/assemble-stamp-test.sh - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown # Two workspaces, because `harness/opfs-contract` carries an empty # `[workspace]` table and so has its own target dir, which the default # single-workspace configuration never cached (it recompiled its whole # dependency tree every run). - uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target harness/opfs-contract -> target # Pinned release binary rather than `cargo install` (a three-minute # source build) or a third-party action. wasm-pack runs its own bundled # `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names # `--enable-bulk-memory`: without it the optimizer rejects the # `memory.copy` LLVM emits regardless. # v0.15.0 is what `nix develop` supplies, and the bundle was built and # measured against it locally (346 KB, 120 KB gzipped). Pinning the same # version is what makes that local run evidence about this one. - name: Install wasm-pack env: WASM_PACK_VERSION: v0.15.0 run: | set -eu url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \ "wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" wasm-pack --version # Both assemblies below generate the prose site with node. The runner # image ships one, but pinning it is what makes a local run evidence. - uses: actions/setup-node@v4 with: node-version: "24" # The same script a developer runs locally, so there is one answer to # "how is the bundle built" rather than two that can drift. It builds all # three wasm bundles (client, worker, opfs-contract) and assembles # `harness/serve` — the deployed arrangement plus /opfs/. - name: Build the bundles and the harness tree run: ./harness/build.sh # Re-assembles from the pkg dirs the step above already produced — no # wasm-pack runs here. A second assembly rather than a copy of # `harness/serve` because the cache-bust token differs: the harness tree # is stamped `harness` and the published site the commit SHA, which is # what makes a returning visitor pick up a fresh bundle (improvise-9x6). - name: Assemble the site run: ./web/assemble.sh site "$GITHUB_SHA" - run: find site -type f | sort - run: du -sh site # The tree the browser harness serves. Uploaded whole rather than # reassembled downstream: a second assembly path is a second answer to # "what does the harness serve", which is the thing improvise-vvl3 exists # to prevent. - name: Upload the harness tree uses: actions/upload-artifact@v7 with: name: harness-serve path: harness/serve retention-days: 1 include-hidden-files: true # For a job that needs the raw wasm-pack output rather than the # assembled site — the Node bundle suite under `web/tests/bundle/`. - name: Upload the wasm bundles uses: actions/upload-artifact@v7 with: name: wasm-bundles path: | web/pkg web/worker-pkg retention-days: 1 include-hidden-files: true - uses: actions/upload-pages-artifact@v5 with: path: site test: name: Test and lint (host) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace # The comment cap and the test-evidence keys (improvise-9c8c); without # this step the rule is advisory. See context/design-principles.md §6. - name: The comment checker catches what it has been fixed for run: python3 scripts/check-comment-runs.py --self-test - name: Comment runs capped at two lines run: python3 scripts/check-comment-runs.py - name: Test-evidence headings name real tests run: python3 scripts/check-comment-runs.py --check-evidence # `pages-deploy` needs every other job, and saying so was not enough: a # job added during review of PR #83 was ungated and nothing noticed. - name: The deploy gate refuses what it claims to run: python3 scripts/check-ci-graph.py --self-test - name: Every check job gates the deploy run: python3 scripts/check-ci-graph.py # Too slow on GitHub Actions # # nix-test: # name: Nix Test # runs-on: ubuntu-latest # steps: # - name: Free up disk space # run: | # sudo rm -rf /usr/share/dotnet \ # /usr/local/lib/android # sudo df -h # - uses: actions/checkout@v7 # - name: Install Nix # uses: DeterminateSystems/nix-installer-action@v14 # - name: Magic Nix Cache # uses: DeterminateSystems/magic-nix-cache-action@v8 # - name: Check Nix health # run: nix doctor # - run: nix build wasm: name: wasm32 check (browser bundle crates) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 # Only the crates that go into the browser bundle. The root `improvise` # crate is the terminal binary and depends on crossterm, which does not # build for wasm — that is correct, not a gap (improvise-ywd). - name: improvise-core run: cargo check -p improvise-core --target wasm32-unknown-unknown - name: improvise-formula run: cargo check -p improvise-formula --target wasm32-unknown-unknown - name: improvise-protocol run: cargo check -p improvise-protocol --target wasm32-unknown-unknown - name: improvise-io run: cargo check -p improvise-io --target wasm32-unknown-unknown - name: improvise-command run: cargo check -p improvise-command --target wasm32-unknown-unknown # The browser client itself. This is the one crate for which a wasm # build is the *point* rather than a property worth keeping, so a # failure here is not a portability regression but a broken deliverable # (improvise-gsw). - name: improvise-wasm-client run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown # The worker half of the standalone deployment (improvise-9x6). Same # terms as the client: a wasm build is the point, so a failure here is a # broken deliverable rather than a portability regression. - name: improvise-worker-server run: cargo check -p improvise-worker-server --target wasm32-unknown-unknown # Not in the browser bundle yet — nothing depends on improvise-gp. Gated # anyway because it is a candidate (fitting a tree in the browser) and # staying wasm-clean is free now, expensive to retrofit (improvise-z5eg). - name: improvise-gp run: cargo check -p improvise-gp --target wasm32-unknown-unknown # --------------------------------------------------------------------------- # The browser JavaScript's unit tests. Its own job, deliberately: a failure # here is a decision `web/shell.js` or `web/serial.js` got wrong, and # reporting that as "Browser harness" would put it behind an 80 MB browser # download and two wasm-pack builds. It needs neither — `node --test` is in # the runtime, and this job installs nothing at all. js: name: Browser JS unit tests (node --test) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v4 with: node-version: 22 # The glob is the SCRIPT's, not the shell's and not node's. Under bash an # unmatched glob survives as a literal, node globs it, matches nothing and # exits 0 having run no test -- so this step gated nothing the moment # `web/tests/` moved (improvise-vz54). `web/tests/bundle/` needs a built # wasm bundle and runs in the browser job instead. - name: What the page decides between its boundaries run: ./scripts/node-test.sh 'web/tests/*.test.js' # The guard above is only worth its line if its refusals are themselves # checked -- the step it replaces was green while asserting nothing. - name: The node test runner refuses what it claims to run: ./scripts/node-test-test.sh kb: name: Knowledge base consistency runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: cachix/install-nix-action@v31 # kb.pl is executable knowledge, so it gets checked like code: rules # must reject what they should, domains must close, and functional keys # must not carry conflicting values. See kb_report/0 in the file. - name: kb_consistent run: | nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \ -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" # scripts/kb is the only thing that writes kb.pl, so its refusals are as # load-bearing as kb_consistent itself. Without this the replacement # writer could regress green -- the review that found the directive # bypass also found that nothing ran this suite. - name: The kb tool refuses what it claims to run: nix shell nixpkgs#trealla --command ./scripts/kb-test.sh # No trealla needed: pr-watch is driven entirely from fixtures. - name: The PR watcher refuses what it claims to run: ./scripts/pr-watch-test.sh # --------------------------------------------------------------------------- # Lints the one crate the `test` job cannot reach. # # `harness/opfs-contract` carries an empty `[workspace]` table, so it is its # own root and `cargo clippy --workspace` at the repo root never sees it. # # Its own job rather than a step inside `browser`, for two reasons. It took # 37s of the browser job's 263s on run 35535225637 while gating nothing the # harness needs — a lint failure and a harness failure are different reports, # and here the lint failure wore the harness's name. And `cargo fmt --all` # from inside that directory formats the WHOLE outer workspace (114 files, # measured) rather than the one crate, because cargo-fmt's `--all` walks past # the nested workspace boundary — so a rustfmt violation anywhere in the repo # failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file. harness-lint: name: Lint the harness crate runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 with: workspaces: harness/opfs-contract -> target - working-directory: harness/opfs-contract run: | cargo fmt -p opfs-contract -- --check cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings # --------------------------------------------------------------------------- # The browser harness (improvise-vvl3, absorbing improvise-xf1). # # This is the only job that RUNS any of the browser half. `OpfsFs`, # `web/app.js` and `web/worker.js` were previously compile-checked, # syntax-checked and read — never executed — so every bug in them was found # by reading or by a person pressing keys, neither of which is repeatable. # # It can fail the build, deliberately: a harness nobody runs is worse than # none, because it reads as coverage. # # It now needs NO Rust toolchain: the tree it serves is downloaded from # `build`, which is what makes "the harness runs against the tree Pages # publishes" (improvise-vvl3) a fact about bytes rather than about two # scripts agreeing. The download is `harness/serve` whole, so there is still # exactly one place that arrangement is decided — `harness/build.sh`. browser: name: Browser harness (OPFS contract + shell) needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v4 with: node-version: 22 - name: Install the harness working-directory: harness run: npm ci # Keyed on the resolved @playwright/test version, so a version bump is a # miss and anything else is a hit. The download was 50s on run # 35535225637 and is pure I/O — nothing about it depends on this commit. - name: Playwright version id: pw working-directory: harness run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - uses: actions/cache@v4 id: pw-cache with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }} # All three, because improvise-xf1's acceptance criterion names Chrome, # Firefox and Safari and `createSyncAccessHandle` is implemented # separately by each engine. WebKit is installed even though its OPFS is # absent today: the spec skips on a runtime probe, so the day that build # gains OPFS the check starts running without a CI change. # `--with-deps` installs apt packages OUTSIDE the cached directory, so it # runs on a cache hit too; only the browser download is skipped. - name: Install browsers working-directory: harness run: npx playwright install --with-deps chromium firefox webkit # The tree `build` assembled, not one this job rebuilds. Same bytes as # the Pages artifact, modulo the cache-bust token `web/assemble.sh` # stamps — the harness tree is stamped `harness` and the site the commit # SHA, which was already true when both jobs built their own. - name: Download the harness tree uses: actions/download-artifact@v7 with: name: harness-serve path: harness/serve # `web/pkg` and `web/worker-pkg` as wasm-pack left them, for the .d.ts # the step below reads. The artifact's root is `web/`, so this restores # both directories at the paths the test resolves relative to itself. - name: Download the wasm bundles uses: actions/download-artifact@v7 with: name: wasm-bundles path: web # The JS↔wasm boundary: every call web/app.js makes is declared by the # bundle, with that arity. It needs the bundle and nothing else, so it # runs here rather than in the `js` job, and before Playwright — a # renamed export is cheaper to read here than as a blank page. - name: The page's calls match the bundle it calls run: ./scripts/node-test.sh 'web/tests/bundle/*.test.js' - name: Run the harness working-directory: harness run: npx playwright test - if: failure() uses: actions/upload-artifact@v7 with: name: playwright-traces path: harness/test-results retention-days: 7 # --------------------------------------------------------------------------- # GitHub Pages delivery (improvise-d31). # # The artifact is uploaded by `build`, which runs on every push and pull # request, so a change that breaks `web/build.sh` or the bundle fails on the # PR that made it — the build is a gate. `pages-deploy` runs only on a push # to main and `needs` the check jobs, so nothing that failed `cargo test`, # the wasm32 gate or the browser harness can reach the published site. The # deploy depends on those checks rather than repeating them (improvise-d31 # AC 2). # # Not path-filtered, deliberately. The bundles are `improvise-wasm-client` and # `improvise-worker-server` plus, between them, most of the workspace — the # worker bundle is the whole App: core, formula, io, protocol and command. A # filter # that meant "skip when the bundle cannot have changed" would have to name # most of the workspace, and the failure mode of getting it wrong is a stale # site with no signal that it is stale. A redundant build is the cheaper error. # # On improvise-864a (concurrent merges racing on one Pages environment, the # superseded run failing "Found 0 artifacts"): building once shortens the gap # between upload and deploy — 182s before, ~115s after, on the run 35535225637 # shape — so the window in which a superseding run can expire this run's # artifact is smaller. It does not close the race; it does not widen it. # `needs` is EVERY check job, which is the only rule that does not go stale. # `kb` was the one absent, on the unstated ground that kb.pl is not shipped by # the site — but neither is `harness-lint`'s crate, and it was in the list, so # the ground proved too much (improvise-x7cq). A failing consistency check # means main is broken, and publishing from a broken main is what this list # exists to stop. pages-deploy: name: Deploy to GitHub Pages needs: [test, wasm, js, browser, harness-lint, kb, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read pages: write id-token: write # A superseded deploy is allowed to finish rather than being cancelled: # `deploy-pages` cancelled midway can leave the Pages deployment in a # half-applied state, and the next push republishes anyway. concurrency: group: pages cancel-in-progress: false environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: # `enablement: false` (the default) configures the environment URL # without attempting to create or modify the Pages site via the API. # GitHub Pages must be enabled once in the repository settings # (Settings → Pages → Source: GitHub Actions) before this job will # produce a live URL. Using `enablement: true` fails when the workflow # token lacks admin permission to create the site (improvise-3gy). - uses: actions/configure-pages@v6 with: enablement: false - id: deployment uses: actions/deploy-pages@v5