From 9d878cc9e28ed6ac7e9a990a366f193556085012 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 21:32:36 -0500 Subject: [PATCH 1/6] ci: build and size-gate every release target, and pin actions to commits A release tag builds six targets and publishes nothing unless every build passes, but CI built only two of them (x86_64 Linux glibc and arm64 macOS), so a target that stopped building surfaced on the release and blocked every install path. The size check only warned, at 8 MiB on one target the binary already filled to about 90%, and every action was referenced by a tag that can be moved to other code. The release-build job now builds each target in [workspace.metadata.dist] with the release profile, on the runner `dist plan` assigns it and with musl-tools for the musl targets, and scripts/check-binary-size.sh fails the job when a binary is over its target's budget: the v2026.10.0 binary's size plus 10%. Every action in ci.yml is pinned to a full commit SHA with its release in a trailing comment; checkout moves to v6.1.0, the major the release workflow already uses. crates/tui/tests/release.rs keeps the matrix equal to the published targets and the manifest's custom runner, the dist profile equal to release, and every action pinned, and checks that the gate passes each target's budget exactly and fails one byte over it. --- .github/workflows/ci.yml | 78 +++++++++++----- Cargo.toml | 4 +- crates/tui/tests/release.rs | 175 +++++++++++++++++++++++++++++++++++ scripts/check-binary-size.sh | 49 ++++++++++ 4 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 crates/tui/tests/release.rs create mode 100755 scripts/check-binary-size.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df43b54c..d1ed5697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ on: env: CARGO_TERM_COLOR: always +# Every action is pinned to a full commit SHA, with its release in a trailing +# comment: a tag can be moved to other code after review, and these jobs run +# with this repository's token, while a commit cannot change under its SHA. +# Bump an action by replacing both. rust-toolchain publishes no releases, so it +# is pinned to a dated master commit and given its toolchain as an input. jobs: test: name: Test (${{ matrix.os }}) @@ -16,13 +21,15 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master, 2026-10-01 + with: + toolchain: stable - name: Cache cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Test run: cargo test --workspace --locked @@ -37,39 +44,66 @@ jobs: # Bumping this is a deliberate commit that carries its own fixes. - name: Clippy toolchain if: matrix.os == 'ubuntu-latest' - uses: dtolnay/rust-toolchain@1.97.0 + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master, 2026-10-01 with: + toolchain: 1.97.0 components: clippy - name: Clippy if: matrix.os == 'ubuntu-latest' run: cargo +1.97.0 clippy --workspace --all-targets --locked -- -D warnings + # A release tag builds every target in Cargo.toml's [workspace.metadata.dist] + # and publishes nothing unless all of them build, so each one is built here, + # on the runner `dist plan` assigns it and with the packages the release + # installs there, and a target that stops building fails the pull request + # that broke it instead of the next release. crates/tui/tests/release.rs + # keeps this matrix equal to the published targets. release-build: - name: Release build (Ubuntu) - runs-on: ubuntu-latest + name: Release build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-15 + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: aarch64-unknown-linux-gnu + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-gnu + runner: ubuntu-22.04 + - target: aarch64-unknown-linux-musl + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + runner: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + # ring compiles C, and a musl target needs musl's C toolchain for it. + - name: Install musl tools + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master, 2026-10-01 + with: + toolchain: stable + targets: ${{ matrix.target }} + # Only main saves these: six targets' caches from every pull request + # would push main's out of the repository's cache space. - name: Cache cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: ${{ matrix.target }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build release binary - run: cargo build --release -p openmax --locked + run: cargo build --release -p openmax --locked --target ${{ matrix.target }} - # Soft gate: warn via workflow annotation, but do not fail the job. + # Hard gate: the job fails when the binary is over its target's budget. + # The budgets, and how to raise one, are in the script. - name: Check binary size - run: | - BIN=target/release/openmax - MAX_BYTES=$((8 * 1024 * 1024)) - SIZE=$(stat -c%s "$BIN") - SIZE_MB=$(awk "BEGIN {printf \"%.2f\", $SIZE / 1024 / 1024}") - echo "Binary size: ${SIZE_MB} MB ($SIZE bytes); soft limit: 8 MB" - if [ "$SIZE" -gt "$MAX_BYTES" ]; then - echo "::warning::openmax binary is ${SIZE_MB} MB (soft limit: 8 MB)" - else - echo "Binary size is within the 8 MB soft limit." - fi + run: scripts/check-binary-size.sh ${{ matrix.target }} target/${{ matrix.target }}/release/openmax diff --git a/Cargo.toml b/Cargo.toml index af605977..be32474e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,9 @@ libc = "0.2" # fails the job because the checked-in workflow no longer matches what it would # generate. The cost is that dist will no longer update the workflow for us, so # bumping cargo-dist-version below means regenerating by hand, re-applying the -# verification step, and re-pinning its hash in the same commit. +# verification step, and re-pinning its hash in the same commit. A bump can +# also move a target to another runner: CI's release-build matrix builds each +# target where `dist plan --output-format=json` assigns it, so re-sync it too. [workspace.metadata.dist] cargo-dist-version = "0.32.0" ci = "github" diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs new file mode 100644 index 00000000..372ba347 --- /dev/null +++ b/crates/tui/tests/release.rs @@ -0,0 +1,175 @@ +//! What CI has to hold for a release tag to ship, checked against the files +//! that define it. A tag builds every target in `[workspace.metadata.dist]`, +//! and one failed build stops the whole release, so each target has to build, +//! and fit its size budget, on the pull request that would break it. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read(path: &str) -> String { + std::fs::read_to_string(repo().join(path)).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +/// The body of one `[header]` table in the workspace Cargo.toml, up to the +/// next table. +fn manifest_table(header: &str) -> String { + let manifest = read("Cargo.toml"); + let start = manifest + .find(&format!("\n[{header}]\n")) + .unwrap_or_else(|| panic!("Cargo.toml has no [{header}]")); + let body = &manifest[start + header.len() + 4..]; + body[..body.find("\n[").unwrap_or(body.len())].to_string() +} + +/// Every target a release tag builds and publishes. +fn dist_targets() -> Vec { + let dist = manifest_table("workspace.metadata.dist"); + let list = dist + .split_once("\ntargets = [") + .and_then(|(_, rest)| rest.split_once(']')) + .expect("[workspace.metadata.dist] lists its targets") + .0; + let targets: Vec = list.split('"').skip(1).step_by(2).map(str::to_string).collect(); + assert!(!targets.is_empty(), "no targets in [workspace.metadata.dist]"); + targets +} + +/// `(target, runner)` for each entry of CI's release-build matrix. +fn ci_release_matrix() -> Vec<(String, String)> { + let mut entries: Vec<(String, String)> = Vec::new(); + for line in read(".github/workflows/ci.yml").lines().map(str::trim) { + if let Some(target) = line.strip_prefix("- target: ") { + entries.push((target.to_string(), String::new())); + } else if let Some(runner) = line.strip_prefix("runner: ") { + if let Some(entry) = entries.last_mut() { + entry.1 = runner.to_string(); + } + } + } + entries +} + +/// CI builds exactly the targets a tag publishes, on the runner the release +/// builds each one on where the manifest chooses it, and gates every binary's +/// size with the profile the published binaries are built with. +#[test] +fn ci_builds_and_size_gates_every_release_target() { + let matrix = ci_release_matrix(); + let mut built: Vec<&str> = matrix.iter().map(|(target, _)| target.as_str()).collect(); + built.sort_unstable(); + let mut published = dist_targets(); + published.sort_unstable(); + assert_eq!(built, published, "CI's release-build matrix is not the set of targets a tag publishes"); + + for line in manifest_table("workspace.metadata.dist.github-custom-runners").lines() { + let Some((target, runner)) = line.split_once(" = ").filter(|_| !line.starts_with('#')) else { + continue; + }; + let runner = runner.trim_matches('"'); + let ci = matrix.iter().find(|(t, _)| t == target).map(|(_, r)| r.as_str()); + assert_eq!(ci, Some(runner), "the release builds {target} on {runner}"); + } + + let gate = "run: scripts/check-binary-size.sh ${{ matrix.target }} target/${{ matrix.target }}/release/openmax"; + assert!( + read(".github/workflows/ci.yml").lines().any(|line| line.trim() == gate), + "the release-build job does not gate each binary's size: `{gate}`" + ); + // The gate measures the release profile; dist publishes with its own + // `dist` profile, so that profile must be release, unchanged. + let dist_profile: Vec = manifest_table("profile.dist") + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect(); + assert_eq!(dist_profile, ["inherits = \"release\""], "[profile.dist] overrides the release profile the gate measures"); +} + +/// The gate passes a binary exactly at its target's budget and fails one a +/// byte over it, for every published target, and fails a target it has no +/// budget for rather than passing it unmeasured. +#[test] +fn the_size_gate_fails_a_binary_over_its_budget() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("openmax-size-gate-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let binary = dir.join("openmax"); + let gate = |target: &str, len: u64| { + // Sparse, so a budget-sized file costs no disk. + std::fs::File::create(&binary).unwrap().set_len(len).unwrap(); + let out = Command::new("sh") + .arg(repo().join("scripts/check-binary-size.sh")) + .arg(target) + .arg(&binary) + .output() + .unwrap(); + let report = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.code(), report) + }; + // The budget is read from the gate's own report, so this pins the + // boundary rather than any particular size. + let budget_in = |report: &str| -> Option { + let end = report.find("-byte budget")?; + let start = report[..end].rfind(|c: char| !c.is_ascii_digit()).map_or(0, |i| i + 1); + report[start..end].parse().ok() + }; + + for target in dist_targets() { + let (code, report) = gate(&target, 0); + assert_eq!(code, Some(0), "{target}: an empty binary fails the gate: {report}"); + let budget = budget_in(&report).unwrap_or_else(|| panic!("{target}: no budget in {report:?}")); + let (code, report) = gate(&target, budget); + assert_eq!(code, Some(0), "{target}: a binary at its {budget}-byte budget fails: {report}"); + let (code, report) = gate(&target, budget + 1); + assert_eq!(code, Some(1), "{target}: a binary over its {budget}-byte budget passes: {report}"); + assert!( + report.contains(&target) && report.contains(&format!("{budget}-byte budget")), + "{target}: the failure does not name the target and its budget: {report}" + ); + } + + let (code, report) = gate("riscv64gc-unknown-linux-gnu", 0); + assert_eq!(code, Some(1), "a target with no budget passes: {report}"); + assert!(report.contains("no size budget for riscv64gc-unknown-linux-gnu"), "{report}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Every action CI runs is pinned to a full commit SHA, with the release it +/// corresponds to in a trailing comment. A tag can be moved to other code +/// after review, and these jobs run with the repository's token; a commit +/// cannot change under its SHA. +#[test] +fn ci_pins_every_action_to_a_commit() { + let ci = read(".github/workflows/ci.yml"); + let actions: Vec<&str> = ci + .lines() + .filter_map(|line| line.trim().trim_start_matches("- ").strip_prefix("uses: ")) + .collect(); + assert!(!actions.is_empty(), "ci.yml runs no actions, so this test pins nothing"); + let unpinned: Vec<&str> = actions + .iter() + .copied() + .filter(|action| { + let Some((_, pin)) = action.split_once('@') else { return true }; + let (sha, version) = pin.split_once(" # ").unwrap_or((pin, "")); + let full_sha = sha.len() == 40 && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + !full_sha || version.trim().is_empty() + }) + .collect(); + assert!( + unpinned.is_empty(), + "actions not pinned to a full commit SHA with their version in a trailing comment: {unpinned:?}" + ); +} diff --git a/scripts/check-binary-size.sh b/scripts/check-binary-size.sh new file mode 100755 index 00000000..b9083dda --- /dev/null +++ b/scripts/check-binary-size.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Fail when a release binary is larger than its target's size budget. +# +# Usage: scripts/check-binary-size.sh +# +# CI runs this on the release-profile build of every target a tag publishes +# (dist builds with a profile that inherits release unchanged), so a +# dependency, feature or profile change that bloats a shipped binary fails the +# pull request that makes it, on the target it bloats. A warning would not: +# nothing stops a binary drifting past a limit that only warns. +# +# Each budget is that target's measured size plus 10% headroom. The sizes are +# the v2026.10.0 release binaries. The headroom absorbs ordinary growth and +# the drift between compiler releases (neither CI nor the release pins one). +# Past it, trim the binary or raise the budget deliberately: replace the +# target's size below with the size this script reports for it, in the commit +# that needs the room, and say there why the binary grew. +set -eu + +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } +target=$1 +binary=$2 + +case "$target" in + aarch64-apple-darwin) measured=7513472 ;; + x86_64-apple-darwin) measured=7606280 ;; + aarch64-unknown-linux-gnu) measured=6748552 ;; + x86_64-unknown-linux-gnu) measured=7570512 ;; + aarch64-unknown-linux-musl) measured=6530928 ;; + x86_64-unknown-linux-musl) measured=7694768 ;; + *) + echo "error: no size budget for $target; add its measured size to $0" >&2 + exit 1 + ;; +esac +budget=$((measured + measured / 10)) + +[ -f "$binary" ] || { echo "error: $binary is not a file" >&2; exit 1; } +size=$(wc -c < "$binary" | tr -d ' ') + +if [ "$size" -gt "$budget" ]; then + # In GitHub Actions the prefix makes the line an error annotation. + echo "${GITHUB_ACTIONS:+::error::}openmax for $target is $size bytes," \ + "$((size - budget)) over its $budget-byte budget" + exit 1 +fi +permille=$((size * 1000 / budget)) +echo "openmax for $target is $size bytes," \ + "$((permille / 10)).$((permille % 10))% of its $budget-byte budget" From 2964a3183934a256370cd9d9bcc173e38755625d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 21:42:59 -0500 Subject: [PATCH 2/6] ci: keep the size gate hard against continue-on-error The test that keeps the release-build size gate hard pinned the gate's run line, which catches a `|| true`, but `continue-on-error: true` on the gate's step or job let an over-budget binary pass the job while every assertion still held, turning the gate back into a warning. The test now fails when any non-comment line of ci.yml sets continue-on-error. The header note on the rust-toolchain pin is also corrected: that action is versioned by branch, and its one release tag, v1, follows master. --- .github/workflows/ci.yml | 5 +++-- crates/tui/tests/release.rs | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ed5697..4555bef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,9 @@ env: # Every action is pinned to a full commit SHA, with its release in a trailing # comment: a tag can be moved to other code after review, and these jobs run # with this repository's token, while a commit cannot change under its SHA. -# Bump an action by replacing both. rust-toolchain publishes no releases, so it -# is pinned to a dated master commit and given its toolchain as an input. +# Bump an action by replacing both. rust-toolchain is versioned by branch +# (stable, 1.97.0, ...), not by release, and its v1 tag follows master, so it is +# pinned to a dated master commit and given its toolchain as an input. jobs: test: name: Test (${{ matrix.os }}) diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs index 372ba347..224f2d57 100644 --- a/crates/tui/tests/release.rs +++ b/crates/tui/tests/release.rs @@ -74,11 +74,15 @@ fn ci_builds_and_size_gates_every_release_target() { assert_eq!(ci, Some(runner), "the release builds {target} on {runner}"); } + let ci = read(".github/workflows/ci.yml"); let gate = "run: scripts/check-binary-size.sh ${{ matrix.target }} target/${{ matrix.target }}/release/openmax"; assert!( - read(".github/workflows/ci.yml").lines().any(|line| line.trim() == gate), + ci.lines().any(|line| line.trim() == gate), "the release-build job does not gate each binary's size: `{gate}`" ); + // On the gate's step or its job, this lets an over-budget binary pass. + let soft = ci.lines().map(str::trim).find(|line| !line.starts_with('#') && line.contains("continue-on-error")); + assert_eq!(soft, None, "a continue-on-error step or job turns the size gate back into a warning"); // The gate measures the release profile; dist publishes with its own // `dist` profile, so that profile must be release, unchanged. let dist_profile: Vec = manifest_table("profile.dist") From 2927cef18435ae942f94c2e9b8a3d6c545880223 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 21:59:05 -0500 Subject: [PATCH 3/6] ci: keep the size gate unconditional in the release-build job The release guard caught a gate that only warns, but not one that never runs: an `if:` on the release-build job, its build step or its gate step skipped the build or the size check on pull requests, and every assertion still passed. A gate moved out of release-build also passed, because the gate line was searched for anywhere in ci.yml. The guard now reads the release-build job on its own, requires the gate step inside it, and fails on any `if:` there except the musl-tools install. The size-gate script's header no longer says every size is a v2026.10.0 release binary, which stops being true after the first raise. --- crates/tui/tests/release.rs | 41 ++++++++++++++++++++++++++++++++---- scripts/check-binary-size.sh | 14 ++++++------ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs index 224f2d57..f5e15441 100644 --- a/crates/tui/tests/release.rs +++ b/crates/tui/tests/release.rs @@ -38,10 +38,26 @@ fn dist_targets() -> Vec { targets } +/// The lines of one job in ci.yml, from its key up to the next key at the +/// jobs' indentation or less (the next job, or the end of `jobs:`). +fn ci_job(name: &str) -> Vec { + let ci = read(".github/workflows/ci.yml"); + let key = format!(" {name}:"); + let mut lines = ci.lines().skip_while(|line| line.trim_end() != key); + assert!(lines.next().is_some(), "ci.yml has no `{name}` job"); + lines + .take_while(|line| { + let body = line.trim_start(); + body.is_empty() || body.starts_with('#') || line.len() - body.len() > 2 + }) + .map(str::to_string) + .collect() +} + /// `(target, runner)` for each entry of CI's release-build matrix. fn ci_release_matrix() -> Vec<(String, String)> { let mut entries: Vec<(String, String)> = Vec::new(); - for line in read(".github/workflows/ci.yml").lines().map(str::trim) { + for line in ci_job("release-build").iter().map(|line| line.trim()) { if let Some(target) = line.strip_prefix("- target: ") { entries.push((target.to_string(), String::new())); } else if let Some(runner) = line.strip_prefix("runner: ") { @@ -55,7 +71,7 @@ fn ci_release_matrix() -> Vec<(String, String)> { /// CI builds exactly the targets a tag publishes, on the runner the release /// builds each one on where the manifest chooses it, and gates every binary's -/// size with the profile the published binaries are built with. +/// size, on every run, with the profile the published binaries are built with. #[test] fn ci_builds_and_size_gates_every_release_target() { let matrix = ci_release_matrix(); @@ -74,13 +90,30 @@ fn ci_builds_and_size_gates_every_release_target() { assert_eq!(ci, Some(runner), "the release builds {target} on {runner}"); } - let ci = read(".github/workflows/ci.yml"); + let job = ci_job("release-build"); let gate = "run: scripts/check-binary-size.sh ${{ matrix.target }} target/${{ matrix.target }}/release/openmax"; assert!( - ci.lines().any(|line| line.trim() == gate), + job.iter().any(|line| line.trim() == gate), "the release-build job does not gate each binary's size: `{gate}`" ); + // A condition on the job, its build step or its gate step skips the gate, + // so the pull request that broke the target passes. Only the musl-tools + // install is conditional. + let conditions: Vec<&str> = job + .iter() + .map(|line| line.trim().trim_start_matches("- ")) + .filter(|line| { + let key = line.split_once(':').map_or("", |(key, _)| key.trim()); + key.trim_matches(|c| c == '"' || c == '\'') == "if" + }) + .collect(); + assert_eq!( + conditions, + ["if: endsWith(matrix.target, '-musl')"], + "an `if:` on the release-build job or its build or gate step skips the gate, which passes the pull request that broke the target" + ); // On the gate's step or its job, this lets an over-budget binary pass. + let ci = read(".github/workflows/ci.yml"); let soft = ci.lines().map(str::trim).find(|line| !line.starts_with('#') && line.contains("continue-on-error")); assert_eq!(soft, None, "a continue-on-error step or job turns the size gate back into a warning"); // The gate measures the release profile; dist publishes with its own diff --git a/scripts/check-binary-size.sh b/scripts/check-binary-size.sh index b9083dda..6efa2310 100755 --- a/scripts/check-binary-size.sh +++ b/scripts/check-binary-size.sh @@ -9,12 +9,14 @@ # pull request that makes it, on the target it bloats. A warning would not: # nothing stops a binary drifting past a limit that only warns. # -# Each budget is that target's measured size plus 10% headroom. The sizes are -# the v2026.10.0 release binaries. The headroom absorbs ordinary growth and -# the drift between compiler releases (neither CI nor the release pins one). -# Past it, trim the binary or raise the budget deliberately: replace the -# target's size below with the size this script reports for it, in the commit -# that needs the room, and say there why the binary grew. +# Each budget is that target's measured size plus 10% headroom. Every size +# started as the target's v2026.10.0 release binary; a raise replaces it with +# a CI measurement, so a size that changed since comes from the commit that +# last changed it. The headroom absorbs ordinary growth and the drift between +# compiler releases (neither CI nor the release pins one). Past it, trim the +# binary or raise the budget deliberately: replace the target's size below +# with the size this script reports for it in that target's CI job, in the +# commit that needs the room, and say there why the binary grew. set -eu [ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } From d26713c04b4b1f3ea61beadc5ec3fa3e23861896 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 22:09:02 -0500 Subject: [PATCH 4/6] ci: check every release-build runner against the release's The release test compared CI's runner with the release's only for targets in github-custom-runners, one of the six. The other five build on runners cargo-dist assigns by default, which no file in the repository records, so a matrix edit that moved one of them, or a cargo-dist bump that moved the release, left CI building that target on another OS image or C library than the release while the test still passed. The test now records the runner cargo-dist 0.32.0 assigns each target by default, as `dist plan --output-format=json` reports it, and checks every matrix entry against its custom runner or that default. It fails when cargo-dist-version changes until the defaults are re-read. --- .github/workflows/ci.yml | 2 +- Cargo.toml | 3 +- crates/tui/tests/release.rs | 56 ++++++++++++++++++++++++++++++------- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4555bef7..501dffd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: # on the runner `dist plan` assigns it and with the packages the release # installs there, and a target that stops building fails the pull request # that broke it instead of the next release. crates/tui/tests/release.rs - # keeps this matrix equal to the published targets. + # keeps this matrix equal to the published targets and their runners. release-build: name: Release build (${{ matrix.target }}) runs-on: ${{ matrix.runner }} diff --git a/Cargo.toml b/Cargo.toml index be32474e..e8e8e42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,8 @@ libc = "0.2" # bumping cargo-dist-version below means regenerating by hand, re-applying the # verification step, and re-pinning its hash in the same commit. A bump can # also move a target to another runner: CI's release-build matrix builds each -# target where `dist plan --output-format=json` assigns it, so re-sync it too. +# target where `dist plan --output-format=json` assigns it, so re-sync it, and +# the default runners crates/tui/tests/release.rs records, too. [workspace.metadata.dist] cargo-dist-version = "0.32.0" ci = "github" diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs index f5e15441..dface551 100644 --- a/crates/tui/tests/release.rs +++ b/crates/tui/tests/release.rs @@ -69,9 +69,27 @@ fn ci_release_matrix() -> Vec<(String, String)> { entries } -/// CI builds exactly the targets a tag publishes, on the runner the release -/// builds each one on where the manifest chooses it, and gates every binary's -/// size, on every run, with the profile the published binaries are built with. +/// The cargo-dist release the runners below were read from. +const DIST_VERSION: &str = "0.32.0"; + +/// The runner `dist plan` assigns each target when the manifest's +/// `github-custom-runners` does not choose one. They live in the dist binary, +/// not in any file here, and a dist bump can move a target to a runner whose +/// OS image or C library differs from the one CI builds it on, so they are +/// recorded here against [`DIST_VERSION`] and re-read from +/// `dist plan --output-format=json` on a bump. +const DIST_DEFAULT_RUNNERS: &[(&str, &str)] = &[ + ("aarch64-apple-darwin", "macos-14"), + ("x86_64-apple-darwin", "macos-15-intel"), + ("aarch64-unknown-linux-gnu", "ubuntu-22.04-arm"), + ("x86_64-unknown-linux-gnu", "ubuntu-22.04"), + ("aarch64-unknown-linux-musl", "ubuntu-22.04-arm"), + ("x86_64-unknown-linux-musl", "ubuntu-22.04"), +]; + +/// CI builds exactly the targets a tag publishes, each on the runner the +/// release builds it on, and gates every binary's size, on every run, with +/// the profile the published binaries are built with. #[test] fn ci_builds_and_size_gates_every_release_target() { let matrix = ci_release_matrix(); @@ -81,13 +99,31 @@ fn ci_builds_and_size_gates_every_release_target() { published.sort_unstable(); assert_eq!(built, published, "CI's release-build matrix is not the set of targets a tag publishes"); - for line in manifest_table("workspace.metadata.dist.github-custom-runners").lines() { - let Some((target, runner)) = line.split_once(" = ").filter(|_| !line.starts_with('#')) else { - continue; - }; - let runner = runner.trim_matches('"'); - let ci = matrix.iter().find(|(t, _)| t == target).map(|(_, r)| r.as_str()); - assert_eq!(ci, Some(runner), "the release builds {target} on {runner}"); + let dist = manifest_table("workspace.metadata.dist"); + let dist_version = dist + .lines() + .find_map(|line| line.strip_prefix("cargo-dist-version = ")) + .map(|version| version.trim_matches('"')); + assert_eq!( + dist_version, + Some(DIST_VERSION), + "cargo-dist changed, and with it maybe the runner a target is released from: re-read DIST_DEFAULT_RUNNERS from `dist plan --output-format=json` and re-sync ci.yml's release-build matrix" + ); + let custom_runners = manifest_table("workspace.metadata.dist.github-custom-runners"); + let custom: Vec<(&str, &str)> = custom_runners + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once(" = ")) + .map(|(target, runner)| (target, runner.trim_matches('"'))) + .collect(); + for (target, runner) in &matrix { + let released = custom + .iter() + .chain(DIST_DEFAULT_RUNNERS) + .find(|(t, _)| t == target) + .map(|(_, r)| *r) + .unwrap_or_else(|| panic!("no release runner recorded for {target}: add the one `dist plan` assigns it to DIST_DEFAULT_RUNNERS")); + assert_eq!(runner, released, "the release builds {target} on {released}, but CI builds it on {runner}"); } let job = ci_job("release-build"); From 50c9e04830558986faf5abf05d780b511192fe3d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 22:20:30 -0500 Subject: [PATCH 5/6] ci: parse Cargo.toml in the release test so no valid entry is skipped The release test matched Cargo.toml as text: a custom runner had to be written `target = "runner"` with single spaces, and a target had to be a double-quoted string. cargo-dist reads any valid TOML, so writing `aarch64-apple-darwin="macos-15"` moved the release to macos-15 while the test skipped the line and compared CI with the recorded default, passing a CI build on macos-14. A single-quoted target was dropped from the published set and the size-gate loop alike, so CI could stop building it unnoticed. The test now parses Cargo.toml with the toml crate the workspace already uses, for the targets, cargo-dist-version, the custom runners and [profile.dist]. A custom runner given as a table, which can also set a host or container that ci.yml does not mirror, fails the test instead of passing on its runner name. --- Cargo.lock | 1 + crates/tui/Cargo.toml | 2 + crates/tui/tests/release.rs | 80 +++++++++++++++++++------------------ 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f697ea42..4906395e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml", "unicode-segmentation", "unicode-width", ] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 9df2b107..5ec2a731 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -39,3 +39,5 @@ ignore.workspace = true # The CLI tests give a child a pseudo-terminal on stdin, which is how an # interactive shell runs openmax. libc.workspace = true +# The release test reads Cargo.toml the way cargo-dist does, as TOML. +toml.workspace = true diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs index dface551..4bb527dc 100644 --- a/crates/tui/tests/release.rs +++ b/crates/tui/tests/release.rs @@ -14,26 +14,31 @@ fn read(path: &str) -> String { std::fs::read_to_string(repo().join(path)).unwrap_or_else(|e| panic!("{path}: {e}")) } -/// The body of one `[header]` table in the workspace Cargo.toml, up to the -/// next table. -fn manifest_table(header: &str) -> String { - let manifest = read("Cargo.toml"); - let start = manifest - .find(&format!("\n[{header}]\n")) - .unwrap_or_else(|| panic!("Cargo.toml has no [{header}]")); - let body = &manifest[start + header.len() + 4..]; - body[..body.find("\n[").unwrap_or(body.len())].to_string() +/// One table of the workspace Cargo.toml, by its dotted path. Parsed as TOML +/// rather than matched as text: cargo-dist reads every valid spelling of an +/// entry (any spacing, quoting or comment), and one this test failed to match +/// would be skipped without a failure. +fn manifest_table(path: &str) -> toml::Table { + let mut table: toml::Table = read("Cargo.toml").parse().unwrap_or_else(|e| panic!("Cargo.toml: {e}")); + for key in path.split('.') { + table = match table.remove(key) { + Some(toml::Value::Table(inner)) => inner, + _ => panic!("Cargo.toml has no [{path}]"), + }; + } + table } /// Every target a release tag builds and publishes. fn dist_targets() -> Vec { let dist = manifest_table("workspace.metadata.dist"); - let list = dist - .split_once("\ntargets = [") - .and_then(|(_, rest)| rest.split_once(']')) + let targets: Vec = dist + .get("targets") + .and_then(toml::Value::as_array) .expect("[workspace.metadata.dist] lists its targets") - .0; - let targets: Vec = list.split('"').skip(1).step_by(2).map(str::to_string).collect(); + .iter() + .map(|target| target.as_str().expect("each dist target is a string").to_string()) + .collect(); assert!(!targets.is_empty(), "no targets in [workspace.metadata.dist]"); targets } @@ -100,29 +105,27 @@ fn ci_builds_and_size_gates_every_release_target() { assert_eq!(built, published, "CI's release-build matrix is not the set of targets a tag publishes"); let dist = manifest_table("workspace.metadata.dist"); - let dist_version = dist - .lines() - .find_map(|line| line.strip_prefix("cargo-dist-version = ")) - .map(|version| version.trim_matches('"')); assert_eq!( - dist_version, + dist.get("cargo-dist-version").and_then(toml::Value::as_str), Some(DIST_VERSION), "cargo-dist changed, and with it maybe the runner a target is released from: re-read DIST_DEFAULT_RUNNERS from `dist plan --output-format=json` and re-sync ci.yml's release-build matrix" ); - let custom_runners = manifest_table("workspace.metadata.dist.github-custom-runners"); - let custom: Vec<(&str, &str)> = custom_runners - .lines() - .filter(|line| !line.starts_with('#')) - .filter_map(|line| line.split_once(" = ")) - .map(|(target, runner)| (target, runner.trim_matches('"'))) - .collect(); + let custom = dist + .get("github-custom-runners") + .map(|runners| runners.as_table().expect("github-custom-runners is a table of target = runner")); for (target, runner) in &matrix { - let released = custom - .iter() - .chain(DIST_DEFAULT_RUNNERS) - .find(|(t, _)| t == target) - .map(|(_, r)| *r) - .unwrap_or_else(|| panic!("no release runner recorded for {target}: add the one `dist plan` assigns it to DIST_DEFAULT_RUNNERS")); + let released = match custom.and_then(|runners| runners.get(target)) { + // A table here also sets a host or container, which ci.yml does + // not mirror, so it fails rather than passing on the runner alone. + Some(custom) => custom + .as_str() + .unwrap_or_else(|| panic!("{target}'s custom runner is not a runner name, which ci.yml cannot mirror: {custom:?}")), + None => DIST_DEFAULT_RUNNERS + .iter() + .find(|(t, _)| t == target) + .map(|(_, r)| *r) + .unwrap_or_else(|| panic!("no release runner recorded for {target}: add the one `dist plan` assigns it to DIST_DEFAULT_RUNNERS")), + }; assert_eq!(runner, released, "the release builds {target} on {released}, but CI builds it on {runner}"); } @@ -154,13 +157,12 @@ fn ci_builds_and_size_gates_every_release_target() { assert_eq!(soft, None, "a continue-on-error step or job turns the size gate back into a warning"); // The gate measures the release profile; dist publishes with its own // `dist` profile, so that profile must be release, unchanged. - let dist_profile: Vec = manifest_table("profile.dist") - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(str::to_string) - .collect(); - assert_eq!(dist_profile, ["inherits = \"release\""], "[profile.dist] overrides the release profile the gate measures"); + let release = toml::Table::from_iter([("inherits".to_string(), toml::Value::from("release"))]); + assert_eq!( + manifest_table("profile.dist"), + release, + "[profile.dist] overrides the release profile the gate measures" + ); } /// The gate passes a binary exactly at its target's budget and fails one a From 73ae0ccf081ec5110c2f8f78ee826a325ead05a1 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 22:30:29 -0500 Subject: [PATCH 6/6] ci: accept a custom runner written as a table that sets only its runner cargo-dist reads `target = { runner = "macos-15" }` exactly as it reads `target = "macos-15"`: same runner, same inferred host, no container. The release test failed any custom runner given as a table, so a maintainer who wrote that valid form got a release-test failure even though CI builds the target on the release's runner. The test now reads the runner from either form and still fails an entry that sets `host` or `container`, which can change how the release builds and which ci.yml does not mirror, or one that names no runner. --- crates/tui/tests/release.rs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/tui/tests/release.rs b/crates/tui/tests/release.rs index 4bb527dc..3bb58892 100644 --- a/crates/tui/tests/release.rs +++ b/crates/tui/tests/release.rs @@ -92,6 +92,30 @@ const DIST_DEFAULT_RUNNERS: &[(&str, &str)] = &[ ("x86_64-unknown-linux-musl", "ubuntu-22.04"), ]; +/// The runner a `github-custom-runners` entry releases `target` on. +/// cargo-dist reads a runner name and a table that sets only `runner` the +/// same way, and ignores keys it does not know. A `host` or `container` can +/// also change how the release builds (a cross-compile, or another image and C +/// library), which ci.yml does not mirror, so an entry that sets either fails +/// rather than passing on its runner alone. +fn custom_runner<'a>(target: &str, entry: &'a toml::Value) -> &'a str { + let runner = match entry { + toml::Value::Table(table) => { + for key in ["host", "container"] { + assert!( + !table.contains_key(key), + "{target}'s custom runner sets `{key}`, which ci.yml does not mirror: {entry:?}" + ); + } + table.get("runner") + } + name => Some(name), + }; + runner + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("{target}'s custom runner names no runner: {entry:?}")) +} + /// CI builds exactly the targets a tag publishes, each on the runner the /// release builds it on, and gates every binary's size, on every run, with /// the profile the published binaries are built with. @@ -115,11 +139,7 @@ fn ci_builds_and_size_gates_every_release_target() { .map(|runners| runners.as_table().expect("github-custom-runners is a table of target = runner")); for (target, runner) in &matrix { let released = match custom.and_then(|runners| runners.get(target)) { - // A table here also sets a host or container, which ci.yml does - // not mirror, so it fails rather than passing on the runner alone. - Some(custom) => custom - .as_str() - .unwrap_or_else(|| panic!("{target}'s custom runner is not a runner name, which ci.yml cannot mirror: {custom:?}")), + Some(entry) => custom_runner(target, entry), None => DIST_DEFAULT_RUNNERS .iter() .find(|(t, _)| t == target)