From 695aaf46bbd5164b6203bcc94b4b86360d9147a2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 15:57:55 -0700 Subject: [PATCH 01/15] chore: claim quest/m0/pr-behavioral-gates Co-Authored-By: Claude Opus 5 From 77765e44bd122a695e472c4f42e4b4820ac5cad2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 21:56:10 -0700 Subject: [PATCH 02/15] ci: run the behavioral gates on the pull requests they cover Co-Authored-By: Claude Opus 5 --- .github/justfile | 28 ++++ .github/scripts/alert.sh | 11 +- .github/scripts/gates.sh | 84 ++++++++++ .github/scripts/gates.test.sh | 72 +++++++++ .github/scripts/select.sh | 162 +++++++++++++++++++ .github/scripts/select.test.sh | 128 +++++++++++++++ .github/workflows/alert.yml | 8 +- .github/workflows/gates.yml | 270 ++++++++++++++++++++++++++++++++ .github/workflows/smoke.yml | 66 ++++---- .github/workflows/wasm.yml | 52 ++---- CONTRIBUTING.md | 31 ++++ quest/m0/README.md | 1 - quest/m0/pr-behavioral-gates.md | 50 ------ test/justfile | 10 ++ test/smoke/README.md | 4 + 15 files changed, 848 insertions(+), 129 deletions(-) create mode 100755 .github/scripts/gates.sh create mode 100755 .github/scripts/gates.test.sh create mode 100755 .github/scripts/select.sh create mode 100755 .github/scripts/select.test.sh create mode 100644 .github/workflows/gates.yml delete mode 100644 quest/m0/pr-behavioral-gates.md diff --git a/.github/justfile b/.github/justfile index b084393903..9282b7d923 100644 --- a/.github/justfile +++ b/.github/justfile @@ -8,3 +8,31 @@ check: @if command -v actionlint >/dev/null 2>&1; then actionlint; fi {{ source_directory() }}/scripts/alert.sh check-coverage @if command -v rustc >/dev/null 2>&1; then {{ source_directory() }}/scripts/package-binary.test.sh; fi + just gh gates-test + @if command -v cargo >/dev/null 2>&1; then just gh select-test; fi + +# Which end-to-end lanes a diff needs, as `=true|false` lines. Takes the +# newline-separated changed-file list `just _changed` prints, and defaults to +# this branch's own diff so the answer is reproducible outside CI: +# +# just gh select +# just gh select "$(just _changed '')" +# +# gates.yml appends the output to $GITHUB_OUTPUT and drives one job per lane. +select $FILES="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -z "$FILES" ]]; then + FILES=$(just _changed "") + fi + printf '%s' "$FILES" | {{ source_directory() }}/scripts/select.sh + +# Check the impact map against the diff shapes it exists to catch. +[private] +select-test: + {{ source_directory() }}/scripts/select.test.sh + +# Check that the aggregate verdict tells an irrelevant lane from a missing one. +[private] +gates-test: + {{ source_directory() }}/scripts/gates.test.sh diff --git a/.github/scripts/alert.sh b/.github/scripts/alert.sh index ebb68bc0e1..f307862d30 100755 --- a/.github/scripts/alert.sh +++ b/.github/scripts/alert.sh @@ -112,9 +112,12 @@ non_pr_workflow_names() { printf "%s\n" "${files[@]}" | bun -e ' const files = (await Bun.stdin.text()).split("\n").filter(Boolean); -// Both report their failure as a check on the PR itself, so alert.yml skips -// them at runtime and a workflow triggered only by these needs no entry. -const PR_EVENTS = new Set(["pull_request", "pull_request_target"]); +// Triggers that report somewhere else, so a workflow with only these needs no +// entry. The pull request events report as a check on the PR itself, which is +// what alert.yml skips at runtime. workflow_call reports as part of the caller: +// a reusable workflow raises no workflow_run event of its own, so an entry for +// one would sit in alert.yml never firing. +const DELEGATED_EVENTS = new Set(["pull_request", "pull_request_target", "workflow_call"]); const names = []; for (const file of files) { const doc = Bun.YAML.parse(await Bun.file(file).text()); @@ -131,7 +134,7 @@ for (const file of files) { console.error("alert.sh: cannot read the on: value of " + file); process.exit(2); } - if (!triggers.some((t) => !PR_EVENTS.has(t))) continue; + if (!triggers.some((t) => !DELEGATED_EVENTS.has(t))) continue; const name = doc.name; if (typeof name !== "string" || name.trim() === "") { diff --git a/.github/scripts/gates.sh b/.github/scripts/gates.sh new file mode 100755 index 0000000000..a6fc75ab9f --- /dev/null +++ b/.github/scripts/gates.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# The aggregate verdict for gates.yml: one stable result a branch ruleset can +# require, whatever the diff selected. +# +# A required check has to report on every pull request, including the docs-only +# ones no lane covers. A path-filtered workflow cannot: it never starts, so its +# context never appears and the merge waits forever. So every lane is a +# conditional job inside one workflow that always starts, and this decides. +# +# The point of the script rather than a `needs` list is that `skipped` is +# ambiguous. GitHub reports the same word whether a lane was irrelevant to the +# diff or was never given the chance to run, and only the selector knows which. +# So each lane is checked against what the selector asked for: +# +# selected, success pass +# selected, anything else fail: the lane the diff needed did not pass +# not selected, skipped pass: irrelevant to this diff +# not selected, anything else fail: the workflow and the impact map disagree +# +# The last two rules also make the wiring self-checking: a lane the impact map +# emits with no job behind it, or a job with no lane in front of it, fails here +# rather than passing silently for however long nobody notices. +# +# Reads `toJSON(needs)` from GATES_NEEDS: a map of job id to `{result, outputs}`, +# where the `select` job's outputs are the impact map. + +set -euo pipefail + +: "${GATES_NEEDS:?GATES_NEEDS must hold toJSON(needs)}" + +needs="$GATES_NEEDS" + +# Without the selector there is nothing to compare a lane against, and treating +# an absent map as "nothing was selected" would pass every lane by skipping it. +selector="$(jq -r '.select.result // "missing"' <<<"$needs")" +if [[ "$selector" != success ]]; then + echo "gates: the selector did not succeed ($selector); no lane can be verified" >&2 + exit 1 +fi + +report="$(jq -r ' + . as $needs + | ($needs.select.outputs // {}) as $map + | ($map | keys) as $lanes + | (($needs | keys) - ["select"]) as $jobs + | ( + ($jobs[] | { + name: ., + selected: ($map[.] // "no-lane"), + result: ($needs[.].result // "missing") + }), + (($lanes - $jobs)[] | { name: ., selected: $map[.], result: "no-job" }) + ) + | "\(.name) \(.selected) \(.result)" +' <<<"$needs" | sort)" + +if [[ -z "$report" ]]; then + echo "gates: no lanes and no jobs; the impact map and the workflow are both empty" >&2 + exit 1 +fi + +status=0 +while read -r lane selected result; do + case "$selected/$result" in + true/success | false/skipped) + printf ' ok %-12s selected=%s result=%s\n' "$lane" "$selected" "$result" + ;; + true/*) + printf ' FAILED %-12s selected=%s result=%s\n' "$lane" "$selected" "$result" + status=1 + ;; + *) + printf ' MISWIRED %-12s selected=%s result=%s\n' "$lane" "$selected" "$result" + status=1 + ;; + esac +done <<<"$report" + +if ((status)); then + echo "gates: a selected lane did not pass, or the impact map and the workflow disagree" >&2 +fi + +exit "$status" diff --git a/.github/scripts/gates.test.sh b/.github/scripts/gates.test.sh new file mode 100755 index 0000000000..dd03326f75 --- /dev/null +++ b/.github/scripts/gates.test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Fixtures for the aggregate verdict. The cases that matter are the ones that +# look green: a lane the diff selected that never ran reports `skipped`, exactly +# like a lane the diff did not need. + +set -euo pipefail + +scripts="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fail() { + echo "gates: $1" >&2 + exit 1 +} + +# A `toJSON(needs)` payload: the selector's map plus one result per job. +needs() { + local map=$1 results=$2 + printf '{"select":{"result":"success","outputs":%s},%s}' "$map" "$results" +} + +passes() { + GATES_NEEDS="$1" "$scripts/gates.sh" >/dev/null 2>&1 || fail "$2" +} + +fails() { + ! GATES_NEEDS="$1" "$scripts/gates.sh" >/dev/null 2>&1 || fail "$2" +} + +map='{"smoke":"true","wasm":"false"}' + +passes "$(needs "$map" '"smoke":{"result":"success"},"wasm":{"result":"skipped"}')" \ + "a selected lane that passed and an irrelevant one must aggregate green" + +# The whole reason this is a script. Both lanes report `skipped`; only the +# selector knows that one of them was needed. +fails "$(needs "$map" '"smoke":{"result":"skipped"},"wasm":{"result":"skipped"}')" \ + "a selected lane that never ran must not pass as irrelevant" + +fails "$(needs "$map" '"smoke":{"result":"failure"},"wasm":{"result":"skipped"}')" \ + "a failed lane must fail" + +# A timed-out job reports `failure`; a superseded one reports `cancelled`. Both +# mean the lane did not prove anything. +fails "$(needs "$map" '"smoke":{"result":"cancelled"},"wasm":{"result":"skipped"}')" \ + "a cancelled lane must fail" + +# The wiring checks. A lane with no job behind it never runs and never reports, +# and a job with no lane in front of it is never selected and never runs. +fails "$(needs '{"smoke":"true","ts":"true"}' '"smoke":{"result":"success"}')" \ + "a lane with no job must fail" +fails "$(needs '{"smoke":"true"}' '"smoke":{"result":"success"},"ts":{"result":"success"}')" \ + "a job with no lane must fail" + +# An unselected lane that ran anyway means the job's `if` and the impact map +# disagree, which is the same bug seen from the other side. +fails "$(needs "$map" '"smoke":{"result":"success"},"wasm":{"result":"success"}')" \ + "an unselected lane that ran must fail" + +# Docs-only: nothing selected, nothing ran, and the required check still reports. +passes "$(needs '{"smoke":"false","wasm":"false"}' '"smoke":{"result":"skipped"},"wasm":{"result":"skipped"}')" \ + "a docs-only pull request must aggregate green" + +# Without the selector every lane would be compared against an empty map and +# pass by being skipped. +fails '{"select":{"result":"failure","outputs":{}},"smoke":{"result":"skipped"}}' \ + "a failed selector must fail the aggregate" + +fails '{"select":{"result":"success","outputs":{}}}' \ + "an empty impact map must fail rather than pass vacuously" + +echo "gates: aggregate ok" diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh new file mode 100755 index 0000000000..745db451e0 --- /dev/null +++ b/.github/scripts/select.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# +# The impact map: which behavioral gates a diff selects. +# +# `just check` and `just test` already answer "which packages did this change +# touch". This answers the other half: which end-to-end lane proves the changed +# behavior still works. Path filters in a workflow's `on:` block cannot, because +# a lane's real input is a crate's dependents, not a directory. +# +# Reads a newline-separated changed-file list on stdin, the same list +# `just _changed` prints, and writes one `=true|false` line per lane. Every +# lane is always printed, so the output doubles as the list of lanes and can be +# appended straight to $GITHUB_OUTPUT. +# +# Two different questions are asked of the diff, and the difference matters: +# +# closure the crates a change can reach, from `just rs _select`: the changed +# crates plus everything depending on them. This is the right question +# for a behavioral lane, because a moq-net edit breaks the relay +# without touching a file under rs/moq-relay. +# seeds the crate directories the diff actually edited. This is the right +# question for a lane whose cost is a whole extra runner and whose +# yield is code in that crate: a dependency-side API break reaching +# platform code is left to nightly. + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +files="$(cat)" + +# Every lane, in output order. Also the contract `gates.sh` checks the workflow +# against: a lane here with no job, or a job with no lane, fails the aggregate. +lanes=(smoke smoke_full wasm ts windows macos features) + +declare -A selected +for lane in "${lanes[@]}"; do + selected[$lane]=false +done + +emit() { + for lane in "${lanes[@]}"; do + printf '%s=%s\n' "$lane" "${selected[$lane]}" + done +} + +everything() { + for lane in "${lanes[@]}"; do + selected[$lane]=true + done + # The two smoke lanes are the same harness at two widths, so the wide one + # subsumes the narrow one; running both would pay for the small matrix twice. + selected[smoke]=false + emit + exit 0 +} + +# `just _changed` says ALL when the file list outgrew what argv can carry. That +# is a diff too large to reason about, so every lane runs. +if [[ "$files" == ALL ]]; then + everything +fi + +# The gate machinery itself. A pull request that rewrites how lanes are selected +# matches no lane's own inputs, so without this it would validate none of them. +# Mirrors the root `justfile`'s "orchestration changed, check everything" rule. +if grep -qE '^(\.github/(justfile|scripts/(select|gates)(\.test)?\.sh|workflows/(gates|smoke|wasm)\.yml)|justfile|test/justfile)$' <<<"$files"; then + everything +fi + +# `_select` emits `--package ` flags, or the bare word ALL when the diff +# touched something workspace-wide (a manifest, the lockfile, the toolchain +# pin). `_names` turns the flags back into crate names. +packages="$(just --justfile "$root/justfile" --working-directory "$root" rs _select "$files")" +if [[ "$packages" == ALL ]]; then + closure=ALL +else + closure="$(just --justfile "$root/justfile" --working-directory "$root" rs _names "$packages")" +fi + +seeds="$(sed -n 's|^rs/\([^/]*\)/.*|\1|p' <<<"$files" | sort -u)" + +# True when the diff can reach any of the named crates through the dependency +# graph. ALL is workspace-wide, so it reaches everything. +reaches() { + [[ "$closure" == ALL ]] && return 0 + grep -qwE "$(printf '%s|' "$@" | sed 's/|$//')" <<<"$closure" +} + +# True when the diff edited any of the named crates directly. +edits() { + grep -qxE "$(printf '%s|' "$@" | sed 's/|$//')" <<<"$seeds" +} + +# True when the diff touched any of the given path patterns. +touches() { + grep -qE "$1" <<<"$files" +} + +# The full interop matrix, per the Cross-Package Sync rule in CLAUDE.md: wire, +# FFI, and gateway changes run every publisher against every subscriber. +# +# Seeds rather than the closure, deliberately. moq-ffi sits on top of most of the +# workspace, so "the diff can reach moq-ffi" is true for nearly every Rust change +# and would make the wide matrix the default lane. What the rule actually names +# is a change TO the wire, the binding, or a gateway. +# +# The python and GStreamer arms exist only here, so a change to either client's +# source selects the wide matrix even though nothing else about it is wide. +if edits moq-net moq-ffi libmoq moq-gst moq-rtmp moq-srt moq-rtc moq-hls || + touches '^(py/|pyproject\.toml$|uv\.lock$)' || + touches '^(test/smoke/|test/justfile$|package\.json$|bun\.lock$)'; then + selected[smoke_full]=true +elif reaches moq-relay moq-cli libmoq moq-ffi moq-gst || + touches '^(js/|demo/web/)'; then + # The representative set: rust and browser publish, rust, browser and C + # subscribe. Every client here is built from a crate or package in the + # closure above, so this covers the delivery path end to end at roughly a + # third of the wide matrix's cost. + # + # The five crates are the matrix's entry points, not its whole dependency + # set: `reaches` already walks dependents, so a kio or hang edit arrives here + # as moq-relay and moq-cli. + selected[smoke]=true +fi + +# moq-wasm's crate root is `#![cfg(target_arch = "wasm32")]`, so every other gate +# compiles it to nothing and `just rs wasm` only compiles it. This lane is the +# only thing that runs it. The closure is exactly right here: the crate is a thin +# wrapper, and what breaks it lives in what it depends on. +if reaches moq-wasm || touches '^(js/wasm/|test/wasm/|\.cargo/config\.toml$)'; then + selected[wasm]=true +fi + +# The MPEG-TS exporter graded against a real analyzer. moq-mux owns the muxer and +# moq-cli owns the `export ts` that drives it. +if reaches moq-mux moq-cli || touches '^test/ts/'; then + selected[ts]=true +fi + +# `#[cfg(target_os = ...)]` code no Linux job compiles at all. Seeds rather than +# the closure: these cost a whole extra runner on a throttled pool, and the code +# they cover changes when its own crate changes. rust-toolchain.toml is in +# because a compiler bump is the other way this code stops building. +if edits moq-video moq-audio moq-nvenc moq-transcode moq-native moq-cli || + touches '^rust-toolchain\.toml$'; then + selected[windows]=true +fi +if edits moq-video moq-audio || touches '^rust-toolchain\.toml$'; then + selected[macos]=true +fi + +# The feature permutations. Selected on the manifests and build scripts that +# define the feature graph, not on source: an optional dependency going missing +# or a `#[cfg(feature)]` arm losing its gate shows up when the wiring is edited. +# Cargo.lock alone is out, so a lockfile-only bump does not pay for four extra +# workspace compiles. +if touches '^(Cargo\.toml|rs/.*/(Cargo\.toml|build\.rs)|rust-toolchain\.toml)$'; then + selected[features]=true +fi + +emit diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh new file mode 100755 index 0000000000..cbd2b3ebaf --- /dev/null +++ b/.github/scripts/select.test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Fixtures for the impact map. Every one of these is a diff shape that used to +# reach `main` with no end-to-end coverage at all, because the lane that covers +# it was selected by a path filter naming the harness rather than the source. +# +# Both directions are asserted for every lane. A selector that says yes to +# everything costs an hour a pull request; one that says no to everything is the +# hole this exists to close, and it is the one that stays green. + +set -euo pipefail + +scripts="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fail() { + echo "select: $1" >&2 + exit 1 +} + +# `=` for one changed-file list, memoised: each call runs +# `cargo metadata`, and the fixtures below ask about the same diffs repeatedly. +declare -A memo +select_for() { + if [[ -z "${memo[$1]+set}" ]]; then + memo[$1]="$(printf '%s' "$1" | "$scripts/select.sh")" + fi + printf '%s' "${memo[$1]}" +} + +expect() { + local files=$1 lane=$2 want=$3 + local got + got="$(select_for "$files" | sed -n "s/^$lane=//p")" + [[ -n "$got" ]] || fail "no lane named $lane" + [[ "$got" == "$want" ]] || fail "$lane=$got for [${files//$'\n'/, }], expected $want" +} + +# A wire change. The Cross-Package Sync rule in CLAUDE.md asks for the full +# matrix here, and the wide lane subsumes the narrow one. +wire='rs/moq-net/src/lib.rs' +expect "$wire" smoke_full true +expect "$wire" smoke false +expect "$wire" wasm true + +# The FFI surface every non-Rust binding is generated from. Same rule. +ffi='rs/moq-ffi/src/lib.rs' +expect "$ffi" smoke_full true +expect "$ffi" smoke false + +# A browser player change: covered by the representative set, which publishes and +# subscribes from a real headless browser. Nothing here reaches wasm32. +watch='js/watch/src/element.ts' +expect "$watch" smoke true +expect "$watch" smoke_full false +expect "$watch" wasm false +expect "$watch" windows false + +# The relay is the server every matrix cell connects through, but it is not the +# wire, the binding, or a gateway, so the narrow lane is the right width. +relay='rs/moq-relay/src/web.rs' +expect "$relay" smoke true +expect "$relay" smoke_full false + +# A platform backend. moq-video holds `#[cfg(target_os = ...)]` capture and +# encode that no Linux job compiles, and it is a moq-cli dependency, so the +# delivery path it feeds is worth proving too. +backend='rs/moq-video/src/lib.rs' +expect "$backend" windows true +expect "$backend" macos true +expect "$backend" smoke true + +# The relay holds macOS-gated code as well, but `just rs macos` does not compile +# it, so claiming the lane covers it would be a lie. +expect "$relay" macos false +expect "$relay" windows false + +# A lockfile bump can change any crate's behavior, so every behavioral lane runs. +# The wide matrix does not: what the lockfile moved is a dependency, not the wire +# format, the binding, or a gateway. +lock='Cargo.lock' +expect "$lock" smoke true +expect "$lock" smoke_full false +expect "$lock" wasm true +expect "$lock" ts true +# Four extra workspace compiles for a bump that did not touch the feature graph. +expect "$lock" features false + +# A manifest does touch the feature graph, and is the one place an optional +# dependency or a `#[cfg(feature)]` gate goes missing. +expect 'rs/moq-video/Cargo.toml' features true +expect 'rs/moq-relay/build.rs' features true + +# Docs cannot change behavior, and this is the case that must finish without +# waiting on a lane: it is why the aggregate exists. +docs='doc/concept/index.md' +for lane in smoke smoke_full wasm ts windows macos features; do + expect "$docs" "$lane" false +done + +# The gate machinery itself matches no lane's own inputs, so a pull request +# rewriting it would otherwise validate none of them. +expect '.github/scripts/select.sh' smoke_full true +expect '.github/scripts/select.sh' wasm true +expect 'test/justfile' smoke_full true + +# `just _changed` says ALL when the diff outgrew argv. Nothing is known about it, +# so nothing is assumed. +expect 'ALL' smoke_full true +expect 'ALL' features true + +# The narrow and wide smoke lanes are the same harness at two widths; running +# both would pay for the small matrix twice. +for files in "$wire" "$ffi" 'ALL' 'test/justfile'; do + [[ "$(select_for "$files" | grep -c '^smoke\(_full\)\?=true$')" -eq 1 ]] || + fail "smoke and smoke_full must not both run for [$files]" +done + +# The aggregate catches a lane whose job is missing only once both are wired into +# the same run. A lane added here and nowhere else has no job to be missing. +gates="$scripts/../workflows/gates.yml" +while IFS='=' read -r lane _; do + grep -qE "^ $lane:$" "$gates" || + fail "lane $lane has no job in gates.yml" + grep -qE "^ $lane: \\\$\{\{ steps\..*\.outputs\.$lane \}\}$" "$gates" || + fail "lane $lane is not an output of the gates.yml selector job" +done < <(select_for "$docs") + +echo "select: impact map ok" diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index ead3e4d373..b6c2da03db 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -18,8 +18,12 @@ on: workflow_run: # Matched by workflow `name:`, not filename. Every workflow that can run # outside a pull request belongs here; the pull-request-only ones (Check, - # macOS, Windows) are omitted so they don't spawn a skipped Alert run on - # every push to every PR. `just gh check` enforces both halves of that. + # Gates) are omitted so they don't spawn a skipped Alert run on every push + # to every PR. `just gh check` enforces both halves of that. + # + # A reusable workflow (WASM) is omitted too, for a different reason: it + # raises no workflow_run event of its own, so an entry for one would sit + # here never firing. Its caller is what reports. # # Swift is here despite also running on pull requests: it warms its own # Rust cache on `main`, and that push run is nobody's check, so a break in diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml new file mode 100644 index 0000000000..3f6876c98a --- /dev/null +++ b/.github/workflows/gates.yml @@ -0,0 +1,270 @@ +name: Gates + +# The behavioral gates, run on the pull requests they cover. +# +# `check.yml` proves the tree compiles and its unit tests pass. It cannot prove a +# broadcast still reaches a browser, that the WASM bindings still connect, or +# that the MPEG-TS exporter still produces a compliant stream. Those lanes exist, +# but each used to be a workflow with a `paths:` filter naming its own harness, +# so an ordinary relay, FFI, or player change ran none of them and the regression +# surfaced the next night, on `main`. +# +# A `paths:` filter cannot fix that, for two reasons. A lane's real input is a +# crate's dependents, not a directory: moq-net owns no file under rs/moq-relay +# and breaks it anyway. And a filtered workflow that does not match never starts, +# so its context never appears, which means it can never be a required check -- +# a docs-only pull request would wait on it forever. +# +# So the filter moves inside. This workflow always starts; `select` asks the +# impact map (.github/scripts/select.sh, `just gh select`) which lanes the diff +# needs; each lane is a job conditioned on that answer; and `Gates` is the one +# stable result to require, which passes only when every selected lane passed and +# every unselected one really was skipped. +# +# Adding a lane means adding it to select.sh, adding an output below, and adding +# a job whose id is the lane name. Miss any of the three and `Gates` says so. + +permissions: + contents: read + id-token: write + +on: + pull_request: + # `closed` is here only so merging/closing a PR cancels its in-flight run + # via the concurrency group below; the jobs are skipped on close. + types: [opened, synchronize, reopened, closed] + +concurrency: + group: gates-${{ github.ref }} + cancel-in-progress: true + +jobs: + select: + name: Select + if: github.event.action != 'closed' + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + outputs: + smoke: ${{ steps.map.outputs.smoke }} + smoke_full: ${{ steps.map.outputs.smoke_full }} + wasm: ${{ steps.map.outputs.wasm }} + ts: ${{ steps.map.outputs.ts }} + windows: ${{ steps.map.outputs.windows }} + macos: ${{ steps.map.outputs.macos }} + features: ${{ steps.map.outputs.features }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full history so `just _changed` can diff against origin/$GITHUB_BASE_REF. + fetch-depth: 0 + + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + # Trust the flake's cachix substituter, so the dev shell substitutes + # instead of building from source. See check.yml for the full story. + extra-conf: | + extra-substituters = https://kixelated.cachix.org + extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= + + # No Rust cache: the map reads `cargo metadata`, which resolves the graph + # without compiling anything. Restoring a multi-gigabyte target/ to answer + # a question in ten seconds would cost every lane its head start. + + # `tee` rather than a plain redirect: an unexpected lane is the first thing + # anyone looks for when a gate runs or doesn't, and $GITHUB_OUTPUT is not + # readable from the log. + - name: Impact map + id: map + run: | + nix develop --command just gh select | tee -a "$GITHUB_OUTPUT" + + # The representative interop set: rust and browser publish, rust, browser and C + # subscribe. Selected by any change the delivery path can reach. + smoke: + name: Smoke + needs: select + if: needs.select.outputs.smoke == 'true' + uses: ./.github/workflows/smoke.yml + with: + matrix: core + + # The full publisher x subscriber matrix, including the python and GStreamer + # arms. Selected by wire, FFI, and gateway changes, per Cross-Package Sync. + smoke_full: + name: Smoke (full) + needs: select + if: needs.select.outputs.smoke_full == 'true' + uses: ./.github/workflows/smoke.yml + with: + matrix: full + + wasm: + name: WASM + needs: select + if: needs.select.outputs.wasm == 'true' + uses: ./.github/workflows/wasm.yml + + # Round-trips a PCR-paced TS through a relay and grades the subscriber's + # `export ts` output with TSDuck. The `--live` arm, which grades release timing + # over a two-minute window, stays in nightly.yml. + ts: + name: TS + needs: select + if: needs.select.outputs.ts == 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + with: + tool-cache: false + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + extra-conf: | + extra-substituters = https://kixelated.cachix.org + extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= + + # Restore only. cache.yml is the single writer, on `main`; see the comment + # there for why. + - name: Rust cache + uses: ./.github/actions/rust-cache + + - name: TS compliance + run: nix develop --command just test ts + shell: bash -leo pipefail {0} + + # The feature permutations. Selected on the manifests and build scripts that + # define the feature graph; nightly.yml runs the same recipe diff-independently + # for the breakage that arrives through source instead. + features: + name: Features + needs: select + if: needs.select.outputs.features == 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + with: + tool-cache: false + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + extra-conf: | + extra-substituters = https://kixelated.cachix.org + extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= + + - name: Rust cache + uses: ./.github/actions/rust-cache + + - name: Features + run: nix develop --command just rs features + env: + NEXTEST_PROFILE: ci + + # A compile gate, not a device test: `just rs windows` is the only thing that + # puts moq-video's Media Foundation capture/encode/decode and every + # `#[cfg(target_os = "windows")]` test module past a compiler. Running it does + # not mean the code works on Windows, and nothing here claims otherwise. + # + # Same shape as nightly.yml's copy, including the deliberate absence of a Rust + # cache: the repository's 10 GB budget is spent warming the Linux target/ that + # every pull request restores, and a lane this narrow should not evict it. + windows: + name: Windows + needs: select + if: needs.select.outputs.windows == 'true' + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. + - name: Install NASM + shell: pwsh + run: | + choco install nasm -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # Pinned: `--locked` fixes just's own dependencies, not which version of + # just cargo selects. + - name: Install just + shell: bash + run: cargo install --locked just@1.52.0 + + - name: Check + shell: bash + run: just rs windows + + # The same compile gate for moq-video's VideoToolbox and ScreenCaptureKit code + # and moq-audio's system audio capture. See `windows` above. + macos: + name: macOS + needs: select + if: needs.select.outputs.macos == 'true' + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install just + run: cargo install --locked just@1.52.0 + + - name: Check + run: just rs macos + + # The one result to require. `always()` so it reports on a docs-only pull + # request, where every lane above is skipped and there is nothing to wait for. + gates: + name: Gates + if: always() && github.event.action != 'closed' + needs: [select, smoke, smoke_full, wasm, ts, windows, macos, features] + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # `skipped` means both "irrelevant to this diff" and "never got to run", so + # the results are checked against what the selector asked for rather than + # rolled up. See .github/scripts/gates.sh. + - name: Verify + run: .github/scripts/gates.sh + env: + GATES_NEEDS: ${{ toJSON(needs) }} diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 053e3b186d..300b453789 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -2,31 +2,35 @@ name: Smoke # Cross-language interop smoke test built from THIS checkout (not the published # packages, unlike the moq-dev/smoke repo). Stands up a relay and runs the -# publish x subscribe matrix across rust / python / go / browser / native-js / c, -# then the TS/IRD compliance harness over the exporter's output. +# publish x subscribe matrix across rust / python / go / browser / native-js / c / gst. +# +# The TS/IRD compliance harness used to ride along here. It is its own lane in +# gates.yml now, so it runs on the pull requests that change the exporter rather +# than on the ones that change this harness, and nightly.yml still runs the +# stronger `--live` arm of it. on: # Manual on-demand runs. workflow_dispatch: # Nightly: catch an interop regression in the current tree before a user does. schedule: - cron: "0 8 * * *" - # On PRs that touch the harness itself, so a change to the smoke test is - # validated before it merges. Source regressions still surface nightly. - pull_request: - paths: - - "test/smoke/**" - # The TS/IRD compliance harness runs in the same job (see below). - - "test/ts/**" - # The smoke job also depends on the recipe that invokes it and the - # workspace membership that links the JS clients to source. - - "test/justfile" - - "package.json" - # The Go client builds against the modules this script stages. - - "go/scripts/**" - - ".github/workflows/smoke.yml" + # Pull requests reach this through gates.yml, which asks the impact map which + # width the diff needs instead of filtering on paths. A `paths:` filter here + # only ever named the harness, so an ordinary relay, hang, or player change ran + # no matrix at all and the regression surfaced the next night, on `main`. + workflow_call: + inputs: + matrix: + description: >- + `full` for every publisher against every subscriber, or `core` for the + representative rust/browser/C set. Empty on schedule, meaning full. + type: string + default: full concurrency: - group: smoke-${{ github.ref }} + # Keyed by width as well as ref: gates.yml picks one of the two, but a run of + # each must not cancel the other if both are ever selected. + group: smoke-${{ inputs.matrix || 'full' }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -65,12 +69,14 @@ jobs: extra-substituters = https://kixelated.cachix.org extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= - # Reuse ~/.cargo + ./target across nightly runs so only changed crates - # recompile (the relay, cli, moq-ffi, and libmoq all build from source). + # Restore only, from the x64 entry cache.yml warms on `main`. This used to + # save its own, which was affordable while the only pull requests reaching + # here were the ones editing the harness. Selected by the impact map it is + # on a large share of them, and a per-PR entry is readable only by that PR + # while still counting against the repository's 10 GB budget, evicting the + # shared entries everyone else restores. - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - cache-on-failure: true + uses: ./.github/actions/rust-cache # Playwright's Chromium isn't in the nix devShell. Install it plus its apt # runtime libs (`--with-deps` needs the host package manager) so the @@ -80,22 +86,20 @@ jobs: run: nix develop --command bash -c "bun install --frozen-lockfile && bunx playwright install --with-deps chromium" shell: bash -leo pipefail {0} - - name: Smoke (full matrix) - run: nix develop --command just test smoke-full + - name: Smoke + run: nix develop --command just test smoke-${{ inputs.matrix || 'full' }} shell: bash -leo pipefail {0} + # Proves the harness can still report a failure at all, which every cell + # passing does not. Only on the full matrix: the core set is the same + # orchestrator at three fewer subscribers, so a second negative run of it + # would re-prove the same thing on the pull requests paying for it. - name: Negative control + if: inputs.matrix != 'core' run: nix develop --command just test smoke-negative shell: bash -leo pipefail {0} - # Browser-to-browser media QA: presented frame progress, audible tone, audio/video skew, and # the publication lifecycle, plus the negative controls that prove each assertion can fail. - name: Media output and lifecycle run: nix develop --command just test smoke-media shell: bash -leo pipefail {0} - - # Round-trips a PCR-paced TS through a relay and checks the subscriber's - # `export ts` output with TSDuck (tsp/tsanalyze from the nix devShell). - - name: TS compliance - run: nix develop --command just test ts - shell: bash -leo pipefail {0} diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index a846bd760f..f51890a028 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -6,52 +6,23 @@ name: WASM # compile gate: it catches a moq-net change that stops the bindings building, # never one that stops them working. See test/wasm/README.md. # -# Narrow trigger, like obs.yml and swift.yml. The filter covers what the bindings -# are built out of: moq-net, because that is the break this exists to catch in -# review (they are a thin wrapper over it, and every other gate compiles them to -# nothing), and kio underneath it, because a lost wakeup or a timer regression -# reaches the browser through moq-net without touching a file named here -# otherwise. Those two are the whole in-repo dependency graph of the crate. +# Pull requests reach this through gates.yml, which selects it whenever the diff +# can reach moq-wasm through the dependency graph, plus the machinery that turns +# the crate into the package under test: `js/wasm`, the harness under `test/wasm`, +# and `.cargo/config.toml`, which carries the wasm32 rustflags +# (`getrandom_backend`, `web_sys_unstable_apis`) without which it does not compile +# at all. # -# The rest of the filter is the machinery that turns the crate into the package -# under test, which is just as able to break it: the root `justfile` owns the -# `wasm` recipe, and `.cargo/config.toml` carries the wasm32 rustflags -# (`getrandom_backend`, `web_sys_unstable_apis`) without which it does not -# compile at all. -# -# Two things the harness needs are deliberately out, because they are fixtures -# rather than the code under test, and both are already gated elsewhere: -# `js/net/**` (the publisher; its own unit tests plus the smoke matrix) and -# `rs/moq-relay/**` (the server; the Rust test suite plus smoke). Naming either -# would launch a browser on a large share of pull requests. The cost is that a -# break arriving through one of them lands on `main` and shows up the next time -# this runs. +# That is strictly wider than the `paths:` filter this replaced, which named +# moq-net and kio directly and so ran on neither `js/net` (the publisher fixture) +# nor `rs/moq-relay` (the server), both of which can break the bindings without +# touching a file it listed. permissions: contents: read on: - pull_request: - # `closed` is here only so merging/closing a PR cancels its in-flight run - # via the concurrency group below; the job itself is skipped on close. - types: [opened, synchronize, reopened, closed] - paths: - - "rs/moq-wasm/**" - - "rs/moq-net/**" - - "rs/kio/**" - - "js/wasm/**" - - "test/wasm/**" - - "justfile" - - "test/justfile" - - ".cargo/config.toml" - - "package.json" - - "bun.lock" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "flake.nix" - - "flake.lock" - - ".github/workflows/wasm.yml" + workflow_call: concurrency: group: wasm-${{ github.ref }} @@ -60,7 +31,6 @@ concurrency: jobs: wasm: name: WASM - if: github.event.action != 'closed' # x64, not the arm runner the other jobs use: this is the configuration # smoke.yml already launches Playwright's Chromium on. runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dfaf3dd63..bea76e8392 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,37 @@ Keep the body short and structured, not narrated. When pushing additional commits to an existing PR, update the title and description if needed. When taking over someone else's PR, push commits on top of theirs so they keep credit. +# CI + +`Check` and `Test` compile the packages a branch changed and run their unit tests. +`Gates` is the behavioral half: it always starts, asks the impact map which end-to-end lanes the diff needs, runs those, and reports one result whatever was selected. + +Ask for the same answer locally before pushing: + +```bash +just gh select # this branch's lanes, as `=true|false` +just test smoke-core # what the `smoke` lane runs +``` + +| Lane | Runs | Selected by | Cost | +|---|---|---|---| +| `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, or a `js/` package | ~10 min | +| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, or the python client | ~25 min | +| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm, plus `js/wasm`, `test/wasm`, `.cargo/config.toml` | ~7 min | +| `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` | ~5 min | +| `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~12 min, uncached | +| `macos` | `just rs macos`: same, for VideoToolbox and ScreenCaptureKit | an edit to moq-video or moq-audio | ~5 min, uncached | +| `features` | `just rs features`: the `--all-features` and `--no-default-features` permutations | a manifest, a build script, or the toolchain pin | ~20 min | + +The map lives in `.github/scripts/select.sh`, its fixtures in `select.test.sh`, and the aggregate in `gates.sh`. A lane is three things: an entry in the map, an output on gates.yml's `select` job, and a job whose id is the lane name. Miss one and `Gates` fails rather than passing quietly. + +Deliberately still nightly, and so landing on `main` rather than in review: + +- Go, Swift, Kotlin, and Dart. The interop matrix has no client for any of them, so no aggregate result covers those bindings however green it is. +- Feature-arm breakage that arrives through source rather than a manifest. +- A dependency-side API break reaching `#[cfg(target_os = ...)]` code, since the platform lanes key on the crate that holds it. +- The OBS link (`obs.yml`), the Swift package (`swift.yml`), `just rs audit`, and the TS exporter's live release timing. + # AI AI-assisted issues, pull requests, reviews, and comments are welcome. diff --git a/quest/m0/README.md b/quest/m0/README.md index 1a6b442b4b..1eeb293150 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -23,7 +23,6 @@ regression test per Root Cause First. - [TS timebase discontinuity](/quest/m0/ts-forward-discontinuity.md) - preserve source-signalled clock changes through import and export - [Group charge](/quest/m0/group-charge.md) - charge real per-group cost so MOQ_CACHE_CAPACITY bounds real memory - [uring all-features](/quest/m0/uring-all-features-build.md) - moq-uring does not compile with `--all-features`, so the nightly features gate fails on it -- [PR behavioral gates](/quest/m0/pr-behavioral-gates.md) - run the applicable interop and platform gates on source PRs before merge - [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - retain inspectable traces, logs, and rerun commands when QA fails - [Browser permission QA](/quest/m0/browser-permission-qa.md) - nothing covers what the publisher does when the user denies the camera or microphone - [Publisher audio unlock](/quest/m0/publish-audio-unlock.md) - the publisher's capture AudioContext stays suspended when the page had no gesture, so no audio is ever encoded diff --git a/quest/m0/pr-behavioral-gates.md b/quest/m0/pr-behavioral-gates.md deleted file mode 100644 index 5cb74490f9..0000000000 --- a/quest/m0/pr-behavioral-gates.md +++ /dev/null @@ -1,50 +0,0 @@ -# [M] Run behavioral gates on the PRs they cover - -## Goal - -A PR changing media delivery or a binding covered by the existing smoke matrix -(Rust, Python, browser/native JS, C, and GStreamer) gets the applicable -end-to-end check before merge. Expensive gates remain scoped, but a source -change cannot miss its only behavioral test because it did not edit the harness. - -## Plan - -`.github/workflows/smoke.yml` runs on harness/config changes and nightly, not -ordinary relay, FFI, or browser source PRs. `wasm.yml` covers the WASM dependency -graph but deliberately excludes its JS publisher and relay fixtures. Native -Windows/macOS and broad feature checks live in `nightly.yml`. - -The live `main` and `dev` rulesets inspected on 2026-09-05 UTC require `Check` -and `Test` only. A separate Smoke, WASM, or platform result is not a required -context in either ruleset; inspect effective branch protection as well when -wiring the aggregate gate. - -Adding Go, Swift, Kotlin, and Dart participants is outside this quest. Report -those bindings as uncovered, and extend the impact map as participants land; -a successful aggregate must not claim behavioral coverage for them. - -- Define an explicit impact map in reusable local recipes, extending the - existing changed-package selection. List what each lane proves and which - source, build-script, lockfile, feature, or fixture changes select it. -- Run a small representative Rust/browser/C interoperability set on relevant - source PRs, and the full matrix for wire, FFI, and gateway changes as required - by Cross-Package Sync. Keep broader combinations nightly when they add cost - without covering the changed behavior. -- Select native platform and feature compile gates when the changed backend or - shared build machinery needs them. Keep hardware execution a separate result; - a platform compile is not a device test. -- Add a stable aggregate result that distinguishes irrelevant from missing, - failed, cancelled, or timed-out selected jobs. Ensure docs-only PRs complete - without waiting for a path-filtered check that will never start. -- Audit live branch protection/rulesets before choosing the required result. - Workflow YAML alone does not establish what GitHub requires for merging. - Preserve the main-only cache writer policy for new PR lanes. - -Acceptance: selector fixtures cover source-only changes in moq-net, moq-ffi, -js/watch, relay, a platform backend, a lockfile, and docs. A deliberately broken -consumer fails the selected gate. Record representative warm/cold costs and -the remaining nightly-only coverage in CONTRIBUTING. - -## Related - -- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - supplies execution where hosted compile gates cannot diff --git a/test/justfile b/test/justfile index 11e80f5862..802ea78bc9 100644 --- a/test/justfile +++ b/test/justfile @@ -81,6 +81,16 @@ smoke *args: smoke-full: ./smoke/smoke.sh --publishers rust,python,go,js --subscribers rust,python,go,js,js-native-node,js-native-bun,c,gst --timeout 30 +# The representative set gates.yml runs on a pull request the delivery path can +# reach: rust and browser publish; rust, browser, and C subscribe. Three +# languages and both roles, so a broken frame, catalog, or connection fails here, +# at roughly a third of the full matrix. What it does not cover is the python and +# GStreamer clients, which is why a change to either selects `smoke-full` + +# instead. --timeout 30 gives headless Chromium cold-start headroom. +smoke-core: + ./smoke/smoke.sh --publishers rust,js --subscribers rust,js,c --timeout 30 + # Negative control: no publisher, every subscriber must time out (proves the # harness can actually report failure). diff --git a/test/smoke/README.md b/test/smoke/README.md index 944b1ecd16..779ed804d5 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -71,6 +71,10 @@ just test smoke # Full matrix: rust/python/go/browser publish; everyone subscribes. just test smoke-full +# The representative set a pull request runs: rust/browser publish; rust, +# browser and C subscribe. See the CI table in CONTRIBUTING.md. +just test smoke-core + # Pick your own axes: just test smoke --publishers rust,python --subscribers rust,c,js-native-bun From 2ff4df65945f2223f7871968041d14335a73aa3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 16:46:32 -0700 Subject: [PATCH 03/15] ci: run the wasm lane on its harness fixtures and drop unused OIDC The wasm harness builds a real moq-relay and publishes from @moq/net, neither of which is in moq-wasm's Cargo dependency graph, so the closure alone left a break arriving through either unrun. Nothing in these workflows authenticates over OIDC, and a called workflow cannot ask for more than its caller grants. Co-Authored-By: Claude Opus 5 --- .github/scripts/select.sh | 14 +++++++++++--- .github/scripts/select.test.sh | 6 ++++++ .github/workflows/gates.yml | 1 - .github/workflows/smoke.yml | 4 +++- .github/workflows/wasm.yml | 10 ++++++---- CONTRIBUTING.md | 8 +++++--- 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 745db451e0..66ea1bcd8a 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -126,9 +126,17 @@ fi # moq-wasm's crate root is `#![cfg(target_arch = "wasm32")]`, so every other gate # compiles it to nothing and `just rs wasm` only compiles it. This lane is the -# only thing that runs it. The closure is exactly right here: the crate is a thin -# wrapper, and what breaks it lives in what it depends on. -if reaches moq-wasm || touches '^(js/wasm/|test/wasm/|\.cargo/config\.toml$)'; then +# only thing that runs it. +# +# moq-relay and js/net are the harness's fixtures rather than the code under +# test, and they are in anyway: test/wasm/run.sh builds a real relay and +# publishes from @moq/net, and neither is in moq-wasm's Cargo dependency graph, +# so a break arriving through either would otherwise reach `main` unrun. That is +# what the paths filter this replaced deliberately gave up (a browser on a large +# share of pull requests); the impact map buys it back, because those diffs run +# the smoke lane alongside rather than after. +if reaches moq-wasm moq-relay || + touches '^(js/(wasm|net|signals)/|test/wasm/|\.cargo/config\.toml$)'; then selected[wasm]=true fi diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index cbd2b3ebaf..e3858bdee4 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -61,6 +61,12 @@ relay='rs/moq-relay/src/web.rs' expect "$relay" smoke true expect "$relay" smoke_full false +# The two lanes whose harness builds a relay and publishes from @moq/net. Neither +# fixture is in moq-wasm's Cargo dependency graph, so the closure alone says no +# and a break arriving through either would run nowhere. +expect "$relay" wasm true +expect 'js/net/src/connection.ts' wasm true + # A platform backend. moq-video holds `#[cfg(target_os = ...)]` capture and # encode that no Linux job compiles, and it is a moq-cli dependency, so the # delivery path it feeds is worth proving too. diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 3f6876c98a..78a8ea98aa 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -26,7 +26,6 @@ name: Gates permissions: contents: read - id-token: write on: pull_request: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 300b453789..32790f8880 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -33,8 +33,10 @@ concurrency: group: smoke-${{ inputs.matrix || 'full' }}-${{ github.ref }} cancel-in-progress: true +# No id-token: nothing here authenticates over OIDC, and a called workflow cannot +# ask for more than its caller grants, so an unused write would force gates.yml to +# hand every lane the same. permissions: - id-token: write contents: read jobs: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index f51890a028..2f1bc51a76 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -13,10 +13,12 @@ name: WASM # (`getrandom_backend`, `web_sys_unstable_apis`) without which it does not compile # at all. # -# That is strictly wider than the `paths:` filter this replaced, which named -# moq-net and kio directly and so ran on neither `js/net` (the publisher fixture) -# nor `rs/moq-relay` (the server), both of which can break the bindings without -# touching a file it listed. +# The harness's own fixtures are in as well: run.sh builds a real `moq-relay` and +# publishes from `@moq/net`. The `paths:` filter this replaced left both out on +# purpose, because naming them would have launched a browser on a large share of +# pull requests, and accepted that a break arriving through either lands on `main` +# and shows up the next run. Selection makes them affordable: a diff reaching +# either already runs the smoke lane, so this one runs beside it, not after it. permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bea76e8392..8e9ab1fd64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,13 +32,15 @@ just test smoke-core # what the `smoke` lane runs | Lane | Runs | Selected by | Cost | |---|---|---|---| | `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, or a `js/` package | ~10 min | -| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, or the python client | ~25 min | -| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm, plus `js/wasm`, `test/wasm`, `.cargo/config.toml` | ~7 min | +| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, or the python client | ~20 min | +| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `test/wasm`, `.cargo/config.toml` | ~8 min | | `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` | ~5 min | -| `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~12 min, uncached | +| `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~13 min, uncached | | `macos` | `just rs macos`: same, for VideoToolbox and ScreenCaptureKit | an edit to moq-video or moq-audio | ~5 min, uncached | | `features` | `just rs features`: the `--all-features` and `--no-default-features` permutations | a manifest, a build script, or the toolchain pin | ~20 min | +Costs are wall clock on a cold shared cache, measured on the run that added this table; every selected lane runs in parallel, so a diff selecting all seven finishes in the slowest one. Selection itself costs ~90s, which every pull request pays. + The map lives in `.github/scripts/select.sh`, its fixtures in `select.test.sh`, and the aggregate in `gates.sh`. A lane is three things: an entry in the map, an output on gates.yml's `select` job, and a job whose id is the lane name. Miss one and `Gates` fails rather than passing quietly. Deliberately still nightly, and so landing on `main` rather than in review: From 25b27a3f3e2aa3b1f9b23bce662c79a2e183d458 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 17:25:58 -0700 Subject: [PATCH 04/15] ci: select the wasm and dev-shell harness inputs flake.nix and flake.lock supply ffmpeg, TSDuck, and the wasm-bindgen CLI every harness runs on, and nothing in the Cargo or bun graph names them, so a lock bump selected no lane at all. The bun workspace decides what the wasm harness loads. Co-Authored-By: Claude Opus 5 --- .github/scripts/select.sh | 24 ++++++++++++++++++++---- .github/scripts/select.test.sh | 17 +++++++++++++++++ CONTRIBUTING.md | 10 ++++++---- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 66ea1bcd8a..9c13ade44c 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -97,6 +97,13 @@ touches() { grep -qE "$1" <<<"$files" } +# The dev shell every harness runs inside. It is where ffmpeg, TSDuck, and the +# wasm-bindgen CLI (whose version has to match the crate) come from, and the +# rust-cache key already rolls on it because build scripts link against its store +# paths. Nothing in the Cargo or bun graph names it, so without this a lock bump +# selects no lane at all. +shell='^flake\.(nix|lock)$' + # The full interop matrix, per the Cross-Package Sync rule in CLAUDE.md: wire, # FFI, and gateway changes run every publisher against every subscriber. # @@ -112,7 +119,7 @@ if edits moq-net moq-ffi libmoq moq-gst moq-rtmp moq-srt moq-rtc moq-hls || touches '^(test/smoke/|test/justfile$|package\.json$|bun\.lock$)'; then selected[smoke_full]=true elif reaches moq-relay moq-cli libmoq moq-ffi moq-gst || - touches '^(js/|demo/web/)'; then + touches '^(js/|demo/web/)' || touches "$shell"; then # The representative set: rust and browser publish, rust, browser and C # subscribe. Every client here is built from a crate or package in the # closure above, so this covers the delivery path end to end at roughly a @@ -135,14 +142,20 @@ fi # what the paths filter this replaced deliberately gave up (a browser on a large # share of pull requests); the impact map buys it back, because those diffs run # the smoke lane alongside rather than after. +# +# The bun workspace is here for the same reason: run.sh installs it frozen and +# bundles the publisher out of it, so the root manifest and lockfile decide what +# the harness actually loads. if reaches moq-wasm moq-relay || - touches '^(js/(wasm|net|signals)/|test/wasm/|\.cargo/config\.toml$)'; then + touches '^(js/(wasm|net|signals)/|test/wasm/|\.cargo/config\.toml$)' || + touches '^(package\.json|bun\.lock)$' || touches "$shell"; then selected[wasm]=true fi # The MPEG-TS exporter graded against a real analyzer. moq-mux owns the muxer and -# moq-cli owns the `export ts` that drives it. -if reaches moq-mux moq-cli || touches '^test/ts/'; then +# moq-cli owns the `export ts` that drives it; TSDuck grades the output and comes +# from the dev shell. +if reaches moq-mux moq-cli || touches '^test/ts/' || touches "$shell"; then selected[ts]=true fi @@ -150,6 +163,9 @@ fi # the closure: these cost a whole extra runner on a throttled pool, and the code # they cover changes when its own crate changes. rust-toolchain.toml is in # because a compiler bump is the other way this code stops building. +# +# The dev shell is deliberately absent: these two jobs need an Apple or Windows +# host and use the runner's own toolchain, so nix never runs in them. if edits moq-video moq-audio moq-nvenc moq-transcode moq-native moq-cli || touches '^rust-toolchain\.toml$'; then selected[windows]=true diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index e3858bdee4..93ff3c3f85 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -96,6 +96,23 @@ expect "$lock" features false expect 'rs/moq-video/Cargo.toml' features true expect 'rs/moq-relay/build.rs' features true +# The dev shell supplies ffmpeg, TSDuck, and the wasm-bindgen CLI whose version +# has to match the crate, and nothing in the Cargo or bun graph names it. A lock +# bump used to select no lane at all. +shell='flake.lock' +expect "$shell" smoke true +expect "$shell" wasm true +expect "$shell" ts true +# Both of these run on a runner-native toolchain, so nix never enters them. +expect "$shell" windows false +expect "$shell" macos false + +# The bun workspace the wasm harness installs frozen and bundles its publisher +# out of. It reaches the native node and bun clients too, which only the wide +# matrix runs. +expect 'bun.lock' wasm true +expect 'bun.lock' smoke_full true + # Docs cannot change behavior, and this is the case that must finish without # waiting on a lane: it is why the aggregate exists. docs='doc/concept/index.md' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e9ab1fd64..3145c15dbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,14 +31,16 @@ just test smoke-core # what the `smoke` lane runs | Lane | Runs | Selected by | Cost | |---|---|---|---| -| `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, or a `js/` package | ~10 min | -| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, or the python client | ~20 min | -| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `test/wasm`, `.cargo/config.toml` | ~8 min | -| `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` | ~5 min | +| `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, a `js/` package, or the dev shell | ~10 min | +| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, the python client, or the bun workspace | ~20 min | +| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `test/wasm`, `.cargo/config.toml`, the bun workspace, or the dev shell | ~8 min | +| `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` or the dev shell | ~5 min | | `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~13 min, uncached | | `macos` | `just rs macos`: same, for VideoToolbox and ScreenCaptureKit | an edit to moq-video or moq-audio | ~5 min, uncached | | `features` | `just rs features`: the `--all-features` and `--no-default-features` permutations | a manifest, a build script, or the toolchain pin | ~20 min | +"The dev shell" is `flake.nix` and `flake.lock`, which supply ffmpeg, TSDuck, and the `wasm-bindgen` CLI every harness runs on; `windows` and `macos` use the runner's own toolchain instead, so nix never enters them. + Costs are wall clock on a cold shared cache, measured on the run that added this table; every selected lane runs in parallel, so a diff selecting all seven finishes in the slowest one. Selection itself costs ~90s, which every pull request pays. The map lives in `.github/scripts/select.sh`, its fixtures in `select.test.sh`, and the aggregate in `gates.sh`. A lane is three things: an entry in the map, an output on gates.yml's `select` job, and a job whose id is the lane name. Miss one and `Gates` fails rather than passing quietly. From 507f9d6cdfa7c5f205942767dcc82b25c6fc929d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 17:31:15 -0700 Subject: [PATCH 05/15] ci: send Go client changes to the full matrix The Go publisher and subscriber landed in #3505 and exist only in smoke-full, so a change to the wrapper or its staging scripts has to select the wide lane. Co-Authored-By: Claude Opus 5 --- .github/scripts/select.sh | 5 +++-- .github/scripts/select.test.sh | 5 +++++ CONTRIBUTING.md | 4 ++-- test/justfile | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 9c13ade44c..0e2eeaae70 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -112,10 +112,11 @@ shell='^flake\.(nix|lock)$' # and would make the wide matrix the default lane. What the rule actually names # is a change TO the wire, the binding, or a gateway. # -# The python and GStreamer arms exist only here, so a change to either client's -# source selects the wide matrix even though nothing else about it is wide. +# The python, Go, and GStreamer arms exist only here, so a change to any of those +# clients selects the wide matrix even though nothing else about it is wide. if edits moq-net moq-ffi libmoq moq-gst moq-rtmp moq-srt moq-rtc moq-hls || touches '^(py/|pyproject\.toml$|uv\.lock$)' || + touches '^go/' || touches '^(test/smoke/|test/justfile$|package\.json$|bun\.lock$)'; then selected[smoke_full]=true elif reaches moq-relay moq-cli libmoq moq-ffi moq-gst || diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index 93ff3c3f85..1ab5e0da51 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -47,6 +47,11 @@ ffi='rs/moq-ffi/src/lib.rs' expect "$ffi" smoke_full true expect "$ffi" smoke false +# The Go wrapper. Its client is a full-matrix-only participant, so the narrow set +# would prove nothing about it. +expect 'go/wrapper/moq/broadcast.go' smoke_full true +expect 'go/scripts/stage.sh' smoke_full true + # A browser player change: covered by the representative set, which publishes and # subscribes from a real headless browser. Nothing here reaches wasm32. watch='js/watch/src/element.ts' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3145c15dbb..528d91ea55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ just test smoke-core # what the `smoke` lane runs | Lane | Runs | Selected by | Cost | |---|---|---|---| | `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, a `js/` package, or the dev shell | ~10 min | -| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, the python client, or the bun workspace | ~20 min | +| `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, the python or Go client, or the bun workspace | ~20 min | | `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `test/wasm`, `.cargo/config.toml`, the bun workspace, or the dev shell | ~8 min | | `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` or the dev shell | ~5 min | | `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~13 min, uncached | @@ -47,7 +47,7 @@ The map lives in `.github/scripts/select.sh`, its fixtures in `select.test.sh`, Deliberately still nightly, and so landing on `main` rather than in review: -- Go, Swift, Kotlin, and Dart. The interop matrix has no client for any of them, so no aggregate result covers those bindings however green it is. +- Swift, Kotlin, and Dart. The interop matrix has no client for any of them, so no aggregate result covers those bindings however green it is. Go is covered, but only by `smoke_full`. - Feature-arm breakage that arrives through source rather than a manifest. - A dependency-side API break reaching `#[cfg(target_os = ...)]` code, since the platform lanes key on the crate that holds it. - The OBS link (`obs.yml`), the Swift package (`swift.yml`), `just rs audit`, and the TS exporter's live release timing. diff --git a/test/justfile b/test/justfile index 802ea78bc9..6f54aca177 100644 --- a/test/justfile +++ b/test/justfile @@ -84,8 +84,8 @@ smoke-full: # The representative set gates.yml runs on a pull request the delivery path can # reach: rust and browser publish; rust, browser, and C subscribe. Three # languages and both roles, so a broken frame, catalog, or connection fails here, -# at roughly a third of the full matrix. What it does not cover is the python and -# GStreamer clients, which is why a change to either selects `smoke-full` +# at roughly a third of the full matrix. What it does not cover is the python, Go, +# and GStreamer clients, which is why a change to any of them selects `smoke-full` # instead. --timeout 30 gives headless Chromium cold-start headroom. smoke-core: From a1e4d2d8b4ab1b5346678f0b557847816284c206 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 17:57:59 -0700 Subject: [PATCH 06/15] ci: select wasm on the shared tsconfig and document the ts lane's new home test/wasm extends js/tsconfig.json, and run.sh's tsc --noEmit is the only thing that type-checks the harness against the generated @moq/wasm declarations. The TS compliance harness no longer rides along in smoke.yml, so its README said an on-demand Smoke run covered it. Co-Authored-By: Claude Opus 5 --- .github/scripts/select.sh | 7 +++++-- .github/scripts/select.test.sh | 5 +++++ CONTRIBUTING.md | 2 +- test/ts/README.md | 12 ++++++++---- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 0e2eeaae70..87cd22702d 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -146,9 +146,12 @@ fi # # The bun workspace is here for the same reason: run.sh installs it frozen and # bundles the publisher out of it, so the root manifest and lockfile decide what -# the harness actually loads. +# the harness actually loads. js/tsconfig.json likewise: test/wasm extends it, and +# this lane is the only thing that type-checks the harness against the generated +# @moq/wasm declarations, since the wasm workspace has no `check` script for +# `just js check` to run. if reaches moq-wasm moq-relay || - touches '^(js/(wasm|net|signals)/|test/wasm/|\.cargo/config\.toml$)' || + touches '^(js/(wasm|net|signals)/|js/tsconfig\.json$|test/wasm/|\.cargo/config\.toml$)' || touches '^(package\.json|bun\.lock)$' || touches "$shell"; then selected[wasm]=true fi diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index 1ab5e0da51..bde8ba78a0 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -118,6 +118,11 @@ expect "$shell" macos false expect 'bun.lock' wasm true expect 'bun.lock' smoke_full true +# test/wasm extends the shared compiler options, and run.sh's `tsc --noEmit` is +# the only thing that type-checks the harness against the generated @moq/wasm +# declarations: the workspace has no `check` script for `just js check` to run. +expect 'js/tsconfig.json' wasm true + # Docs cannot change behavior, and this is the case that must finish without # waiting on a lane: it is why the aggregate exists. docs='doc/concept/index.md' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 528d91ea55..cf946fbe87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ just test smoke-core # what the `smoke` lane runs |---|---|---|---| | `smoke` | `just test smoke-core`: rust and browser publish; rust, browser and C subscribe | any change reaching moq-relay, moq-cli, libmoq, moq-ffi or moq-gst through the dependency graph, a `js/` package, or the dev shell | ~10 min | | `smoke_full` | `just test smoke-full` plus the negative control: every publisher against every subscriber | a change *to* the wire (moq-net), the FFI (moq-ffi, libmoq, moq-gst), a gateway, the python or Go client, or the bun workspace | ~20 min | -| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `test/wasm`, `.cargo/config.toml`, the bun workspace, or the dev shell | ~8 min | +| `wasm` | `just test wasm`: the `@moq/wasm` bindings in headless Chromium | any change reaching moq-wasm or moq-relay, plus `js/wasm`, `js/net`, `js/signals`, `js/tsconfig.json`, `test/wasm`, `.cargo/config.toml`, the bun workspace, or the dev shell | ~8 min | | `ts` | `just test ts`: the MPEG-TS exporter graded with TSDuck | any change reaching moq-mux or moq-cli, plus `test/ts` or the dev shell | ~5 min | | `windows` | `just rs windows`: a compile gate, not a device test | an edit to moq-video, moq-audio, moq-nvenc, moq-transcode, moq-native or moq-cli | ~13 min, uncached | | `macos` | `just rs macos`: same, for VideoToolbox and ScreenCaptureKit | an edit to moq-video or moq-audio | ~5 min, uncached | diff --git a/test/ts/README.md b/test/ts/README.md index d40349b84f..e6aa319e06 100644 --- a/test/ts/README.md +++ b/test/ts/README.md @@ -226,10 +226,14 @@ exporter re-emits SI on its own repetition cadence rather than the source's. ## CI -`.github/workflows/smoke.yml` runs `just test ts` after the interop -matrix (nightly, on demand, and on PRs touching `test/ts/`). TSDuck -comes from the `nix develop` shell, so the run uses the same `tsp`/`tsanalyze` a -local developer would. +`.github/workflows/gates.yml` runs `just test ts` as its own `ts` lane, on any +pull request the impact map says can reach moq-mux or moq-cli, plus one touching +`test/ts/` or the dev shell. TSDuck comes from the `nix develop` shell, so the run +uses the same `tsp`/`tsanalyze` a local developer would. + +This used to ride along in `smoke.yml` after the interop matrix. It does not any +more, so a manually dispatched or scheduled Smoke run is the matrix alone and +proves nothing about the exporter. `.github/workflows/nightly.yml` runs `just test ts --live --duration 120` as well. It stays off the PR path because release timing needs a real-time window to From d3d801a105c7d894ff6ef7631d5704da362e3c41 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 20:51:39 -0700 Subject: [PATCH 07/15] ci: exercise every lane for gate machinery changes --- .github/scripts/select.sh | 5 +---- .github/scripts/select.test.sh | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 87cd22702d..a2d6fcd46b 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -48,9 +48,6 @@ everything() { for lane in "${lanes[@]}"; do selected[$lane]=true done - # The two smoke lanes are the same harness at two widths, so the wide one - # subsumes the narrow one; running both would pay for the small matrix twice. - selected[smoke]=false emit exit 0 } @@ -64,7 +61,7 @@ fi # The gate machinery itself. A pull request that rewrites how lanes are selected # matches no lane's own inputs, so without this it would validate none of them. # Mirrors the root `justfile`'s "orchestration changed, check everything" rule. -if grep -qE '^(\.github/(justfile|scripts/(select|gates)(\.test)?\.sh|workflows/(gates|smoke|wasm)\.yml)|justfile|test/justfile)$' <<<"$files"; then +if grep -qE '^(\.github/(justfile|scripts/(select|gates)(\.test)?\.sh|workflows/(gates|smoke|wasm)\.yml)|justfile|rs/justfile|test/justfile)$' <<<"$files"; then everything fi diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index bde8ba78a0..7073c5790a 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -132,21 +132,35 @@ done # The gate machinery itself matches no lane's own inputs, so a pull request # rewriting it would otherwise validate none of them. +expect '.github/scripts/select.sh' smoke true expect '.github/scripts/select.sh' smoke_full true expect '.github/scripts/select.sh' wasm true +expect 'test/justfile' smoke true expect 'test/justfile' smoke_full true +# rs/justfile owns the platform and feature recipes, so a change to its command +# lines has to execute those recipes rather than merely widening the Cargo +# dependency closure. +expect 'rs/justfile' windows true +expect 'rs/justfile' macos true +expect 'rs/justfile' features true + # `just _changed` says ALL when the diff outgrew argv. Nothing is known about it, # so nothing is assumed. expect 'ALL' smoke_full true expect 'ALL' features true -# The narrow and wide smoke lanes are the same harness at two widths; running -# both would pay for the small matrix twice. -for files in "$wire" "$ffi" 'ALL' 'test/justfile'; do +# For ordinary source changes the wide matrix subsumes the narrow one. Gate +# machinery and an unreasonably large diff deliberately run both, because the +# core recipe and workflow input are themselves behavior under test. +for files in "$wire" "$ffi"; do [[ "$(select_for "$files" | grep -c '^smoke\(_full\)\?=true$')" -eq 1 ]] || fail "smoke and smoke_full must not both run for [$files]" done +for files in 'ALL' 'test/justfile' '.github/scripts/select.sh'; do + [[ "$(select_for "$files" | grep -c '^smoke\(_full\)\?=true$')" -eq 2 ]] || + fail "smoke and smoke_full must both run for [$files]" +done # The aggregate catches a lane whose job is missing only once both are wired into # the same run. A lane added here and nowhere else has no job to be missing. From b8d211298530c8475600ac42c3fcc9cb08adfb4a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 7 Sep 2026 22:13:29 -0700 Subject: [PATCH 08/15] ci: preserve browser media smoke after rebase --- .github/justfile | 2 +- .github/workflows/smoke.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/justfile b/.github/justfile index 9282b7d923..097277983c 100644 --- a/.github/justfile +++ b/.github/justfile @@ -23,7 +23,7 @@ select $FILES="": #!/usr/bin/env bash set -euo pipefail if [[ -z "$FILES" ]]; then - FILES=$(just _changed "") + FILES=$(just _changed "") fi printf '%s' "$FILES" | {{ source_directory() }}/scripts/select.sh diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 32790f8880..78bb305acf 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -100,6 +100,7 @@ jobs: if: inputs.matrix != 'core' run: nix develop --command just test smoke-negative shell: bash -leo pipefail {0} + # Browser-to-browser media QA: presented frame progress, audible tone, audio/video skew, and # the publication lifecycle, plus the negative controls that prove each assertion can fail. - name: Media output and lifecycle From f88a9abbde6d4e5f58532eab80550ff9b6065625 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 08:50:44 -0700 Subject: [PATCH 09/15] test(smoke): establish the leaked session before detaching --- test/smoke/clients/js/media.ts | 11 ++++++++++- test/smoke/clients/js/src/contract.ts | 4 ++-- test/smoke/clients/js/src/setup.ts | 9 ++++----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/test/smoke/clients/js/media.ts b/test/smoke/clients/js/media.ts index e9d299fbba..9860ea93f3 100644 --- a/test/smoke/clients/js/media.ts +++ b/test/smoke/clients/js/media.ts @@ -480,7 +480,16 @@ try { () => `the player holds nothing to release while playing: ${JSON.stringify(busy.resources)}`, ); - await command(player, values.leak ? "detachLeaky" : "detach"); + if (values.leak) { + await command(player, "startLeak"); + await waitForResources(player, playerErrors, { + deadline: Date.now() + SETTLE_MS, + assertion: "resource instrumentation", + description: `the deliberately leaked player to open another session beyond ${JSON.stringify(busy.resources)}`, + predicate: (r) => r.transports + r.sockets > busy.resources.transports + busy.resources.sockets, + }); + } + await command(player, "detach"); await waitForResources(player, playerErrors, { deadline: Date.now() + SETTLE_MS, assertion: "resource baseline", diff --git a/test/smoke/clients/js/src/contract.ts b/test/smoke/clients/js/src/contract.ts index bd5aefad6a..088ee744c1 100644 --- a/test/smoke/clients/js/src/contract.ts +++ b/test/smoke/clients/js/src/contract.ts @@ -140,8 +140,8 @@ export type SmokeControl = { detach(): void; /** Put the player back and resume sampling. */ reattach(): void; - /** Detach the player but leave a second one connected: the leaked-session negative control. */ - detachLeaky(): void; + /** Connect a second player and leave it behind for the leaked-session negative control. */ + startLeak(): void; }; /** The `window` property the commands are published on. */ diff --git a/test/smoke/clients/js/src/setup.ts b/test/smoke/clients/js/src/setup.ts index 4745936b73..7242ba5b23 100644 --- a/test/smoke/clients/js/src/setup.ts +++ b/test/smoke/clients/js/src/setup.ts @@ -81,15 +81,14 @@ if (role === "publish") { stop(); el.remove(); }, - detachLeaky: () => { - // Stand up a second player on the same broadcast and leave it connected. This is the old - // session a detach is supposed to end, so the resource baseline must not come back clean. + startLeak: () => { + // Stand up a second player on the same broadcast and leave it connected. The driver proves + // its session exists before detaching the real player, so a zero-resource instant cannot + // satisfy the negative control before the deliberate leak has started. const stray = document.createElement("moq-watch") as MoqWatch; stray.setAttribute("url", url); stray.setAttribute("name", broadcast); leak.appendChild(stray); - stop(); - el.remove(); }, reattach: () => { stop(); From abc2616669010b5e42408e2f16f8dee59f67c00e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 08:59:32 -0700 Subject: [PATCH 10/15] docs: remove completed gate quest references --- quest/m0/transport-impairment-profile.md | 3 +-- test/drill/README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/quest/m0/transport-impairment-profile.md b/quest/m0/transport-impairment-profile.md index fdcb9d49a5..277db9fa13 100644 --- a/quest/m0/transport-impairment-profile.md +++ b/quest/m0/transport-impairment-profile.md @@ -36,10 +36,9 @@ than escalating when it does not. Acceptance: the three drills pass under a moderate profile and the recorded baseline matches the requested one within a stated tolerance. A profile that cannot install fails the run. Leave CI lane scheduling to the PR behavioral -gates quest, and do not use retries to make intermittent failures green. +gate selector, and do not use retries to make intermittent failures green. ## Related -- [PR behavioral gates](/quest/m0/pr-behavioral-gates.md) - selects bounded scenarios by changed scope - [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - stores timelines, seeds, and traces - [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - provides Linux execution for the profile diff --git a/test/drill/README.md b/test/drill/README.md index 302840cd3b..4195efa1d1 100644 --- a/test/drill/README.md +++ b/test/drill/README.md @@ -105,6 +105,6 @@ corpus nobody replays. - An impaired path (delay, loss, rate limits). Loopback is the only path these drills see; `quest/m0/transport-impairment-profile.md` adds the Linux network-namespace profile they run under. -- CI lane scheduling, which belongs to `quest/m0/pr-behavioral-gates.md`. +- CI lane scheduling, which belongs to the shared impact map. - Failure bundles beyond what the test harness prints, which belongs to `quest/m0/qa-failure-artifacts.md`. From e9bb0359bd7c38150e6fa989471d581ac156a68d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 09:13:09 -0700 Subject: [PATCH 11/15] ci: cover shared behavioral harness inputs --- .github/scripts/select.sh | 8 +++++--- .github/scripts/select.test.sh | 14 +++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index a2d6fcd46b..6d9628a1ef 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -100,6 +100,7 @@ touches() { # paths. Nothing in the Cargo or bun graph names it, so without this a lock bump # selects no lane at all. shell='^flake\.(nix|lock)$' +harness='^test/lib/harness\.sh$' # The full interop matrix, per the Cross-Package Sync rule in CLAUDE.md: wire, # FFI, and gateway changes run every publisher against every subscriber. @@ -114,7 +115,7 @@ shell='^flake\.(nix|lock)$' if edits moq-net moq-ffi libmoq moq-gst moq-rtmp moq-srt moq-rtc moq-hls || touches '^(py/|pyproject\.toml$|uv\.lock$)' || touches '^go/' || - touches '^(test/smoke/|test/justfile$|package\.json$|bun\.lock$)'; then + touches '^(test/smoke/|test/justfile$|package\.json$|bun\.lock$)' || touches "$harness"; then selected[smoke_full]=true elif reaches moq-relay moq-cli libmoq moq-ffi moq-gst || touches '^(js/|demo/web/)' || touches "$shell"; then @@ -149,14 +150,15 @@ fi # `just js check` to run. if reaches moq-wasm moq-relay || touches '^(js/(wasm|net|signals)/|js/tsconfig\.json$|test/wasm/|\.cargo/config\.toml$)' || - touches '^(package\.json|bun\.lock)$' || touches "$shell"; then + touches '^(package\.json|bun\.lock)$' || touches "$shell" || touches "$harness"; then selected[wasm]=true fi # The MPEG-TS exporter graded against a real analyzer. moq-mux owns the muxer and # moq-cli owns the `export ts` that drives it; TSDuck grades the output and comes # from the dev shell. -if reaches moq-mux moq-cli || touches '^test/ts/' || touches "$shell"; then +if reaches moq-mux moq-cli || touches '^test/ts/' || touches '^test/smoke/smoke\.toml$' || + touches "$shell" || touches "$harness"; then selected[ts]=true fi diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index 7073c5790a..c296574525 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -30,7 +30,8 @@ select_for() { expect() { local files=$1 lane=$2 want=$3 local got - got="$(select_for "$files" | sed -n "s/^$lane=//p")" + select_for "$files" >/dev/null + got="$(sed -n "s/^$lane=//p" <<<"${memo[$files]}")" [[ -n "$got" ]] || fail "no lane named $lane" [[ "$got" == "$want" ]] || fail "$lane=$got for [${files//$'\n'/, }], expected $want" } @@ -123,6 +124,17 @@ expect 'bun.lock' smoke_full true # declarations: the workspace has no `check` script for `just js check` to run. expect 'js/tsconfig.json' wasm true +# Every shell harness sources this process, port, and readiness machinery. A +# change there has to run each consumer rather than relying on their own paths. +shared_harness='test/lib/harness.sh' +expect "$shared_harness" smoke_full true +expect "$shared_harness" smoke false +expect "$shared_harness" wasm true +expect "$shared_harness" ts true + +# The TS round trip rewrites the smoke relay config before launching it. +expect 'test/smoke/smoke.toml' ts true + # Docs cannot change behavior, and this is the case that must finish without # waiting on a lane: it is why the aggregate exists. docs='doc/concept/index.md' From 2b1fed6475f065df7a75ff9582edadf826459864 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 09:31:55 -0700 Subject: [PATCH 12/15] ci: keep pull request gate cancellation stable --- .github/scripts/gates.test.sh | 5 +++++ .github/workflows/gates.yml | 2 +- test/smoke/README.md | 8 +++++--- test/smoke/clients/js/src/setup.ts | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/scripts/gates.test.sh b/.github/scripts/gates.test.sh index dd03326f75..91d43e4583 100755 --- a/.github/scripts/gates.test.sh +++ b/.github/scripts/gates.test.sh @@ -69,4 +69,9 @@ fails '{"select":{"result":"failure","outputs":{}},"smoke":{"result":"skipped"}} fails '{"select":{"result":"success","outputs":{}}}' \ "an empty impact map must fail rather than pass vacuously" +# A merged pull request's closed event has the base branch ref, so the pull +# request number is the stable identity that cancels its still-running jobs. +grep -qF 'group: gates-${{ github.event.pull_request.number }}' "$scripts/../workflows/gates.yml" || + fail "the concurrency group must stay stable across pull request events" + echo "gates: aggregate ok" diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 78a8ea98aa..dda9ead4bc 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -34,7 +34,7 @@ on: types: [opened, synchronize, reopened, closed] concurrency: - group: gates-${{ github.ref }} + group: gates-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/test/smoke/README.md b/test/smoke/README.md index 779ed804d5..909a6a5715 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -170,6 +170,8 @@ clients/ ## CI -`.github/workflows/smoke.yml` runs the full matrix nightly (and on demand, and on -PRs that touch `test/smoke/`). A red cell means a real interop break in the -current tree. +`.github/workflows/smoke.yml` runs the full matrix nightly and on demand. Pull +requests reach it through the `Gates` workflow: the impact map selects the full +matrix for `test/smoke/` and wide interop inputs, or the representative core +matrix for ordinary delivery-path changes. A red cell means a real interop break +in the current tree. diff --git a/test/smoke/clients/js/src/setup.ts b/test/smoke/clients/js/src/setup.ts index 7242ba5b23..268a7ce7ae 100644 --- a/test/smoke/clients/js/src/setup.ts +++ b/test/smoke/clients/js/src/setup.ts @@ -88,6 +88,8 @@ if (role === "publish") { const stray = document.createElement("moq-watch") as MoqWatch; stray.setAttribute("url", url); stray.setAttribute("name", broadcast); + stray.setAttribute("visible", "always"); + stray.appendChild(document.createElement("canvas")); leak.appendChild(stray); }, reattach: () => { From b4b88d6c0da97c08ce49bf9ae77f7cb3ea8ff2cb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 09:49:26 -0700 Subject: [PATCH 13/15] test(ci): quote the workflow expression literally --- .github/scripts/gates.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/gates.test.sh b/.github/scripts/gates.test.sh index 91d43e4583..298e364a02 100755 --- a/.github/scripts/gates.test.sh +++ b/.github/scripts/gates.test.sh @@ -71,7 +71,7 @@ fails '{"select":{"result":"success","outputs":{}}}' \ # A merged pull request's closed event has the base branch ref, so the pull # request number is the stable identity that cancels its still-running jobs. -grep -qF 'group: gates-${{ github.event.pull_request.number }}' "$scripts/../workflows/gates.yml" || +grep -qF "group: gates-\${{ github.event.pull_request.number }}" "$scripts/../workflows/gates.yml" || fail "the concurrency group must stay stable across pull request events" echo "gates: aggregate ok" From 3277bd1cf65c5ff6e88b85a29d1abbf0f997306e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 10:08:24 -0700 Subject: [PATCH 14/15] test(ci): cover shared gate inputs --- .github/scripts/select.sh | 4 ++-- .github/scripts/select.test.sh | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/scripts/select.sh b/.github/scripts/select.sh index 6d9628a1ef..94c9b567e3 100755 --- a/.github/scripts/select.sh +++ b/.github/scripts/select.sh @@ -61,7 +61,7 @@ fi # The gate machinery itself. A pull request that rewrites how lanes are selected # matches no lane's own inputs, so without this it would validate none of them. # Mirrors the root `justfile`'s "orchestration changed, check everything" rule. -if grep -qE '^(\.github/(justfile|scripts/(select|gates)(\.test)?\.sh|workflows/(gates|smoke|wasm)\.yml)|justfile|rs/justfile|test/justfile)$' <<<"$files"; then +if grep -qE '^(\.github/(actions/rust-cache/.*|justfile|scripts/(select|gates)(\.test)?\.sh|workflows/(gates|smoke|wasm)\.yml)|justfile|rs/justfile|test/justfile)$' <<<"$files"; then everything fi @@ -100,7 +100,7 @@ touches() { # paths. Nothing in the Cargo or bun graph names it, so without this a lock bump # selects no lane at all. shell='^flake\.(nix|lock)$' -harness='^test/lib/harness\.sh$' +harness='^test/lib/' # The full interop matrix, per the Cross-Package Sync rule in CLAUDE.md: wire, # FFI, and gateway changes run every publisher against every subscriber. diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index c296574525..02afa43ad2 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -132,6 +132,13 @@ expect "$shared_harness" smoke false expect "$shared_harness" wasm true expect "$shared_harness" ts true +# Port reservation is a separate helper executed by the shared harness. Cover +# the directory so adding or changing another shared helper cannot bypass every +# consumer lane. +expect 'test/lib/reserve.sh' smoke_full true +expect 'test/lib/reserve.sh' wasm true +expect 'test/lib/reserve.sh' ts true + # The TS round trip rewrites the smoke relay config before launching it. expect 'test/smoke/smoke.toml' ts true @@ -147,6 +154,11 @@ done expect '.github/scripts/select.sh' smoke true expect '.github/scripts/select.sh' smoke_full true expect '.github/scripts/select.sh' wasm true +expect '.github/actions/rust-cache/action.yml' smoke true +expect '.github/actions/rust-cache/action.yml' smoke_full true +expect '.github/actions/rust-cache/action.yml' wasm true +expect '.github/actions/rust-cache/action.yml' ts true +expect '.github/actions/rust-cache/action.yml' features true expect 'test/justfile' smoke true expect 'test/justfile' smoke_full true From 4368a155bc7b90f8e1024ab08a153cb4a47da4c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 10:24:40 -0700 Subject: [PATCH 15/15] test(ci): assert every machinery lane --- .github/scripts/select.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/scripts/select.test.sh b/.github/scripts/select.test.sh index 02afa43ad2..9bcbcbd83e 100755 --- a/.github/scripts/select.test.sh +++ b/.github/scripts/select.test.sh @@ -159,6 +159,8 @@ expect '.github/actions/rust-cache/action.yml' smoke_full true expect '.github/actions/rust-cache/action.yml' wasm true expect '.github/actions/rust-cache/action.yml' ts true expect '.github/actions/rust-cache/action.yml' features true +expect '.github/actions/rust-cache/action.yml' windows true +expect '.github/actions/rust-cache/action.yml' macos true expect 'test/justfile' smoke true expect 'test/justfile' smoke_full true