From 0e0a96080c8e9cb81fa272fa8beb1582cea788e4 Mon Sep 17 00:00:00 2001 From: Steven Carter Date: Sat, 25 Jul 2026 19:05:44 -0400 Subject: [PATCH 1/4] ci: fail a PR whose Cargo.lock is stale release.yml builds with `--locked`, but `just test`/`just lint` do not, so a stale lock passed every PR check and first failed at tag-push time -- the one moment it is most expensive to discover. Add a `just check-lock` recipe covering all six committed lockfiles (the workspace plus each of the five excluded plugins/*, which the root cargo commands never see) and run it in ci.yml's test job ahead of `just test`. Kept as its own recipe rather than bolting `--locked` onto test/lint so a local `just test` keeps auto-refreshing the lock as it does today. It uses `cargo metadata`, which resolves without compiling, so the gate costs about a second. Verified it actually catches something, not just that it passes: temporarily adding an unlocked dependency to a workspace member and, separately, to plugins/counter makes the recipe fail in both cases. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG --- .github/workflows/ci.yml | 4 ++++ justfile | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f3cd7..42ccbfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: extractions/setup-just@v3 + # `release.yml` builds with `--locked`; nothing else does. Without this a + # stale Cargo.lock passes every PR check and first fails at tag-push time. + # Covers all six committed locks (workspace + the excluded plugins). + - run: just check-lock - run: just test plugins: diff --git a/justfile b/justfile index 0f85acb..0b0fd36 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,24 @@ build: test: cargo test --workspace +# Verify every committed Cargo.lock is up to date (workspace + the five +# excluded example plugins, which each carry their own lock). +# +# `release.yml` builds with `--locked`, but `just test`/`just lint` do not -- +# so without this gate a stale lock passes every PR check and only fails at +# tag-push time, the one moment it is most expensive to discover. Kept as its +# own recipe rather than adding `--locked` to `test`/`lint` so local runs keep +# their normal auto-refresh behaviour; `cargo metadata` resolves the graph +# without compiling, so this costs seconds. +check-lock: + #!/usr/bin/env bash + set -euo pipefail + cargo metadata --locked --format-version 1 --manifest-path Cargo.toml >/dev/null + for p in {{plugins}}; do + cargo metadata --locked --format-version 1 \ + --manifest-path "plugins/$p/Cargo.toml" >/dev/null + done + # Build the weather plugin and run the end-to-end WASM host tests (opt-in) test-wasm: build-weather cargo test -p rustline-wasm --features wasm-e2e --test e2e From abd1c08f649e74ce2531e27b0ee39b754197f97b Mon Sep 17 00:00:00 2001 From: Steven Carter Date: Sat, 25 Jul 2026 19:06:04 -0400 Subject: [PATCH 2/4] ci(release): refuse a tag that disagrees with the built binary Tarballs are named from GITHUB_REF_NAME while the binary reports CARGO_PKG_VERSION, and nothing connected the two -- a `v0.2.0` tag would happily ship a binary that identifies itself as 0.1.0, under a filename claiming otherwise. Add a guard between Build and Package on every leg. It asks the freshly built binary (`rustline --version`) instead of re-reading Cargo.toml, so it checks the artifact actually being shipped; that costs nothing, since Package already runs the same binary to emit shell completions and every target is native to its runner. Gated on `github.ref_type == 'tag'`. A workflow_dispatch dry run is on a branch, whose name is not a version, so an ungated check would fail every dry run -- confirmed by running the step's shell body locally against a branch-shaped ref, alongside the matching and mismatching tag cases. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcdc9c4..29778cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,29 @@ jobs: - name: Build run: cargo build --release --locked --target ${{ matrix.target }} -p rustline + # Nothing else ties the pushed tag to the version compiled into the + # binary: tarballs are named from GITHUB_REF_NAME while the binary + # reports CARGO_PKG_VERSION, so a `v0.2.0` tag would happily ship a + # binary that identifies itself as 0.1.0. Ask the freshly built binary + # what it thinks it is rather than re-reading Cargo.toml -- that checks + # the artifact actually being shipped, and every target is native to its + # runner (the Package step below already runs it to emit completions). + # + # Only meaningful on a tag: a `workflow_dispatch` dry run is on a branch, + # whose name is not a version, so it is skipped rather than failed. + - name: Verify tag matches binary version + if: github.ref_type == 'tag' + shell: bash + run: | + set -euo pipefail + bin="target/${{ matrix.target }}/release/rustline" + binary_version="$("$bin" --version | awk '{print $2}')" + if [ "${GITHUB_REF_NAME}" != "v${binary_version}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match binary version ${binary_version} (expected tag v${binary_version}); bump [workspace.package] version in Cargo.toml or retag" + exit 1 + fi + echo "tag ${GITHUB_REF_NAME} matches binary version ${binary_version}" + - name: Package shell: bash run: | From 097d56f167b84b9c5362bbeb97db6a7c6b70824a Mon Sep 17 00:00:00 2001 From: Steven Carter Date: Sat, 25 Jul 2026 19:06:28 -0400 Subject: [PATCH 3/4] ci: pin actions to commit SHAs and add dependabot Every action was on a mutable major tag (@v4, @v2) and dtolnay/rust-toolchain on a *branch*, so upstream could change under us -- and release.yml runs with `contents: write`, which is where that matters. Pin all seven to full commit SHAs in both workflows, each carrying a trailing `# vX.Y.Z` comment, and add .github/dependabot.yml so the pins get refreshed by PR rather than silently rotting into frozen unpatched versions. Dependabot is deliberately github-actions only: Rust bumps go through /upgrade-packages, and cargo PRs would bury the action refreshes this exists to surface. Two things worth recording about the pins themselves: dtolnay/rust-toolchain's ref is not merely a version, it selects the toolchain -- each branch (stable, nightly, 1.90.0) ships an action.yml whose `toolchain` input defaults to that branch's own name. Its pin is therefore the tip of the `stable` branch, verified by reading action.yml at that SHA and confirming the default is `stable`. Repointing it at another branch's commit would silently change the Rust version with nothing in the diff to say so. It publishes no release tags, so dependabot cannot bump that one. Swatinem/rust-cache is pinned to the v2.9.1 release commit rather than to what the floating v2 tag resolved to; v2 sat exactly one changelog-only commit ahead of the release, so this is functionally identical and makes the comment true. Note this pins the action *code*, not the Rust version: `stable` still resolves at job time, so a new stable release can still redden CI with no repo change. Addressing that would need a rust-toolchain.toml, which CLAUDE.md rules out because it would change every developer's local toolchain resolution. Each pinned SHA was verified against the version its comment claims via the GitHub API. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG --- .github/dependabot.yml | 25 ++++++++++++++++++++ .github/workflows/ci.yml | 44 ++++++++++++++++++++++------------- .github/workflows/release.yml | 23 +++++++++++++----- 3 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..28c5692 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# Keeps the SHA-pinned actions in .github/workflows/* from rotting. +# +# Pinning by commit SHA is what stops an upstream action changing under us +# (release.yml runs with `contents: write`), but a pin no one refreshes is just +# a frozen, unpatched version. Dependabot opens a PR when a newer release +# exists, rewriting both the SHA and its trailing `# vX.Y.Z` comment. +# +# Deliberately github-actions only -- no `cargo` ecosystem entry. Rust +# dependency bumps are handled deliberately via the `/upgrade-packages` +# workflow described in CLAUDE.md, and a stream of cargo PRs here would bury +# the action-pin refreshes this file exists to surface. +version: 2 +updates: + - package-ecosystem: github-actions + # "/" means .github/workflows/, not the repo root -- this is the path + # convention the github-actions ecosystem expects. + directory: "/" + schedule: + interval: weekly + commit-message: + prefix: ci + labels: + - dependencies + # One PR per action keeps an unrelated breakage from blocking the others. + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ccbfe..79fa15d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,17 @@ name: CI +# Actions are pinned to full commit SHAs (the trailing comment records the +# readable version); `.github/dependabot.yml` refreshes them by PR. +# +# dtolnay/rust-toolchain is the exception worth knowing about: its ref is not +# just a version, it *selects the toolchain* -- each branch (`stable`, +# `nightly`, `1.90.0`) ships an action.yml whose `toolchain` input defaults to +# that branch's own name. The SHA below is the tip of the `stable` branch, so +# it still installs stable Rust; repointing it at a commit from any other +# branch would silently change the Rust version with nothing else in the diff +# to show it. It publishes no release tags, so dependabot cannot bump it -- +# refresh that one by hand. + on: pull_request: push: @@ -18,12 +30,12 @@ jobs: name: fmt + clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch tip with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3.1.0 # `just lint` is the same command CLAUDE.md/README.md tell a developer # to run locally, so the gate and the local check cannot drift. - run: just lint @@ -32,10 +44,10 @@ jobs: name: workspace tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch tip + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3.1.0 # `release.yml` builds with `--locked`; nothing else does. Without this a # stale Cargo.lock passes every PR check and first fails at tag-push time. # Covers all six committed locks (workspace + the excluded plugins). @@ -46,13 +58,13 @@ jobs: name: example plugins runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch tip with: components: rustfmt, clippy targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3.1.0 # plugins/* are excluded workspace members — invisible to the jobs above. - run: just lint-plugins - run: just test-plugins @@ -61,12 +73,12 @@ jobs: name: wasm end-to-end runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch tip with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3.1.0 # Builds weather.wasm, then runs both opt-in suites — the only coverage of # the Extism host boundary. Both bind localhost mock servers; no network. - run: just test-wasm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29778cc..3be97cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,16 @@ name: Release +# Actions are pinned to full commit SHAs (the trailing comment records the +# readable version); `.github/dependabot.yml` refreshes them by PR. This +# workflow runs with `contents: write`, so it is the one where an upstream +# action changing under us actually matters. +# +# dtolnay/rust-toolchain's ref *selects the toolchain* -- each branch +# (`stable`, `nightly`, `1.90.0`) ships an action.yml defaulting `toolchain` to +# that branch's name -- so the SHA below is the tip of the `stable` branch. +# Repointing it at another branch's commit would silently change the Rust +# version. It has no release tags, so dependabot cannot bump it. + on: push: tags: ['v*'] @@ -38,13 +49,13 @@ jobs: # from source — an aarch64 binary will not run there. Revisit if a # supported Intel runner label reappears. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch tip with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: ${{ matrix.target }} @@ -110,7 +121,7 @@ jobs: tar -C dist -czf "dist/${stage}.tar.gz" "${stage}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: rustline-${{ matrix.target }} path: dist/*.tar.gz @@ -121,7 +132,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: artifacts merge-multiple: true @@ -139,7 +150,7 @@ jobs: # other step above (build, package, checksum) still runs and can be # inspected via the uploaded artifacts, so the whole pipeline gets # exercised without creating a real GitHub release. - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') with: files: | From e42f104b012975d6a30def35c128fc6b2d829de8 Mon Sep 17 00:00:00 2001 From: Steven Carter Date: Sat, 25 Jul 2026 19:06:39 -0400 Subject: [PATCH 4/4] docs: record the lock gate, tag guard, and action-pin conventions CLAUDE.md's Development section described the CI/release workflows in enough detail that all three changes on this branch would have left it stale. - `just check-lock` added to the recipe list and to the "commit Cargo.lock alongside any dependency change" bullet, which now has a gate behind it. - The CI paragraph notes the test job runs check-lock ahead of just test. - A new bullet covers the SHA pins, dependabot's github-actions-only scope, the dtolnay/rust-toolchain ref-selects-the-toolchain wrinkle, and the fact that pinning fixes action code but not the Rust version. - The release roadmap entry documents the tag/version guard and why it is gated on ref_type == 'tag'. README gains check-lock in its recipe list, framed around what it catches. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG --- CLAUDE.md | 45 ++++++++++++++++++++++++++++++++++++++++----- README.md | 5 ++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35c028a..a622c4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2472,7 +2472,12 @@ branch on platform. ## Development - **`just`** recipes: `just build`, `just test` (hermetic — no wasm toolchain - needed), `just lint`, `just preview` (colour preview via `cargo run --`, live + needed), `just lint`, `just check-lock` (verifies all **six** committed + `Cargo.lock` files — the workspace plus each of the five excluded + `plugins/*` — are up to date, via `cargo metadata --locked`, which resolves + without compiling and so runs in about a second; deliberately its own recipe + rather than `--locked` bolted onto `test`/`lint`, so a local `just test` + keeps auto-refreshing the lock as before), `just preview` (colour preview via `cargo run --`, live tmux context when inside tmux, else samples — needs a Nerd/powerline font for the glyphs), `just build-plugin NAME` (builds `plugins/` for `wasm32-unknown-unknown` and installs `.wasm` into the plugin dir — @@ -2517,8 +2522,12 @@ branch on platform. wrapper around an existing `just` recipe so the gate and the documented local command can't drift apart: `lint` → `just lint`, `test` → `just test`, `plugins` → `just lint-plugins` + `just test-plugins`, and `wasm-e2e` → - `just test-wasm`. Toolchain via `dtolnay/rust-toolchain@stable` + - `Swatinem/rust-cache@v2`; `just` itself via `extractions/setup-just@v3`. + `just test-wasm`. The `test` job runs `just check-lock` ahead of `just test`: + `release.yml` builds with `--locked` and nothing else did, so before this a + stale `Cargo.lock` passed every PR check and first failed at tag-push time — + the most expensive moment to discover it. Toolchain via + `dtolnay/rust-toolchain` + `Swatinem/rust-cache`; `just` itself via + `extractions/setup-just`. Deliberately no `rust-toolchain.toml` (would change every developer's local toolchain resolution) and no workspace-level `RUSTFLAGS: -D warnings` (redundant — `just lint`'s own clippy `-D warnings` already enforces the @@ -2526,7 +2535,24 @@ branch on platform. `.github/workflows/release.yml` — see the Roadmap entry below for what it builds/publishes and README's install section for verifying a downloaded tarball against `SHA256SUMS`. -- Commit `Cargo.lock` alongside any dependency change. +- **Actions are pinned to full commit SHAs** in both workflows, each with a + trailing `# vX.Y.Z` comment, and `.github/dependabot.yml` (github-actions + ecosystem, weekly) opens PRs to refresh them — a pin nothing refreshes is + just a frozen unpatched version. Dependabot is deliberately **not** given a + `cargo` entry: Rust bumps go through the `/upgrade-packages` flow, and cargo + PRs would bury the action refreshes. One wrinkle worth knowing before + touching those pins: **`dtolnay/rust-toolchain`'s ref selects the + toolchain** — each branch (`stable`, `nightly`, `1.90.0`) ships an + `action.yml` whose `toolchain` input defaults to that branch's own name — so + its pin is the tip of the **`stable` branch**, and repointing it at a commit + from another branch would silently change the Rust version with nothing in + the diff to say so. It publishes no release tags, so dependabot cannot bump + that one; refresh it by hand. Note this pins the *action code*, not the Rust + version: `stable` still resolves to whatever stable is on the day a job runs, + so a new stable release can still redden CI with no repo change. +- Commit `Cargo.lock` alongside any dependency change — `just check-lock` (run + in CI's `test` job) fails the build if you forget, for the workspace lock and + all five plugin locks alike. - Tests are TDD unit tests in each core module (incl. the powerline renderer and the ANSI transcoder) plus `crates/rustline/tests/smoke.rs` integration tests. `rustline-wasm` adds unit tests for allow-patterns, path sandboxing, and @@ -2907,7 +2933,16 @@ branch on platform. `x86_64-apple-darwin` leg — GitHub's `macos-13` label no longer picks up jobs (a dry run sat queued indefinitely while the other legs finished), and since `publish` declares `needs: build`, a never-scheduled leg would - hang the entire release rather than fail it. + hang the entire release rather than fail it. A **`Verify tag matches + binary version`** step sits between Build and Package on every leg: + tarballs are named from `GITHUB_REF_NAME` while the binary reports + `CARGO_PKG_VERSION`, so nothing otherwise stopped a `v0.2.0` tag shipping a + binary that identifies itself as `0.1.0`. It asks the freshly built binary + (`"$bin" --version`) rather than re-reading `Cargo.toml`, so it checks the + artifact actually being shipped — free, since Package already runs that + binary to emit completions. Gated on `github.ref_type == 'tag'`, because a + `workflow_dispatch` dry run is on a *branch* whose name is not a version + and would otherwise fail every time. - Per-widget richer customization; naming the widget in the panic-guard `warn!`. - Range-on-binding — today a `run`/`open_url` click binding only fires on a widget that already emits a clickable range (i.e. has a non-empty diff --git a/README.md b/README.md index 2aeb083..f89cf19 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,10 @@ just preview Other recipes: `just build`, `just test`, `just lint`, `just lint-plugins` and `just test-plugins` (fmt/clippy/tests for the excluded example plugins under -`plugins/*`, which the root `cargo` commands never see). +`plugins/*`, which the root `cargo` commands never see), and `just check-lock` +(verifies every committed `Cargo.lock` — the workspace's plus each plugin's — +is up to date; CI runs it, so it is what catches a forgotten lock refresh +before a release build with `--locked` would). ## Benchmarking