Skip to content

Keep one-letter skills without corrupting others #230

Keep one-letter skills without corrupting others

Keep one-letter skills without corrupting others #230

Workflow file for this run

# 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