From 8b0a38c39384c9ffac343534006a6c4b59efd47a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:44:16 +0000 Subject: [PATCH 1/3] ci: replace simple Python CI helpers with bash + jq First phase of removing Python from CI. Port the workshop-rs dependency contract check (and its matcher unit test), the dist build-identity writer, and the build-identity verifier to bash + jq, which GitHub-hosted runners provide on every OS. Behavior and output match the Python originals; consumer lists in error messages are now sorted. The dist/release scripts still use Python and move in a later phase. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178vQfKDP4QsRd2Kykm3FZ4 --- .github/workflows/ci.yml | 10 +- scripts/check-workshop-dependency.py | 125 ---------------------- scripts/check-workshop-dependency.sh | 87 +++++++++++++++ scripts/test-check-workshop-dependency.sh | 44 ++++++++ scripts/test_check_workshop_dependency.py | 43 -------- scripts/verify-build-identity.py | 40 ------- scripts/verify-build-identity.sh | 52 +++++++++ scripts/write-build-identity.py | 38 ------- scripts/write-build-identity.sh | 49 +++++++++ 9 files changed, 238 insertions(+), 250 deletions(-) delete mode 100644 scripts/check-workshop-dependency.py create mode 100755 scripts/check-workshop-dependency.sh create mode 100755 scripts/test-check-workshop-dependency.sh delete mode 100644 scripts/test_check_workshop_dependency.py delete mode 100755 scripts/verify-build-identity.py create mode 100755 scripts/verify-build-identity.sh delete mode 100755 scripts/write-build-identity.py create mode 100755 scripts/write-build-identity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d122bd2..a1acb0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,8 +124,8 @@ jobs: env: RUSTUP_TOOLCHAIN: 1.85.0 run: | - python3 -m unittest discover -s scripts -p 'test_check_workshop_dependency.py' - python3 scripts/check-workshop-dependency.py + scripts/test-check-workshop-dependency.sh + scripts/check-workshop-dependency.sh # Keep the surviving owner-independent Wright crates on the declared # MSRV. The provider-backed consumer crates are covered by the stable @@ -521,8 +521,9 @@ jobs: run: cargo build --locked -p wright-cli -p wright-lsp - name: Record build identity + shell: bash run: >- - python3 scripts/write-build-identity.py + bash scripts/write-build-identity.sh --output target/debug/wright-dist.build.json --revision "${{ github.sha }}" --runner-os "${{ runner.os }}" @@ -601,8 +602,9 @@ jobs: path: target/debug - name: Verify build identity + shell: bash run: >- - python scripts/verify-build-identity.py + bash scripts/verify-build-identity.sh --identity target/debug/wright-dist.build.json --revision "${{ github.sha }}" --runner-os "${{ runner.os }}" diff --git a/scripts/check-workshop-dependency.py b/scripts/check-workshop-dependency.py deleted file mode 100644 index 0101e33..0000000 --- a/scripts/check-workshop-dependency.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Wright's single released or candidate workshop-rs dependency contract.""" - -from __future__ import annotations - -import json -import re -import subprocess -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent -CANDIDATE_SOURCE = re.compile( - r"^git\+https://github\.com/wrightkit/workshop-rs\.git\?rev=([0-9a-f]{40})#([0-9a-f]{40})$" -) - - -def is_pinned_git_candidate(source: str | None) -> bool: - if source is None: - return False - match = CANDIDATE_SOURCE.fullmatch(source) - return match is not None and match.group(1) == match.group(2) - - -def metadata() -> dict: - result = subprocess.run( - ["cargo", "metadata", "--locked", "--format-version", "1"], - cwd=ROOT, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise SystemExit( - "workshop dependency validation failed: cargo metadata failed\n" - + result.stderr - ) - return json.loads(result.stdout) - - -def main() -> int: - data = metadata() - packages_by_id = {package["id"]: package for package in data["packages"]} - workspace_packages = [ - packages_by_id[package_id] for package_id in data["workspace_members"] - ] - workshop_packages = [ - package for package in data["packages"] if package["name"] == "workshop-rs" - ] - - if len(workshop_packages) != 1: - versions = ", ".join( - f"{package['version']} ({package['source'] or 'unpublished'})" - for package in workshop_packages - ) - raise SystemExit( - "workshop dependency validation failed: expected exactly one resolved " - f"workshop-rs package, found {len(workshop_packages)}: {versions or 'none'}" - ) - - workshop = workshop_packages[0] - source = workshop.get("source") - is_registry = bool(source and source.startswith("registry+")) - is_pinned_git = is_pinned_git_candidate(source) - - if not (is_registry or is_pinned_git): - raise SystemExit( - "workshop dependency validation failed: workshop-rs must come from a " - f"released registry or pinned git candidate, got {source or 'unpublished'}" - ) - - direct = [] - for package in workspace_packages: - for dependency in package["dependencies"]: - if dependency["name"] == "workshop-rs": - direct.append((package["name"], dependency)) - - if not direct: - raise SystemExit( - "workshop dependency validation failed: no workspace package directly " - "consumes workshop-rs" - ) - - aliases = [ - f"{package}: {dependency['rename']}" - for package, dependency in direct - if dependency.get("rename") - ] - if aliases: - raise SystemExit( - "workshop dependency validation failed: renamed workshop-rs " - f"dependencies are not allowed ({', '.join(aliases)})" - ) - - requirements = {dependency["req"] for _, dependency in direct} - if is_registry: - if len(requirements) != 1 or not re.fullmatch( - r"\^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", next(iter(requirements), "") - ): - consumers = ", ".join( - f"{package} ({dependency['req']})" for package, dependency in direct - ) - raise SystemExit( - "workshop dependency validation failed: direct consumers must use " - f"one ordinary compatible SemVer requirement, found {consumers}" - ) - else: - if requirements != {"*"}: - consumers = ", ".join( - f"{package} ({dependency['req']})" for package, dependency in direct - ) - raise SystemExit( - "workshop dependency validation failed: git candidate direct consumers " - f"must use '*', found {consumers}" - ) - - consumers = ", ".join(sorted(package for package, _ in direct)) - print( - f"workshop-rs contract: {workshop['version']} from {source} " - f"({next(iter(requirements))}; {len(direct)} direct consumers: {consumers})" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check-workshop-dependency.sh b/scripts/check-workshop-dependency.sh new file mode 100755 index 0000000..03ebd77 --- /dev/null +++ b/scripts/check-workshop-dependency.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Validate Wright's single released or candidate workshop-rs dependency contract. + +set -euo pipefail + +CANDIDATE_SOURCE='^git\+https://github\.com/wrightkit/workshop-rs\.git\?rev=([0-9a-f]{40})#([0-9a-f]{40})$' +REGISTRY_REQUIREMENT='^\^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +is_pinned_git_candidate() { + local source="${1-}" + [[ "$source" =~ $CANDIDATE_SOURCE ]] && [[ "${BASH_REMATCH[1]}" == "${BASH_REMATCH[2]}" ]] +} + +fail() { + echo "workshop dependency validation failed: $*" >&2 + exit 1 +} + +main() { + local root metadata + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + if ! metadata="$(cd "$root" && cargo metadata --locked --format-version 1)"; then + fail "cargo metadata failed" + fi + + local workshop_count + workshop_count="$(jq '[.packages[] | select(.name == "workshop-rs")] | length' <<<"$metadata")" + if [[ "$workshop_count" -ne 1 ]]; then + local versions + versions="$(jq -r '[.packages[] | select(.name == "workshop-rs") + | "\(.version) (\(.source // "unpublished"))"] | join(", ")' <<<"$metadata")" + fail "expected exactly one resolved workshop-rs package, found $workshop_count: ${versions:-none}" + fi + + local version source + version="$(jq -r '.packages[] | select(.name == "workshop-rs") | .version' <<<"$metadata")" + source="$(jq -r '.packages[] | select(.name == "workshop-rs") | .source // ""' <<<"$metadata")" + + local is_registry=false + if [[ "$source" == registry+* ]]; then + is_registry=true + elif ! is_pinned_git_candidate "$source"; then + fail "workshop-rs must come from a released registry or pinned git candidate, got ${source:-unpublished}" + fi + + # One "\t\t" line per direct workspace consumer. + local direct + direct="$(jq -r ' + (.workspace_members) as $members + | .packages[] + | select(.id as $id | $members | index($id)) + | .name as $package + | .dependencies[] + | select(.name == "workshop-rs") + | [$package, .req, (.rename // "")] | @tsv' <<<"$metadata")" + if [[ -z "$direct" ]]; then + fail "no workspace package directly consumes workshop-rs" + fi + + local aliases + aliases="$(awk -F '\t' '$3 != "" { printf "%s%s: %s", sep, $1, $3; sep = ", " }' <<<"$direct")" + if [[ -n "$aliases" ]]; then + fail "renamed workshop-rs dependencies are not allowed ($aliases)" + fi + + local requirements requirement_count requirement listed + requirements="$(cut -f2 <<<"$direct" | sort -u)" + requirement_count="$(wc -l <<<"$requirements" | tr -d ' ')" + requirement="$(head -n1 <<<"$requirements")" + listed="$(awk -F '\t' '{ printf "%s%s (%s)", sep, $1, $2; sep = ", " }' <<<"$direct")" + if [[ "$is_registry" == true ]]; then + if [[ "$requirement_count" -ne 1 || ! "$requirement" =~ $REGISTRY_REQUIREMENT ]]; then + fail "direct consumers must use one ordinary compatible SemVer requirement, found $listed" + fi + elif [[ "$requirement_count" -ne 1 || "$requirement" != "*" ]]; then + fail "git candidate direct consumers must use '*', found $listed" + fi + + local consumer_count consumers + consumer_count="$(wc -l <<<"$direct" | tr -d ' ')" + consumers="$(cut -f1 <<<"$direct" | LC_ALL=C sort | paste -sd, - | sed 's/,/, /g')" + echo "workshop-rs contract: $version from $source ($requirement; $consumer_count direct consumers: $consumers)" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/test-check-workshop-dependency.sh b/scripts/test-check-workshop-dependency.sh new file mode 100755 index 0000000..839f261 --- /dev/null +++ b/scripts/test-check-workshop-dependency.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Unit tests for the pinned git candidate matcher in check-workshop-dependency.sh. + +set -euo pipefail + +# shellcheck source=scripts/check-workshop-dependency.sh +source "$(dirname "${BASH_SOURCE[0]}")/check-workshop-dependency.sh" + +failures=0 +revision="ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053" +repo="git+https://github.com/wrightkit/workshop-rs.git" + +expect_accept() { + if ! is_pinned_git_candidate "$1"; then + echo "FAIL: expected accept: $1" >&2 + failures=$((failures + 1)) + fi +} + +expect_reject() { + if is_pinned_git_candidate "$1"; then + echo "FAIL: expected reject: $1" >&2 + failures=$((failures + 1)) + fi +} + +# Exact revision pin. +expect_accept "$repo?rev=$revision#$revision" + +# Git sources without an explicit full revision. +expect_reject "$repo#$revision" +expect_reject "$repo?branch=main#$revision" +expect_reject "$repo?rev=v1.0.0#$revision" +expect_reject "$repo?rev=$revision#0000000000000000000000000000000000000000" +expect_reject "git+https://github.com/other/workshop-rs.git?rev=$revision#$revision" + +# Missing source. +expect_reject "" + +if [[ "$failures" -ne 0 ]]; then + echo "$failures check-workshop-dependency test(s) failed" >&2 + exit 1 +fi +echo "check-workshop-dependency tests passed" diff --git a/scripts/test_check_workshop_dependency.py b/scripts/test_check_workshop_dependency.py deleted file mode 100644 index 22a3583..0000000 --- a/scripts/test_check_workshop_dependency.py +++ /dev/null @@ -1,43 +0,0 @@ -import unittest -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -spec = spec_from_file_location( - "check_workshop_dependency", Path(__file__).with_name("check-workshop-dependency.py") -) -assert spec is not None and spec.loader is not None -checker = module_from_spec(spec) -spec.loader.exec_module(checker) -is_pinned_git_candidate = checker.is_pinned_git_candidate - - -class PinnedGitCandidateTests(unittest.TestCase): - def test_accepts_exact_revision_pin(self): - revision = "ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053" - source = ( - "git+https://github.com/wrightkit/workshop-rs.git?rev=" - f"{revision}#{revision}" - ) - self.assertTrue(is_pinned_git_candidate(source)) - - def test_rejects_git_sources_without_an_explicit_full_revision(self): - resolved = "ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053" - sources = ( - f"git+https://github.com/wrightkit/workshop-rs.git#{resolved}", - f"git+https://github.com/wrightkit/workshop-rs.git?branch=main#{resolved}", - "git+https://github.com/wrightkit/workshop-rs.git?rev=v1.0.0#" - f"{resolved}", - "git+https://github.com/wrightkit/workshop-rs.git?rev=" - f"{resolved}#0000000000000000000000000000000000000000", - f"git+https://github.com/other/workshop-rs.git?rev={resolved}#{resolved}", - ) - for source in sources: - with self.subTest(source=source): - self.assertFalse(is_pinned_git_candidate(source)) - - def test_rejects_missing_source(self): - self.assertFalse(is_pinned_git_candidate(None)) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/verify-build-identity.py b/scripts/verify-build-identity.py deleted file mode 100755 index 52c1fd8..0000000 --- a/scripts/verify-build-identity.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Verify a downloaded CI build identity and prepare native executables.""" - -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--identity", type=Path, required=True) - parser.add_argument("--revision", required=True) - parser.add_argument("--runner-os", required=True) - parser.add_argument("--wright", type=Path, required=True) - parser.add_argument("--wright-lsp", type=Path, required=True) - args = parser.parse_args() - - identity = json.loads(args.identity.read_text()) - expected = { - "revision": args.revision, - "runner_os": args.runner_os, - "toolchain": "stable", - "profile": "dev", - "packages": ["wright-cli", "wright-lsp"], - } - for key, value in expected.items(): - if identity.get(key) != value: - raise SystemExit(f"build identity mismatch for {key}: {identity.get(key)!r}") - - if args.runner_os != "Windows": - os.chmod(args.wright, 0o755) - os.chmod(args.wright_lsp, 0o755) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/verify-build-identity.sh b/scripts/verify-build-identity.sh new file mode 100755 index 0000000..f95235c --- /dev/null +++ b/scripts/verify-build-identity.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Verify a downloaded CI build identity and prepare native executables. +# +# Usage: verify-build-identity.sh --identity PATH --revision SHA --runner-os OS +# --wright PATH --wright-lsp PATH + +set -euo pipefail + +identity="" revision="" runner_os="" wright="" wright_lsp="" +while [[ $# -gt 0 ]]; do + case "$1" in + --identity) identity="$2" ;; + --revision) revision="$2" ;; + --runner-os) runner_os="$2" ;; + --wright) wright="$2" ;; + --wright-lsp) wright_lsp="$2" ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift 2 +done + +for name in identity revision runner_os wright wright_lsp; do + if [[ -z "${!name}" ]]; then + echo "missing required argument --${name//_/-}" >&2 + exit 2 + fi +done + +# Prints the first mismatching "key: actual" pair, or nothing when all match. +mismatch="$(jq -r \ + --arg revision "$revision" \ + --arg runner_os "$runner_os" \ + '. as $identity + | { + revision: $revision, + runner_os: $runner_os, + toolchain: "stable", + profile: "dev", + packages: ["wright-cli", "wright-lsp"] + } + | to_entries + | map(select($identity[.key] != .value)) + | first(.[] | "\(.key): \($identity[.key] | tojson)") // empty' \ + "$identity")" +if [[ -n "$mismatch" ]]; then + echo "build identity mismatch for ${mismatch}" >&2 + exit 1 +fi + +if [[ "$runner_os" != Windows ]]; then + chmod 755 "$wright" "$wright_lsp" +fi diff --git a/scripts/write-build-identity.py b/scripts/write-build-identity.py deleted file mode 100755 index 7bbdcfc..0000000 --- a/scripts/write-build-identity.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -"""Write the machine-readable identity for a CI build artifact.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -from pathlib import Path - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--revision", required=True) - parser.add_argument("--runner-os", required=True) - parser.add_argument("--target") - parser.add_argument("--toolchain", required=True) - parser.add_argument("--profile", required=True) - args = parser.parse_args() - - identity = { - "revision": args.revision, - "runner_os": args.runner_os, - "target": args.target - or subprocess.check_output(["rustc", "-vV"], text=True).split("host: ", 1)[1].splitlines()[0], - "toolchain": args.toolchain, - "profile": args.profile, - "packages": ["wright-cli", "wright-lsp"], - "features": [], - } - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(identity, separators=(",", ":")) + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/write-build-identity.sh b/scripts/write-build-identity.sh new file mode 100755 index 0000000..4325634 --- /dev/null +++ b/scripts/write-build-identity.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Write the machine-readable identity for a CI build artifact. +# +# Usage: write-build-identity.sh --output PATH --revision SHA --runner-os OS +# --toolchain NAME --profile NAME [--target TRIPLE] + +set -euo pipefail + +output="" revision="" runner_os="" target="" toolchain="" profile="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) output="$2" ;; + --revision) revision="$2" ;; + --runner-os) runner_os="$2" ;; + --target) target="$2" ;; + --toolchain) toolchain="$2" ;; + --profile) profile="$2" ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift 2 +done + +for name in output revision runner_os toolchain profile; do + if [[ -z "${!name}" ]]; then + echo "missing required argument --${name//_/-}" >&2 + exit 2 + fi +done + +if [[ -z "$target" ]]; then + target="$(rustc -vV | sed -n 's/^host: //p' | tr -d '\r')" +fi + +mkdir -p "$(dirname "$output")" +jq -cn \ + --arg revision "$revision" \ + --arg runner_os "$runner_os" \ + --arg target "$target" \ + --arg toolchain "$toolchain" \ + --arg profile "$profile" \ + '{ + revision: $revision, + runner_os: $runner_os, + target: $target, + toolchain: $toolchain, + profile: $profile, + packages: ["wright-cli", "wright-lsp"], + features: [] + }' >"$output" From 0692878460db054b793a0fea4b01f334a2d9516f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:49:52 +0000 Subject: [PATCH 2/3] ci: drop script steps for workshop dependency and build identity Keep CI steps free of bash/Python helper scripts where possible: - Move the workshop-rs dependency contract check into a Rust integration test in wright-analyzer (a direct workshop-rs consumer), so it runs in the existing stable and MSRV `cargo test` gates instead of a dedicated CI step. - Remove the dist build-identity writer/verifier. Artifacts are already scoped to the workflow run; dist-validation now only restores the executable bit on non-Windows runners. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178vQfKDP4QsRd2Kykm3FZ4 --- .github/workflows/ci.yml | 31 +-- .../tests/workshop_dependency.rs | 202 ++++++++++++++++++ scripts/check-workshop-dependency.sh | 87 -------- scripts/test-check-workshop-dependency.sh | 44 ---- scripts/verify-build-identity.sh | 52 ----- scripts/write-build-identity.sh | 49 ----- 6 files changed, 205 insertions(+), 260 deletions(-) create mode 100644 crates/wright-analyzer/tests/workshop_dependency.rs delete mode 100755 scripts/check-workshop-dependency.sh delete mode 100755 scripts/test-check-workshop-dependency.sh delete mode 100755 scripts/verify-build-identity.sh delete mode 100755 scripts/write-build-identity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1acb0c..523eec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,13 +120,6 @@ jobs: - name: Check formatting run: cargo +1.85.0 fmt --all -- --check - - name: Check Workshop dependency - env: - RUSTUP_TOOLCHAIN: 1.85.0 - run: | - scripts/test-check-workshop-dependency.sh - scripts/check-workshop-dependency.sh - # Keep the surviving owner-independent Wright crates on the declared # MSRV. The provider-backed consumer crates are covered by the stable # full-workspace gate and the dedicated integration jobs below. @@ -520,16 +513,6 @@ jobs: shell: bash run: cargo build --locked -p wright-cli -p wright-lsp - - name: Record build identity - shell: bash - run: >- - bash scripts/write-build-identity.sh - --output target/debug/wright-dist.build.json - --revision "${{ github.sha }}" - --runner-os "${{ runner.os }}" - --toolchain stable - --profile dev - - name: Upload distribution binaries uses: actions/upload-artifact@v7 with: @@ -539,7 +522,6 @@ jobs: target/debug/wright.exe target/debug/wright-lsp target/debug/wright-lsp.exe - target/debug/wright-dist.build.json if-no-files-found: error dist-validation: @@ -601,16 +583,9 @@ jobs: name: wright-dist-cli-${{ matrix.os }} path: target/debug - - name: Verify build identity - shell: bash - run: >- - bash scripts/verify-build-identity.sh - --identity target/debug/wright-dist.build.json - --revision "${{ github.sha }}" - --runner-os "${{ runner.os }}" - --wright target/debug/wright - --wright-lsp target/debug/wright-lsp - + - name: Prepare distribution executables + if: runner.os != 'Windows' + run: chmod +x target/debug/wright target/debug/wright-lsp - name: Validate package metadata and installers if: matrix.channel == 'install.sh' || matrix.channel == 'install.ps1' diff --git a/crates/wright-analyzer/tests/workshop_dependency.rs b/crates/wright-analyzer/tests/workshop_dependency.rs new file mode 100644 index 0000000..639bd92 --- /dev/null +++ b/crates/wright-analyzer/tests/workshop_dependency.rs @@ -0,0 +1,202 @@ +//! Workspace contract: Wright consumes exactly one released or pinned +//! candidate workshop-rs package, through direct, non-renamed dependencies +//! that share one requirement. Hosted here because this crate is a direct +//! workshop-rs consumer covered by both the stable and MSRV test gates. + +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +const CANDIDATE_REPOSITORY: &str = "git+https://github.com/wrightkit/workshop-rs.git?rev="; + +/// Accepts only `git+?rev=#` where both are the same +/// full 40-character lowercase hex revision. +fn is_pinned_git_candidate(source: Option<&str>) -> bool { + let Some(pin) = source.and_then(|source| source.strip_prefix(CANDIDATE_REPOSITORY)) else { + return false; + }; + let Some((requested, resolved)) = pin.split_once('#') else { + return false; + }; + let is_revision = |text: &str| { + text.len() == 40 + && text + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }; + is_revision(requested) && requested == resolved +} + +/// Matches `^MAJOR.MINOR.PATCH` with an optional `-prerelease` suffix. +fn is_compatible_semver_requirement(requirement: &str) -> bool { + let Some(version) = requirement.strip_prefix('^') else { + return false; + }; + let (core, prerelease) = match version.split_once('-') { + Some((core, prerelease)) => (core, Some(prerelease)), + None => (version, None), + }; + let core_ok = { + let parts: Vec<&str> = core.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + }; + let prerelease_ok = prerelease.is_none_or(|text| { + !text.is_empty() + && text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + }); + core_ok && prerelease_ok +} + +fn cargo_metadata() -> Value { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned()); + let output = Command::new(cargo) + .args(["metadata", "--locked", "--format-version", "1"]) + .current_dir(root) + .output() + .expect("run cargo metadata"); + assert!( + output.status.success(), + "cargo metadata failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("parse cargo metadata") +} + +#[test] +fn workspace_consumes_one_released_or_pinned_workshop_rs() { + let metadata = cargo_metadata(); + let packages = metadata["packages"].as_array().expect("packages"); + + let workshop: Vec<&Value> = packages + .iter() + .filter(|package| package["name"] == "workshop-rs") + .collect(); + assert_eq!( + workshop.len(), + 1, + "expected exactly one resolved workshop-rs package, found: {:?}", + workshop + .iter() + .map(|package| format!("{} ({})", package["version"], package["source"])) + .collect::>() + ); + let source = workshop[0]["source"].as_str(); + let is_registry = source.is_some_and(|source| source.starts_with("registry+")); + assert!( + is_registry || is_pinned_git_candidate(source), + "workshop-rs must come from a released registry or pinned git candidate, got {}", + source.unwrap_or("unpublished") + ); + + let members: BTreeSet<&str> = metadata["workspace_members"] + .as_array() + .expect("workspace_members") + .iter() + .filter_map(Value::as_str) + .collect(); + let direct: Vec<(&str, &Value)> = packages + .iter() + .filter(|package| { + package["id"] + .as_str() + .is_some_and(|id| members.contains(id)) + }) + .flat_map(|package| { + let name = package["name"].as_str().expect("package name"); + package["dependencies"] + .as_array() + .expect("dependencies") + .iter() + .filter(|dependency| dependency["name"] == "workshop-rs") + .map(move |dependency| (name, dependency)) + }) + .collect(); + assert!( + !direct.is_empty(), + "no workspace package directly consumes workshop-rs" + ); + + let aliases: Vec = direct + .iter() + .filter_map(|(package, dependency)| { + dependency["rename"] + .as_str() + .map(|rename| format!("{package}: {rename}")) + }) + .collect(); + assert!( + aliases.is_empty(), + "renamed workshop-rs dependencies are not allowed: {aliases:?}" + ); + + let requirements: BTreeSet<&str> = direct + .iter() + .map(|(_, dependency)| dependency["req"].as_str().expect("requirement")) + .collect(); + let consumers: Vec = direct + .iter() + .map(|(package, dependency)| { + format!("{package} ({})", dependency["req"].as_str().unwrap_or("?")) + }) + .collect(); + if is_registry { + assert!( + requirements.len() == 1 + && requirements + .iter() + .all(|requirement| is_compatible_semver_requirement(requirement)), + "direct consumers must use one ordinary compatible SemVer requirement, found {consumers:?}" + ); + } else { + assert_eq!( + requirements, + BTreeSet::from(["*"]), + "git candidate direct consumers must use '*', found {consumers:?}" + ); + } +} + +#[test] +fn pinned_git_candidate_requires_exact_full_revision() { + let revision = "ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053"; + let repo = "git+https://github.com/wrightkit/workshop-rs.git"; + + assert!(is_pinned_git_candidate(Some(&format!( + "{repo}?rev={revision}#{revision}" + )))); + + for source in [ + format!("{repo}#{revision}"), + format!("{repo}?branch=main#{revision}"), + format!("{repo}?rev=v1.0.0#{revision}"), + format!("{repo}?rev={revision}#0000000000000000000000000000000000000000"), + format!("git+https://github.com/other/workshop-rs.git?rev={revision}#{revision}"), + ] { + assert!(!is_pinned_git_candidate(Some(&source)), "accepted {source}"); + } + assert!(!is_pinned_git_candidate(None)); +} + +#[test] +fn registry_requirement_must_be_compatible_semver() { + for requirement in ["^1.0.0", "^0.5.12", "^1.0.0-rc.1"] { + assert!( + is_compatible_semver_requirement(requirement), + "{requirement}" + ); + } + for requirement in ["1.0.0", "=1.0.0", "^1.0", "^1.0.0-", ">=1.0.0", "*"] { + assert!( + !is_compatible_semver_requirement(requirement), + "{requirement}" + ); + } +} diff --git a/scripts/check-workshop-dependency.sh b/scripts/check-workshop-dependency.sh deleted file mode 100755 index 03ebd77..0000000 --- a/scripts/check-workshop-dependency.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# Validate Wright's single released or candidate workshop-rs dependency contract. - -set -euo pipefail - -CANDIDATE_SOURCE='^git\+https://github\.com/wrightkit/workshop-rs\.git\?rev=([0-9a-f]{40})#([0-9a-f]{40})$' -REGISTRY_REQUIREMENT='^\^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' - -is_pinned_git_candidate() { - local source="${1-}" - [[ "$source" =~ $CANDIDATE_SOURCE ]] && [[ "${BASH_REMATCH[1]}" == "${BASH_REMATCH[2]}" ]] -} - -fail() { - echo "workshop dependency validation failed: $*" >&2 - exit 1 -} - -main() { - local root metadata - root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - if ! metadata="$(cd "$root" && cargo metadata --locked --format-version 1)"; then - fail "cargo metadata failed" - fi - - local workshop_count - workshop_count="$(jq '[.packages[] | select(.name == "workshop-rs")] | length' <<<"$metadata")" - if [[ "$workshop_count" -ne 1 ]]; then - local versions - versions="$(jq -r '[.packages[] | select(.name == "workshop-rs") - | "\(.version) (\(.source // "unpublished"))"] | join(", ")' <<<"$metadata")" - fail "expected exactly one resolved workshop-rs package, found $workshop_count: ${versions:-none}" - fi - - local version source - version="$(jq -r '.packages[] | select(.name == "workshop-rs") | .version' <<<"$metadata")" - source="$(jq -r '.packages[] | select(.name == "workshop-rs") | .source // ""' <<<"$metadata")" - - local is_registry=false - if [[ "$source" == registry+* ]]; then - is_registry=true - elif ! is_pinned_git_candidate "$source"; then - fail "workshop-rs must come from a released registry or pinned git candidate, got ${source:-unpublished}" - fi - - # One "\t\t" line per direct workspace consumer. - local direct - direct="$(jq -r ' - (.workspace_members) as $members - | .packages[] - | select(.id as $id | $members | index($id)) - | .name as $package - | .dependencies[] - | select(.name == "workshop-rs") - | [$package, .req, (.rename // "")] | @tsv' <<<"$metadata")" - if [[ -z "$direct" ]]; then - fail "no workspace package directly consumes workshop-rs" - fi - - local aliases - aliases="$(awk -F '\t' '$3 != "" { printf "%s%s: %s", sep, $1, $3; sep = ", " }' <<<"$direct")" - if [[ -n "$aliases" ]]; then - fail "renamed workshop-rs dependencies are not allowed ($aliases)" - fi - - local requirements requirement_count requirement listed - requirements="$(cut -f2 <<<"$direct" | sort -u)" - requirement_count="$(wc -l <<<"$requirements" | tr -d ' ')" - requirement="$(head -n1 <<<"$requirements")" - listed="$(awk -F '\t' '{ printf "%s%s (%s)", sep, $1, $2; sep = ", " }' <<<"$direct")" - if [[ "$is_registry" == true ]]; then - if [[ "$requirement_count" -ne 1 || ! "$requirement" =~ $REGISTRY_REQUIREMENT ]]; then - fail "direct consumers must use one ordinary compatible SemVer requirement, found $listed" - fi - elif [[ "$requirement_count" -ne 1 || "$requirement" != "*" ]]; then - fail "git candidate direct consumers must use '*', found $listed" - fi - - local consumer_count consumers - consumer_count="$(wc -l <<<"$direct" | tr -d ' ')" - consumers="$(cut -f1 <<<"$direct" | LC_ALL=C sort | paste -sd, - | sed 's/,/, /g')" - echo "workshop-rs contract: $version from $source ($requirement; $consumer_count direct consumers: $consumers)" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/test-check-workshop-dependency.sh b/scripts/test-check-workshop-dependency.sh deleted file mode 100755 index 839f261..0000000 --- a/scripts/test-check-workshop-dependency.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# Unit tests for the pinned git candidate matcher in check-workshop-dependency.sh. - -set -euo pipefail - -# shellcheck source=scripts/check-workshop-dependency.sh -source "$(dirname "${BASH_SOURCE[0]}")/check-workshop-dependency.sh" - -failures=0 -revision="ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053" -repo="git+https://github.com/wrightkit/workshop-rs.git" - -expect_accept() { - if ! is_pinned_git_candidate "$1"; then - echo "FAIL: expected accept: $1" >&2 - failures=$((failures + 1)) - fi -} - -expect_reject() { - if is_pinned_git_candidate "$1"; then - echo "FAIL: expected reject: $1" >&2 - failures=$((failures + 1)) - fi -} - -# Exact revision pin. -expect_accept "$repo?rev=$revision#$revision" - -# Git sources without an explicit full revision. -expect_reject "$repo#$revision" -expect_reject "$repo?branch=main#$revision" -expect_reject "$repo?rev=v1.0.0#$revision" -expect_reject "$repo?rev=$revision#0000000000000000000000000000000000000000" -expect_reject "git+https://github.com/other/workshop-rs.git?rev=$revision#$revision" - -# Missing source. -expect_reject "" - -if [[ "$failures" -ne 0 ]]; then - echo "$failures check-workshop-dependency test(s) failed" >&2 - exit 1 -fi -echo "check-workshop-dependency tests passed" diff --git a/scripts/verify-build-identity.sh b/scripts/verify-build-identity.sh deleted file mode 100755 index f95235c..0000000 --- a/scripts/verify-build-identity.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# Verify a downloaded CI build identity and prepare native executables. -# -# Usage: verify-build-identity.sh --identity PATH --revision SHA --runner-os OS -# --wright PATH --wright-lsp PATH - -set -euo pipefail - -identity="" revision="" runner_os="" wright="" wright_lsp="" -while [[ $# -gt 0 ]]; do - case "$1" in - --identity) identity="$2" ;; - --revision) revision="$2" ;; - --runner-os) runner_os="$2" ;; - --wright) wright="$2" ;; - --wright-lsp) wright_lsp="$2" ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac - shift 2 -done - -for name in identity revision runner_os wright wright_lsp; do - if [[ -z "${!name}" ]]; then - echo "missing required argument --${name//_/-}" >&2 - exit 2 - fi -done - -# Prints the first mismatching "key: actual" pair, or nothing when all match. -mismatch="$(jq -r \ - --arg revision "$revision" \ - --arg runner_os "$runner_os" \ - '. as $identity - | { - revision: $revision, - runner_os: $runner_os, - toolchain: "stable", - profile: "dev", - packages: ["wright-cli", "wright-lsp"] - } - | to_entries - | map(select($identity[.key] != .value)) - | first(.[] | "\(.key): \($identity[.key] | tojson)") // empty' \ - "$identity")" -if [[ -n "$mismatch" ]]; then - echo "build identity mismatch for ${mismatch}" >&2 - exit 1 -fi - -if [[ "$runner_os" != Windows ]]; then - chmod 755 "$wright" "$wright_lsp" -fi diff --git a/scripts/write-build-identity.sh b/scripts/write-build-identity.sh deleted file mode 100755 index 4325634..0000000 --- a/scripts/write-build-identity.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Write the machine-readable identity for a CI build artifact. -# -# Usage: write-build-identity.sh --output PATH --revision SHA --runner-os OS -# --toolchain NAME --profile NAME [--target TRIPLE] - -set -euo pipefail - -output="" revision="" runner_os="" target="" toolchain="" profile="" -while [[ $# -gt 0 ]]; do - case "$1" in - --output) output="$2" ;; - --revision) revision="$2" ;; - --runner-os) runner_os="$2" ;; - --target) target="$2" ;; - --toolchain) toolchain="$2" ;; - --profile) profile="$2" ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac - shift 2 -done - -for name in output revision runner_os toolchain profile; do - if [[ -z "${!name}" ]]; then - echo "missing required argument --${name//_/-}" >&2 - exit 2 - fi -done - -if [[ -z "$target" ]]; then - target="$(rustc -vV | sed -n 's/^host: //p' | tr -d '\r')" -fi - -mkdir -p "$(dirname "$output")" -jq -cn \ - --arg revision "$revision" \ - --arg runner_os "$runner_os" \ - --arg target "$target" \ - --arg toolchain "$toolchain" \ - --arg profile "$profile" \ - '{ - revision: $revision, - runner_os: $runner_os, - target: $target, - toolchain: $toolchain, - profile: $profile, - packages: ["wright-cli", "wright-lsp"], - features: [] - }' >"$output" From 73a469aabe787dd9d6a4855e996f45ac95c4f70e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:56:52 +0000 Subject: [PATCH 3/3] ci: drop redundant workshop-rs dependency contract check workshop-rs is pinned once in [workspace.dependencies], every consumer inherits it with `workspace = true`, no other dependency pulls in workshop-rs, and CI resolves with --locked. The separate contract check no longer guards anything those already guarantee, so remove it instead of porting it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178vQfKDP4QsRd2Kykm3FZ4 --- .../tests/workshop_dependency.rs | 202 ------------------ 1 file changed, 202 deletions(-) delete mode 100644 crates/wright-analyzer/tests/workshop_dependency.rs diff --git a/crates/wright-analyzer/tests/workshop_dependency.rs b/crates/wright-analyzer/tests/workshop_dependency.rs deleted file mode 100644 index 639bd92..0000000 --- a/crates/wright-analyzer/tests/workshop_dependency.rs +++ /dev/null @@ -1,202 +0,0 @@ -//! Workspace contract: Wright consumes exactly one released or pinned -//! candidate workshop-rs package, through direct, non-renamed dependencies -//! that share one requirement. Hosted here because this crate is a direct -//! workshop-rs consumer covered by both the stable and MSRV test gates. - -use std::collections::BTreeSet; -use std::path::Path; -use std::process::Command; - -use serde_json::Value; - -const CANDIDATE_REPOSITORY: &str = "git+https://github.com/wrightkit/workshop-rs.git?rev="; - -/// Accepts only `git+?rev=#` where both are the same -/// full 40-character lowercase hex revision. -fn is_pinned_git_candidate(source: Option<&str>) -> bool { - let Some(pin) = source.and_then(|source| source.strip_prefix(CANDIDATE_REPOSITORY)) else { - return false; - }; - let Some((requested, resolved)) = pin.split_once('#') else { - return false; - }; - let is_revision = |text: &str| { - text.len() == 40 - && text - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - }; - is_revision(requested) && requested == resolved -} - -/// Matches `^MAJOR.MINOR.PATCH` with an optional `-prerelease` suffix. -fn is_compatible_semver_requirement(requirement: &str) -> bool { - let Some(version) = requirement.strip_prefix('^') else { - return false; - }; - let (core, prerelease) = match version.split_once('-') { - Some((core, prerelease)) => (core, Some(prerelease)), - None => (version, None), - }; - let core_ok = { - let parts: Vec<&str> = core.split('.').collect(); - parts.len() == 3 - && parts - .iter() - .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) - }; - let prerelease_ok = prerelease.is_none_or(|text| { - !text.is_empty() - && text - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') - }); - core_ok && prerelease_ok -} - -fn cargo_metadata() -> Value { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned()); - let output = Command::new(cargo) - .args(["metadata", "--locked", "--format-version", "1"]) - .current_dir(root) - .output() - .expect("run cargo metadata"); - assert!( - output.status.success(), - "cargo metadata failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - serde_json::from_slice(&output.stdout).expect("parse cargo metadata") -} - -#[test] -fn workspace_consumes_one_released_or_pinned_workshop_rs() { - let metadata = cargo_metadata(); - let packages = metadata["packages"].as_array().expect("packages"); - - let workshop: Vec<&Value> = packages - .iter() - .filter(|package| package["name"] == "workshop-rs") - .collect(); - assert_eq!( - workshop.len(), - 1, - "expected exactly one resolved workshop-rs package, found: {:?}", - workshop - .iter() - .map(|package| format!("{} ({})", package["version"], package["source"])) - .collect::>() - ); - let source = workshop[0]["source"].as_str(); - let is_registry = source.is_some_and(|source| source.starts_with("registry+")); - assert!( - is_registry || is_pinned_git_candidate(source), - "workshop-rs must come from a released registry or pinned git candidate, got {}", - source.unwrap_or("unpublished") - ); - - let members: BTreeSet<&str> = metadata["workspace_members"] - .as_array() - .expect("workspace_members") - .iter() - .filter_map(Value::as_str) - .collect(); - let direct: Vec<(&str, &Value)> = packages - .iter() - .filter(|package| { - package["id"] - .as_str() - .is_some_and(|id| members.contains(id)) - }) - .flat_map(|package| { - let name = package["name"].as_str().expect("package name"); - package["dependencies"] - .as_array() - .expect("dependencies") - .iter() - .filter(|dependency| dependency["name"] == "workshop-rs") - .map(move |dependency| (name, dependency)) - }) - .collect(); - assert!( - !direct.is_empty(), - "no workspace package directly consumes workshop-rs" - ); - - let aliases: Vec = direct - .iter() - .filter_map(|(package, dependency)| { - dependency["rename"] - .as_str() - .map(|rename| format!("{package}: {rename}")) - }) - .collect(); - assert!( - aliases.is_empty(), - "renamed workshop-rs dependencies are not allowed: {aliases:?}" - ); - - let requirements: BTreeSet<&str> = direct - .iter() - .map(|(_, dependency)| dependency["req"].as_str().expect("requirement")) - .collect(); - let consumers: Vec = direct - .iter() - .map(|(package, dependency)| { - format!("{package} ({})", dependency["req"].as_str().unwrap_or("?")) - }) - .collect(); - if is_registry { - assert!( - requirements.len() == 1 - && requirements - .iter() - .all(|requirement| is_compatible_semver_requirement(requirement)), - "direct consumers must use one ordinary compatible SemVer requirement, found {consumers:?}" - ); - } else { - assert_eq!( - requirements, - BTreeSet::from(["*"]), - "git candidate direct consumers must use '*', found {consumers:?}" - ); - } -} - -#[test] -fn pinned_git_candidate_requires_exact_full_revision() { - let revision = "ac5a6a4cf15bfccc5597cfd6ccb7b5028dfd5053"; - let repo = "git+https://github.com/wrightkit/workshop-rs.git"; - - assert!(is_pinned_git_candidate(Some(&format!( - "{repo}?rev={revision}#{revision}" - )))); - - for source in [ - format!("{repo}#{revision}"), - format!("{repo}?branch=main#{revision}"), - format!("{repo}?rev=v1.0.0#{revision}"), - format!("{repo}?rev={revision}#0000000000000000000000000000000000000000"), - format!("git+https://github.com/other/workshop-rs.git?rev={revision}#{revision}"), - ] { - assert!(!is_pinned_git_candidate(Some(&source)), "accepted {source}"); - } - assert!(!is_pinned_git_candidate(None)); -} - -#[test] -fn registry_requirement_must_be_compatible_semver() { - for requirement in ["^1.0.0", "^0.5.12", "^1.0.0-rc.1"] { - assert!( - is_compatible_semver_requirement(requirement), - "{requirement}" - ); - } - for requirement in ["1.0.0", "=1.0.0", "^1.0", "^1.0.0-", ">=1.0.0", "*"] { - assert!( - !is_compatible_semver_requirement(requirement), - "{requirement}" - ); - } -}