Keep one-letter skills without corrupting others #230
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # The gate that scripts/test.sh already describes, run where nobody can forget | |
| # it. | |
| # | |
| # The gates stay in one job, because they share a cargo build and a vendor tree | |
| # and splitting them would re-download 24 MB of wasm to save seconds. Mutation | |
| # is the exception and always was: it is the only step whose cost tracks the | |
| # size of the diff rather than the size of the repo, and on a large one it runs | |
| # an order of magnitude longer than everything else combined. Serialised behind | |
| # the gates it hid their result for an hour and reported nothing while it | |
| # worked. It now runs beside them, split into shards that finish in a quarter of | |
| # the time and say where they are. | |
| name: check | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| permissions: | |
| contents: read | |
| # A second push to a branch makes the first run's answer worthless, so stop | |
| # paying for it. Never on main: those runs are the record of what passed. | |
| concurrency: | |
| group: check-${{ github.ref }} | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | |
| env: | |
| # Every build here is a link-bound build: eleven test binaries between 29 and | |
| # 84 MB, each statically linking libwebrtc. Two settings attack that, and both | |
| # pay twice because the mutation gate rebuilds once per mutant. | |
| # | |
| # Debug info is the larger half and was measured: touching one file and | |
| # relinking the suite took 16.6s with full debug and 9.9s with | |
| # line-tables-only, a 40% cut per mutant. `line-tables-only` rather than `0` | |
| # keeps file and line in a CI backtrace, which is the only part of debug info | |
| # a failed run here actually uses. | |
| # | |
| # mold replaces the linker itself. It is the tool built for this shape of | |
| # problem, many large static inputs, and it is an apt package on this image. | |
| # | |
| # Both change the build fingerprint, which is why the cargo cache key below | |
| # hashes this file. Without that, a cache saved under the old flags would be | |
| # restored forever and rebuilt from scratch every run, which is worse than | |
| # having no cache at all. | |
| CARGO_PROFILE_DEV_DEBUG: line-tables-only | |
| CARGO_PROFILE_TEST_DEBUG: line-tables-only | |
| RUSTFLAGS: -Clink-arg=-fuse-ld=mold | |
| # One source for the pin. It was written twice, in the cache key and in the | |
| # install, and the guard below skips the install whenever a cached binary | |
| # exists: bumping only the install line would leave the old key matching, the | |
| # old binary restored, and CI auditing with a version nobody asked for. | |
| CARGO_AUDIT_VERSION: 0.22.2 | |
| CARGO_MUTANTS_VERSION: 27.1.0 | |
| # Pinned for the same reason the two above are: a linter that moves under you | |
| # turns an unrelated push red, and the fix is then a version bump nobody chose | |
| # to make. Kept in step with whatever contributors run locally, which is | |
| # whatever `ruff` is on their PATH; the gate skips rather than fails when they | |
| # have none. | |
| RUFF_VERSION: 0.15.2 | |
| # The workflow's own linter, pinned like the three above. Installed rather | |
| # than assumed: `scripts/test.sh` skips this gate when the binary is absent, | |
| # and absent is what it was here, so the file that describes every other | |
| # check was the one file nothing checked. | |
| ACTIONLINT_VERSION: 1.7.12 | |
| jobs: | |
| check: | |
| # Pinned rather than `ubuntu-latest`: that label moves to a new image on | |
| # GitHub's schedule, which turns an unrelated morning into a debugging | |
| # session. Bumping this is a commit somebody chose to make. | |
| runs-on: ubuntu-24.04 | |
| # The browser gate starts a server and polls it for twenty seconds before | |
| # giving up, and cargo builds the LiveKit SDK from source. Both are minutes, | |
| # neither is an hour: the default six-hour ceiling only ever gets reached by | |
| # something wedged. | |
| # | |
| # Back to 45 now that mutation has its own job. This is a wedge detector | |
| # rather than a budget: against the six-hour default it catches a job that | |
| # has stopped making progress, which is all it is for. | |
| timeout-minutes: 45 | |
| steps: | |
| # Full history, because the mutation step below diffs against the base | |
| # branch and a shallow clone has no merge base to diff from. 52 commits | |
| # and 26 MB: the fetch does not show up next to the cargo build. | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| # The commit-msg hook binds whoever installed it. This binds everyone | |
| # else: a rebase, an amend, `--no-verify`, or a subject typed into the | |
| # GitHub merge box all reach the branch unread otherwise. It runs the same | |
| # script the pre-push hook runs, so a contributor with hooks and a | |
| # contributor without are judged by one list. | |
| # | |
| # Pull requests only, and deliberately. `base.sha..HEAD` is the work being | |
| # proposed, which is the thing these rules are about. The push event's | |
| # range is not: a merge to main replays every commit of the merged branch, | |
| # so a rule this log only started following recently would reject history | |
| # rather than the change in front of it. | |
| # | |
| # First among the steps, because it costs a second and the rest of this | |
| # job costs minutes. | |
| - name: Check the commit messages | |
| if: github.event_name == 'pull_request' | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| set -euo pipefail | |
| # An unfetched base is not an empty range. Say which one this is | |
| # rather than reporting "nothing to check" for a check that could not | |
| # run. | |
| if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then | |
| echo "base commit $BASE_SHA is not in this checkout" >&2 | |
| exit 1 | |
| fi | |
| git rev-list --no-merges "$BASE_SHA..HEAD" | ./scripts/check-commit-log.sh | |
| # This job ran out of disk on main before it ran out of disk here: the | |
| # LiveKit SDK and libwebrtc build to a target/ measured in gigabytes, the | |
| # cache restores another copy of the registry beside it, Chromium and the | |
| # vendored wasm add their own, and `cargo install` builds a second crate | |
| # graph in a temp dir of its own. The image ships tens of gigabytes of | |
| # toolchains this repository never asks for, so the space is there to be | |
| # had without giving anything up. | |
| # | |
| # `df` after, so the next failure of this kind reports what was actually | |
| # available rather than leaving it to be guessed. | |
| - name: Reclaim runner disk | |
| run: | | |
| sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ | |
| /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup \ | |
| "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" | |
| df -h / | |
| # shellcheck and shfmt ride along on an apt call this job already makes. | |
| # The runner image ships shellcheck today, but both gates skip themselves | |
| # when the tool is absent, and a gate that stops running quietly is the | |
| # thing it guards against. shfmt takes its style from .editorconfig, so | |
| # installing it here is the whole configuration. | |
| - name: Install native build dependency | |
| run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev mold shellcheck shfmt | |
| # The runner image ships a stable toolchain, but not necessarily one new | |
| # enough for edition 2024, and clippy and rustfmt are gates rather than | |
| # extras here. | |
| # | |
| # The version is hashed into the cargo cache key below. Without it a | |
| # toolchain bump inherits a target/ full of objects the new rustc will | |
| # rebuild anyway, and the cache carries the dead weight until it is | |
| # evicted. | |
| - name: Install Rust | |
| id: rust | |
| run: | | |
| rustup update stable | |
| rustup default stable | |
| rustup component add clippy rustfmt | |
| echo "id=$(rustc -V | sha256sum | cut -c1-12)" >>"$GITHUB_OUTPUT" | |
| # `registry/src` is deliberately absent: it is unpacked from | |
| # `registry/cache` on demand, so caching both stores every dependency | |
| # twice. | |
| # | |
| # Keyed on the lockfile, so a dependency bump rebuilds and a source edit | |
| # does not. The restore-keys line lets that bump start from the previous | |
| # tree instead of from nothing. | |
| # Restore and save are separate steps on purpose. `actions/cache` saves | |
| # from a post-job step that is skipped unless the job succeeded, so a run | |
| # that built the whole dependency graph and then failed one test threw the | |
| # build away and made the next run pay for it again. That is not | |
| # hypothetical here: nothing saved a cache between 21 and 26 August while | |
| # runs failed on disk and were superseded by pushes, so every run in that | |
| # window restored a five-day-old target/ and rebuilt the difference, which | |
| # is most of why the gates took twelve minutes. | |
| # | |
| # Keyed on the lockfile, so a dependency bump rebuilds and a source edit | |
| # does not. The restore-keys line lets that bump start from the previous | |
| # tree instead of from nothing. | |
| - name: Restore cargo cache | |
| id: cargo-cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index | |
| ~/.cargo/registry/cache | |
| ~/.cargo/git/db | |
| target | |
| key: cargo-${{ runner.os }}-${{ steps.rust.outputs.id }}-${{ hashFiles('Cargo.lock', '.github/workflows/check.yml') }} | |
| restore-keys: cargo-${{ runner.os }}-${{ steps.rust.outputs.id }}- | |
| # Keyed on the whole tree, not on SHA256SUMS alone. The cached path holds | |
| # the committed vendor files as well as the fetched ones, so a key that | |
| # only reads the pins is unchanged when a committed file is edited: the | |
| # restore then puts the old bytes back over the edit and verify-vendor | |
| # passes on content the pull request does not contain. Hashing the tree | |
| # costs a re-fetch when a README under web/vendor changes, which is the | |
| # cheaper side of that trade. | |
| # | |
| # At restore time the directory holds only checked-out files, so the key | |
| # is a function of the commit. No restore-keys: a stale entry here is | |
| # 24 MB of bytes that fail their hash. | |
| - name: Cache vendored bytes | |
| uses: actions/cache@v6 | |
| with: | |
| path: web/vendor | |
| key: vendor-${{ hashFiles('web/vendor/**') }} | |
| # The browser binary, not the npm package. package-lock.json pins the | |
| # playwright version, and the version is what decides which build of | |
| # Chromium lands in this directory. | |
| # | |
| # The install itself is not cached. It is 90 packages now that eslint is | |
| # here, but they are small and pure JavaScript, and the step runs in | |
| # seconds against a cargo build that runs in minutes. A cache round trip | |
| # would not show up next to that. | |
| # | |
| # `npm ci`, not `npm install`: eslint's transitive dependencies are caret | |
| # ranges, so an install resolves them fresh on every run and the gate that | |
| # passed yesterday is not the gate running today. The exact pins in | |
| # package.json only cover the three direct dependencies. | |
| - name: Cache Chromium | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| - name: Install node dependencies | |
| run: npm ci | |
| # tests/browser/face-detector.test.js skips itself when no browser is | |
| # downloaded, so without this the gate would pass while testing nothing. | |
| # Left as one command on a cache hit too: it then only installs the system | |
| # libraries, which are not in the cached directory. | |
| - name: Install Chromium | |
| run: npx playwright install --with-deps chromium | |
| # The gate skips this audit when the binary is missing, which is right for | |
| # a contributor's laptop and wrong for CI: a silent skip here means the | |
| # dependency surface stops being checked and nothing says so. Installed | |
| # ahead of the gate so the skip branch is never the one CI takes. | |
| - name: Cache cargo-audit | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cargo/bin/cargo-audit | |
| key: cargo-audit-${{ env.CARGO_AUDIT_VERSION }}-${{ runner.os }} | |
| # Guarded rather than bare: `cargo install` refuses when the binary is | |
| # already in place and `~/.cargo/.crates.toml` is not, which is exactly | |
| # what a cache hit restores. | |
| # | |
| # The guard asks the binary its version rather than merely whether one | |
| # exists, so a restored binary that does not match the pin is replaced | |
| # instead of silently used. With the version in the cache key that should | |
| # not happen; this is what makes it fail loudly rather than quietly if it | |
| # ever does. | |
| - name: Install cargo-audit | |
| run: | | |
| cargo-audit --version 2>/dev/null | grep -qF " $CARGO_AUDIT_VERSION" \ | |
| || cargo install cargo-audit --locked --version "$CARGO_AUDIT_VERSION" --force | |
| # The Python here generates the problem bank and drives two integration | |
| # harnesses, and `ruff check` is its only linter. The gate skips when the | |
| # binary is missing, which is right on a laptop and wrong here. pipx is on | |
| # the image and installs into its own environment, so this does not fight | |
| # the runner's externally-managed Python. | |
| - name: Install ruff | |
| run: pipx install "ruff==$RUFF_VERSION" | |
| # commentflow settles comment width, which no other formatter here does: | |
| # rustfmt leaves a short-wrapped comment short and shfmt does not touch | |
| # comment text at all. The indent gate skips its reflow lane when the | |
| # binary is missing, which is right on a laptop and wrong here, so it is | |
| # installed ahead of the gate. | |
| # | |
| # It ships as a release binary rather than an apt package, and its only | |
| # tag is a rolling "latest", so the version is not frozen: a republished | |
| # "latest" is picked up silently. That is tolerable here and nowhere | |
| # else in this job. Comment reflow is a formatting gate, so the failure | |
| # mode is a diff in the indent check rather than a wrong binary running | |
| # against the tree, and that diff is what surfaces it. | |
| - name: Install commentflow | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: >- | |
| ./scripts/install-release-binary.sh sysprog21/commentflow latest | |
| commentflow-x86_64-unknown-linux-gnu.tar.gz commentflow | |
| # Pinned to a tag rather than tracking a rolling one, unlike commentflow | |
| # above: this one judges the workflow, so a linter that moves on its own | |
| # turns an unrelated push red. | |
| - name: Install actionlint | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: >- | |
| ./scripts/install-release-binary.sh rhysd/actionlint | |
| "v$ACTIONLINT_VERSION" | |
| "actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" actionlint | |
| - name: Run gates | |
| run: ./scripts/test.sh | |
| # Its own step, not part of `cargo test`. It drives a real browser through | |
| # the offline interview and is hermetic now, with the Compiler Explorer | |
| # calls intercepted, but it still fails on a two minute navigation timeout | |
| # when the machine is busy: `cargo test` is what people run while doing | |
| # something else, and a gate that goes red for being run at a bad moment | |
| # teaches everyone to ignore red. A dedicated runner has no such | |
| # contention, so this is where it can be trusted. | |
| # | |
| # browser-check.sh exits 3 when Playwright is missing and the test skips | |
| # on that. Chromium is installed above, so a skip here means the install | |
| # step silently did nothing and is worth chasing. | |
| - name: Run the browser interview check | |
| run: cargo test --locked --test web -- --ignored browser_check_accepts_running_rust_server_offline_interview | |
| # Last, and not conditional on the gates passing. An exact key hit has | |
| # nothing new to store; anything else is a build worth keeping whatever | |
| # the gates decided about the code. `cancelled()` is excluded because a | |
| # superseded run is killed mid-step and has nothing coherent to save. | |
| - name: Save cargo cache | |
| if: ${{ !cancelled() && steps.cargo-cache.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index | |
| ~/.cargo/registry/cache | |
| ~/.cargo/git/db | |
| target | |
| key: cargo-${{ runner.os }}-${{ steps.rust.outputs.id }}-${{ hashFiles('Cargo.lock', '.github/workflows/check.yml') }} | |
| # How many shards the diff is worth, decided before any of them start. | |
| # | |
| # A fixed width optimises for the outlier and taxes the common case: four | |
| # runners each paying six minutes of setup to test two mutants is worse than | |
| # one runner testing eight. `--list` parses the tree without building it, one | |
| # second here, so the count is cheap to ask for and the width can follow it. | |
| # | |
| # One shard per 25 mutants, capped at four. The cap is not about arithmetic: | |
| # past that the setup cost of another runner stops paying for itself against | |
| # the mutants it would take. | |
| plan-mutants: | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| env: | |
| # This job builds nothing but the tool, and only on a cache miss, so the | |
| # workflow-wide mold flag would make it install a linker it has no use | |
| # for. Cleared here rather than installing mold to satisfy a flag. | |
| RUSTFLAGS: "" | |
| outputs: | |
| shards: ${{ steps.plan.outputs.shards }} | |
| width: ${{ steps.plan.outputs.width }} | |
| count: ${{ steps.plan.outputs.count }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Rust | |
| run: | | |
| rustup update stable | |
| rustup default stable | |
| - name: Cache cargo-mutants | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cargo/bin/cargo-mutants | |
| key: cargo-mutants-${{ env.CARGO_MUTANTS_VERSION }}-${{ runner.os }} | |
| - name: Install cargo-mutants | |
| run: | | |
| cargo-mutants --version 2>/dev/null | grep -qF " $CARGO_MUTANTS_VERSION" \ | |
| || cargo install cargo-mutants --locked --version "$CARGO_MUTANTS_VERSION" --force | |
| - name: Count the mutants this diff is worth | |
| id: plan | |
| run: | | |
| # The default shell is `bash -e` with no pipefail, so the status of a | |
| # pipeline is the last command's. Without this, a cargo-mutants that | |
| # died would leave count at zero from `wc`, the shard job would skip | |
| # on `count != '0'`, and the pull request would go green having | |
| # mutation-tested nothing. The list goes to a file first so "cargo | |
| # said zero" and "cargo failed" cannot look alike. | |
| set -o pipefail | |
| git diff "origin/${{ github.base_ref }}...HEAD" -- '*.rs' >"$RUNNER_TEMP/pr.diff" | |
| cargo mutants --list --in-diff "$RUNNER_TEMP/pr.diff" >"$RUNNER_TEMP/list.txt" | |
| count=$(wc -l <"$RUNNER_TEMP/list.txt" | tr -d ' ') | |
| width=$(( (count + 24) / 25 )) | |
| [ "$width" -lt 1 ] && width=1 | |
| [ "$width" -gt 4 ] && width=4 | |
| { | |
| echo "count=$count" | |
| echo "width=$width" | |
| echo "shards=$(seq 0 $((width - 1)) | jq -R . | jq -sc .)" | |
| } >>"$GITHUB_OUTPUT" | |
| echo "$count mutants over $width shard(s)" >>"$GITHUB_STEP_SUMMARY" | |
| # Mutation testing, beside the gates rather than behind them. | |
| # | |
| # `cargo test` proves the tests pass. This proves they can fail. The gap it | |
| # closes is not hypothetical: the CODE_SETTLE gate in livekit.rs shipped with | |
| # its whole predicate revertible to a constant and every Rust test still | |
| # green, because the one test naming the constant had been edited to step | |
| # around it rather than exercise it. | |
| # | |
| # Scoped to the diff, not the crate. The crate is 1840 mutants and hours of | |
| # runner time, and a mutant surviving in code this branch never touched is | |
| # somebody else's gap reported at the worst possible moment. Scoped this way | |
| # the cost tracks the change. | |
| # | |
| # Sharded because that cost is not bounded by anything else here. A 34-file | |
| # refactor produced 98 mutants at roughly 35s each, an hour of one runner, | |
| # which overran the ceiling and told nobody how far it had got. Four shards | |
| # divide the mutants between them, so each finishes in about a quarter of the | |
| # time and each reports its own count and progress. `fail-fast: false` so one | |
| # shard finding a surviving mutant does not cancel the other three and hide | |
| # the rest of the answer. | |
| # | |
| # Pull requests only. A push to main has already merged; the question this | |
| # asks is what a change failed to test, and that is worth answering while the | |
| # change can still be edited. | |
| mutants: | |
| needs: plan-mutants | |
| # No mutants means no runners. A docs-only or JS-only pull request should | |
| # not start a job to discover it has nothing to do. | |
| if: needs.plan-mutants.outputs.count != '0' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # Zero-indexed, and it has to stay that way. cargo-mutants slices the | |
| # list as "the first n/k on shard 0", so a one-based matrix silently | |
| # drops every mutant in the first slice and runs an empty last one: on | |
| # a 101-mutant diff that was 75 tested, four green shards, and nothing | |
| # anywhere saying a quarter of it went unexamined. The plan job emits | |
| # zero-based indices; check that per-shard counts sum to the unsharded | |
| # total before trusting any change here. | |
| shard: ${{ fromJson(needs.plan-mutants.outputs.shards) }} | |
| steps: | |
| # Full history: the shard diffs against the base branch, and a shallow | |
| # clone has no merge base to diff from. | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Reclaim runner disk | |
| run: | | |
| sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ | |
| /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup \ | |
| "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" | |
| df -h / | |
| - name: Install native build dependency | |
| run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev mold | |
| - name: Install Rust | |
| id: rust | |
| run: | | |
| rustup update stable | |
| rustup default stable | |
| echo "id=$(rustc -V | sha256sum | cut -c1-12)" >>"$GITHUB_OUTPUT" | |
| # Restore, never save. The gates job owns this cache; four shards writing | |
| # a 1 GB entry under one key would be four uploads for the copy that | |
| # happens to finish last. Restoring a prior lockfile's artifacts is what | |
| # keeps each shard's baseline build incremental rather than cold, which is | |
| # the whole reason `--in-place` is worth using. | |
| - name: Restore cargo cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index | |
| ~/.cargo/registry/cache | |
| ~/.cargo/git/db | |
| target | |
| key: cargo-${{ runner.os }}-${{ steps.rust.outputs.id }}-${{ hashFiles('Cargo.lock', '.github/workflows/check.yml') }} | |
| restore-keys: cargo-${{ runner.os }}-${{ steps.rust.outputs.id }}- | |
| # Needed even though no browser runs here: a Rust test serves the | |
| # fetched MediaPipe binaries and asserts their content types, and | |
| # cargo-mutants runs the real suite once per mutant. | |
| - name: Restore vendored bytes | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: web/vendor | |
| key: vendor-${{ hashFiles('web/vendor/**') }} | |
| - name: Fetch any vendored bytes the cache did not have | |
| run: ./scripts/fetch-vendor.sh | |
| - name: Cache cargo-mutants | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cargo/bin/cargo-mutants | |
| key: cargo-mutants-${{ env.CARGO_MUTANTS_VERSION }}-${{ runner.os }} | |
| - name: Install cargo-mutants | |
| run: | | |
| cargo-mutants --version 2>/dev/null | grep -qF " $CARGO_MUTANTS_VERSION" \ | |
| || cargo install cargo-mutants --locked --version "$CARGO_MUTANTS_VERSION" --force | |
| # `--in-place` because cargo-mutants otherwise copies the tree and builds | |
| # it from cold, discarding the target/ the cache above just restored. The | |
| # runner is disposable, so a dirtied tree costs nothing. It is also why | |
| # the shards are separate runners rather than `--jobs`: target/ is tens of | |
| # gigabytes, so a build directory per job does not fit. | |
| # | |
| # The mutant list is printed before the run. cargo-mutants draws progress | |
| # with a terminal bar that renders as nothing in a log, so without this a | |
| # shard looks identical whether it is working or wedged. | |
| - name: Mutation-test the diff | |
| run: | | |
| git diff "origin/${{ github.base_ref }}...HEAD" -- '*.rs' >"$RUNNER_TEMP/pr.diff" | |
| echo "::group::Mutants in shard ${{ matrix.shard }}/${{ needs.plan-mutants.outputs.width }}" | |
| cargo mutants --list --in-diff "$RUNNER_TEMP/pr.diff" \ | |
| --shard ${{ matrix.shard }}/${{ needs.plan-mutants.outputs.width }} \ | |
| | tee "$RUNNER_TEMP/shard.txt" | |
| echo "::endgroup::" | |
| echo "shard ${{ matrix.shard }}/${{ needs.plan-mutants.outputs.width }}: \ | |
| $(wc -l <"$RUNNER_TEMP/shard.txt") mutants" >>"$GITHUB_STEP_SUMMARY" | |
| cargo mutants --in-place --in-diff "$RUNNER_TEMP/pr.diff" \ | |
| --shard ${{ matrix.shard }}/${{ needs.plan-mutants.outputs.width }} | |
| # The release is a binary, not a directory beside a binary: `rust-embed` | |
| # compiles `web/` (including its WASM) into it. Native runners avoid a | |
| # cross-platform linker or packaging toolchain. | |
| build: | |
| needs: check | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| name: ${{ matrix.name }} | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: Linux x86_64 | |
| os: ubuntu-24.04 | |
| target: x86_64-unknown-linux-gnu | |
| image: rust:1.98.0-bullseye | |
| glibc: '2.31' | |
| glibcxx: '3.4.28' | |
| extension: '' | |
| archive: tar.gz | |
| - name: macOS arm64 | |
| os: macos-15 | |
| target: aarch64-apple-darwin | |
| extension: '' | |
| archive: zip | |
| - name: Windows x86_64 | |
| os: windows-2025 | |
| target: x86_64-pc-windows-msvc | |
| extension: .exe | |
| archive: zip | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| # Cleared for the same reason `plan-mutants` clears it: the workflow-wide | |
| # value is `-Clink-arg=-fuse-ld=mold`, and only the Linux jobs that | |
| # install mold can honor it. Two of these three runners cannot have mold | |
| # at all, and MSVC hands the flag to `link.exe`, which dies with LNK1117. | |
| # Release builds happen once per push rather than once per mutant, so the | |
| # linker that is present everywhere is the right trade. | |
| RUSTFLAGS: ${{ matrix.target == 'x86_64-pc-windows-msvc' && '-C target-feature=+crt-static' || '' }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| # The image is part of the key, because on this leg it is the compiler. | |
| # `rust-cache` keys on the runner's rustc, which is no longer the one that | |
| # builds here, and it declines to save when an exact key hit came back: a | |
| # `target/` filled by the old runner build therefore survives under this | |
| # key indefinitely. Let the container's rustc match the runner's once, and | |
| # cargo would call those objects fresh and link glibc 2.39 C objects into | |
| # a Bullseye binary. Naming the image keeps each compiler's artifacts in | |
| # its own entry, and bumping the pin rotates the cache with it. | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| key: release-${{ matrix.target }}${{ matrix.image && format('-{0}', matrix.image) || '' }} | |
| # The same entry the check job saves, on the same key. Without it all | |
| # three platform legs re-download 24 MB that one job already fetched and | |
| # stored on this commit, and the fetch script no-ops on a hit. | |
| - name: Restore vendored bytes | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: web/vendor | |
| key: vendor-${{ hashFiles('web/vendor/**') }} | |
| - name: Fetch browser assets | |
| run: ./scripts/fetch-vendor.sh | |
| shell: bash | |
| # `rust-embed` omits a missing file rather than failing the build, and the | |
| # startup warning that would catch it cannot fire in a binary with no | |
| # `web/` directory to inspect. Without this, a fetch that quietly no-ops | |
| # ships an executable that boots, serves the page, and then strands the | |
| # candidate on a Python runtime that never starts. | |
| - name: Verify the assets about to be embedded | |
| run: ./scripts/verify-vendor.sh | |
| shell: bash | |
| - name: Build the self-contained executable | |
| if: runner.os != 'Linux' | |
| run: cargo build --release --locked --target ${{ matrix.target }} | |
| # GitHub retired the Ubuntu 20.04 runner, but its glibc 2.31 remains the | |
| # useful Linux release baseline. Build inside Bullseye, which carries the | |
| # same glibc, rather than on the runner. This is deliberately not musl: | |
| # the archive stays an ordinary dynamically linked Linux executable. | |
| # | |
| # The image pin in the matrix is the one thing here that has to be | |
| # maintained by hand. Every other leg compiles with whatever stable | |
| # resolved to that day, so this is the only compiler in the workflow that | |
| # can fall behind the one the gates ran, and it runs on push to main | |
| # alone: a stable-only API compiles in the pull request and breaks the leg | |
| # that publishes, after the merge. Bump it when stable moves. | |
| # | |
| # `--user` rather than the container's root. Root here writes root-owned | |
| # objects into the mounted workspace, `Swatinem/rust-cache` then hits | |
| # EACCES pruning them, and it swallows that error at debug level: the | |
| # cache silently keeps a multi-gigabyte unpruned `target/` and evicts the | |
| # entries the gates depend on. | |
| # | |
| # No `apt-get`, which is what leaves nothing here needing root. The base | |
| # image already carries `libglib2.0-dev`, and reaching for Debian's | |
| # mirrors at build time would buy a scheduled outage: bullseye is past | |
| # its LTS window and moves to archive.debian.org on someone else's | |
| # timetable, which `Acquire::Check-Valid-Until=false` does not answer. A | |
| # base image that ever drops glib fails loudly at pkg-config. | |
| # | |
| # `CARGO_HOME` is the runner's, mounted in. It is what the cache restored | |
| # a step ago, and the container cannot see it otherwise: without this | |
| # every release re-downloads the registry and the prebuilt libwebrtc. | |
| - name: Build Linux executable against the compatibility baseline | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| docker run --rm \ | |
| --user "$(id -u):$(id -g)" \ | |
| --volume "$GITHUB_WORKSPACE:/workspace" \ | |
| --volume "$HOME/.cargo:/cargo" \ | |
| --workdir /workspace \ | |
| --env CARGO_HOME=/cargo \ | |
| ${{ matrix.image }} \ | |
| cargo build --release --locked --target ${{ matrix.target }} | |
| # Two libraries set the floor, not one. The C++ half of this build links | |
| # libstdc++, so a newer compiler raises the GLIBCXX_ requirement whether | |
| # or not it touches GLIBC_, and a binary refused for either reason is | |
| # refused the same way: in the loader, before main. The pattern reads | |
| # numeric versions only, which is what leaves a name like | |
| # GLIBC_ABI_DT_RELR out: it is a marker, not a version to compare. | |
| - name: Verify Linux release compatibility | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| binary=target/${{ matrix.target }}/release/codetrial | |
| # Not `readelf | grep -q`. A `shell: bash` step runs under pipefail, | |
| # `grep -q` closes the pipe at its first match, and a producer whose | |
| # output does not fit the pipe buffer then dies of SIGPIPE and takes | |
| # the pipeline's status with it: the check would fail here on a | |
| # binary that passes it. A dynamic section is small enough today that | |
| # it never fires, which is the worst size for a trap to be. | |
| grep -qF 'Shared library: [libc.so.6]' <<< "$(readelf -d "$binary")" \ | |
| || { echo 'Linux release is not dynamically linked to glibc' >&2; exit 1; } | |
| floor() { | |
| required=$(LC_ALL=C readelf -W --version-info "$binary" \ | |
| | sed -nE "s/.*Name: $1_([0-9.]+)[[:space:]]+Flags:.*/\1/p" \ | |
| | sort -V | tail -n 1) | |
| [ -n "$required" ] \ | |
| || { echo "Linux release has no $1 symbol requirement" >&2; exit 1; } | |
| newest=$(printf '%s\n%s\n' "$required" "$2" | sort -V | tail -n 1) | |
| [ "$newest" = "$2" ] \ | |
| || { echo "Linux release requires $1_$required, not $1_$2 or older" >&2; exit 1; } | |
| } | |
| floor GLIBC '${{ matrix.glibc }}' | |
| floor GLIBCXX '${{ matrix.glibcxx }}' | |
| # The symbol table says the binary asks for nothing newer; this says the | |
| # loader agrees. Every other step that runs it runs it on the runner, | |
| # whose glibc is 2.39, where the failure in issue #30 cannot reproduce. | |
| # `--help` is the whole test: a binary that needs a newer glibc never | |
| # reaches main to print it. | |
| - name: Prove the Linux release starts on the baseline userland | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| docker run --rm \ | |
| --volume "$GITHUB_WORKSPACE:/workspace:ro" \ | |
| --workdir /workspace \ | |
| debian:bullseye-slim \ | |
| ./target/${{ matrix.target }}/release/codetrial --help | |
| # The only place the embed is exercised as an embed. `rust-embed` matches | |
| # keys exactly in release and reads the same names off the filesystem in | |
| # debug, so every test binary the suite builds resolves these paths | |
| # through the kernel instead, and normalization bugs are invisible to all | |
| # of them. Run from an empty directory, so a fallback to disk cannot | |
| # answer and make a missing embed look present. | |
| - name: Prove the binary serves without a web tree | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| empty=$(mktemp -d) | |
| cp target/${{ matrix.target }}/release/codetrial "$empty/" | |
| # `web` refuses to start without a primary config file, so the empty | |
| # directory gets one. It is still empty of a web tree, which is the | |
| # only emptiness this step is about. | |
| printf '%s\n' \ | |
| 'LIVEKIT_URL=wss://example.livekit.cloud' \ | |
| 'LIVEKIT_API_KEY=embed-check-key' \ | |
| 'LIVEKIT_API_SECRET=embed-check-secret' >"$empty/codetrial.env.local" | |
| # Killed from a trap rather than only on the happy path, and with its | |
| # output redirected: a server left running past a failed probe holds | |
| # the step's stdout open, which turns a one-line failure into a job | |
| # that sits until the 45-minute timeout. | |
| trap 'pkill -f "codetrial web" || true' EXIT | |
| (cd "$empty" && CODETRIAL_WEB_ADDR=127.0.0.1:18999 ./codetrial web >server.log 2>&1 &) | |
| for _ in $(seq 1 30); do | |
| curl -fs -o /dev/null http://127.0.0.1:18999/ && break | |
| sleep 1 | |
| done | |
| status() { curl -sS -o /dev/null -w '%{http_code}' --path-as-is "http://127.0.0.1:18999$1"; } | |
| title() { curl -sS --path-as-is "http://127.0.0.1:18999$1" | tr -d '\n' | sed -n 's|.*<title>\(.*\)</title>.*|\1|p'; } | |
| # The wasm is last because it is the one the fetch supplies rather | |
| # than git: it fails here if the vendored bytes never reached | |
| # `.rodata`. The doubled separator is second-to-last because only a | |
| # release binary matches embedded keys exactly, so this is the only | |
| # place normalization is observable. | |
| for probe in / /interview /vendor/avatar/three-vrm.js \ | |
| //vendor/avatar/three-vrm.js /vendor/pyodide/pyodide.asm.wasm; do | |
| code=$(status "$probe") | |
| [ "$code" = 200 ] || { echo "embedded $probe answered $code, not 200" >&2; exit 1; } | |
| done | |
| [ "$(status /.git/config)" = 404 ] \ | |
| || { echo "a dotfile was served" >&2; exit 1; } | |
| # Status is not enough for this one. `web/recording/index.html` is a | |
| # nested index, so a resolver that fails to normalize the trailing | |
| # slash does not 404: it exhausts its own candidates and falls back | |
| # to the root index, answering 200 with the wrong page. Only the | |
| # release binary can show it, because a debug build resolves the | |
| # embed through the filesystem, where the kernel collapses the | |
| # separator whatever the resolver does. | |
| for probe in /recording /recording/ //recording/; do | |
| found=$(title "$probe") | |
| [ "$found" = "CodeTrial - Recording" ] \ | |
| || { echo "$probe served \"$found\", not the recording page" >&2; exit 1; } | |
| done | |
| # No backslash probe here. This runner is Linux, where a backslash is | |
| # an ordinary filename byte, so the traversal spelling 404s whether or | |
| # not the resolver refuses it: the check cannot fail and would only | |
| # read as coverage. `normalization_cannot_launder_a_refused_path` | |
| # owns that case, asserting the refusal itself rather than an outcome | |
| # the platform supplies for free. | |
| - name: Name release asset | |
| run: >- | |
| cp target/${{ matrix.target }}/release/codetrial${{ matrix.extension }} | |
| codetrial-${{ matrix.target }}${{ matrix.extension }} | |
| shell: bash | |
| - name: Compress Linux release asset | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: tar -czf codetrial-${{ matrix.target }}.tar.gz codetrial-${{ matrix.target }} | |
| # `zip`, not `ditto`. `--keepParent` keeps the parent *directory* of what | |
| # it is given, and what it is given here is a file, so the archive carried | |
| # `codetrial/codetrial-aarch64-apple-darwin`: the runner's checkout | |
| # directory name, leaked into a download, with the binary a level deeper | |
| # than the other two platforms put it. ditto also writes an AppleDouble | |
| # `._` sidecar that is noise everywhere it is unpacked. | |
| # | |
| # Nothing is lost by dropping it: the ad-hoc signature the linker applies | |
| # lives inside the Mach-O, not in the extended attributes ditto exists to | |
| # preserve, and it survives a zip round trip along with the mode bits. | |
| - name: Compress macOS release asset | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: zip -q -9 codetrial-${{ matrix.target }}.zip codetrial-${{ matrix.target }} | |
| - name: Compress Windows release asset | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: Compress-Archive -LiteralPath codetrial-${{ matrix.target }}.exe -DestinationPath codetrial-${{ matrix.target }}.zip | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: codetrial-${{ matrix.target }} | |
| path: codetrial-${{ matrix.target }}.${{ matrix.archive }} | |
| # One rolling release for the current main build. GitHub releases require a | |
| # tag; `latest` avoids exposing an implementation SHA as a release name. | |
| release: | |
| needs: [check, build] | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: release-latest | |
| cancel-in-progress: false | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| steps: | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| pattern: codetrial-* | |
| path: dist | |
| merge-multiple: true | |
| - name: Verify and publish the latest release | |
| shell: bash | |
| run: | | |
| assets=( | |
| dist/codetrial-x86_64-unknown-linux-gnu.tar.gz | |
| dist/codetrial-aarch64-apple-darwin.zip | |
| dist/codetrial-x86_64-pc-windows-msvc.zip | |
| ) | |
| for asset in "${assets[@]}"; do | |
| [ -f "$asset" ] || { echo "missing release asset: $asset" >&2; exit 1; } | |
| done | |
| tag=latest | |
| # The attempt, not just the run: a re-run reuses GITHUB_RUN_ID, and | |
| # `gh release create` fails outright on a leftover draft. | |
| staged="staging-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" | |
| # Upload to a draft first: a failed upload must not take the current | |
| # public release down with it. Nothing else here looks for a draft, | |
| # so it collects itself on the way out, including on the superseded | |
| # path below, which exits green. No `--cleanup-tag`: publishing is | |
| # what cuts a tag, so a draft has none and deleting that ref is a 422. | |
| published=false | |
| trap '[ "$published" = true ] || gh release delete "$staged" --yes >/dev/null 2>&1 || true' EXIT | |
| gh release create "$staged" --draft --target "$GITHUB_SHA" \ | |
| --title "$tag" --notes "Automated build of ${GITHUB_SHA:0:7}." | |
| gh release upload "$staged" "${assets[@]}" | |
| # Sizes, not just presence: an upload cut off partway still leaves a | |
| # named asset behind, and a truncated binary is the one outcome this | |
| # whole dance exists to keep off the download page. | |
| sizes=$(gh release view "$staged" --json assets --jq '.assets[] | "\(.name) \(.size)"') | |
| for asset in "${assets[@]}"; do | |
| name=$(basename "$asset") | |
| want=$(stat -c%s "$asset") | |
| got=$(printf '%s\n' "$sizes" | awk -v n="$name" '$1 == n { print $2 }') | |
| [ "$want" = "$got" ] || { | |
| echo "$name uploaded as ${got:-nothing}, expected $want bytes" >&2 | |
| exit 1 | |
| } | |
| done | |
| # One moving pointer means order matters, and the concurrency group | |
| # serializes these jobs without ordering them: a re-run of an older | |
| # commit, or a build overtaken by a newer push, would otherwise walk | |
| # `latest` backwards onto stale binaries. The race stays open until | |
| # the swap, so this asks as late as it usefully can. | |
| tip=$(gh api "repos/$GH_REPO/git/ref/heads/main" --jq .object.sha) | |
| if [ "$tip" != "$GITHUB_SHA" ]; then | |
| echo "superseded by $tip; leaving $tag as it is" | |
| exit 0 | |
| fi | |
| # Existence read out of a listing rather than a status code. `gh` | |
| # exits non-zero on a 404 and `shell: bash` runs with `pipefail`, so | |
| # pulling the code out of `gh api -i` kills the job at the | |
| # assignment; and `view || skip` reads a rate limit or an expired | |
| # token as "not there". Both queries below answer with data and a | |
| # zero status when the thing is absent, so anything else stops the | |
| # job here instead of being mistaken for absence. | |
| # | |
| # GitHub allows one release per tag, so the old release has to go | |
| # before the draft can take its name, and `latest` resolves to | |
| # nothing until the edit lands. | |
| # | |
| # The release and the tag come off in two calls because they go | |
| # missing separately: a tag outlives its release when a cleanup was | |
| # interrupted or a ref is protected, and a release outlives its tag | |
| # when somebody deletes the tag by hand. `--cleanup-tag` would fold | |
| # them into one round trip, but on a ref that is already gone it | |
| # fails, and it fails having already deleted the release, which ends | |
| # the job with `latest` taken down and nothing put back. | |
| releases=$(gh release list --limit 100 --json tagName --jq '.[].tagName') | |
| if printf '%s\n' "$releases" | grep -qxF "$tag"; then | |
| gh release delete "$tag" --yes | |
| fi | |
| # `target_commitish` is documented as unused when the tag already | |
| # exists, so a tag left in place here would publish this build's | |
| # assets under a name still pointing at an older commit, with nothing | |
| # on the page saying so. | |
| refs=$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" --jq '.[].ref') | |
| if printf '%s\n' "$refs" | grep -qxF "refs/tags/$tag"; then | |
| gh api -X DELETE "repos/$GH_REPO/git/refs/tags/$tag" --silent | |
| fi | |
| # `--target` is read only when the tag is cut, which is what the two | |
| # deletes above are for. | |
| gh release edit "$staged" --tag "$tag" --target "$GITHUB_SHA" --draft=false | |
| published=true | |