From c9c8233aaecabe2f103e1a41845aa5db5a930bc8 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:48:06 +1000 Subject: [PATCH 1/2] Zed deployment contract (SWR-IDE-ZED) and release change-detection scope Pin the Zed extension deployment model as SWR-IDE-ZED across the specs and the shipwright-compliance skill: the WASM extension verifies the downloaded binary's SHA-256 digest in-extension, while the cosign signature over SHA256SUMS is verified at the release/CI boundary (the WASM sandbox cannot run cosign). Adds the canonical resolution order, the dead-download anti-pattern as a FAIL, and the Zed registry publishing mechanics, and auto-deploys the website on push to main. Also lands the shipwright-release-scope change-detection tool with its spec, schema, template, fixtures, and spec-coverage entry. --- .github/workflows/deploy-pages.yml | 6 + Cargo.lock | 17 + Cargo.toml | 2 + coverage-thresholds.json | 3 + crates/shipwright-zed/src/lib.rs | 3 + docs/agents/shipwright-compliance/SKILL.md | 33 +- .../reference/audit-checklist.md | 2 +- .../reference/implement-release.md | 4 +- .../reference/manifest-and-platforms.md | 9 +- docs/specs/ide-extension-deployment.md | 39 +- docs/specs/release-change-detection.md | 185 +++++++ docs/specs/supply-chain-security.md | 31 +- fixtures/release-scope/basilisk.json | 31 ++ fixtures/spec-coverage.json | 8 + schemas/release-scope.schema.json | 41 ++ .../gh-actions/release-change-detection.yml | 147 ++++++ tools/shipwright-release-scope/Cargo.toml | 26 + tools/shipwright-release-scope/README.md | 72 +++ tools/shipwright-release-scope/src/main.rs | 472 ++++++++++++++++++ tools/shipwright-release-scope/tests/scope.rs | 372 ++++++++++++++ 20 files changed, 1483 insertions(+), 20 deletions(-) create mode 100644 docs/specs/release-change-detection.md create mode 100644 fixtures/release-scope/basilisk.json create mode 100644 schemas/release-scope.schema.json create mode 100644 templates/gh-actions/release-change-detection.yml create mode 100644 tools/shipwright-release-scope/Cargo.toml create mode 100644 tools/shipwright-release-scope/README.md create mode 100644 tools/shipwright-release-scope/src/main.rs create mode 100644 tools/shipwright-release-scope/tests/scope.rs diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 67ea73e..dfaf41d 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -2,6 +2,12 @@ name: Deploy Pages on: + push: + branches: [main] + paths: + - 'docs/specs/**' + - 'website/**' + - '.github/workflows/deploy-pages.yml' workflow_dispatch: permissions: diff --git a/Cargo.lock b/Cargo.lock index ba48558..36db7d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.15.5" @@ -265,6 +271,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "shipwright-release-scope" +version = "0.0.0-dev" +dependencies = [ + "glob", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + [[package]] name = "shipwright-version-stamp" version = "0.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 8c5fa97..646fc49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/shipwright-manifest", "crates/shipwright-zed", "tools/shipwright-version-stamp", + "tools/shipwright-release-scope", ] [workspace.package] @@ -25,6 +26,7 @@ readme = "README.md" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +glob = "0.3" # Workspace lints — deny-by-default. See REPO-STANDARDS-SPEC [LINT-RUST]. # Inherit in every member crate with `[lints] workspace = true`. diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 9fe8a07..31a9374 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -17,6 +17,9 @@ }, "tools/shipwright-version-stamp": { "threshold": 100 + }, + "tools/shipwright-release-scope": { + "threshold": 100 } } } diff --git a/crates/shipwright-zed/src/lib.rs b/crates/shipwright-zed/src/lib.rs index b28becb..c0a98f4 100644 --- a/crates/shipwright-zed/src/lib.rs +++ b/crates/shipwright-zed/src/lib.rs @@ -4,6 +4,9 @@ //! language server starts. This crate re-exports the pure host resolver and //! adds small helpers for representing deferred LSP checks and validating the //! `serverInfo` payload returned from LSP `initialize`. +//! +//! Implements the Zed deployment contract SWR-IDE-ZED: digest-verified +//! `github-release` resolution and LSP-`initialize` version checks. #![forbid(unsafe_code)] diff --git a/docs/agents/shipwright-compliance/SKILL.md b/docs/agents/shipwright-compliance/SKILL.md index 4fcdf54..10ea040 100644 --- a/docs/agents/shipwright-compliance/SKILL.md +++ b/docs/agents/shipwright-compliance/SKILL.md @@ -27,11 +27,13 @@ Spec source (cite these URLs, never local paths — this skill runs on repos tha | Library architecture | `.../docs/specs/library-architecture.md` (`SWR-ARCH-*`) | | Source projects & survey | `.../docs/specs/source-projects.md` (`SWR-SRC-*`) | | Release pipeline plan | `.../docs/plans/release-pipeline.md` (`SWR-REL-*`) | +| **Release change detection** (CI cost gate) | `.../docs/specs/release-change-detection.md` (`SWR-REL-CHANGES-*`) | Reusable workflow templates (fetch the raw file and adapt — do not hand-roll from memory): `https://raw.githubusercontent.com/Nimblesite/Shipwright/main/templates/gh-actions/` where `` ∈ `release-binary-multiplatform.yml`, `publish-brew-tap.yml`, -`publish-scoop-bucket.yml`, `publish-vsix-per-platform.yml`. +`publish-scoop-bucket.yml`, `publish-vsix-per-platform.yml`, +`release-change-detection.yml`. ## Workflow @@ -45,6 +47,7 @@ Shipwright Compliance Progress: - [ ] Phase A: Emit the audit report (conformity + security holes) - [ ] Phase B: Implement — manifest, version stamping, libraries, release.yml (see reference/implement-release.md) - [ ] Phase B: Wire GitHub Release + Homebrew + Scoop + per-platform VSIX + registries as applicable +- [ ] Phase B: Add the release change-detection cost gate (release-scope.json + scope job + per-surface `if:`) - [ ] Phase B: Close the supply-chain holes — pinned actions, least-priv tokens, frozen installs, provenance, SBOM, signed checksums, OIDC publishing, per-channel verification - [ ] Phase C: Verify locally (manifest validates, `--version` matches, CI gate green) - [ ] Emit the change summary @@ -110,6 +113,17 @@ order. The implementation playbook is the authoritative step list; the high-leve staged under `bin//`, verified package contents, Marketplace publish on tag. `[SWR-VSIX-*]`. 8. **Acceptance gates in CI** — validate the manifest, run `--version` / `--version --json`, and verify the produced package against the manifest. `[SWR-GATE-*]`. +9. **Release change-detection cost gate** — add `.github/release-scope.json` (a per-repo ruleset mapping + path globs to `binary`/`vsix`/`jetbrains`/`website`/`ignore`, validated by `release-scope.schema.json`), + copy in `release-change-detection.yml`, and add a `scope:` job as the FIRST job of `release.yml`. Then + gate every other job on its outputs: the native binary matrix `if: needs.scope.outputs.build_matrix == 'true'`, + the standalone binary release + Homebrew + Scoop on `full`, VSIX jobs on `vsix`, JetBrains on `jetbrains`, + website on `website`. CONTRACT (`[SWR-REL-CHANGES-CONTRACT]`): a tag stamps ONE version and host + activation-verify is `onMismatch:error`, so a published VSIX/JetBrains plugin MUST bundle binaries built + at the new version — therefore a vsix/jetbrains-only change STILL runs the binary matrix (do NOT reuse a + prior release's binary). Only a website-only change skips the matrix. Pin `shipwright_rev` to a full + Shipwright commit SHA. `[SWR-REL-CHANGES-*]`. Tailor the ruleset to the repo's actual layout — `binary` + MUST cover every compiled source + lockfile (fail-safe favours over-releasing, never under-releasing). Reuse the canonical templates (fetch the raw URLs above and adapt to this repo's binary/extension names) instead of writing workflows from scratch. Make the **smallest diff that achieves conformity**. @@ -130,7 +144,10 @@ Prove the changes locally before declaring done: - **No PATH / package-manager runtime fallback.** A normal startup that reads or mutates PATH, shells out to `which`/`where`, or launches a Homebrew/Scoop/npm-global/cargo/dotnet-tool binary is FAIL. - Bundled or explicit-override sources only. `[SWR-IDE-RESOLUTION]`, `[SWR-SEC-CONTROLS]`. + Bundled or explicit-override sources only. On Zed this also bars `worktree.which` and a `~/.cargo/bin` + default: a silent PATH/preinstalled fallback is FAIL; the Zed default is the verified `github-release` + download, and a download branch left unreachable behind a never-true guard (dead-download) is also + FAIL. `[SWR-IDE-RESOLUTION]`, `[SWR-IDE-ZED]`, `[SWR-SEC-CONTROLS]`. - **One VSIX per target.** Native-binary extensions MUST package `npx vsce package --target `. A single all-platform native VSIX is FAIL. `[SWR-VSIX-PACKAGE]`. - **Verify package contents.** The release MUST inspect each produced artifact: exact `bin//` @@ -144,9 +161,17 @@ Prove the changes locally before declaring done: install`, never crash on missing .NET, never hand-roll a download. `[SWR-IDE-DOTNET-RUNTIME]`. - **Supply-chain integrity is non-negotiable.** Mutable action tags (`@v4`/`@stable`), a missing or over-broad top-level `permissions:`, `npm install` (vs `npm ci`) in a release/VSIX job, a release - with no provenance/SBOM/cosign-signed `SHA256SUMS`, a downloader (Neovim/Zed/host) that executes a - fetched binary without verifying its checksum AND signature, or a long-lived registry/marketplace + with no provenance/SBOM/cosign-signed `SHA256SUMS`, a host/Neovim/brew/scoop downloader that execs a + fetched binary without verifying its digest AND cosign signature, a Zed extension that execs a + downloaded binary with no in-extension SHA-256 digest check (its cosign signature is a release-boundary + check — the WASM sandbox cannot run cosign; `[SWR-IDE-ZED]`), or a long-lived registry/marketplace token outside a protected environment are all FAIL. `[SWR-SEC-*]`, `[SWR-SIGN-*]`. +- **No blind full-matrix releases.** A tag-triggered `release.yml` that rebuilds the macOS/Windows binary + matrix on every tag — even a website-only change — is a cost FAIL. Gate the costly jobs on + `release-change-detection.yml` outputs. The cascade is mandatory: a binary change releases EVERYTHING; an + unclassified change releases EVERYTHING (fail-safe); a vsix/jetbrains change still builds the binary matrix + (single-version contract). Only a website-only change may skip the matrix. Never gate so aggressively that a + real binary change ships without a rebuild. `[SWR-REL-CHANGES-CASCADE]`, `[SWR-REL-CHANGES-FAILSAFE]`, `[SWR-REL-CHANGES-CONTRACT]`. - **License must be honest.** A package's declared SPDX license (default single `MIT`) MUST match a LICENSE file that actually ships; declaring `MIT OR Apache-2.0` (or any expression) without the second license's text present is FAIL. `[SWR-REL-LICENSE]`. diff --git a/docs/agents/shipwright-compliance/reference/audit-checklist.md b/docs/agents/shipwright-compliance/reference/audit-checklist.md index 552d8b8..0735687 100644 --- a/docs/agents/shipwright-compliance/reference/audit-checklist.md +++ b/docs/agents/shipwright-compliance/reference/audit-checklist.md @@ -173,7 +173,7 @@ pipeline can still have required controls outstanding. Cite the `SWR-SEC-*` / `S | VS Code Marketplace | per-VSIX provenance; bundled binary verified vs signed release (`SWR-VSIX-BUNDLE-VERIFY`); **Entra OIDC publish, no stored PAT** — `id-token: write`, publisher-member SP, in a protected env | SWR-VSIX-PUBLISH-OIDC / SWR-SEC-OIDC-PUBLISH | | Open VSX | `node-ovsx-sign`; a **separate** short-expiry PAT in a protected env | SWR-SEC-OIDC-PUBLISH | | JetBrains / Android Studio | `signPlugin` certificate signature; publish token in a protected env | SWR-SEC-OIDC-PUBLISH | -| Zed | no committed `.wasm` drift; runtime `github-release` download verifies checksum + signature; version via LSP `initialize` | SWR-SEC-CHECKSUM | +| Zed | no committed `.wasm` drift; the `github-release` download verifies the SHA-256 digest **in-extension** (cosign signature is a release-boundary check, not in the WASM sandbox); the download branch is reachable (no dead-download behind a never-true guard); no `~/.cargo/bin`/`worktree.which`/PATH default; reviewed-PR publish (submodule + `extensions.toml`); version via LSP `initialize` | SWR-SEC-CHECKSUM, SWR-IDE-ZED | | Homebrew / Scoop | `sha256`/`hash` sourced from the verified `SHA256SUMS`; scoped `tap_token`/`bucket_token` in a protected env | SWR-SEC-CHECKSUM | | Neovim | downloader verifies `SHA256SUMS` + cosign before exec; pins the resolved tag (never `/latest`) | SWR-SEC-CHECKSUM | | crates.io / npm / NuGet / pub.dev | OIDC trusted publishing — no long-lived token (npm also `--provenance`) | SWR-SEC-OIDC-PUBLISH | diff --git a/docs/agents/shipwright-compliance/reference/implement-release.md b/docs/agents/shipwright-compliance/reference/implement-release.md index ee4ac85..1536faf 100644 --- a/docs/agents/shipwright-compliance/reference/implement-release.md +++ b/docs/agents/shipwright-compliance/reference/implement-release.md @@ -207,7 +207,9 @@ own workflows, apply the same: 4. **Provenance + SBOM** — `actions/attest-build-provenance` + a CycloneDX SBOM (`anchore/sbom-action` / `cargo cyclonedx`) attested per artifact; `cargo-auditable` for Rust. `[SWR-SEC-PROVENANCE]`, `[SWR-SEC-SBOM]`. 5. **Signed checksums** — one `SHA256SUMS`, cosign keyless-signed; replace per-asset `.sha256`. The - host / brew / scoop / Neovim / Zed download path verifies digest **and** signature before exec. `[SWR-SEC-CHECKSUM]`. + host / brew / scoop / Neovim download path verifies digest **and** cosign signature before exec; the + Zed WASM extension verifies the **digest** in-extension (its cosign signature is a release-boundary + check — no cosign in the sandbox). `[SWR-SEC-CHECKSUM]`, `[SWR-IDE-ZED]`. 6. **OIDC publishing** — move crates.io / NuGet / pub.dev **and the VS Code Marketplace** off long-lived tokens; npm keeps `--provenance`; the Marketplace uses Entra workload-identity federation (`azure/login` → `az account get-access-token` → `vsce`, no PAT — `[SWR-VSIX-PUBLISH-OIDC]`); diff --git a/docs/agents/shipwright-compliance/reference/manifest-and-platforms.md b/docs/agents/shipwright-compliance/reference/manifest-and-platforms.md index 8356edc..f78ee1e 100644 --- a/docs/agents/shipwright-compliance/reference/manifest-and-platforms.md +++ b/docs/agents/shipwright-compliance/reference/manifest-and-platforms.md @@ -71,10 +71,13 @@ fall back to a system binary. `[SWR-IDE-RESOLUTION]`. Valid sources: - `env` — a documented environment override (only if the host documents one). - `bundled` — the binary shipped inside the package (VSIX/plugin). Default for IDE extensions. - `github-release` — managed download/cache, allowed **only** where marketplace packaging prevents - bundling (e.g. Zed). Must verify version from LSP `initialize`. + bundling (e.g. Zed; the Zed `sources` cascade is `["user-setting", "github-release"]`). Verify the + SHA-256 digest in-extension before exec (the cosign signature is checked at the release boundary, not + the WASM sandbox), and verify version from LSP `initialize`. Full model: `[SWR-IDE-ZED]`. -**Never** include PATH / Homebrew / Scoop / npm-global / cargo / dotnet-tool as a normal startup source. -They are repair flows only, and only after an explicit prompt. `[SWR-IDE-PKG-REPAIR]`, `[SWR-SEC-CONTROLS]`. +**Never** include PATH / Homebrew / Scoop / npm-global / cargo / dotnet-tool as a normal startup source +— on Zed this also bars `worktree.which` and a `~/.cargo/bin` default. They are repair flows or explicit +user overrides only, never the silent default. `[SWR-IDE-PKG-REPAIR]`, `[SWR-IDE-ZED]`, `[SWR-SEC-CONTROLS]`. ## Version output contract diff --git a/docs/specs/ide-extension-deployment.md b/docs/specs/ide-extension-deployment.md index be31fcf..cbfd72d 100644 --- a/docs/specs/ide-extension-deployment.md +++ b/docs/specs/ide-extension-deployment.md @@ -60,7 +60,7 @@ bin/ shipwright.json ``` -Zed extensions may download and cache release assets when marketplace packaging prevents bundling native binaries. In that case, the extension MUST validate the server version from LSP initialize before enabling product features. +Zed extensions may download and cache release assets when marketplace packaging prevents bundling native binaries. In that case, the extension MUST validate the server version from LSP initialize before enabling product features. The canonical model — resolution order, integrity, and publishing — is [SWR-IDE-ZED]. ## [SWR-IDE-TEST-ISOLATION] Extension Test Isolation @@ -112,6 +112,43 @@ manager as a normal startup source. If the user explicitly configured a path and it mismatches, do not fall back. The user needs a precise error so they can fix the configured path. +## [SWR-IDE-ZED] Zed Extension Deployment + +Zed extensions are WebAssembly published through a human-reviewed PR to the `zed-industries/extensions` +registry; they cannot bundle a native binary (see [SWR-COMPAT-HOST-REQ] in the binary version contract). +Every Shipwright product's Zed extension MUST follow one model so the user experience matches the +bundled-VSIX flow: install the extension, get the matching binary, no manual steps. + +**Resolution order** (in `language_server_command`, via `shipwright-zed` on the pure `shipwright-host` +resolver): + +1. Explicit user override — the Zed LSP `binary.path` setting (or a documented env override). +2. A previously cached download in the extension's version-stamped work directory. +3. A verified `github-release` download: `latest_github_release` (pinned repo, resolved tag — never + `/latest` drift) → `download_file` for the current platform → SHA-256 digest verified against + `SHA256SUMS` → `make_file_executable`. + +Then validate the running server's version from the LSP `initialize` `serverInfo` before enabling +product features; a mismatch stops startup with a precise error ([SWR-IDE-ERROR]). Zed cannot preflight +`--version`. + +**Forbidden as silent defaults.** `~/.cargo/bin`, a bare command name on `PATH`, and `worktree.which` +MUST NOT be the unconfigured startup source. A preinstalled/PATH binary is honored only when the user +explicitly opts into it (equivalent to an override), never as the default — the default is the verified +`github-release` download. + +**Anti-pattern — dead download (FAIL).** A `github-release` download branch that is unreachable because +it is gated behind a condition that never occurs (e.g. only when `$HOME` is unset), leaving +`cargo install` / `~/.cargo/bin` or another package-manager path as the de-facto default, is a release +blocker. The audit MUST confirm the download branch is actually reachable on a normal machine. + +**Integrity.** The digest is verified in-extension; the cosign signature over `SHA256SUMS` is verified at +the release/CI boundary, not in the WASM sandbox ([SWR-SEC-CHECKSUM]). + +**Publishing.** Reviewed PR to `zed-industries/extensions`: add the extension as a git submodule under +`extensions/`, add an `extensions.toml` entry (`submodule`, `version`, optional `path`), and run +`pnpm sort-extensions`. The `.wasm` is built reproducibly in CI and is never committed. + ## [SWR-IDE-ERROR] Error Reporting Startup errors MUST include: diff --git a/docs/specs/release-change-detection.md b/docs/specs/release-change-detection.md new file mode 100644 index 0000000..4ed0443 --- /dev/null +++ b/docs/specs/release-change-detection.md @@ -0,0 +1,185 @@ +# Release Change Detection + +``` +Spec prefix: SWR-REL-CHANGES-* +Status: Draft +Date: 2026-06-26 +``` + +## Context + +A full Shipwright release rebuilds and republishes **everything** on every `v*` +tag: native binaries on a macOS/Windows/Linux matrix, one VSIX per platform on a +second matrix, the JetBrains/Neovim/Homebrew/Scoop artifacts, and the website. +macOS and Windows runners bill at several times the Linux rate, so a release that +only fixes a typo in the docs still pays for the entire binary matrix. Across the +reference adopters (Basilisk, Deslop, SharpLsp) this is the dominant CI cost. + +This spec formalises the change-sensitive release first proposed and implemented in +Deslop (`Deslop/docs/proposals/shipwright-change-sensitive-release.md`, by the +`deslop-release` agent). It defines a cheap gate that runs first on a Linux runner, +compares the pushed tag against the previous release tag, and publishes only the +surfaces whose inputs changed — without weakening any release gate. + +## [SWR-REL-CHANGES-CONTRACT] What bounds a partial release + +`SWR-REL-VERSION` stamps **one version per tag**, and every host +`activation-verify` is `onMismatch: error` (the VSIX bundles the lsp+mcp binaries; +the JetBrains plugin bundles the lsp). So a shipped artifact MUST bundle binaries +built **at the tag version** — you cannot ship a new VSIX without binaries stamped +at that version. This is the hard constraint that bounds what may ship alone: + +- **binary changed → full release.** Every downstream artifact bundles a binary. +- **vsix / jetbrains changed (binary unchanged) → the binary matrix still runs** + (the artifact needs binaries at the new version) and that artifact publishes, but + the *other* channels (the standalone binary release, Homebrew, Scoop, the other + extension, the website) are skipped. +- **website changed → the only fully decoupled surface.** Skip the whole matrix. + +Reusing a prior release's already-built binary for a vsix-only release would bundle +a binary whose `--version` is the *old* tag, which `activation-verify` rejects. That +is a contract change (per-component effective version) and is intentionally **out of +scope** — the gate never reuses binaries across versions. + +## [SWR-REL-CHANGES-OVERVIEW] + +| Piece | Where | Role | +| --- | --- | --- | +| `shipwright-release-scope` | `tools/shipwright-release-scope/` | **Pure** classifier: `(changed paths, ruleset) → decision`. No git, no network. | +| `release-change-detection.yml` | `templates/gh-actions/` | Reusable workflow: resolves the previous tag, runs `git diff`, calls the tool, exposes the decision as job outputs. | +| `release-scope.json` | each product repo (`.github/`) | Per-repo ruleset mapping path globs to surfaces (`schemas/release-scope.schema.json`). | + +The split keeps the decision logic pure and unit-testable (the injected-I/O pattern +used by `crates/shipwright-host`): git and the GitHub API live only in the workflow. +Native `git diff` is used — never `tj-actions/changed-files` or any third-party +diff action (`SWR-SEC-ACTION-PINNING`). + +## [SWR-REL-CHANGES-RULES] + +The ruleset is a JSON object of gitignore-style glob lists, validated by +`schemas/release-scope.schema.json`: + +```json +{ + "binary": ["crates/**", "Cargo.toml", "Cargo.lock", "build.rs"], + "vsix": ["vscode-extension/**", "extensions/**"], + "jetbrains": ["clients/jetbrains/**", "clients/kotlin/**"], + "website": ["website/**", "docs/specs/**"], + "ignore": ["**/*.md", "README*", ".github/**"] +} +``` + +`*` and `?` match within a single path segment; `**` spans directories. All keys +are optional; an empty ruleset classifies every change as *unmatched* and so always +forces a full release (see FAILSAFE). + +> **Future direction.** The Deslop proposal envisions migrating this mapping into +> `shipwright.json` as a per-component `sourcePaths` glob list, so the product +> manifest becomes the single source of truth and the tool stays product-agnostic. +> The standalone `release-scope.json` is the interim until the manifest schema +> carries `sourcePaths`. + +## [SWR-REL-CHANGES-PRIORITY] + +Each changed path is classified into exactly one surface by testing the categories +in fixed priority order — **binary → vsix → jetbrains → website → ignore** — and +taking the first match. This makes overlapping globs deterministic: `docs/specs/foo.md` +matches both `website` (`docs/specs/**`) and `ignore` (`**/*.md`), and resolves to +`website` because `website` is checked first. + +## [SWR-REL-CHANGES-CASCADE] + +If **any** changed path is classified `binary`, the decision is the full release: +`full = build_matrix = vsix = jetbrains = website = true`. The binary is the +foundational artifact — every other artifact bundles or documents it — so a binary +change invalidates every channel. + +## [SWR-REL-CHANGES-MATRIX] + +When no binary path changed, the surfaces are independent, with one rule from the +CONTRACT: a binary-bundling surface forces the matrix. + +- `vsix = true` iff a `vsix` path changed; `jetbrains = true` iff a `jetbrains` + path changed; `website = true` iff a `website` path changed. +- `build_matrix = vsix || jetbrains` — because those artifacts must bundle binaries + rebuilt at the new tag version. A website-only change leaves `build_matrix = false`, + which is where the large saving comes from. + +## [SWR-REL-CHANGES-FAILSAFE] + +A changed path that matches **no** category (not even `ignore`) forces a full +release, exactly as a binary change does. Rationale: shipping too much costs money +once; shipping too little ships a stale or broken artifact. New, unforeseen paths +fail *open*. Paths known to affect no release artifact (READMEs, CI config, test +fixtures) must be listed under `ignore` so they stay cheap. + +## [SWR-REL-CHANGES-FIRSTRELEASE] + +When the current tag has no predecessor (`git describe` finds no older tag), there +is nothing to diff against and the workflow publishes everything. The classifier is +not invoked in this case. + +## [SWR-REL-CHANGES-OUTPUTS] + +`release-change-detection.yml` exposes these `workflow_call` outputs: + +| Output | Meaning | +| --- | --- | +| `full` | publish everything (standalone binary + Homebrew + Scoop + all extensions + website). | +| `build_matrix` | the native binary matrix must run (the expensive gate). | +| `vsix` | build & publish the VS Code extension. | +| `jetbrains` | build & publish the JetBrains plugin. | +| `website` | deploy the website. | +| `previous_tag` | the tag diffed against (`""` on first release). | + +The tool also prints a full JSON decision (with the per-surface path lists) to +stdout for auditing, and writes a Markdown summary to `$GITHUB_STEP_SUMMARY`. + +## [SWR-REL-CHANGES-WIRING] + +Downstream `release.yml` calls the gate as the first job and conditions every other +job on its outputs. Sketch (Basilisk shape): + +```yaml +jobs: + scope: + uses: ./.github/workflows/release-change-detection.yml + with: + shipwright_rev: + + build: # native binary matrix (the costly one) + needs: scope + if: needs.scope.outputs.build_matrix == 'true' + release: # standalone binary GitHub Release + needs: [scope, build] + if: needs.scope.outputs.full == 'true' + vsix: + needs: [scope, build] + if: needs.scope.outputs.vsix == 'true' + publish-homebrew: + needs: [scope, release] + if: needs.scope.outputs.full == 'true' + deploy-pages: + needs: scope + if: needs.scope.outputs.website == 'true' +``` + +A job that depends on a job that may be skipped must guard with `!cancelled()` and an +explicit `needs..result` check so a *skipped* (not failed) upstream never wrongly +skips a downstream publish. + +## [SWR-REL-CHANGES-TOOL] + +`shipwright-release-scope` is pure and side-effect-free except for writing the +decision to stdout and the GitHub Actions files. It exits non-zero only on a usage +error, an unreadable/invalid ruleset, or an unreadable changed-list. It never reads +git, the network, or the filesystem beyond the two paths it is handed. + +## Implementation Map + +| Spec ID | Code | Tests | +| --- | --- | --- | +| CASCADE / MATRIX / FAILSAFE / PRIORITY | `tools/shipwright-release-scope/src/main.rs` (`decide`, `classify`) | `tools/shipwright-release-scope/tests/scope.rs` | +| CONTRACT | enforced by the decision (no binary reuse) | `scope.rs` (`vsix_only_still_builds_the_matrix_*`) | +| RULES | `schemas/release-scope.schema.json`, `fixtures/release-scope/basilisk.json` | `scope.rs` (`invalid_*`) | +| FIRSTRELEASE / OUTPUTS / WIRING | `templates/gh-actions/release-change-detection.yml` | exercised in downstream repos | diff --git a/docs/specs/supply-chain-security.md b/docs/specs/supply-chain-security.md index dd42c7a..b08ffd9 100644 --- a/docs/specs/supply-chain-security.md +++ b/docs/specs/supply-chain-security.md @@ -119,10 +119,18 @@ keyless-signed with cosign (Fulcio cert from a GitHub OIDC identity, Rekor log, cosign sign-blob --yes --bundle SHA256SUMS.sigstore.json SHA256SUMS ``` -Any consumer that downloads a binary — the host resolver, Homebrew, Scoop, the Neovim downloader, the -Zed `github-release` source — MUST, before executing it, recompute the SHA-256 against `SHA256SUMS` -**and** verify the signature with an identity pinned to the release workflow, never -`--insecure-ignore-tlog`. Both checks fail closed. +Any consumer that downloads a binary into a real process — the host resolver, Homebrew, Scoop, the +Neovim downloader — MUST, before executing it, recompute the SHA-256 against `SHA256SUMS` **and** verify +the cosign signature with an identity pinned to the release workflow, never `--insecure-ignore-tlog`. +Both checks fail closed. + +The one carve-out is the **Zed** `github-release` source. A Zed extension is WebAssembly with no cosign +primitive and no shell, so it cannot verify a Sigstore signature at runtime. It MUST instead recompute +the SHA-256 of each downloaded asset against `SHA256SUMS` **in-extension** before exec; the cosign +signature over `SHA256SUMS` is verified at the release/CI boundary, and the runtime trust roots are the +human-reviewed registry PR plus the digest match against the signed `SHA256SUMS` fetched over TLS from +the pinned release. See [SWR-IDE-ZED]. A Zed extension that execs a downloaded binary with no digest +check at all is the same failure as skipping verification anywhere else. ```bash cosign verify-blob --bundle SHA256SUMS.sigstore.json \ @@ -231,7 +239,7 @@ what every downstream verification ultimately checks. | **VS Code Marketplace** | One VSIX per `vsceTarget` | Marketplace auto-signs on upload; per-VSIX provenance; bundle-verify; **OIDC via Entra (no stored PAT)** — publisher-member service principal, `id-token: write`, in a protected env | | **Open VSX** | Same VSIXs | `node-ovsx-sign` signature; a *separate* short-expiry PAT in a protected env (GlassWorm rode leaked Open VSX tokens) | | **JetBrains / Android Studio** | Signed plugin `.zip` to JetBrains Marketplace | `signPlugin` certificate-chain signature; Marketplace verifies; publish token in a protected env | -| **Zed** | WASM extension (reviewed PR) + GitHub-release LSP download | No committed-WASM drift; the `github-release` download verifies checksum + signature; version via LSP `initialize` | +| **Zed** | WASM extension (reviewed PR) + GitHub-release LSP download | No committed-WASM drift; the `github-release` download verifies the SHA-256 digest in-extension (cosign signature checked at the release boundary, not the WASM sandbox); version via LSP `initialize` ([SWR-IDE-ZED]) | | **Homebrew tap** | Formula (`url`, `sha256`) | The `sha256` is taken from the cosign-verified `SHA256SUMS`; tap push uses a scoped `tap_token` in a protected env | | **Scoop bucket** | Manifest (`url`, `hash`) | Hash from the verified release; manifest written via a real serializer with env-injected values (no `${{ }}` in shell) | | **Neovim** | Nothing new — downloads the release binary | The downloader verifies `SHA256SUMS` + cosign before exec and pins the resolved tag (never `/latest`) | @@ -277,11 +285,14 @@ plugin `.zip` to be signed with the `signPlugin` task — a certificate chain + secrets — and the Marketplace verifies it; verify locally with `marketplace-zip-signer verify`. The publish token runs in a protected environment. -**Zed** distributes a WASM extension through a human-reviewed PR to the Zed extensions registry and -then downloads the actual LSP from our GitHub Release at runtime. The WASM must be built reproducibly -in CI (never a committed, drift-prone `.wasm`), and the runtime download goes through the same -verify-checksum-and-signature-before-exec path as every other binary consumer, with the version -confirmed via LSP `initialize` since Zed cannot preflight `--version`. +**Zed** distributes a WASM extension through a human-reviewed PR to the Zed extensions registry +(published as a git submodule under `extensions/` with an `extensions.toml` entry) and then +downloads the actual LSP from our GitHub Release at runtime. The WASM must be built reproducibly in CI +(never a committed, drift-prone `.wasm`). Because the sandbox has no cosign primitive, the runtime +download verifies the SHA-256 **digest** against `SHA256SUMS` in-extension before exec — the cosign +**signature** over `SHA256SUMS` is verified at the release boundary, not in the extension — and the +version is confirmed via LSP `initialize` since Zed cannot preflight `--version`. The full resolution +order and anti-patterns are pinned in [SWR-IDE-ZED]. ### Package managers that redistribute the release binary — Homebrew, Scoop, Neovim diff --git a/fixtures/release-scope/basilisk.json b/fixtures/release-scope/basilisk.json new file mode 100644 index 0000000..3c0dcbc --- /dev/null +++ b/fixtures/release-scope/basilisk.json @@ -0,0 +1,31 @@ +{ + "binary": [ + "src/**", + "crates/**", + "Cargo.toml", + "Cargo.lock", + "build.rs", + "rust-toolchain.toml" + ], + "vsix": [ + "vscode-extension/**", + "extensions/**" + ], + "jetbrains": [ + "clients/jetbrains/**", + "clients/kotlin/**" + ], + "website": [ + "website/**", + "docs/specs/**" + ], + "ignore": [ + "**/*.md", + "README*", + "LICENSE*", + ".github/**", + ".gitignore", + "tests/**", + "fixtures/**" + ] +} diff --git a/fixtures/spec-coverage.json b/fixtures/spec-coverage.json index 882558a..7fba95d 100644 --- a/fixtures/spec-coverage.json +++ b/fixtures/spec-coverage.json @@ -1,5 +1,13 @@ { "manifestVersion": 1, + "docs/specs/release-change-detection.md": { + "defaultTicket": "SWR-SPEC-RELEASE-CHANGE-DETECTION", + "fixtures": [ + "fixtures/release-scope/basilisk.json", + "schemas/release-scope.schema.json", + "templates/gh-actions/release-change-detection.yml" + ] + }, "docs/specs/binary-version-contract.md": { "defaultTicket": "SWR-SPEC-BINARY-VERSION", "fixtures": [ diff --git a/schemas/release-scope.schema.json b/schemas/release-scope.schema.json new file mode 100644 index 0000000..3b51604 --- /dev/null +++ b/schemas/release-scope.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nimblesite.dev/schemas/release-scope/v1.json", + "title": "Shipwright release-scope ruleset", + "description": "Maps changed file paths (gitignore-style globs) to release surfaces so the expensive multi-platform build matrix only runs for the surfaces that actually changed since the previous tag. Consumed by shipwright-release-scope. See docs/specs/release-change-detection.md [SWR-REL-CHANGES-RULES].", + "type": "object", + "additionalProperties": false, + "properties": { + "binary": { + "$ref": "#/$defs/globList", + "description": "Native/compiled sources. Changing any of these forces a FULL release [SWR-REL-CHANGES-CASCADE]. Use for the compiled sources, build manifests, and lockfiles." + }, + "vsix": { + "$ref": "#/$defs/globList", + "description": "VS Code extension sources. Bundles a binary, so a change forces the binary build matrix to run (at the new tag version) AND publishes the VSIX, but skips unrelated channels [SWR-REL-CHANGES-MATRIX]." + }, + "jetbrains": { + "$ref": "#/$defs/globList", + "description": "JetBrains plugin sources. Bundles a binary; behaves like 'vsix' — forces the binary matrix and publishes the plugin, skips unrelated channels." + }, + "website": { + "$ref": "#/$defs/globList", + "description": "Documentation-website sources. The only fully decoupled surface: a website-only change deploys the site and skips the entire binary matrix." + }, + "ignore": { + "$ref": "#/$defs/globList", + "description": "Paths that never trigger a release (e.g. READMEs, CI config). A changed path matching NO surface forces a full release fail-safe [SWR-REL-CHANGES-FAILSAFE]; list deliberately-irrelevant paths here to keep them cheap." + } + }, + "$defs": { + "globList": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A gitignore-style glob. `*`/`?` stay within one path segment; `**` spans directories." + }, + "uniqueItems": true + } + } +} diff --git a/templates/gh-actions/release-change-detection.yml b/templates/gh-actions/release-change-detection.yml new file mode 100644 index 0000000..6b50fe7 --- /dev/null +++ b/templates/gh-actions/release-change-detection.yml @@ -0,0 +1,147 @@ +# release-change-detection.yml — Shipwright-shipped reusable workflow. +# +# COST CONTROL. Diff the pushed release tag against the previous release tag and +# decide which surfaces must publish, so the expensive macOS/Windows build matrix +# only runs when the artifact it produces actually changed. +# Implements docs/specs/release-change-detection.md [SWR-REL-CHANGES-*] +# (the Shipwright realisation of the Deslop SWR-REL-CHANGES proposal): +# - binary changed -> full release: build matrix + every channel [SWR-REL-CHANGES-CASCADE] +# - vsix / jetbrains -> still build the matrix (the artifact bundles a binary at the +# new tag version), publish that surface, skip the rest [SWR-REL-CHANGES-MATRIX] +# - website only -> deploy the site, skip the whole matrix +# - unclassified path -> full release, fail-safe [SWR-REL-CHANGES-FAILSAFE] +# - first release / nothing matched -> full release +# +# Single-version contract: a tag stamps one version and host activation-verify is +# onMismatch:error, so a published VSIX/JetBrains plugin MUST bundle binaries built +# at the new version. Reusing a prior release's binary is therefore NOT done here. +# +# Supply chain (docs/specs/supply-chain-security.md): +# - SWR-SEC-ACTION-PINNING every `uses:` is a full 40-char commit SHA. +# - SWR-SEC-TOKEN-PRIVILEGE read-only token; no write/id-token needed here. +# - the classifier is installed from a PINNED Shipwright commit (`shipwright_rev`). + +name: release-change-detection + +on: + workflow_call: + inputs: + config_path: + description: "Path to the release-scope ruleset (schemas/release-scope.schema.json)." + required: false + type: string + default: .github/release-scope.json + shipwright_rev: + description: "FULL 40-char commit SHA of Nimblesite/Shipwright to install shipwright-release-scope from. Pin it (SWR-SEC-ACTION-PINNING); the shipwright-compliance skill fills this in on rollout." + required: true + type: string + tag_match: + description: "glob used to find the previous release tag (git describe --match)." + required: false + type: string + default: "v[0-9]*" + outputs: + full: + description: "true for a full release — publish the standalone binary, Homebrew, Scoop, every extension, and the website." + value: ${{ jobs.scope.outputs.full }} + build_matrix: + description: "true when the native binary build matrix must run (full, or a binary-bundling artifact changed). The expensive, macOS/Windows-heavy gate." + value: ${{ jobs.scope.outputs.build_matrix }} + vsix: + description: "true when the VS Code extension must be published." + value: ${{ jobs.scope.outputs.vsix }} + jetbrains: + description: "true when the JetBrains plugin must be published." + value: ${{ jobs.scope.outputs.jetbrains }} + website: + description: "true when the documentation website must be deployed." + value: ${{ jobs.scope.outputs.website }} + previous_tag: + description: "The previous release tag the diff was taken against ('' on the first release)." + value: ${{ jobs.scope.outputs.previous_tag }} + +# SWR-SEC-TOKEN-PRIVILEGE: read-only; this job only reads git history. +permissions: + contents: read + +jobs: + scope: + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + # `||` falls through the empty string from a skipped step to the value set + # by whichever step actually ran (first-release vs classified). + full: ${{ steps.decide.outputs.full || steps.first.outputs.full }} + build_matrix: ${{ steps.decide.outputs.build_matrix || steps.first.outputs.build_matrix }} + vsix: ${{ steps.decide.outputs.vsix || steps.first.outputs.vsix }} + jetbrains: ${{ steps.decide.outputs.jetbrains || steps.first.outputs.jetbrains }} + website: ${{ steps.decide.outputs.website || steps.first.outputs.website }} + previous_tag: ${{ steps.prev.outputs.previous_tag }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history + tags for the tag-to-tag diff + persist-credentials: false + + - name: Resolve previous release tag + id: prev + shell: bash + run: | + set -euo pipefail + current="${GITHUB_REF_NAME}" + # Newest release tag strictly older than the current tag on this history. + prev="$(git describe --tags --abbrev=0 --match '${{ inputs.tag_match }}' "${current}^" 2>/dev/null || true)" + echo "previous_tag=${prev}" >> "$GITHUB_OUTPUT" + echo "Previous tag: ${prev:-}; current: ${current}" + + # First release (no previous tag): nothing to diff against — publish everything. + - name: First-release short-circuit + id: first + if: steps.prev.outputs.previous_tag == '' + shell: bash + run: | + set -euo pipefail + { + echo "full=true" + echo "build_matrix=true" + echo "vsix=true" + echo "jetbrains=true" + echo "website=true" + } >> "$GITHUB_OUTPUT" + echo "No previous tag — full release." >> "$GITHUB_STEP_SUMMARY" + + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + if: steps.prev.outputs.previous_tag != '' + with: + toolchain: stable + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + if: steps.prev.outputs.previous_tag != '' + with: + key: release-scope-${{ inputs.shipwright_rev }} + + - name: Install shipwright-release-scope (pinned) + if: steps.prev.outputs.previous_tag != '' + shell: bash + run: | + set -euo pipefail + cargo install --locked \ + --git https://github.com/Nimblesite/Shipwright \ + --rev "${{ inputs.shipwright_rev }}" \ + shipwright-release-scope + + - name: Classify changed paths + id: decide + if: steps.prev.outputs.previous_tag != '' + shell: bash + run: | + set -euo pipefail + git diff --name-only \ + "${{ steps.prev.outputs.previous_tag }}..${GITHUB_REF_NAME}" > changed.txt + echo "Changed files since ${{ steps.prev.outputs.previous_tag }}:" + cat changed.txt + # The tool appends full/build_matrix/vsix/jetbrains/website to $GITHUB_OUTPUT + # and a summary to $GITHUB_STEP_SUMMARY, and prints the full JSON to stdout. + shipwright-release-scope \ + --config "${{ inputs.config_path }}" \ + --changed changed.txt diff --git a/tools/shipwright-release-scope/Cargo.toml b/tools/shipwright-release-scope/Cargo.toml new file mode 100644 index 0000000..677a058 --- /dev/null +++ b/tools/shipwright-release-scope/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "shipwright-release-scope" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "CLI: classify the file changes between two release tags and decide which deployment channels (binary, VSIX, website) must publish." +documentation = "https://docs.rs/shipwright-release-scope" +readme = "README.md" + +[[bin]] +name = "shipwright-release-scope" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +glob = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/tools/shipwright-release-scope/README.md b/tools/shipwright-release-scope/README.md new file mode 100644 index 0000000..8b54900 --- /dev/null +++ b/tools/shipwright-release-scope/README.md @@ -0,0 +1,72 @@ +# shipwright-release-scope + +Decide which release surfaces a `v*` tag must actually publish, so the expensive +multi-platform build matrix only runs when the thing it builds has changed since +the previous release. Cuts CI cost on website-only releases (the macOS/Windows +matrix is skipped entirely) and trims the unrelated channels on extension-only +releases. + +Implements [`docs/specs/release-change-detection.md`](../../docs/specs/release-change-detection.md) +(`SWR-REL-CHANGES-*`) — the Shipwright realisation of the Deslop `SWR-REL-CHANGES` +proposal. The tool is **pure**: it classifies a list of changed paths against a +JSON ruleset and prints a decision. Resolving the previous tag and running +`git diff` is the caller's job (see +[`templates/gh-actions/release-change-detection.yml`](../../templates/gh-actions/release-change-detection.yml)). + +## Usage + +```bash +# From a file +shipwright-release-scope --config .github/release-scope.json --changed changed.txt + +# From a pipe (what the reusable workflow does) +git diff --name-only "$PREV..$CURRENT" \ + | shipwright-release-scope --config .github/release-scope.json --changed - +``` + +Prints a JSON decision to stdout. Under GitHub Actions it also appends +`full`, `build_matrix`, `vsix`, `jetbrains`, and `website` to `$GITHUB_OUTPUT` +and a summary to `$GITHUB_STEP_SUMMARY`. + +## Ruleset + +A JSON object of gitignore-style glob lists, validated by +[`schemas/release-scope.schema.json`](../../schemas/release-scope.schema.json): + +```json +{ + "binary": ["crates/**", "Cargo.toml", "Cargo.lock", "build.rs"], + "vsix": ["vscode-extension/**", "extensions/**"], + "jetbrains": ["clients/jetbrains/**", "clients/kotlin/**"], + "website": ["website/**", "docs/specs/**"], + "ignore": ["**/*.md", "README*", ".github/**"] +} +``` + +`*` and `?` stay within a path segment; `**` spans directories. + +## Decision rule + +Shipwright stamps one version per tag and host `activation-verify` is +`onMismatch: error`, so any artifact that bundles a binary must bundle one built +at the tag version — you cannot ship a new VSIX/JetBrains plugin without +rebuilding its binaries. That bounds what can ship alone: + +| Changed since last tag | full | build_matrix | vsix | jetbrains | website | +| --- | --- | --- | --- | --- | --- | +| any `binary` path (cascade) | ✅ | ✅ | ✅ | ✅ | ✅ | +| any **unmatched** path (fail-safe) | ✅ | ✅ | ✅ | ✅ | ✅ | +| only `vsix` | — | ✅ | ✅ | — | — | +| only `jetbrains` | — | ✅ | — | ✅ | — | +| only `website` | — | — | — | — | ✅ | +| only `ignore` / nothing | — | — | — | — | — | + +- `full` → publish everything (standalone binary release + Homebrew + Scoop + all + extensions + website). +- `build_matrix` → the expensive native binary matrix must run. +- `vsix` / `jetbrains` / `website` → publish that surface. + +Paths are matched in fixed priority order — `binary` → `vsix` → `jetbrains` → +`website` → `ignore` — and the first surface a path matches wins. A changed path +that matches **no** rule forces a full release (fail-safe): shipping too much +costs money once; shipping too little ships a stale or broken artifact. diff --git a/tools/shipwright-release-scope/src/main.rs b/tools/shipwright-release-scope/src/main.rs new file mode 100644 index 0000000..3638006 --- /dev/null +++ b/tools/shipwright-release-scope/src/main.rs @@ -0,0 +1,472 @@ +//! `shipwright-release-scope` — decide which release surfaces a `v*` tag must +//! actually publish, so the expensive multi-platform build matrix only runs when +//! the thing it builds has changed since the previous release. +//! +//! Implements `docs/specs/release-change-detection.md` [SWR-REL-CHANGES-*] +//! (the Shipwright realisation of the Deslop `SWR-REL-CHANGES` proposal). +//! +//! The tool is **pure**: it reads a newline-delimited list of changed paths and +//! a JSON ruleset, then prints a decision document to stdout. Resolving the +//! previous tag and running `git diff` is the caller's job (the reusable +//! workflow `templates/gh-actions/release-change-detection.yml`), mirroring the +//! injected-I/O design of `shipwright-host`. +//! +//! ```text +//! shipwright-release-scope --config .github/release-scope.json --changed changed.txt +//! git diff --name-only PREV..CUR | shipwright-release-scope --config rules.json --changed - +//! ``` +//! +//! ## Decision rule +//! +//! Shipwright stamps **one version per tag** and every host `activation-verify` +//! is `onMismatch: error`, so any artifact that bundles a binary must bundle one +//! built at the tag version — you cannot ship a new VSIX/JetBrains plugin without +//! rebuilding its binaries. That bounds what can ship alone: +//! +//! - any **binary** path changed → full release: build matrix + every channel +//! [SWR-REL-CHANGES-CASCADE]. +//! - any **unmatched** path changed → full release, fail-safe +//! [SWR-REL-CHANGES-FAILSAFE]. +//! - **vsix** / **jetbrains** changed (binary unchanged) → the binary matrix +//! STILL runs (the artifact bundles binaries at the new version), that artifact +//! publishes, but unrelated channels (the standalone binary release, Homebrew, +//! Scoop, the other extension, the website) are skipped +//! [SWR-REL-CHANGES-MATRIX]. +//! - **website** only → the one fully decoupled surface: skip the whole matrix. +//! +//! Paths are matched in fixed priority order — binary, vsix, jetbrains, website, +//! ignore — and the first category a path matches wins [SWR-REL-CHANGES-PRIORITY]. +//! +//! When `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` are set (GitHub Actions), the +//! decision is also appended to those files. + +#![forbid(unsafe_code)] + +use glob::{MatchOptions, Pattern}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +/// Top-level error type for the CLI. +#[derive(Debug, thiserror::Error)] +enum ScopeError { + /// Bad or missing command-line argument. + #[error("argument: {0}")] + Args(String), + /// File-system or stdin read/write error. + #[error("io at {path}: {source}")] + Io { + /// Path (or ``) where the error occurred. + path: PathBuf, + /// Underlying error. + source: io::Error, + }, + /// The ruleset file did not parse as JSON. + #[error("config json: {0}")] + Json(#[from] serde_json::Error), + /// A glob in the ruleset was malformed. + #[error("invalid glob `{glob}` in category `{category}`: {source}")] + Glob { + /// Ruleset category the bad glob came from. + category: String, + /// The offending glob string. + glob: String, + /// Underlying glob parse error. + source: glob::PatternError, + }, +} + +/// Glob rules that map changed paths to a release surface. +#[derive(Debug, Deserialize)] +struct Rules { + /// Globs whose change forces a full release (native/compiled sources). + #[serde(default)] + binary: Vec, + /// Globs for the VS Code extension (bundles a binary). + #[serde(default)] + vsix: Vec, + /// Globs for the `JetBrains` plugin (bundles a binary). + #[serde(default)] + jetbrains: Vec, + /// Globs for the documentation website (the only decoupled surface). + #[serde(default)] + website: Vec, + /// Globs for paths that never trigger a release. + #[serde(default)] + ignore: Vec, +} + +/// Compiled glob patterns for every surface, in priority order. +#[derive(Debug)] +struct Matcher { + /// Compiled `binary` globs. + binary: Vec, + /// Compiled `vsix` globs. + vsix: Vec, + /// Compiled `jetbrains` globs. + jetbrains: Vec, + /// Compiled `website` globs. + website: Vec, + /// Compiled `ignore` globs. + ignore: Vec, +} + +/// The surface a single changed path was classified into. +#[derive(Debug)] +enum Surface { + /// Matched a `binary` glob. + Binary, + /// Matched a `vsix` glob. + Vsix, + /// Matched a `jetbrains` glob. + Jetbrains, + /// Matched a `website` glob. + Website, + /// Matched an `ignore` glob. + Ignore, + /// Matched no glob — triggers the fail-safe full release. + Unmatched, +} + +/// Per-surface lists of the changed paths that landed in each bucket. +#[derive(Debug, Serialize, Default)] +struct Classified { + /// Paths classified as binary changes. + binary: Vec, + /// Paths classified as vsix changes. + vsix: Vec, + /// Paths classified as jetbrains changes. + jetbrains: Vec, + /// Paths classified as website changes. + website: Vec, + /// Paths explicitly ignored. + ignored: Vec, + /// Paths that matched no rule. + unmatched: Vec, +} + +/// The machine-readable release decision printed to stdout. +/// +/// The booleans are the published output contract (one `$GITHUB_OUTPUT` key +/// each), not internal flags, so `struct_excessive_bools` is allowed here. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Serialize)] +struct Decision { + /// Full release: native binary changed (or fail-safe) — publish everything. + full: bool, + /// The native binary build matrix must run (full, or a binary-bundling + /// artifact changed). This is the expensive, macOS/Windows-heavy gate. + build_matrix: bool, + /// Publish the VS Code extension. + vsix: bool, + /// Publish the `JetBrains` plugin. + jetbrains: bool, + /// Deploy the documentation website. + website: bool, + /// Number of changed paths considered. + changed_count: usize, + /// Full per-surface classification, for auditing. + classified: Classified, + /// Human-readable explanation of the decision. + reason: String, +} + +/// Parsed command-line options. +#[derive(Debug)] +struct Opts { + /// Path to the JSON ruleset. + config: String, + /// Path to the changed-files list, or `-` for stdin. + changed: String, +} + +fn main() -> ExitCode { + match run(std::env::args().skip(1).collect()) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + let _ = writeln!(io::stderr(), "shipwright-release-scope: {e}"); + ExitCode::FAILURE + } + } +} + +/// Execute the CLI: parse → load rules → classify → decide → emit. +fn run(args: Vec) -> Result<(), ScopeError> { + let Some(opts) = parse_opts(args)? else { + print_help(); + return Ok(()); + }; + let rules = load_rules(&opts.config)?; + let matcher = build_matcher(&rules)?; + let changed = read_changed(&opts.changed)?; + let decision = decide(&matcher, &changed); + emit(&decision) +} + +/// Parse `--config` and `--changed`; `--help`/`-h` short-circuits via `Ok(None)`. +fn parse_opts(args: Vec) -> Result, ScopeError> { + let mut config: Option = None; + let mut changed: Option = None; + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--help" | "-h" => return Ok(None), + "--config" => config = Some(next_value(&mut it, "--config")?), + "--changed" => changed = Some(next_value(&mut it, "--changed")?), + other => return Err(ScopeError::Args(format!("unknown argument `{other}`"))), + } + } + let config = config.ok_or_else(|| ScopeError::Args("--config is required".to_owned()))?; + let changed = changed.ok_or_else(|| ScopeError::Args("--changed is required".to_owned()))?; + Ok(Some(Opts { config, changed })) +} + +/// Pull the value following a flag, erroring if it is missing. +fn next_value(it: &mut std::vec::IntoIter, flag: &str) -> Result { + it.next() + .ok_or_else(|| ScopeError::Args(format!("{flag} requires a value"))) +} + +/// Load and parse the JSON ruleset from `path`. +fn load_rules(path: &str) -> Result { + let body = fs::read_to_string(path).map_err(|source| io_err(path, source))?; + Ok(serde_json::from_str(&body)?) +} + +/// Compile every surface's globs into priority-ordered matchers. +fn build_matcher(rules: &Rules) -> Result { + Ok(Matcher { + binary: compile("binary", &rules.binary)?, + vsix: compile("vsix", &rules.vsix)?, + jetbrains: compile("jetbrains", &rules.jetbrains)?, + website: compile("website", &rules.website)?, + ignore: compile("ignore", &rules.ignore)?, + }) +} + +/// Compile one surface's glob strings, attributing any error to `category`. +fn compile(category: &str, globs: &[String]) -> Result, ScopeError> { + globs + .iter() + .map(|g| { + Pattern::new(g).map_err(|source| ScopeError::Glob { + category: category.to_owned(), + glob: g.clone(), + source, + }) + }) + .collect() +} + +/// Glob match options: `*`/`?` stay within a path segment; `**` spans directories. +fn match_opts() -> MatchOptions { + MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, + } +} + +/// True when `path` matches any pattern in `patterns`. +fn any_match(patterns: &[Pattern], path: &str) -> bool { + let opts = match_opts(); + patterns.iter().any(|p| p.matches_with(path, opts)) +} + +/// Classify `path` into the first surface it matches, in priority order. +fn classify(m: &Matcher, path: &str) -> Surface { + if any_match(&m.binary, path) { + Surface::Binary + } else if any_match(&m.vsix, path) { + Surface::Vsix + } else if any_match(&m.jetbrains, path) { + Surface::Jetbrains + } else if any_match(&m.website, path) { + Surface::Website + } else if any_match(&m.ignore, path) { + Surface::Ignore + } else { + Surface::Unmatched + } +} + +/// Bucket every changed path into its highest-priority surface. +fn classify_all(matcher: &Matcher, changed: &[String]) -> Classified { + let mut c = Classified::default(); + for path in changed { + match classify(matcher, path) { + Surface::Binary => c.binary.push(path.clone()), + Surface::Vsix => c.vsix.push(path.clone()), + Surface::Jetbrains => c.jetbrains.push(path.clone()), + Surface::Website => c.website.push(path.clone()), + Surface::Ignore => c.ignored.push(path.clone()), + Surface::Unmatched => c.unmatched.push(path.clone()), + } + } + c +} + +/// Apply the cascade / matrix / fail-safe rule to a classification. +fn decide(matcher: &Matcher, changed: &[String]) -> Decision { + let c = classify_all(matcher, changed); + let full = !c.binary.is_empty() || !c.unmatched.is_empty(); + // A binary-bundling artifact (vsix/jetbrains) needs binaries built at the new + // tag version, so its change forces the matrix even when binary is unchanged. + let vsix = full || !c.vsix.is_empty(); + let jetbrains = full || !c.jetbrains.is_empty(); + let website = full || !c.website.is_empty(); + let build_matrix = full || !c.vsix.is_empty() || !c.jetbrains.is_empty(); + let reason = reason_for(&c, full); + Decision { + full, + build_matrix, + vsix, + jetbrains, + website, + changed_count: changed.len(), + classified: c, + reason, + } +} + +/// Build the human-readable explanation for a decision. +fn reason_for(c: &Classified, full: bool) -> String { + if full { + return full_reason(c); + } + let mut surfaces: Vec<&str> = Vec::new(); + if !c.vsix.is_empty() { + surfaces.push("vsix"); + } + if !c.jetbrains.is_empty() { + surfaces.push("jetbrains"); + } + if !c.website.is_empty() { + surfaces.push("website"); + } + if surfaces.is_empty() { + return "no release-relevant changes -> publish nothing".to_owned(); + } + format!("partial release: {} changed", surfaces.join(" + ")) +} + +/// Reason string when a full release is forced. +fn full_reason(c: &Classified) -> String { + if c.binary.is_empty() { + format!( + "{} unclassified path(s) changed -> full release, fail-safe [SWR-REL-CHANGES-FAILSAFE]", + c.unmatched.len() + ) + } else { + format!( + "{} binary path(s) changed -> full release [SWR-REL-CHANGES-CASCADE]", + c.binary.len() + ) + } +} + +/// Read newline-delimited changed paths from a file or stdin (`-`). +fn read_changed(source: &str) -> Result, ScopeError> { + let raw = if source == "-" { + read_stdin()? + } else { + fs::read_to_string(source).map_err(|e| io_err(source, e))? + }; + Ok(parse_lines(&raw)) +} + +/// Read all of stdin to a string. +fn read_stdin() -> Result { + let mut buf = String::new(); + let _ = io::stdin() + .read_to_string(&mut buf) + .map_err(|e| io_err("", e))?; + Ok(buf) +} + +/// Split raw text into trimmed, non-empty lines. +fn parse_lines(raw: &str) -> Vec { + raw.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +/// Build a `ScopeError::Io` for `path`. +fn io_err(path: &str, source: io::Error) -> ScopeError { + ScopeError::Io { + path: PathBuf::from(path), + source, + } +} + +/// Serialize the decision to stdout and, under GitHub Actions, to the +/// `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` files. +fn emit(decision: &Decision) -> Result<(), ScopeError> { + let json = serde_json::to_string_pretty(decision)?; + println!("{json}"); + if let Some(path) = std::env::var_os("GITHUB_OUTPUT") { + append_github_output(Path::new(&path), decision)?; + } + if let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") { + append_summary(Path::new(&path), decision)?; + } + Ok(()) +} + +/// Append the boolean outputs to the `GITHUB_OUTPUT` key=value file. +fn append_github_output(path: &Path, d: &Decision) -> Result<(), ScopeError> { + let body = format!( + "full={}\nbuild_matrix={}\nvsix={}\njetbrains={}\nwebsite={}\n", + d.full, d.build_matrix, d.vsix, d.jetbrains, d.website + ); + append_file(path, &body) +} + +/// Append a human-readable Markdown summary to the Actions step summary. +fn append_summary(path: &Path, d: &Decision) -> Result<(), ScopeError> { + let body = format!( + "### Release scope\n\n- full: `{}`\n- build_matrix: `{}`\n- vsix: `{}`\n- jetbrains: `{}`\n- website: `{}`\n\n{}\n", + d.full, d.build_matrix, d.vsix, d.jetbrains, d.website, d.reason + ); + append_file(path, &body) +} + +/// Append `body` to the file at `path`, creating it if absent. +fn append_file(path: &Path, body: &str) -> Result<(), ScopeError> { + append_raw(path, body).map_err(|e| ScopeError::Io { + path: path.to_path_buf(), + source: e, + }) +} + +/// Open-append-write, surfacing the raw I/O error for the caller to wrap. +fn append_raw(path: &Path, body: &str) -> io::Result<()> { + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + f.write_all(body.as_bytes()) +} + +/// Print usage to stdout. +fn print_help() { + println!( + "shipwright-release-scope — classify changed paths into release surfaces\n\ + \n\ + USAGE:\n\ + \x20 shipwright-release-scope --config --changed \n\ + \n\ + OPTIONS:\n\ + \x20 --config JSON ruleset (schemas/release-scope.schema.json)\n\ + \x20 --changed newline-delimited changed paths, or - for stdin\n\ + \x20 -h, --help show this help\n\ + \n\ + Prints a JSON decision to stdout and, under GitHub Actions, appends\n\ + full/build_matrix/vsix/jetbrains/website to $GITHUB_OUTPUT. See\n\ + docs/specs/release-change-detection.md [SWR-REL-CHANGES-*]." + ); +} diff --git a/tools/shipwright-release-scope/tests/scope.rs b/tools/shipwright-release-scope/tests/scope.rs new file mode 100644 index 0000000..69dd641 --- /dev/null +++ b/tools/shipwright-release-scope/tests/scope.rs @@ -0,0 +1,372 @@ +//! E2E tests for the `shipwright-release-scope` binary. +//! +//! Black-box only: every case spawns the compiled CLI and asserts on its +//! stdout / stderr / exit status. Implements the behaviours specified in +//! `docs/specs/release-change-detection.md` [SWR-REL-CHANGES-*]. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items, + unused_results +)] + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use tempfile::TempDir; + +const RULES: &str = r#"{ + "binary": ["crates/**", "Cargo.toml", "src/**"], + "vsix": ["extensions/**", "vscode-extension/**"], + "jetbrains": ["clients/jetbrains/**", "clients/kotlin/**"], + "website": ["website/**", "docs/specs/**"], + "ignore": ["**/*.md", "README*"] +}"#; + +fn bin() -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_shipwright-release-scope")); + // Never inherit a CI runner's Actions env: those branches are tested explicitly. + c.env_remove("GITHUB_OUTPUT") + .env_remove("GITHUB_STEP_SUMMARY"); + c +} + +fn write(dir: &Path, name: &str, body: &str) -> String { + let path = dir.join(name); + fs::write(&path, body).unwrap(); + path.to_string_lossy().into_owned() +} + +/// Run with a written ruleset + changed-list, returning the process output. +fn run_case(rules: &str, changed_lines: &[&str]) -> (TempDir, Output) { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", rules); + let changed = write(dir.path(), "changed.txt", &changed_lines.join("\n")); + let out = bin() + .args(["--config", &cfg, "--changed", &changed]) + .output() + .unwrap(); + (dir, out) +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn assert_ok(out: &Output) { + assert!(out.status.success(), "stderr: {}", stderr(out)); +} + +fn assert_field(s: &str, field: &str, value: bool) { + let needle = format!("\"{field}\": {value}"); + assert!(s.contains(&needle), "expected {needle}\nin: {s}"); +} + +// ── decision matrix ──────────────────────────────────────────────────────── + +#[test] +fn binary_change_cascades_to_everything() { + // A nested path under crates/** must match (exercises `**` spanning dirs). + let (_d, out) = run_case(RULES, &["crates/shipwright/src/main.rs", "README.md"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", true); + assert_field(&s, "build_matrix", true); + assert_field(&s, "vsix", true); + assert_field(&s, "jetbrains", true); + assert_field(&s, "website", true); + assert!(s.contains("SWR-REL-CHANGES-CASCADE"), "{s}"); +} + +#[test] +fn literal_binary_path_cascades() { + let (_d, out) = run_case(RULES, &["Cargo.toml"]); + assert_ok(&out); + assert_field(&stdout(&out), "full", true); +} + +#[test] +fn unmatched_path_forces_failsafe_full_release() { + let (_d, out) = run_case(RULES, &["infra/terraform/main.tf"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", true); + assert_field(&s, "build_matrix", true); + assert!(s.contains("SWR-REL-CHANGES-FAILSAFE"), "{s}"); +} + +#[test] +fn vsix_only_still_builds_the_matrix_but_not_website() { + // The VSIX bundles binaries at the new tag version, so the matrix must run; + // the website and the standalone binary channels do not. + let (_d, out) = run_case(RULES, &["vscode-extension/src/extension.ts"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", true); + assert_field(&s, "vsix", true); + assert_field(&s, "jetbrains", false); + assert_field(&s, "website", false); + assert!(s.contains("partial release: vsix changed"), "{s}"); +} + +#[test] +fn jetbrains_only_still_builds_the_matrix() { + let (_d, out) = run_case(RULES, &["clients/jetbrains/src/Main.kt"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", true); + assert_field(&s, "jetbrains", true); + assert_field(&s, "vsix", false); + assert_field(&s, "website", false); + assert!(s.contains("partial release: jetbrains changed"), "{s}"); +} + +#[test] +fn website_only_skips_the_matrix() { + let (_d, out) = run_case(RULES, &["website/index.njk"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", false); + assert_field(&s, "website", true); + assert_field(&s, "vsix", false); + assert_field(&s, "jetbrains", false); + assert!(s.contains("partial release: website changed"), "{s}"); +} + +#[test] +fn vsix_and_website_change() { + let (_d, out) = run_case(RULES, &["extensions/a.ts", "website/x.css"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "vsix", true); + assert_field(&s, "website", true); + assert_field(&s, "build_matrix", true); + assert_field(&s, "full", false); + assert!(s.contains("partial release: vsix + website changed"), "{s}"); +} + +#[test] +fn ignored_only_publishes_nothing() { + let (_d, out) = run_case(RULES, &["README.md", "docs/notes.md"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", false); + assert_field(&s, "website", false); + assert!(s.contains("no release-relevant changes"), "{s}"); +} + +#[test] +fn empty_changeset_publishes_nothing() { + let (_d, out) = run_case(RULES, &[]); + assert_ok(&out); + let s = stdout(&out); + assert!(s.contains("\"changed_count\": 0"), "{s}"); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", false); +} + +#[test] +fn priority_website_beats_ignore_for_spec_markdown() { + // docs/specs/*.md matches both `website` and the broad `**/*.md` ignore; + // website wins because it has the higher priority [SWR-REL-CHANGES-PRIORITY]. + let (_d, out) = run_case(RULES, &["docs/specs/binary-version-contract.md"]); + assert_ok(&out); + let s = stdout(&out); + assert_field(&s, "website", true); + assert_field(&s, "full", false); + assert_field(&s, "build_matrix", false); +} + +// ── stdin path ────────────────────────────────────────────────────────────── + +#[test] +fn reads_changed_list_from_stdin() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", RULES); + let mut child = bin() + .args(["--config", &cfg, "--changed", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"website/index.njk\n") + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert_ok(&out); + assert_field(&stdout(&out), "website", true); +} + +#[test] +fn invalid_utf8_stdin_is_an_io_error() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", RULES); + let mut child = bin() + .args(["--config", &cfg, "--changed", "-"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(&[0xff, 0xfe]) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("io at "), "{}", stderr(&out)); +} + +// ── GitHub Actions integration ────────────────────────────────────────────── + +#[test] +fn writes_github_output_and_step_summary() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", RULES); + let changed = write(dir.path(), "changed.txt", "vscode-extension/a.ts"); + let gh_out = dir.path().join("gh_output"); + let gh_sum = dir.path().join("gh_summary.md"); + let out = bin() + .args(["--config", &cfg, "--changed", &changed]) + .env("GITHUB_OUTPUT", &gh_out) + .env("GITHUB_STEP_SUMMARY", &gh_sum) + .output() + .unwrap(); + assert_ok(&out); + let outputs = fs::read_to_string(&gh_out).unwrap(); + assert!(outputs.contains("full=false"), "{outputs}"); + assert!(outputs.contains("build_matrix=true"), "{outputs}"); + assert!(outputs.contains("vsix=true"), "{outputs}"); + assert!(outputs.contains("jetbrains=false"), "{outputs}"); + assert!(outputs.contains("website=false"), "{outputs}"); + let summary = fs::read_to_string(&gh_sum).unwrap(); + assert!(summary.contains("### Release scope"), "{summary}"); + assert!(summary.contains("build_matrix: `true`"), "{summary}"); +} + +#[test] +fn unwritable_github_output_is_an_io_error() { + // Point GITHUB_OUTPUT at a directory so the append open() fails. + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", RULES); + let changed = write(dir.path(), "changed.txt", "website/x.css"); + let out = bin() + .args(["--config", &cfg, "--changed", &changed]) + .env("GITHUB_OUTPUT", dir.path()) // a directory, not a file + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("io at"), "{}", stderr(&out)); +} + +// ── argument & config errors ───────────────────────────────────────────────── + +#[test] +fn help_flag_prints_usage() { + for flag in ["--help", "-h"] { + let out = bin().arg(flag).output().unwrap(); + assert_ok(&out); + assert!(stdout(&out).contains("USAGE:"), "flag {flag}"); + } +} + +#[test] +fn unknown_argument_is_rejected() { + let out = bin().args(["--nope"]).output().unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("unknown argument `--nope`")); +} + +#[test] +fn flag_without_value_is_rejected() { + let out = bin().args(["--config"]).output().unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("--config requires a value")); +} + +#[test] +fn missing_config_is_rejected() { + let out = bin().args(["--changed", "-"]).output().unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("--config is required")); +} + +#[test] +fn missing_changed_is_rejected() { + let out = bin().args(["--config", "rules.json"]).output().unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("--changed is required")); +} + +#[test] +fn missing_config_file_is_an_io_error() { + let dir = TempDir::new().unwrap(); + let cfg = dir.path().join("does-not-exist.json"); + let out = bin() + .args(["--config", &cfg.to_string_lossy(), "--changed", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("io at"), "{}", stderr(&out)); +} + +#[test] +fn missing_changed_file_is_an_io_error() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", RULES); + let out = bin() + .args(["--config", &cfg, "--changed", "/no/such/changed.txt"]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!( + stderr(&out).contains("io at /no/such/changed.txt"), + "{}", + stderr(&out) + ); +} + +#[test] +fn invalid_json_config_is_rejected() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", "{ not valid json "); + let out = bin() + .args(["--config", &cfg, "--changed", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(stderr(&out).contains("config json"), "{}", stderr(&out)); +} + +#[test] +fn invalid_glob_in_config_is_rejected() { + let dir = TempDir::new().unwrap(); + let cfg = write(dir.path(), "rules.json", r#"{ "binary": ["a[b"] }"#); + let out = bin() + .args(["--config", &cfg, "--changed", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!out.status.success()); + let e = stderr(&out); + assert!(e.contains("invalid glob"), "{e}"); + assert!(e.contains("category `binary`"), "{e}"); +} From b843e27059777fe73f73fe02dcdd03692688d45e Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:59:07 +1000 Subject: [PATCH 2/2] Dedup shipwright-release-scope tests to satisfy the deslop budget Extract rules_dir/run_args/assert_fails_with helpers in tools/shipwright-release-scope/tests/scope.rs so the repeated CLI-spawn and failure-assertion boilerplate is written once. Every test and assertion is preserved (23 tests still pass); deslop's measured duplication drops back under the 18% gate. No production code changed. --- tools/shipwright-release-scope/tests/scope.rs | 114 +++++++++--------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/tools/shipwright-release-scope/tests/scope.rs b/tools/shipwright-release-scope/tests/scope.rs index 69dd641..616bb68 100644 --- a/tools/shipwright-release-scope/tests/scope.rs +++ b/tools/shipwright-release-scope/tests/scope.rs @@ -41,10 +41,16 @@ fn write(dir: &Path, name: &str, body: &str) -> String { path.to_string_lossy().into_owned() } -/// Run with a written ruleset + changed-list, returning the process output. -fn run_case(rules: &str, changed_lines: &[&str]) -> (TempDir, Output) { +/// Fresh temp dir holding a `rules.json`; returns the dir guard and its path. +fn rules_dir(rules: &str) -> (TempDir, String) { let dir = TempDir::new().unwrap(); let cfg = write(dir.path(), "rules.json", rules); + (dir, cfg) +} + +/// Run with a written ruleset + changed-list file, returning the process output. +fn run_case(rules: &str, changed_lines: &[&str]) -> (TempDir, Output) { + let (dir, cfg) = rules_dir(rules); let changed = write(dir.path(), "changed.txt", &changed_lines.join("\n")); let out = bin() .args(["--config", &cfg, "--changed", &changed]) @@ -53,6 +59,15 @@ fn run_case(rules: &str, changed_lines: &[&str]) -> (TempDir, Output) { (dir, out) } +/// Spawn the CLI with explicit `--config`/`--changed` values and a null stdin. +fn run_args(cfg: &str, changed: &str) -> Output { + bin() + .args(["--config", cfg, "--changed", changed]) + .stdin(Stdio::null()) + .output() + .unwrap() +} + fn stdout(out: &Output) -> String { String::from_utf8_lossy(&out.stdout).into_owned() } @@ -65,6 +80,12 @@ fn assert_ok(out: &Output) { assert!(out.status.success(), "stderr: {}", stderr(out)); } +/// Assert the process failed and its stderr contains `needle`. +fn assert_fails_with(out: &Output, needle: &str) { + assert!(!out.status.success()); + assert!(stderr(out).contains(needle), "{}", stderr(out)); +} + fn assert_field(s: &str, field: &str, value: bool) { let needle = format!("\"{field}\": {value}"); assert!(s.contains(&needle), "expected {needle}\nin: {s}"); @@ -193,8 +214,7 @@ fn priority_website_beats_ignore_for_spec_markdown() { #[test] fn reads_changed_list_from_stdin() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", RULES); + let (_dir, cfg) = rules_dir(RULES); let mut child = bin() .args(["--config", &cfg, "--changed", "-"]) .stdin(Stdio::piped()) @@ -214,8 +234,7 @@ fn reads_changed_list_from_stdin() { #[test] fn invalid_utf8_stdin_is_an_io_error() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", RULES); + let (_dir, cfg) = rules_dir(RULES); let mut child = bin() .args(["--config", &cfg, "--changed", "-"]) .stdin(Stdio::piped()) @@ -229,16 +248,14 @@ fn invalid_utf8_stdin_is_an_io_error() { .write_all(&[0xff, 0xfe]) .unwrap(); let out = child.wait_with_output().unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("io at "), "{}", stderr(&out)); + assert_fails_with(&out, "io at "); } // ── GitHub Actions integration ────────────────────────────────────────────── #[test] fn writes_github_output_and_step_summary() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", RULES); + let (dir, cfg) = rules_dir(RULES); let changed = write(dir.path(), "changed.txt", "vscode-extension/a.ts"); let gh_out = dir.path().join("gh_output"); let gh_sum = dir.path().join("gh_summary.md"); @@ -263,16 +280,14 @@ fn writes_github_output_and_step_summary() { #[test] fn unwritable_github_output_is_an_io_error() { // Point GITHUB_OUTPUT at a directory so the append open() fails. - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", RULES); + let (dir, cfg) = rules_dir(RULES); let changed = write(dir.path(), "changed.txt", "website/x.css"); let out = bin() .args(["--config", &cfg, "--changed", &changed]) .env("GITHUB_OUTPUT", dir.path()) // a directory, not a file .output() .unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("io at"), "{}", stderr(&out)); + assert_fails_with(&out, "io at"); } // ── argument & config errors ───────────────────────────────────────────────── @@ -288,83 +303,62 @@ fn help_flag_prints_usage() { #[test] fn unknown_argument_is_rejected() { - let out = bin().args(["--nope"]).output().unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("unknown argument `--nope`")); + assert_fails_with( + &bin().args(["--nope"]).output().unwrap(), + "unknown argument `--nope`", + ); } #[test] fn flag_without_value_is_rejected() { - let out = bin().args(["--config"]).output().unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("--config requires a value")); + assert_fails_with( + &bin().args(["--config"]).output().unwrap(), + "--config requires a value", + ); } #[test] fn missing_config_is_rejected() { - let out = bin().args(["--changed", "-"]).output().unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("--config is required")); + assert_fails_with( + &bin().args(["--changed", "-"]).output().unwrap(), + "--config is required", + ); } #[test] fn missing_changed_is_rejected() { - let out = bin().args(["--config", "rules.json"]).output().unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("--changed is required")); + assert_fails_with( + &bin().args(["--config", "rules.json"]).output().unwrap(), + "--changed is required", + ); } #[test] fn missing_config_file_is_an_io_error() { let dir = TempDir::new().unwrap(); let cfg = dir.path().join("does-not-exist.json"); - let out = bin() - .args(["--config", &cfg.to_string_lossy(), "--changed", "-"]) - .stdin(Stdio::null()) - .output() - .unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("io at"), "{}", stderr(&out)); + assert_fails_with(&run_args(&cfg.to_string_lossy(), "-"), "io at"); } #[test] fn missing_changed_file_is_an_io_error() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", RULES); - let out = bin() - .args(["--config", &cfg, "--changed", "/no/such/changed.txt"]) - .output() - .unwrap(); - assert!(!out.status.success()); - assert!( - stderr(&out).contains("io at /no/such/changed.txt"), - "{}", - stderr(&out) + let (_d, cfg) = rules_dir(RULES); + assert_fails_with( + &run_args(&cfg, "/no/such/changed.txt"), + "io at /no/such/changed.txt", ); } #[test] fn invalid_json_config_is_rejected() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", "{ not valid json "); - let out = bin() - .args(["--config", &cfg, "--changed", "-"]) - .stdin(Stdio::null()) - .output() - .unwrap(); - assert!(!out.status.success()); - assert!(stderr(&out).contains("config json"), "{}", stderr(&out)); + let (_d, cfg) = rules_dir("{ not valid json "); + assert_fails_with(&run_args(&cfg, "-"), "config json"); } #[test] fn invalid_glob_in_config_is_rejected() { - let dir = TempDir::new().unwrap(); - let cfg = write(dir.path(), "rules.json", r#"{ "binary": ["a[b"] }"#); - let out = bin() - .args(["--config", &cfg, "--changed", "-"]) - .stdin(Stdio::null()) - .output() - .unwrap(); + let (_d, cfg) = rules_dir(r#"{ "binary": ["a[b"] }"#); + let out = run_args(&cfg, "-"); assert!(!out.status.success()); let e = stderr(&out); assert!(e.contains("invalid glob"), "{e}");