From 2a62fc5165eb420062e82174be81d07c774bae51 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:07:46 +0700 Subject: [PATCH] ci: gate the public API against the published release, and stop the pins drifting Eleven crates are on crates.io at 2.1.0 -- the issue said ten -- and no workflow ran cargo-semver-checks. Removing a `pub fn` from `launchbound-space` shipped without a signal. ## Why the release type is forced Measured, not assumed. Removing `Config::kernel` from launchbound-space while bumping the manifest to 3.0.0 in the same PR: inferred "0 checks: 0 pass, 254 skip" exit 0 --release-type patch "1 major and 0 minor checks failed" exit 100 cargo-semver-checks compares the manifest version to the baseline and runs only the lints that bump would not already excuse. Declare the major bump in the same commit as the break and it has nothing left to say -- the gate ends up agreeing with whatever the PR claims about itself. `patch` means "no API change of any kind is excused", so every lint fires. `patch` does NOT forbid additions: cargo-semver-checks reports breaking changes only. Verified against this branch, which carries #61's `pub use Summary` and #62's `known_capabilities()`: 223 checks, 223 pass. A deliberate break carries the new `breaking` label, which switches the job to `major`. Three of the eleven published crates are binary-only (cli, tui, runner) and have no library API; the eight that do are covered by `--workspace`. cargo-semver-checks arrives as a prebuilt binary through the same install-action the rest of this workflow uses -- never built from source, never restored from a cache. ## The pins were not only cosmetic `[workspace.dependencies]` pinned the path crates at 2.0.0 while `workspace.package.version` was 2.1.0, so `launchbound-prune` 2.1.0 declared compatibility with a `launchbound-space` release it was never built against. Harmless for a path build, which is why it survived the whole 2.1.0 line. Not harmless for the next major: with the pins at 2.0.0, bumping the workspace to 3.0.0 makes `cargo metadata` refuse outright -- failed to select a version for the requirement `launchbound-build = "^2.0.0"` candidate versions found which didn't match: 3.0.0 -- which I hit while building the experiment above. The drift breaks the release after next, not this one, which is the worst kind. `scripts/check-versions.sh` (in `just ci`, beside `check-pins.sh` and bash-3.2-portable for the macOS leg) asserts the workspace version, the eight internal pins, and that no crate sets a literal version of its own. Both failure shapes were exercised: a pin left at 2.0.0, and a crate replacing `version.workspace = true` with a literal. ## docs/RELEASING.md Step 1 now says to bump the pins with the version, and `baseline-version` gets its own step after the publish: left at the old release it compares against a version nobody can install and carries this release's own breaks forward; moved before the publish it names one that does not exist yet. Closes #56 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++ Cargo.toml | 21 +++++++----- docs/RELEASING.md | 22 +++++++++++-- justfile | 9 +++++- scripts/check-versions.sh | 67 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) create mode 100755 scripts/check-versions.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba89d43..5f52b74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,54 @@ jobs: name: termlens-report-ci-${{ matrix.os }} cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh + # The public API against the last published release. + # + # `--release-type` is FORCED, never inferred, and the reason is measured + # rather than assumed. Removing `Config::kernel` from launchbound-space + # while bumping the manifest to 3.0.0 in the same PR: + # + # inferred -> "0 checks: 0 pass, 254 skip", exit 0 + # --release-type patch -> "1 major and 0 minor checks failed", exit 100 + # + # cargo-semver-checks compares the manifest version to the baseline and + # runs only the lints that bump would not already excuse. Declare the major + # bump in the same commit as the break and it has nothing left to say. That + # is the hole: the gate agrees with whatever the PR claims about itself. + # + # `patch` means "no API change of any kind is excused", so every lint + # fires. It does NOT forbid additions -- cargo-semver-checks only reports + # breaking changes, so a new `pub fn` passes (verified: 223 checks, 223 + # pass, with a `pub use` added). + # + # A deliberate break carries the `breaking` label, which switches this to + # `major`. Three of the eleven published crates are binary-only and have no + # library API; the eight that do are all covered by --workspace. + # + # `baseline-version` is a literal, moved in the release PR after publish -- + # left at the old release it would compare against a version nobody can + # install, and moved before publish it would compare against one that does + # not exist yet. + semver: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install pinned toolchain from rust-toolchain.toml + run: rustup show + # A prebuilt binary, downloaded every run: never built from source, and + # never restored from a cache. A gate that reports on whatever a cache + # held the day it was filled is not reporting on this PR. + - uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks + - name: Compare the public API against the published baseline + env: + RELEASE_TYPE: ${{ contains(github.event.pull_request.labels.*.name, 'breaking') && 'major' || 'patch' }} + run: | + echo "release-type: $RELEASE_TYPE" + cargo semver-checks --workspace \ + --baseline-version 2.1.0 \ + --release-type "$RELEASE_TYPE" + # MSRV applies to the crates that do not need the pinned nightly # (CONTRIBUTING.md); checked against the committed lockfile. msrv: diff --git a/Cargo.toml b/Cargo.toml index 9d18737..3ac490b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,14 +33,19 @@ keywords = ["cuda", "gpu", "autotuner", "convergence", "kernel"] categories = ["development-tools", "development-tools::profiling"] [workspace.dependencies] -launchbound-space = { path = "crates/launchbound-space", version = "2.0.0" } -launchbound-prune = { path = "crates/launchbound-prune", version = "2.0.0" } -launchbound-build = { path = "crates/launchbound-build", version = "2.0.0" } -launchbound-bench = { path = "crates/launchbound-bench", version = "2.0.0" } -launchbound-report = { path = "crates/launchbound-report", version = "2.0.0" } -launchbound-search = { path = "crates/launchbound-search", version = "2.0.0" } -launchbound-model = { path = "crates/launchbound-model", version = "2.0.0" } -launchbound-metal = { path = "crates/launchbound-metal", version = "2.0.0" } +# These `version =` pins must equal `workspace.package.version` above. They +# are what a crates.io consumer resolves against, so a stale pin says a +# published crate is compatible with a release it was never built against -- +# harmless for a path build, wrong as a record. They sat at 2.0.0 through the +# whole 2.1.0 line; `just versions` (and a test) now refuse the drift. +launchbound-space = { path = "crates/launchbound-space", version = "2.1.0" } +launchbound-prune = { path = "crates/launchbound-prune", version = "2.1.0" } +launchbound-build = { path = "crates/launchbound-build", version = "2.1.0" } +launchbound-bench = { path = "crates/launchbound-bench", version = "2.1.0" } +launchbound-report = { path = "crates/launchbound-report", version = "2.1.0" } +launchbound-search = { path = "crates/launchbound-search", version = "2.1.0" } +launchbound-model = { path = "crates/launchbound-model", version = "2.1.0" } +launchbound-metal = { path = "crates/launchbound-metal", version = "2.1.0" } anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ff57f3f..eefe649 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -22,9 +22,13 @@ maintainer moving between them is not relearning the process. gh workflow run stress.yml -f iterations=100 gh run watch # ten shards, both OSes -# 1. Bump the version. It appears once per crate plus the workspace pins. -$EDITOR Cargo.toml # version = "X.Y.Z" +# 1. Bump the version. `workspace.package.version` AND the internal +# `version =` pins in [workspace.dependencies] -- both, or `just +# versions` fails. They are not cosmetic: pins left behind a major +# bump make `cargo metadata` refuse to resolve the workspace at all. +$EDITOR Cargo.toml # version = "X.Y.Z", and the eight pins cargo check --workspace # refreshes Cargo.lock +just versions # the two agree # 2. Move the CHANGELOG section: [Unreleased] -> [X.Y.Z] - YYYY-MM-DD, # leaving an empty [Unreleased] above it. @@ -70,6 +74,20 @@ tag had to be pushed by hand. ```sh gh workflow run install.yml ``` +- **Move the semver baseline, in a PR of its own after the publish.** + `baseline-version` in `ci.yml`'s `semver` job is a literal. Left at the old + release it compares every PR against a version nobody can install any more, + and it would also carry this release's own breaks forward as if they were + new. Moved *before* the publish it names a version that does not exist yet + and the job cannot fetch it. So: publish, confirm the index has it, then + bump the literal. + + ```sh + $EDITOR .github/workflows/ci.yml # baseline-version: X.Y.Z + ``` +- **A break needs the `breaking` label on its PR**, which switches the semver + job from `patch` to `major`. Without it the job fails, which is the point; + with it, the release notes owe the reader a migration note. ## What a version number means here diff --git a/justfile b/justfile index 4205d08..de30a3f 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,7 @@ default: ci # "all required jobs green" aggregator job in ci.yml — this recipe is the # aggregator, and the `ci` job runs it verbatim on both OSes — so a new gate # becomes required by being listed here. -ci: fmt-check clippy test deny schemas pins skill +ci: fmt-check clippy test deny schemas pins versions skill # Cargo errors on a memberless virtual workspace, so the cargo recipes no-op # until the first crate lands in S1. `grep -c` prints 1 when packages is empty. @@ -48,6 +48,13 @@ prune cc="8.6": pins: ./scripts/check-pins.sh +# The workspace version, every crate's version, and the internal `version =` +# pins in [workspace.dependencies] agree. The pins sat at 2.0.0 through the +# whole 2.1.0 line: harmless for a path build, wrong as a record, and fatal +# to the next major bump (`cargo metadata` refuses to resolve). +versions: + ./scripts/check-versions.sh + # Golden + JSON Schema validation of report documents (S4). schemas: cargo test -p launchbound-report --test schema_and_golden diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh new file mode 100755 index 0000000..71eb78e --- /dev/null +++ b/scripts/check-versions.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# CI gate: the workspace version, every crate's version, and the internal +# `version =` pins in [workspace.dependencies] all agree. +# +# The pins are what a crates.io consumer resolves against. `launchbound-prune` +# 2.1.0 depending on `launchbound-space = "^2.0.0"` says it works with a +# release it was never built or tested against -- harmless for a path build, +# which is why it went unnoticed through the whole 2.1.0 line, and wrong as a +# record. It is not only cosmetic: a `3.0.0` workspace bump with the pins left +# at `2.0.0` fails `cargo metadata` outright ("candidate versions found which +# didn't match: 3.0.0"), so the drift breaks the next major release rather +# than the current one. +# +# Portability: macOS ships bash 3.2 -- no `declare -A`, no `local -n`, no +# GNU-only sed. (scripts/check-pins.sh, which learned this the hard way.) +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +status=0 + +# `workspace.package.version` -- the one everything else must match. Anchored +# to the section so a dependency's `version =` cannot be read by mistake. +workspace_version=$( + sed -n '/^\[workspace\.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml | head -1 +) +if [ -z "$workspace_version" ]; then + echo "VERSION GATE: could not read workspace.package.version from Cargo.toml" >&2 + exit 1 +fi +echo "workspace.package.version: $workspace_version" + +# The internal path pins. +pins=$(sed -n 's/^\(launchbound-[a-z-]*\) = { path = "[^"]*", version = "\([^"]*\)".*/\1 \2/p' Cargo.toml) +if [ -z "$pins" ]; then + echo "VERSION GATE: no internal version pins found -- has [workspace.dependencies] changed shape?" >&2 + exit 1 +fi + +count=0 +while read -r name pin; do + [ -z "$name" ] && continue + count=$((count + 1)) + if [ "$pin" != "$workspace_version" ]; then + echo "VERSION GATE: $name is pinned at $pin, workspace is $workspace_version" >&2 + status=1 + fi +done <&2 + status=1 + fi +done + +if [ "$status" -eq 0 ]; then + echo "every recorded version agrees" +fi +exit "$status"