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 31f3cd7..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,23 +44,27 @@ 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). + - run: just check-lock - run: just test plugins: 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 @@ -57,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 dcdc9c4..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 }} @@ -56,6 +67,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: | @@ -87,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 @@ -98,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 @@ -116,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: | 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 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