diff --git a/.github/actions/desktop-check/action.yml b/.github/actions/desktop-check/action.yml new file mode 100644 index 00000000..bab71a71 --- /dev/null +++ b/.github/actions/desktop-check/action.yml @@ -0,0 +1,170 @@ +name: Desktop check +description: Run the existing desktop checks with Cargo-Rail's validated package scope. + +inputs: + artifact: + description: Name of the uploaded Cargo-Rail plan bundle. + required: true + host-full: + description: Whether repository policy widened host checks to the workspace. + required: true + build: + description: Whether cargo.build is required. + required: true + clippy: + description: Whether cargo.clippy is required. + required: true + test: + description: Whether cargo.test is required. + required: true + +runs: + using: composite + steps: + - name: Download validated CI plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact }} + path: ${{ runner.temp }}/ci-plan + + - name: Install native Cargo-Rail 0.25.0 + shell: bash + run: python3 .github/scripts/install_cargo_rail.py + + - name: Verify CI plan + shell: bash + env: + PLAN_DIR: ${{ runner.temp }}/ci-plan + run: | + chmod +x "$PLAN_DIR/read.py" + python3 "$PLAN_DIR/read.py" verify-checkout "$PLAN_DIR/plan.json" + + - name: Enforce process helper boundaries + if: runner.os == 'Linux' + shell: bash + run: | + violations=0 + + smol_unblock_violations="$( + grep -rn "smol::unblock" crates --include="*.rs" \ + | grep -v -e "crates/runtime/src/host.rs:" -e "crates/agent/src/process.rs:" \ + | grep -v '^[^:]*:[0-9]*:[[:space:]]*//' \ + || true + )" + if [[ -n "$smol_unblock_violations" ]]; then + echo "Direct smol::unblock calls found outside approved helpers:" + echo "$smol_unblock_violations" + violations=1 + fi + + command_new_violations="$( + grep -rn "Command::new(" crates --include="*.rs" \ + | grep -v -e "crates/services/src/process.rs:" -e "crates/agent/src/process.rs:" \ + -e "crates/voice/build.rs:" \ + | grep -v '^[^:]*:[0-9]*:[[:space:]]*//' \ + || true + )" + if [[ -n "$command_new_violations" ]]; then + echo "Direct Command::new calls found outside approved helpers:" + echo "$command_new_violations" + violations=1 + fi + + if (( violations != 0 )); then + exit 1 + fi + + - name: Disable Windows Defender real-time scanning + if: runner.os == 'Windows' + shell: pwsh + continue-on-error: true + run: Set-MpPreference -DisableRealtimeMonitoring $true + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo build data + uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-${{ runner.os }}-${{ runner.arch }} + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config libssl-dev libzstd-dev \ + libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-dev \ + libxkbcommon-x11-dev libx11-xcb-dev libxcb1-dev libgbm-dev \ + libvulkan-dev xvfb + if cargo tree -e normal -i glib-sys >/dev/null 2>&1; then + sudo apt-get install -y --no-install-recommends \ + libgtk-3-dev libwebkit2gtk-4.1-dev + fi + + - name: Check formatting + shell: bash + run: cargo fmt --all --check + + - name: Run Clippy + if: inputs.clippy == 'true' + shell: bash + env: + HOST_FULL: ${{ inputs.host-full }} + PLAN_DIR: ${{ runner.temp }}/ci-plan + run: | + full=() + if [[ "$HOST_FULL" == true ]]; then + full+=(--host-full) + fi + python3 .github/scripts/run_rail_cargo.py \ + --plan "$PLAN_DIR/plan.json" --reader "$PLAN_DIR/read.py" \ + --work cargo.clippy "${full[@]}" clippy --all-targets --locked -- -D warnings + + - name: Build affected packages + if: inputs.build == 'true' + shell: bash + env: + HOST_FULL: ${{ inputs.host-full }} + PLAN_DIR: ${{ runner.temp }}/ci-plan + run: | + full=() + if [[ "$HOST_FULL" == true ]]; then + full+=(--host-full) + fi + python3 .github/scripts/run_rail_cargo.py \ + --plan "$PLAN_DIR/plan.json" --reader "$PLAN_DIR/read.py" \ + --work cargo.build "${full[@]}" build --locked + + - name: Run affected package tests under Xvfb + if: inputs.test == 'true' && runner.os == 'Linux' + shell: bash + env: + HOST_FULL: ${{ inputs.host-full }} + PLAN_DIR: ${{ runner.temp }}/ci-plan + run: | + full=() + if [[ "$HOST_FULL" == true ]]; then + full+=(--host-full) + fi + python3 .github/scripts/run_rail_cargo.py \ + --plan "$PLAN_DIR/plan.json" --reader "$PLAN_DIR/read.py" \ + --work cargo.test --xvfb "${full[@]}" test --locked + + - name: Run affected package tests + if: inputs.test == 'true' && runner.os != 'Linux' + shell: bash + env: + HOST_FULL: ${{ inputs.host-full }} + PLAN_DIR: ${{ runner.temp }}/ci-plan + run: | + full=() + if [[ "$HOST_FULL" == true ]]; then + full+=(--host-full) + fi + python3 .github/scripts/run_rail_cargo.py \ + --plan "$PLAN_DIR/plan.json" --reader "$PLAN_DIR/read.py" \ + --work cargo.test "${full[@]}" test --locked diff --git a/.github/scripts/ci_scope.py b/.github/scripts/ci_scope.py new file mode 100644 index 00000000..4841423b --- /dev/null +++ b/.github/scripts/ci_scope.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Add tcode's small repository policy layer to a Cargo-Rail CI plan.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import sys + + +GLOBAL_BUILD_PATHS = {"Cargo.toml", "Cargo.lock"} +GLOBAL_BUILD_PREFIXES = ( + ".cargo/", ".github/actions/", ".github/scripts/", ".github/workflows/" +) +DOC_SUFFIXES = {".md", ".markdown", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg"} +EMBEDDED_PROMPTS = { + "assets/orchestrate/astra.md", + "assets/orchestrate/collaboration.md", + "assets/orchestrate/fable-5-1.md", + "assets/orchestrate/workflow.md", +} +IGNORED_RESOURCES = { + "assets/fonts/OFL.txt", + "assets/fonts/lilex/OFL.txt", + "assets/icons/app/tcode.icns", + "assets/icons/app/tcode.png", + "assets/macos/tcode.entitlements", + "crates/web/assets/NotoSans-LICENSE", +} +PLATFORMS = { + "ios": ("tcode-ios", "aarch64-apple-ios-sim"), + "android": ("tcode-android", "aarch64-linux-android"), + "web": ("tcode-web", "wasm32-unknown-unknown"), +} + + +class ScopeError(RuntimeError): + pass + + +def run(command: list[str], root: Path) -> bytes: + try: + return subprocess.run( + command, cwd=root, check=True, stdout=subprocess.PIPE + ).stdout + except (OSError, subprocess.CalledProcessError) as error: + raise ScopeError(f"command failed: {' '.join(command)}") from error + + +def write_outputs(path: Path, values: dict[str, object]) -> None: + with path.open("a", encoding="utf-8") as output: + for key, value in values.items(): + if isinstance(value, bool): + value = str(value).lower() + output.write(f"{key}={value}\n") + + +def event_range(root: Path, event_path: Path) -> tuple[str, str, bool]: + try: + event = json.loads(event_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ScopeError(f"cannot read GitHub event {event_path}") from error + + event_name = os.environ.get("GITHUB_EVENT_NAME") + if event_name == "pull_request": + try: + base = event["pull_request"]["base"]["sha"] + head = event["pull_request"]["head"]["sha"] + except (KeyError, TypeError) as error: + raise ScopeError("pull request event has no base/head SHA") from error + try: + merge_base = run(["git", "merge-base", base, head], root).decode().strip() + except ScopeError: + return "", head, True + return merge_base, head, False + + if event_name == "push": + before = event.get("before") + head = event.get("after") or os.environ.get("GITHUB_SHA") + if not isinstance(before, str) or not isinstance(head, str): + return "", str(head or "HEAD"), True + if not before or set(before) == {"0"}: + return "", head, True + try: + run(["git", "cat-file", "-e", f"{before}^{{commit}}"], root) + except ScopeError: + return "", head, True + return before, head, False + + raise ScopeError(f"unsupported GitHub event {event_name!r}") + + +def changed_paths(root: Path, base: str, head: str) -> list[tuple[str, str]]: + data = run( + ["git", "diff", "--name-status", "--no-renames", "-z", base, head, "--"], root + ) + fields = data.split(b"\0") + if fields and not fields[-1]: + fields.pop() + if len(fields) % 2: + raise ScopeError("malformed NUL-delimited Git diff") + return [ + (os.fsdecode(fields[index])[:1], os.fsdecode(fields[index + 1]).replace(os.sep, "/")) + for index in range(0, len(fields), 2) + ] + + +def is_documentation(path: str) -> bool: + pure = PurePosixPath(path) + if path in {"LICENSE", "CODE_OF_CONDUCT.md"} or path in IGNORED_RESOURCES: + return True + if len(pure.parts) == 1 and pure.suffix.lower() in {".md", ".markdown", ".rst"}: + return True + if path.startswith("docs/") and pure.suffix.lower() in DOC_SUFFIXES: + return True + if path.startswith(".github/") and pure.suffix.lower() == ".md": + return True + return pure.name.lower() == "readme.md" and path.startswith("crates/") + + +def is_global_build_input(path: str) -> bool: + name = PurePosixPath(path).name + return ( + path in GLOBAL_BUILD_PATHS + or path.startswith(GLOBAL_BUILD_PREFIXES) + or path.endswith("/Cargo.toml") + or name == "build.rs" + or name.startswith("rust-toolchain") + or name in {"clippy.toml", "rustfmt.toml", "Cross.toml", "Makefile", "Justfile"} + ) + + +def classify(changes: list[tuple[str, str]]) -> dict[str, object]: + relevant: list[str] = [] + full = False + dependencies = False + for status, path in changes: + if path in EMBEDDED_PROMPTS: + if status == "M": + continue + full = True + continue + if is_documentation(path): + continue + relevant.append(path) + if is_global_build_input(path): + full = True + dependencies = True + elif path.endswith(".rs") and path.startswith("crates/"): + dependencies = True + else: + # Non-document inputs may be generated, embedded, or executable. + # Rail still supplies the host plan, but unknown ownership widens it. + full = True + dependencies = dependencies or PurePosixPath(path).suffix.lower() in { + ".rs", ".toml", ".py", ".sh", ".yml", ".yaml" + } + return { + "skip": not relevant and not full, + "full": full, + "dependencies": dependencies, + "relevant": relevant, + } + + +def metadata_model(root: Path) -> tuple[dict[str, Path], set[str]]: + raw = run( + ["cargo", "metadata", "--format-version", "1", "--no-deps", "--locked", "--offline"], + root, + ) + try: + metadata = json.loads(raw) + packages = metadata["packages"] + except (json.JSONDecodeError, KeyError, TypeError) as error: + raise ScopeError("cargo metadata returned an invalid package model") from error + + directories: dict[str, Path] = {} + for package in packages: + try: + directories[package["name"]] = Path(package["manifest_path"]).resolve().parent + except (KeyError, TypeError, AttributeError) as error: + raise ScopeError("cargo metadata returned an incomplete package") from error + for package in packages: + for dependency in package.get("dependencies", []): + dependency_path = dependency.get("path") + dependency_name = dependency.get("name") + if isinstance(dependency_path, str) and isinstance(dependency_name, str): + directories.setdefault(dependency_name, Path(dependency_path).resolve()) + return directories, set(directories) + + +def owner(root: Path, path: str, directories: dict[str, Path]) -> str | None: + candidate = (root / path).resolve() + matches = [] + for package, directory in directories.items(): + try: + candidate.relative_to(directory) + except ValueError: + continue + matches.append((len(directory.parts), package)) + return max(matches)[1] if matches else None + + +def is_dev_only(root: Path, path: str, package_directory: Path) -> bool: + relative = (root / path).resolve().relative_to(package_directory) + return bool(relative.parts) and relative.parts[0] in {"tests", "examples", "benches"} + + +def active_packages(root: Path, package_names: set[str], package: str, target: str) -> set[str]: + raw = run( + [ + "cargo", "tree", "--locked", "-p", package, "--target", target, + "--edges", "normal,build", "--prefix", "none", "--format", "{p}", + ], + root, + ) + active = { + fields[0] + for line in raw.decode("utf-8", errors="surrogateescape").splitlines() + if (fields := line.split()) and fields[0] in package_names + } + if package not in active: + raise ScopeError(f"cargo tree omitted root package {package}") + return active + + +def finalize( + root: Path, + changes: list[tuple[str, str]], + classification: dict[str, object], + required_work_json: str, +) -> dict[str, object]: + if classification["skip"]: + return { + "desktop": False, "build": False, "clippy": False, "test": False, + "host_full": False, "dependencies": False, + "ios": False, "android": False, "web": False, + } + try: + required_projection = json.loads(required_work_json) + except (json.JSONDecodeError, TypeError) as error: + raise ScopeError("Cargo-Rail required-work output is invalid") from error + if not isinstance(required_projection, list) or not all( + isinstance(item, str) for item in required_projection + ): + raise ScopeError("Cargo-Rail required-work output contains a non-string") + required_work = set(required_projection) + + build = "cargo.build" in required_work + clippy = "cargo.clippy" in required_work + test = "cargo.test" in required_work + result = { + "desktop": build or clippy or test, + "host_full": False, + "build": build, + "clippy": clippy, + "test": test, + "dependencies": classification["dependencies"], + } + if not result["desktop"]: + return {**result, "desktop": True, "host_full": True, + "build": True, "clippy": True, "test": True, + "ios": True, "android": True, "web": True} + if classification["full"]: + return {**result, "desktop": True, "host_full": True, + "build": True, "clippy": True, "test": True, + "ios": True, "android": True, "web": True} + + directories, package_names = metadata_model(root) + seeds = set() + for _, path in changes: + if path not in classification["relevant"]: + continue + package = owner(root, path, directories) + if package is None: + return {**result, "desktop": True, "host_full": True, + "build": True, "clippy": True, "test": True, + "dependencies": True, "ios": True, "android": True, "web": True} + if is_dev_only(root, path, directories[package]): + continue + seeds.add(package) + + for platform, (package, target) in PLATFORMS.items(): + result[platform] = bool(seeds & active_packages(root, package_names, package, target)) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + classify_parser = subparsers.add_parser("classify") + classify_parser.add_argument("--root", type=Path, default=Path.cwd()) + classify_parser.add_argument("--event", type=Path) + classify_parser.add_argument("--from-ref") + classify_parser.add_argument("--to-ref", default="HEAD") + classify_parser.add_argument("--github-output", type=Path, required=True) + + finalize_parser = subparsers.add_parser("finalize") + finalize_parser.add_argument("--root", type=Path, default=Path.cwd()) + finalize_parser.add_argument("--from-ref", required=True) + finalize_parser.add_argument("--to-ref", required=True) + finalize_parser.add_argument("--skip", choices=("true", "false"), required=True) + finalize_parser.add_argument("--full", choices=("true", "false"), required=True) + finalize_parser.add_argument("--dependencies", choices=("true", "false"), required=True) + finalize_parser.add_argument("--required-work", default="[]") + finalize_parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args() + root = args.root.resolve() + + try: + if args.command == "classify": + if args.from_ref: + base, head, fallback = args.from_ref, args.to_ref, False + else: + if args.event is None: + raise ScopeError("classify requires --event or --from-ref") + base, head, fallback = event_range(root, args.event) + if fallback: + values = {"base": "", "head": head, "skip": False, "full": True, + "dependencies": True, "changed": 0} + else: + changes = changed_paths(root, base, head) + values = {"base": base, "head": head, **classify(changes), "changed": len(changes)} + values.pop("relevant") + write_outputs(args.github_output, values) + print(json.dumps(values, indent=2, sort_keys=True)) + else: + changes = [] if args.full == "true" and not args.from_ref else changed_paths( + root, args.from_ref, args.to_ref + ) + classification = classify(changes) + classification["skip"] = args.skip == "true" + classification["full"] = args.full == "true" + classification["dependencies"] = args.dependencies == "true" + values = finalize(root, changes, classification, args.required_work) + write_outputs(args.github_output, values) + print(json.dumps(values, indent=2, sort_keys=True)) + except ScopeError as error: + print(f"CI scope planning failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/install_cargo_rail.py b/.github/scripts/install_cargo_rail.py new file mode 100644 index 00000000..c7a12ab1 --- /dev/null +++ b/.github/scripts/install_cargo_rail.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Install the pinned native Cargo-Rail binary from its verified release archive.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import platform +import shutil +import sys +import tarfile +import tempfile +import urllib.request +import zipfile + + +VERSION = "0.25.0" +RELEASE = f"https://github.com/loadingalias/cargo-rail/releases/download/v{VERSION}" +ARCHIVES = { + ("Darwin", "arm64"): ( + "cargo-rail-aarch64-apple-darwin.tar.gz", + "7ba3508ec8c03565bf55a98b3b7b33eae006425c2a7083e4e9c7fd04f69c5dfe", + ), + ("Linux", "x86_64"): ( + "cargo-rail-x86_64-unknown-linux-gnu.tar.gz", + "8199c6736031c0f2d8807f7af50b3a8108b5bd7e5ce08b129b80865477ddbf69", + ), + ("Windows", "AMD64"): ( + "cargo-rail-x86_64-pc-windows-msvc.zip", + "9e798ae2a625cc97cf64aecdb15ccc7a30e8d943315ffe15fc453d0a89077e8c", + ), +} + + +def main() -> int: + target = ARCHIVES.get((platform.system(), platform.machine())) + if target is None: + print(f"unsupported Cargo-Rail host: {platform.system()} {platform.machine()}", file=sys.stderr) + return 1 + archive_name, expected = target + install_root = Path(os.environ["RUNNER_TEMP"]) / f"cargo-rail-{VERSION}" + install_root.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=install_root) as temporary: + archive = Path(temporary) / archive_name + with urllib.request.urlopen(f"{RELEASE}/{archive_name}") as response, archive.open("wb") as output: + shutil.copyfileobj(response, output) + actual = hashlib.sha256(archive.read_bytes()).hexdigest() + if actual != expected: + print(f"Cargo-Rail checksum mismatch: expected {expected}, got {actual}", file=sys.stderr) + return 1 + if archive_name.endswith(".zip"): + with zipfile.ZipFile(archive) as bundle: + with bundle.open("cargo-rail.exe") as source, (install_root / "cargo-rail.exe").open("wb") as output: + shutil.copyfileobj(source, output) + else: + with tarfile.open(archive, "r:gz") as bundle: + member = bundle.getmember("cargo-rail") + source = bundle.extractfile(member) + if source is None: + raise RuntimeError("Cargo-Rail archive contains no executable bytes") + with source, (install_root / "cargo-rail").open("wb") as output: + shutil.copyfileobj(source, output) + + executable = install_root / ("cargo-rail.exe" if platform.system() == "Windows" else "cargo-rail") + executable.chmod(0o755) + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path: + github_path.write(f"{install_root}\n") + print(f"Installed {executable}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/run_rail_cargo.py b/.github/scripts/run_rail_cargo.py new file mode 100644 index 00000000..96594632 --- /dev/null +++ b/.github/scripts/run_rail_cargo.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Run one Cargo command with scope lowered by Cargo-Rail's strict reader.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + + +def reader(reader: Path, *arguments: str) -> bytes: + return subprocess.run( + [sys.executable, str(reader), *arguments], check=True, stdout=subprocess.PIPE + ).stdout + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--plan", type=Path, required=True) + parser.add_argument("--reader", type=Path, required=True) + parser.add_argument("--work", required=True) + parser.add_argument("--host-full", action="store_true") + parser.add_argument("--xvfb", action="store_true") + parser.add_argument("cargo_command") + parser.add_argument("cargo_arguments", nargs=argparse.REMAINDER) + args = parser.parse_args() + + if args.host_full: + cargo_scope = ["--workspace"] + else: + scope = reader(args.reader, "cargo-scope", str(args.plan), args.work).decode().strip() + if scope == "workspace": + cargo_scope = ["--workspace"] + elif scope == "packages": + raw = reader(args.reader, "cargo-args", str(args.plan), args.work) + if not raw.endswith(b"\0"): + raise RuntimeError("Cargo-Rail package arguments are not NUL terminated") + cargo_scope = [part.decode() for part in raw[:-1].split(b"\0")] + if not cargo_scope or any(not argument for argument in cargo_scope): + raise RuntimeError("Cargo-Rail returned an empty package selection") + else: + raise RuntimeError(f"Cargo-Rail returned unusable scope {scope!r} for {args.work}") + + command = ["cargo", args.cargo_command, *cargo_scope, *args.cargo_arguments] + if args.xvfb: + command = ["xvfb-run", "-a", *command] + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_ci_scope.py b/.github/scripts/test_ci_scope.py new file mode 100644 index 00000000..6d7531aa --- /dev/null +++ b/.github/scripts/test_ci_scope.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""Behavioral tests for tcode's policy around the real Cargo-Rail planner.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("ci_scope.py") +EXECUTOR = Path(__file__).with_name("run_rail_cargo.py") + + +def rail_binary() -> str | None: + configured = os.environ.get("CARGO_RAIL_BIN") + if configured: + return configured + found = shutil.which("cargo-rail") + if found: + return found + probe = Path("/tmp/tcode-rail-probe/cargo-rail") + return str(probe) if probe.is_file() else None + + +class Workspace: + def __init__(self, rail: str) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.rail = rail + self.git("init", "-q") + self.git("config", "user.name", "CI scope test") + self.git("config", "user.email", "ci@example.test") + self.write( + "Cargo.toml", + """[workspace] +members = [ + "crates/core", "crates/runtime", "crates/ui", "crates/app", "crates/unrelated", + "crates/mobile", "crates/ios", "crates/android", "crates/web", +] +exclude = ["crates/platform/gpui-ios", "crates/platform/gpui-android"] +resolver = "3" +""", + ) + self.package("core") + self.package("runtime", '[dependencies]\ntcode-core = { path = "../core" }\n') + self.package( + "ui", + '[features]\ndefault = ["desktop"]\ndesktop = ["dep:tcode-runtime"]\n' + '[dependencies]\ntcode-core = { path = "../core" }\n' + 'tcode-runtime = { path = "../runtime", optional = true }\n', + ) + self.package("app", '[dependencies]\ntcode-core = { path = "../core" }\n') + self.package("unrelated") + self.package( + "mobile", + '[dependencies]\ntcode-core = { path = "../core" }\n' + 'tcode-ui = { path = "../ui", default-features = false }\n', + ) + self.package( + "ios", + '[dependencies]\ntcode-mobile = { path = "../mobile" }\n' + 'tcode-ui = { path = "../ui", default-features = false }\n' + 'gpui-ios = { path = "../platform/gpui-ios" }\n', + ) + self.package( + "android", + '[dependencies]\ntcode-mobile = { path = "../mobile" }\n' + 'tcode-ui = { path = "../ui", default-features = false }\n' + 'gpui-android = { path = "../platform/gpui-android" }\n', + ) + self.package( + "web", + '[target.\'cfg(target_family = "wasm")\'.dependencies]\n' + 'tcode-mobile = { path = "../mobile" }\n' + 'tcode-ui = { path = "../ui", default-features = false }\n', + ) + self.package("gpui-ios", directory="crates/platform/gpui-ios") + self.package("gpui-android", directory="crates/platform/gpui-android") + self.write("README.md", "baseline\n") + self.write("assets/orchestrate/workflow.md", "prompt\n") + subprocess.run(["cargo", "generate-lockfile", "--offline"], cwd=self.root, check=True) + self.baseline = self.commit("baseline") + + def close(self) -> None: + self.temporary.cleanup() + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=self.root, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + + def write(self, relative: str, value: str) -> None: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + def package(self, short: str, extra: str = "", *, directory: str | None = None) -> None: + package_name = "tcode" if short == "app" else ( + short if short.startswith("gpui-") else f"tcode-{short}" + ) + package_dir = directory or f"crates/{short}" + self.write( + f"{package_dir}/Cargo.toml", + f'[package]\nname = "{package_name}"\nversion = "0.1.0"\nedition = "2024"\n{extra}', + ) + self.write(f"{package_dir}/src/lib.rs", f"pub fn {short.replace('-', '_')}() {{}}\n") + + def commit(self, message: str) -> str: + self.git("add", "-A") + self.git("commit", "-qm", message) + return self.git("rev-parse", "HEAD") + + def scenario(self, name: str, mutate) -> str: + self.git("checkout", "-q", "-B", name, self.baseline) + mutate() + return self.commit(name) + + def classify(self, head: str) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + output = self.root / ".git/classify.out" + output.unlink(missing_ok=True) + result = subprocess.run( + ["python3", str(SCRIPT), "classify", "--root", str(self.root), + "--from-ref", self.baseline, "--to-ref", head, "--github-output", str(output)], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + values = {} + if output.exists(): + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + return result, values + + def classify_event(self, event_name: str, event: dict[str, object]): + event_path = self.root / ".git/event.json" + event_path.write_text(json.dumps(event), encoding="utf-8") + output = self.root / ".git/event.out" + output.unlink(missing_ok=True) + result = subprocess.run( + ["python3", str(SCRIPT), "classify", "--root", str(self.root), + "--event", str(event_path), "--github-output", str(output)], + env={**os.environ, "GITHUB_EVENT_NAME": event_name}, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + values = {} + if output.exists(): + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + return result, values + + def rail_plan(self, head: str) -> dict[str, object]: + result = subprocess.run( + [self.rail, "--workspace-root", str(self.root), "--json", "plan", + "--from", self.baseline, "--to", head], + check=True, text=True, stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + + def finalize(self, head: str, classification: dict[str, str], plan: dict[str, object] | None): + output = self.root / ".git/final.out" + output.unlink(missing_ok=True) + required = json.dumps(plan["required"], separators=(",", ":")) if plan else "[]" + result = subprocess.run( + ["python3", str(SCRIPT), "finalize", "--root", str(self.root), + "--from-ref", self.baseline, "--to-ref", head, + "--skip", classification["skip"], "--full", classification["full"], + "--dependencies", classification["dependencies"], + "--required-work", required, "--github-output", str(output)], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + values = {} + if output.exists(): + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + return result, values + + +class ScopeTests(unittest.TestCase): + def setUp(self) -> None: + rail = rail_binary() + if rail is None: + if os.environ.get("CI"): + self.fail("CI requires the pinned Cargo-Rail binary") + self.skipTest("set CARGO_RAIL_BIN to run the Cargo-Rail integration tests") + version = subprocess.run([rail, "--version"], check=True, text=True, stdout=subprocess.PIPE).stdout + self.assertIn("0.25.0", version) + self.workspace = Workspace(rail) + + def tearDown(self) -> None: + self.workspace.close() + + def classified(self, head: str) -> dict[str, str]: + result, values = self.workspace.classify(head) + self.assertEqual(result.returncode, 0, result.stderr) + return values + + def test_documentation_and_modified_prompt_skip_before_rail(self) -> None: + head = self.workspace.scenario( + "docs", lambda: ( + self.workspace.write("README.md", "docs\n"), + self.workspace.write("assets/orchestrate/workflow.md", "wording\n"), + ) + ) + classification = self.classified(head) + self.assertEqual(classification["skip"], "true") + result, final = self.workspace.finalize(head, classification, None) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(final["desktop"], "false") + self.assertEqual(final["ios"], "false") + + def test_pull_request_uses_merge_base_and_zero_push_falls_back_full(self) -> None: + head = self.workspace.scenario( + "event-docs", lambda: self.workspace.write("README.md", "docs\n") + ) + result, pull = self.workspace.classify_event( + "pull_request", + {"pull_request": {"base": {"sha": self.workspace.baseline}, "head": {"sha": head}}}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(pull["base"], self.workspace.baseline) + self.assertEqual(pull["skip"], "true") + + result, push = self.workspace.classify_event( + "push", {"before": "0" * 40, "after": head} + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(push["full"], "true") + + def test_rail_scopes_leaf_and_reverse_dependent_source_changes(self) -> None: + leaf = self.workspace.scenario( + "leaf", lambda: self.workspace.write("crates/app/src/lib.rs", "pub fn changed() {}\n") + ) + leaf_plan = self.workspace.rail_plan(leaf) + self.assertEqual( + leaf_plan["work"]["cargo.test"]["scope"]["selection"]["cargo_args"], + ["-p", "tcode"], + ) + core = self.workspace.scenario( + "core", lambda: self.workspace.write("crates/core/src/lib.rs", "pub fn changed() {}\n") + ) + core_plan = self.workspace.rail_plan(core) + args = core_plan["work"]["cargo.test"]["scope"]["selection"]["cargo_args"] + self.assertIn("tcode", args) + self.assertIn("tcode-core", args) + self.assertNotIn("tcode-unrelated", args) + + def test_platform_scope_uses_modified_owner_not_host_reverse_closure(self) -> None: + runtime = self.workspace.scenario( + "runtime", lambda: self.workspace.write("crates/runtime/src/lib.rs", "pub fn changed() {}\n") + ) + classification = self.classified(runtime) + result, final = self.workspace.finalize(runtime, classification, self.workspace.rail_plan(runtime)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((final["ios"], final["android"], final["web"]), ("false", "false", "false")) + + ui = self.workspace.scenario( + "ui", lambda: self.workspace.write("crates/ui/src/lib.rs", "pub fn changed() {}\n") + ) + classification = self.classified(ui) + result, final = self.workspace.finalize(ui, classification, self.workspace.rail_plan(ui)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((final["ios"], final["android"], final["web"]), ("true", "true", "true")) + + def test_excluded_local_path_owner_selects_matching_platform(self) -> None: + head = self.workspace.scenario( + "gpui-ios", + lambda: self.workspace.write("crates/platform/gpui-ios/src/lib.rs", "pub fn changed() {}\n"), + ) + classification = self.classified(head) + result, final = self.workspace.finalize(head, classification, self.workspace.rail_plan(head)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((final["ios"], final["android"], final["web"]), ("true", "false", "false")) + + def test_unknown_config_and_prompt_delete_or_rename_force_full(self) -> None: + scenarios = { + "unknown": lambda: self.workspace.write("tools/generate.py", "pass\n"), + "action": lambda: self.workspace.write( + ".github/actions/check/action.yml", "runs: {using: composite, steps: []}\n" + ), + "delete": lambda: (self.workspace.root / "assets/orchestrate/workflow.md").unlink(), + "rename": lambda: (self.workspace.root / "assets/orchestrate/workflow.md").rename( + self.workspace.root / "assets/orchestrate/renamed.md" + ), + } + for name, mutation in scenarios.items(): + with self.subTest(name=name): + head = self.workspace.scenario(name, mutation) + classification = self.classified(head) + self.assertEqual(classification["full"], "true") + self.assertEqual(classification["skip"], "false") + + def test_invalid_rail_projection_fails_planning(self) -> None: + head = self.workspace.scenario( + "invalid", lambda: self.workspace.write("crates/app/src/lib.rs", "pub fn changed() {}\n") + ) + classification = self.classified(head) + output = self.workspace.root / ".git/invalid.out" + result = subprocess.run( + ["python3", str(SCRIPT), "finalize", "--root", str(self.workspace.root), + "--from-ref", self.workspace.baseline, "--to-ref", head, + "--skip", classification["skip"], "--full", classification["full"], + "--dependencies", classification["dependencies"], + "--required-work", "not-json", "--github-output", str(output)], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("planning failed", result.stderr) + + def test_empty_valid_rail_projection_falls_back_to_full(self) -> None: + head = self.workspace.scenario( + "empty", lambda: self.workspace.write("crates/app/src/lib.rs", "pub fn changed() {}\n") + ) + classification = self.classified(head) + result, final = self.workspace.finalize(head, classification, {"required": []}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(final["host_full"], "true") + self.assertEqual(final["desktop"], "true") + + def test_src_tests_module_is_not_treated_as_dev_only(self) -> None: + head = self.workspace.scenario( + "src-tests", lambda: self.workspace.write("crates/ui/src/tests/helpers.rs", "pub fn changed() {}\n") + ) + classification = self.classified(head) + result, final = self.workspace.finalize(head, classification, self.workspace.rail_plan(head)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((final["ios"], final["android"], final["web"]), ("true", "true", "true")) + + +class RailExecutorTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.plan = self.root / "plan.json" + self.plan.write_text("{}", encoding="utf-8") + self.log = self.root / "cargo.json" + self.reader = self.root / "reader.py" + self.reader.write_text( + """#!/usr/bin/env python3 +import os +import sys +mode = os.environ['READER_MODE'] +if mode == 'fail': + raise SystemExit(2) +if sys.argv[1] == 'cargo-scope': + print('packages' if mode == 'empty' else mode) +elif sys.argv[1] == 'cargo-args' and mode == 'packages': + sys.stdout.buffer.write(b'-p\\0tcode-core\\0') +elif sys.argv[1] == 'cargo-args' and mode == 'empty': + sys.stdout.buffer.write(b'\\0') +""", + encoding="utf-8", + ) + cargo = self.root / "cargo" + cargo.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys +open(os.environ['CARGO_LOG'], 'w').write(json.dumps(sys.argv[1:])) +""", + encoding="utf-8", + ) + cargo.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def execute(self, mode: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(EXECUTOR), "--plan", str(self.plan), "--reader", str(self.reader), + "--work", "cargo.test", "test", "--locked"], + env={**os.environ, "PATH": f"{self.root}{os.pathsep}{os.environ['PATH']}", + "READER_MODE": mode, "CARGO_LOG": str(self.log)}, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + def test_workspace_scope_is_explicit(self) -> None: + result = self.execute("workspace") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.log.read_text()), ["test", "--workspace", "--locked"]) + + def test_package_scope_uses_typed_nul_arguments(self) -> None: + result = self.execute("packages") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.log.read_text()), ["test", "-p", "tcode-core", "--locked"]) + + def test_reader_failure_never_starts_cargo(self) -> None: + result = self.execute("fail") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + + def test_empty_nul_argument_never_starts_cargo(self) -> None: + result = self.execute("empty") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddfb38e9..56dc017c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,145 +8,257 @@ on: permissions: contents: read +env: + CARGO_TERM_COLOR: always + # Limit debug info to avoid evicting other platform caches. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + RUST_BACKTRACE: 1 + concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - dependencies: - name: Dependency hygiene + plan: + name: Plan CI scope runs-on: ubuntu-24.04 + timeout-minutes: 15 + outputs: + head: ${{ steps.classify.outputs.head }} + desktop: ${{ steps.scope.outputs.desktop }} + host_full: ${{ steps.scope.outputs.host_full }} + build: ${{ steps.scope.outputs.build }} + clippy: ${{ steps.scope.outputs.clippy }} + test: ${{ steps.scope.outputs.test }} + dependencies: ${{ steps.scope.outputs.dependencies }} + ios: ${{ steps.scope.outputs.ios }} + android: ${{ steps.scope.outputs.android }} + web: ${{ steps.scope.outputs.web }} steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - - - name: Check unused dependencies + - name: Classify repository changes + id: classify run: | - cargo install cargo-machete --version 0.9.2 --locked - cargo machete - - check: - name: ${{ matrix.name }} - strategy: - fail-fast: false - matrix: - include: - - name: macOS arm64 - os: macos-26 - - name: Linux x64 - os: ubuntu-24.04 - - name: Windows x64 - os: windows-2022 - runs-on: ${{ matrix.os }} - env: - CARGO_TERM_COLOR: always - # Limit debug info to avoid evicting other platform caches. - CARGO_PROFILE_DEV_DEBUG: line-tables-only - RUST_BACKTRACE: 1 - - steps: - - uses: actions/checkout@v7 + python3 .github/scripts/ci_scope.py classify \ + --event "$GITHUB_EVENT_PATH" \ + --github-output "$GITHUB_OUTPUT" - - name: Enforce process helper boundaries - if: runner.os == 'Linux' + - name: Cache Cargo data used by target planning + if: steps.classify.outputs.skip != 'true' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/git/db + ~/.cargo/git/checkouts + key: ci-plan-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ci-plan-${{ runner.os }}- + + - name: Plan affected Cargo work + id: rail + if: steps.classify.outputs.skip != 'true' + uses: loadingalias/cargo-rail-action@78ad385a85627484a5b634cf1bc3caa1de872c6a # v8.2.0 + with: + version: 0.25.0 + since: ${{ steps.classify.outputs.base }} + all: ${{ steps.classify.outputs.base == '' }} + + - name: Bind the plan to the exact commit pair + id: exact + if: steps.classify.outputs.skip != 'true' + env: + BASE: ${{ steps.classify.outputs.base }} + HEAD_COMMIT: ${{ steps.classify.outputs.head }} + ACTION_PLAN: ${{ steps.rail.outputs.plan-file }} + PLAN_READER: ${{ steps.rail.outputs.plan-reader }} + PLAN_DIR: ${{ runner.temp }}/ci-plan run: | - violations=0 - - smol_unblock_violations="$( - grep -rn "smol::unblock" crates --include="*.rs" \ - | grep -v -e "crates/runtime/src/host.rs:" -e "crates/agent/src/process.rs:" \ - | grep -v '^[^:]*:[0-9]*:[[:space:]]*//' \ - || true - )" - if [[ -n "$smol_unblock_violations" ]]; then - echo "Direct smol::unblock calls found outside approved helpers:" - echo "$smol_unblock_violations" - violations=1 + mkdir "$PLAN_DIR" + cp "$PLAN_READER" "$PLAN_DIR/read.py" + if [[ -n "$BASE" ]]; then + cargo rail plan --json --from "$BASE" --to "$HEAD_COMMIT" > "$PLAN_DIR/plan.json" + else + cp "$ACTION_PLAN" "$PLAN_DIR/plan.json" fi + required_work="$(python3 "$PLAN_DIR/read.py" required "$PLAN_DIR/plan.json")" + echo "required_work=$required_work" >> "$GITHUB_OUTPUT" - command_new_violations="$( - grep -rn "Command::new(" crates --include="*.rs" \ - | grep -v -e "crates/services/src/process.rs:" -e "crates/agent/src/process.rs:" \ - -e "crates/voice/build.rs:" \ - | grep -v '^[^:]*:[0-9]*:[[:space:]]*//' \ - || true - )" - if [[ -n "$command_new_violations" ]]; then - echo "Direct Command::new calls found outside approved helpers:" - echo "$command_new_violations" - violations=1 - fi + - name: Test scope policy + if: steps.classify.outputs.skip != 'true' + run: python3 .github/scripts/test_ci_scope.py - if (( violations != 0 )); then - exit 1 - fi + - name: Finalize required checks + id: scope + env: + REQUIRED_WORK: ${{ steps.exact.outputs.required_work }} + run: | + python3 .github/scripts/ci_scope.py finalize \ + --from-ref "${{ steps.classify.outputs.base }}" \ + --to-ref "${{ steps.classify.outputs.head }}" \ + --skip "${{ steps.classify.outputs.skip }}" \ + --full "${{ steps.classify.outputs.full }}" \ + --dependencies "${{ steps.classify.outputs.dependencies }}" \ + --required-work "$REQUIRED_WORK" \ + --github-output "$GITHUB_OUTPUT" + + - name: Upload validated plan + if: steps.classify.outputs.skip != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ci-plan + path: ${{ runner.temp }}/ci-plan + retention-days: 1 - - name: Disable Windows Defender real-time scanning - if: runner.os == 'Windows' - shell: pwsh - continue-on-error: true - run: Set-MpPreference -DisableRealtimeMonitoring $true + dependencies: + name: Dependency hygiene + needs: plan + if: >- + always() && + (needs.plan.result != 'success' || needs.plan.outputs.dependencies == 'true') + runs-on: ubuntu-24.04 + steps: + - name: Fail when CI scope planning failed + if: needs.plan.result != 'success' + run: | + echo 'CI scope planning failed; refusing to skip a required check.' >&2 + exit 1 - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 with: - components: rustfmt, clippy + ref: ${{ needs.plan.outputs.head }} - - name: Cache Cargo build data - uses: Swatinem/rust-cache@v2 + - name: Install cargo-machete + uses: taiki-e/install-action@84f5ac3124727fb3d284d4d22ee9ab3654fd09a6 # v2 with: - shared-key: ci-${{ runner.os }}-${{ runner.arch }} + tool: cargo-machete@0.9.2 - - name: Install Linux system dependencies - if: runner.os == 'Linux' + - name: Check unused dependencies + run: cargo machete + + macos: + name: macOS arm64 + needs: plan + if: >- + always() && + (needs.plan.result != 'success' || needs.plan.outputs.desktop == 'true') + runs-on: macos-26 + steps: + - name: Fail when CI scope planning failed + if: needs.plan.result != 'success' run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential clang cmake pkg-config libssl-dev libzstd-dev \ - libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-dev \ - libxkbcommon-x11-dev libx11-xcb-dev libxcb1-dev libgbm-dev \ - libvulkan-dev xvfb - if cargo tree -e normal -i glib-sys >/dev/null 2>&1; then - sudo apt-get install -y --no-install-recommends \ - libgtk-3-dev libwebkit2gtk-4.1-dev - fi + echo 'CI scope planning failed; refusing to skip a required check.' >&2 + exit 1 - - name: Check formatting - run: cargo fmt --all --check + - uses: actions/checkout@v7 + with: + ref: ${{ needs.plan.outputs.head }} - - name: Run Clippy - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - uses: ./.github/actions/desktop-check + with: + artifact: ci-plan + host-full: ${{ needs.plan.outputs.host_full }} + build: ${{ needs.plan.outputs.build }} + clippy: ${{ needs.plan.outputs.clippy }} + test: ${{ needs.plan.outputs.test }} + + linux: + name: Linux x64 + needs: plan + if: >- + always() && + (needs.plan.result != 'success' || needs.plan.outputs.desktop == 'true') + runs-on: ubuntu-24.04 + steps: + - name: Fail when CI scope planning failed + if: needs.plan.result != 'success' + run: | + echo 'CI scope planning failed; refusing to skip a required check.' >&2 + exit 1 - - name: Build workspace - run: cargo build --workspace --locked + - uses: actions/checkout@v7 + with: + ref: ${{ needs.plan.outputs.head }} - - name: Run workspace tests (Linux under Xvfb) - if: runner.os == 'Linux' - run: xvfb-run -a cargo test --workspace --locked + - uses: ./.github/actions/desktop-check + with: + artifact: ci-plan + host-full: ${{ needs.plan.outputs.host_full }} + build: ${{ needs.plan.outputs.build }} + clippy: ${{ needs.plan.outputs.clippy }} + test: ${{ needs.plan.outputs.test }} + + windows: + name: Windows x64 + needs: plan + if: >- + always() && + (needs.plan.result != 'success' || needs.plan.outputs.desktop == 'true') + runs-on: windows-2022 + steps: + - name: Fail when CI scope planning failed + if: needs.plan.result != 'success' + shell: bash + run: | + echo 'CI scope planning failed; refusing to skip a required check.' >&2 + exit 1 - - name: Run workspace tests - if: runner.os != 'Linux' - run: cargo test --workspace --locked + - uses: actions/checkout@v7 + with: + ref: ${{ needs.plan.outputs.head }} + + - uses: ./.github/actions/desktop-check + with: + artifact: ci-plan + host-full: ${{ needs.plan.outputs.host_full }} + build: ${{ needs.plan.outputs.build }} + clippy: ${{ needs.plan.outputs.clippy }} + test: ${{ needs.plan.outputs.test }} mobile-check: name: Mobile and web checks + needs: plan + if: >- + always() && + (needs.plan.result != 'success' || + needs.plan.outputs.ios == 'true' || + needs.plan.outputs.android == 'true' || + needs.plan.outputs.web == 'true') runs-on: macos-26 env: CARGO_TERM_COLOR: always CARGO_PROFILE_DEV_DEBUG: line-tables-only CARGO_NDK_PLATFORM: 26 RUSTFLAGS: -D warnings - steps: + - name: Fail when CI scope planning failed + if: needs.plan.result != 'success' + run: | + echo 'CI scope planning failed; refusing to skip a required check.' >&2 + exit 1 + - uses: actions/checkout@v7 + with: + ref: ${{ needs.plan.outputs.head }} - name: Install stable Rust uses: dtolnay/rust-toolchain@stable - - name: Add mobile and web Rust targets - run: rustup target add aarch64-apple-ios-sim aarch64-linux-android wasm32-unknown-unknown + - name: Add iOS Rust target + if: needs.plan.outputs.ios == 'true' + run: rustup target add aarch64-apple-ios-sim + + - name: Add Android Rust target + if: needs.plan.outputs.android == 'true' + run: rustup target add aarch64-linux-android + + - name: Add Web Rust target + if: needs.plan.outputs.web == 'true' + run: rustup target add wasm32-unknown-unknown - name: Cache Cargo build data uses: Swatinem/rust-cache@v2 @@ -154,21 +266,25 @@ jobs: shared-key: ci-mobile-${{ runner.os }}-${{ runner.arch }} - name: Set up JDK 21 + if: needs.plan.outputs.android == 'true' uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' - name: Set up Android SDK + if: needs.plan.outputs.android == 'true' uses: android-actions/setup-android@v3 - name: Install Android NDK and cargo-ndk + if: needs.plan.outputs.android == 'true' run: | sdkmanager 'ndk;27.1.12297006' echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.1.12297006" >> "$GITHUB_ENV" cargo install cargo-ndk --locked - name: Report Android toolchain + if: needs.plan.outputs.android == 'true' run: | echo "ANDROID_HOME=$ANDROID_HOME" echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME" @@ -176,10 +292,13 @@ jobs: cargo ndk --version - name: Check iOS crate + if: needs.plan.outputs.ios == 'true' run: IPHONEOS_DEPLOYMENT_TARGET=26.0 cargo check -p tcode-ios --target aarch64-apple-ios-sim --locked - - name: Check web crate + - name: Check Web crate + if: needs.plan.outputs.web == 'true' run: cargo check -p tcode-web --target wasm32-unknown-unknown --locked - name: Check Android crate + if: needs.plan.outputs.android == 'true' run: cargo ndk -t arm64-v8a check -p tcode-android --locked diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 157e9f06..c284ed8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,14 @@ cargo build --workspace --locked cargo test --workspace --locked ``` +The CI workflow always starts so its required check names are reported, but it +plans the affected scope before allocating build runners. Documentation-only +changes and wording-only edits to bundled Orchestrate prompts skip heavyweight +checks; Rust changes run the Cargo checks for affected packages and their +dependents. Cargo, build, workflow and unclassified input changes fall back to +the full workspace. When reporting local evidence, run the full commands above +unless you are reproducing the narrower scope printed by the CI planning job. + CI also runs `cargo machete` to catch unused dependencies, and checks iOS, Android and Web with `RUSTFLAGS='-D warnings'`. Use the commands and tool version in [the workflow](.github/workflows/ci.yml) to reproduce those checks. Fix warnings