From c0ced3a32c4567f45ffacc785a03508435575aa2 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:17:34 +0000 Subject: [PATCH 1/3] chore: gate pull requests on stripped binary size Adds tasks/size.sh and a size workflow comparing stripped release builds of the usage CLI and the mise-sized derived CLI against the merge base. Fails above 1%. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/size.yml | 56 ++++++++++++++++++++ AGENTS.md | 7 ++- mise.toml | 6 +++ tasks/size.sh | 104 +++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/size.yml create mode 100755 tasks/size.sh diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml new file mode 100644 index 000000000..fcbd4ef32 --- /dev/null +++ b/.github/workflows/size.yml @@ -0,0 +1,56 @@ +name: size + +# Stripped release sizes of the `usage` CLI and a mise-sized derived CLI, against the commit +# this pull request would merge onto. Fails when a gated binary grows by more than the +# threshold in `tasks/size.sh`, which says what is measured and why. +# +# A hosted runner, unlike perf-pr: size does not depend on the machine, so there is nothing +# for a dedicated one to hold still. The checkout is GitHub's merge of the pull request into +# its base, so the merge base is the base branch's tip and the difference is this change alone. +# +# Never add `continue-on-error`, raise the threshold, or drop a binary to get a pull request +# through — see "Performance Gate" in AGENTS.md. + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + +jobs: + size: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # `tasks/size.sh` builds the merge base from a worktree. + fetch-depth: 0 + persist-credentials: false + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # zizmor: ignore[cache-poisoning] + with: + shared-key: size + # The report reaches the job summary whether or not the gate passed, and the exit + # status is raised afterwards, so a failure always arrives with the table explaining it. + - name: Compare binary sizes against the base + env: + BASE_REF: origin/${{ github.base_ref }} + run: | + set +e + ./tasks/size.sh "$BASE_REF" /tmp/size-report.md + status=$? + set -e + if [ -s /tmp/size-report.md ]; then + tee -a "$GITHUB_STEP_SUMMARY" /dev/null || true; rm -rf "$tmp"' EXIT + +# One target directory for both builds, so crates.io dependencies compile once. Workspace +# crates still build twice: cargo keys them by path, and the base lives in a worktree. +export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$PWD/target/size} + +# `parse-usage` is mise's whole CLI through the derive, which is the size an adopter's binary +# pays for usage. `parse-none` is the same program without the parse, subtracted so the gate +# measures usage's share rather than std's. `parse-clap` is context and never gated. +BINS=(parse-none parse-usage parse-clap usage) + +build() { + local src=$1 dest=$2 + (cd "$src" && cargo build --release --locked -q -p gate -p usage-cli \ + --bin parse-none --bin parse-usage --bin parse-clap --bin usage) + mkdir -p "$dest" + for bin in "${BINS[@]}"; do + cp "$CARGO_TARGET_DIR/release/$bin" "$dest/$bin" + strip "$dest/$bin" + done +} + +bytes() { wc -c <"$1" | tr -d ' '; } + +git worktree add -q --detach "$tmp/src" "$base" +build "$tmp/src" "$tmp/base" +build . "$tmp/head" + +# The shadow is a fixture that grows on purpose (see tak.toml): a pull request that +# regenerates it changes what `parse-usage` is, so its size is reported but not gated. +fixture_changed=false +if ! git diff --quiet "$base" -- benches/mise.usage.kdl benches/shadows/mise; then + fixture_changed=true +fi + +failed=false +row() { + local label=$1 base_bytes=$2 head_bytes=$3 gated=$4 + local delta=$((head_bytes - base_bytes)) pct verdict + pct=$(awk -v b="$base_bytes" -v d="$delta" 'BEGIN { printf "%+.2f", b ? 100 * d / b : 0 }') + verdict=reported + if [ "$gated" = true ]; then + verdict=ok + if awk -v p="$pct" -v g="$GATE_PCT" 'BEGIN { exit !(p > g) }'; then + verdict="**over ${GATE_PCT}%**" + failed=true + fi + fi + printf '| %s | %s | %s | %+'"'"'d | %s%% | %s |\n' \ + "$label" "$(printf "%'d" "$base_bytes")" "$(printf "%'d" "$head_bytes")" "$delta" "$pct" "$verdict" +} + +size_of() { bytes "$tmp/$1/$2"; } +share() { echo $(($(size_of "$1" parse-usage) - $(size_of "$1" parse-none))); } + +shadow_gated=true +[ "$fixture_changed" = true ] && shadow_gated=false + +report=$tmp/report.md +# The backticks below are markdown, not command substitution. +# shellcheck disable=SC2016 +{ + printf '### Binary size\n\n' + printf 'Stripped release builds, `%s` against the merge base `%s`. ' "$(git rev-parse --short HEAD)" "$(git rev-parse --short "$base")" + printf 'Gated rows fail above +%s%%.\n\n' "$GATE_PCT" + printf '| binary | base | head | change | %% | gate |\n' + printf '|---|---:|---:|---:|---:|---|\n' + row '`usage` CLI' "$(size_of base usage)" "$(size_of head usage)" true + row 'derived mise CLI, usage'"'"'s share' "$(share base)" "$(share head)" "$shadow_gated" + row 'derived mise CLI, whole binary' "$(size_of base parse-usage)" "$(size_of head parse-usage)" false + row 'clap mise CLI, whole binary' "$(size_of base parse-clap)" "$(size_of head parse-clap)" false + printf '\n' + printf '"usage'"'"'s share" is `parse-usage` minus `parse-none`: the same program with and without the parse.\n' + if [ "$fixture_changed" = true ]; then + printf '\nThis change regenerates the mise shadow, so the derived CLI is reported rather than gated: its size moved with the fixture.\n' + fi +} >"$report" + +cat "$report" >"$out" +[ "$failed" = false ] From b8d93bdebbaa20a17f6eebfe8b53d437a098c2d1 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:28:35 +0000 Subject: [PATCH 2/3] chore: harden the size gate's base, target dir, rounding and toolchain Co-Authored-By: Claude Opus 5.5 --- .github/workflows/size.yml | 9 ++++++++- tasks/size.sh | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index fcbd4ef32..4118a26f4 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -35,6 +35,11 @@ jobs: # `tasks/size.sh` builds the merge base from a worktree. fetch-depth: 0 persist-credentials: false + # Named rather than whatever the image ships, so the build does not fail on a + # dependency newer than the preinstalled compiler. Both sides use the same one, which + # is all the comparison needs. + - name: Install Rust + run: rustup toolchain install stable --profile minimal --no-self-update && rustup default stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # zizmor: ignore[cache-poisoning] with: shared-key: size @@ -42,7 +47,9 @@ jobs: # status is raised afterwards, so a failure always arrives with the table explaining it. - name: Compare binary sizes against the base env: - BASE_REF: origin/${{ github.base_ref }} + # From the pull request payload: perf-pr found the generic `github.base_ref` + # naming main for a reopened stacked pull request. + BASE_REF: origin/${{ github.event.pull_request.base.ref }} run: | set +e ./tasks/size.sh "$BASE_REF" /tmp/size-report.md diff --git a/tasks/size.sh b/tasks/size.sh index 319b50ffb..a6226247b 100755 --- a/tasks/size.sh +++ b/tasks/size.sh @@ -26,7 +26,11 @@ trap 'git worktree remove --force "$tmp/src" 2>/dev/null || true; rm -rf "$tmp"' # One target directory for both builds, so crates.io dependencies compile once. Workspace # crates still build twice: cargo keys them by path, and the base lives in a worktree. -export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$PWD/target/size} +# Absolute, because the base builds from inside the worktree: a relative path would put its +# binaries under the worktree while `build` copies them from here. +CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-target/size} +case $CARGO_TARGET_DIR in /*) ;; *) CARGO_TARGET_DIR=$PWD/$CARGO_TARGET_DIR ;; esac +export CARGO_TARGET_DIR # `parse-usage` is mise's whole CLI through the derive, which is the size an adopter's binary # pays for usage. `parse-none` is the same program without the parse, subtracted so the gate @@ -35,6 +39,9 @@ BINS=(parse-none parse-usage parse-clap usage) build() { local src=$1 dest=$2 + # Both checkouts link to the same paths. Removing them first means a build that failed to + # relink stops `cp` here, rather than handing one checkout's binary to the other. + for bin in "${BINS[@]}"; do rm -f "$CARGO_TARGET_DIR/release/$bin"; done (cd "$src" && cargo build --release --locked -q -p gate -p usage-cli \ --bin parse-none --bin parse-usage --bin parse-clap --bin usage) mkdir -p "$dest" @@ -65,7 +72,8 @@ row() { verdict=reported if [ "$gated" = true ]; then verdict=ok - if awk -v p="$pct" -v g="$GATE_PCT" 'BEGIN { exit !(p > g) }'; then + # Against the exact ratio, not the rounded `pct`: +1.004% prints as +1.00% and still fails. + if awk -v b="$base_bytes" -v d="$delta" -v g="$GATE_PCT" 'BEGIN { exit !(b > 0 && 100 * d > g * b) }'; then verdict="**over ${GATE_PCT}%**" failed=true fi From b80d2cb4837feee78e0a6667c1b3f492ae8edff1 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:30:12 +0000 Subject: [PATCH 3/3] chore: build the size gate with the runner's own Rust Co-Authored-By: Claude Opus 5.5 --- .github/workflows/size.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 4118a26f4..0b677b7c1 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -35,11 +35,6 @@ jobs: # `tasks/size.sh` builds the merge base from a worktree. fetch-depth: 0 persist-credentials: false - # Named rather than whatever the image ships, so the build does not fail on a - # dependency newer than the preinstalled compiler. Both sides use the same one, which - # is all the comparison needs. - - name: Install Rust - run: rustup toolchain install stable --profile minimal --no-self-update && rustup default stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # zizmor: ignore[cache-poisoning] with: shared-key: size