diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df43b54c..501dffd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,12 @@ 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 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 }}) @@ -16,13 +22,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 +45,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 and their runners. 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.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/Cargo.toml b/Cargo.toml index af605977..e8e8e42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,10 @@ 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, 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/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 new file mode 100644 index 00000000..3bb58892 --- /dev/null +++ b/crates/tui/tests/release.rs @@ -0,0 +1,270 @@ +//! 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}")) +} + +/// 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 targets: Vec = dist + .get("targets") + .and_then(toml::Value::as_array) + .expect("[workspace.metadata.dist] lists its targets") + .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 +} + +/// 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 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: ") { + if let Some(entry) = entries.last_mut() { + entry.1 = runner.to_string(); + } + } + } + entries +} + +/// 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"), +]; + +/// 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. +#[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"); + + let dist = manifest_table("workspace.metadata.dist"); + assert_eq!( + 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 = 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 = match custom.and_then(|runners| runners.get(target)) { + Some(entry) => custom_runner(target, entry), + 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}"); + } + + let job = ci_job("release-build"); + let gate = "run: scripts/check-binary-size.sh ${{ matrix.target }} target/${{ matrix.target }}/release/openmax"; + assert!( + 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 + // `dist` profile, so that profile must be release, unchanged. + 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 +/// 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..6efa2310 --- /dev/null +++ b/scripts/check-binary-size.sh @@ -0,0 +1,51 @@ +#!/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. 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; } +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"