From 4c736a2b763ca0018e7de288154417dd28f423a0 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:39:00 +0800 Subject: [PATCH 1/6] test(distribution): verify native channel installs Fixes #255 --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 16 +- docs/release.md | 18 +- scripts/smoke-native.py | 96 +++++++++ scripts/test-distribution.py | 357 +++++++++++++++++++++++++++++++ scripts/update-dist-manifests.py | 44 ++-- 6 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 scripts/smoke-native.py create mode 100644 scripts/test-distribution.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea08161..306ecc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -662,3 +662,6 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: ./scripts/test-install.ps1 + + - name: Run native distribution channel smoke tests + run: python scripts/test-distribution.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57abe23..76745a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,11 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: @@ -170,12 +175,11 @@ jobs: LSP="$EXTRACT/$PAYLOAD/wright-lsp$EXE" test -f "$WRIGHT" || { echo "missing $WRIGHT"; exit 1; } test -f "$LSP" || { echo "missing $LSP"; exit 1; } - "$WRIGHT" --version | grep -F "$VERSION" || { echo "wright does not report $VERSION"; exit 1; } - "$LSP" --version | grep -F "$VERSION" || { echo "wright-lsp does not report $VERSION"; exit 1; } - "$WRIGHT" compile compatibility/fixtures/synthetic/basic-rule/source.opy \ - --profile compat >/dev/null - "$WRIGHT" check scenarios/loops.opy --profile compat >/dev/null - echo "packaged $PAYLOAD executes and reports $VERSION" + python scripts/smoke-native.py \ + --wright "$WRIGHT" \ + --wright-lsp "$LSP" \ + --version "$VERSION" \ + --provider-bootstrap - name: Upload release artifacts uses: actions/upload-artifact@v7 diff --git a/docs/release.md b/docs/release.md index 0b2e649..628f33f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -150,11 +150,19 @@ terminal. It does not require Cargo, npm, or a source checkout. ### Release smoke test Each build leg smoke-tests its **packaged archive** (not workspace binaries): -it extracts the archive, runs `wright --version` and `wright-lsp --version`, -asserts both report the tagged version, and compiles/checks the -`synthetic/basic-rule` and `scenarios/loops` fixtures. The upload job -re-verifies that every declared target's archive and checksum are present -before attaching them to the draft Release. +it extracts the archive and runs the shared `scripts/smoke-native.py` contract +against the extracted binaries. The contract checks both version banners, +representative OPY compile/check paths, and first-party OPY provider bootstrap +from an empty provider store. The upload job re-verifies that every declared +target's archive and checksum are present before attaching them to the draft +Release. + +The normal CI distribution job separately stages a canonical-shaped local +release archive and generated local metadata. It exercises `install.sh` or +`install.ps1`, Homebrew on macOS, and Scoop plus WinGet on Windows through their +real installation commands; each installed binary then runs the same native +smoke. These channel checks are labelled separately from the native runtime +smoke and do not publish or modify any external package-manager repository. ### Repository configuration diff --git a/scripts/smoke-native.py b/scripts/smoke-native.py new file mode 100644 index 0000000..9261498 --- /dev/null +++ b/scripts/smoke-native.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Run Wright's small post-install native runtime smoke contract (#255).""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NoReturn + + +ROOT = Path(__file__).resolve().parent.parent + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"native runtime smoke failed: {message}") + + +def run(label: str, command: list[str], env: dict[str, str] | None = None) -> str: + print(f"==> native runtime: {label}") + try: + result = subprocess.run( + command, + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as error: + fail(f"{label}: missing executable {error.filename}") + except subprocess.CalledProcessError as error: + output = "\n".join(part for part in (error.stdout, error.stderr) if part) + if output: + print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") + fail(f"{label}: command exited with status {error.returncode}") + return result.stdout + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wright", type=Path, required=True) + parser.add_argument("--wright-lsp", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument( + "--compile", + type=Path, + default=Path("compatibility/fixtures/synthetic/basic-rule/source.opy"), + ) + parser.add_argument("--check", type=Path, default=Path("scenarios/loops.opy")) + parser.add_argument( + "--provider-bootstrap", + action="store_true", + help="bootstrap the first-party OPY provider from an empty provider store", + ) + args = parser.parse_args() + + wright = args.wright.resolve() + lsp = args.wright_lsp.resolve() + for name, path in (("wright", wright), ("wright-lsp", lsp)): + if not path.is_file(): + fail(f"{name} binary is missing: {path}") + + for name, path in (("compile input", args.compile), ("check input", args.check)): + if not (ROOT / path).is_file(): + fail(f"{name} is missing: {ROOT / path}") + + for name, binary in (("wright version", wright), ("wright-lsp version", lsp)): + output = run(name, [str(binary), "--version"]) + if args.version not in output: + fail(f"{name} did not report version {args.version}: {output.strip()}") + + run( + "compile", + [str(wright), "compile", str(args.compile), "--profile", "compat"], + ) + run("check", [str(wright), "check", str(args.check), "--profile", "compat"]) + + if args.provider_bootstrap: + with tempfile.TemporaryDirectory(prefix="wright-provider-smoke-") as store: + env = os.environ.copy() + env["WRIGHT_PROVIDER_DATA_DIR"] = store + run( + "first-party OPY provider bootstrap from clean state", + [str(wright), "provider", "update", "opy"], + env, + ) + + print("native runtime smoke passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-distribution.py b/scripts/test-distribution.py new file mode 100644 index 0000000..a6a5b6a --- /dev/null +++ b/scripts/test-distribution.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Exercise native installation channels against a locally staged artifact (#255).""" + +from __future__ import annotations + +import hashlib +import http.server +import importlib.util +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import threading +import zipfile +from pathlib import Path +from typing import NoReturn + + +ROOT = Path(__file__).resolve().parent.parent +SMOKE = ROOT / "scripts" / "smoke-native.py" + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + pass + + def copyfile(self, source, outputfile) -> None: + try: + super().copyfile(source, outputfile) + except BrokenPipeError: + pass + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"distribution channel validation failed: {message}") + + +def run(label: str, command: list[str], env: dict[str, str] | None = None) -> str: + print(f"==> distribution channel: {label}") + try: + result = subprocess.run( + command, + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as error: + fail(f"{label}: missing executable {error.filename}") + except subprocess.CalledProcessError as error: + output = "\n".join(part for part in (error.stdout, error.stderr) if part) + if output: + print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") + fail(f"{label}: command exited with status {error.returncode}") + return result.stdout + + +def target_info() -> tuple[str, str, str]: + system = platform.system() + machine = platform.machine().lower() + if system == "Linux" and machine in {"x86_64", "amd64"}: + return "x86_64-unknown-linux-gnu", "tar.gz", "" + if system == "Darwin" and machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin", "tar.gz", "" + if system == "Darwin" and machine in {"x86_64", "amd64"}: + return "x86_64-apple-darwin", "tar.gz", "" + if system == "Windows" and machine in {"x86_64", "amd64"}: + return "x86_64-pc-windows-msvc", "zip", ".exe" + fail(f"unsupported validation host {system}/{platform.machine()}") + + +def load_generator(): + spec = importlib.util.spec_from_file_location( + "wright_dist", ROOT / "scripts" / "update-dist-manifests.py" + ) + if spec is None or spec.loader is None: + fail("cannot load scripts/update-dist-manifests.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def stage_artifact(work: Path, version: str, target: str, extension: str, exe: str) -> Path: + source_dir = ROOT / "target" / "debug" + source_wright = source_dir / f"wright{exe}" + source_lsp = source_dir / f"wright-lsp{exe}" + if not source_wright.is_file() or not source_lsp.is_file(): + fail( + "native debug binaries are missing; run " + "cargo build --locked -p wright-cli -p wright-lsp first" + ) + + release_dir = work / "releases" / "download" / f"v{version}" + payload_name = f"wright-{version}-{target}" + payload = release_dir / payload_name + payload.mkdir(parents=True) + shutil.copy2(source_wright, payload / f"wright{exe}") + shutil.copy2(source_lsp, payload / f"wright-lsp{exe}") + (payload / "version.json").write_text(f'{{"version":"{version}"}}\n') + + archive = release_dir / f"{payload_name}.{extension}" + if extension == "zip": + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output: + for path in payload.iterdir(): + output.write(path, f"{payload_name}/{path.name}") + else: + with tarfile.open(archive, "w:gz") as output: + output.add(payload, arcname=payload_name) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + (Path(f"{archive}.sha256")).write_text(f"{digest} {archive.name}\n") + return archive + + +def generate_metadata(work: Path, version: str, target: str, digest: str, base: str) -> Path: + generator = load_generator() + hashes = {key: "" for key in generator.TARGETS} + target_key = next(key for key, value in generator.TARGETS.items() if value == target) + hashes[target_key] = digest + metadata = work / "metadata" + generator.generate(version, hashes, metadata, base) + return metadata + + +def native_smoke(wright: Path, lsp: Path, provider_bootstrap: bool = False) -> None: + command = [ + sys.executable, + str(SMOKE), + "--wright", + str(wright), + "--wright-lsp", + str(lsp), + "--version", + VERSION, + ] + if provider_bootstrap: + command.append("--provider-bootstrap") + run( + "native post-install smoke", + command, + ) + + +def test_unix_installer(work: Path, base: str) -> None: + install_dir = work / "install-sh" + home = work / "home" + home.mkdir() + run( + "install.sh installation", + [ + "bash", + str(ROOT / "install.sh"), + "--version", + VERSION, + "--dir", + str(install_dir), + ], + { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "WRIGHT_INSTALL_BASE_URL": base, + }, + ) + native_smoke(install_dir / "wright", install_dir / "wright-lsp") + + +def test_windows_installer(work: Path, base: str) -> None: + install_dir = work / "install-ps1" + run( + "install.ps1 installation", + [ + "pwsh", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + str(ROOT / "install.ps1"), + "-Version", + VERSION, + "-InstallDir", + str(install_dir), + "-BaseUrl", + base, + "-ApiUrl", + f"{base}/unused/latest", + ], + ) + native_smoke(install_dir / "wright.exe", install_dir / "wright-lsp.exe") + + +def initialize_homebrew_tap(tap_root: Path, formula: Path) -> Path: + tap_formula = tap_root / "Formula" / "wright.rb" + tap_formula.parent.mkdir(parents=True) + shutil.copy2(formula, tap_formula) + run("initialize local Homebrew tap", ["git", "init", "--quiet", str(tap_root)]) + run("configure local Homebrew tap", ["git", "-C", str(tap_root), "config", "user.name", "wright-ci"]) + run( + "configure local Homebrew tap email", + ["git", "-C", str(tap_root), "config", "user.email", "wright-ci@example.invalid"], + ) + run("commit local Homebrew tap", ["git", "-C", str(tap_root), "add", "Formula/wright.rb"]) + run( + "commit local Homebrew tap contents", + ["git", "-C", str(tap_root), "commit", "--quiet", "-m", "test tap"], + ) + return tap_root + + +def test_homebrew(metadata: Path, work: Path) -> None: + formula = metadata / "dist" / "homebrew" / "wright.rb" + tap_root = initialize_homebrew_tap(work / "homebrew-tap", formula) + brew_env = {**os.environ, "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ENV_HINTS": "1"} + tap_name = "wright-ci/local-tap" + tapped = False + try: + run("Homebrew add generated local tap", ["brew", "tap", tap_name, str(tap_root)], brew_env) + tapped = True + run("Homebrew install from generated local formula", ["brew", "install", f"{tap_name}/wright"], brew_env) + run("Homebrew formula test", ["brew", "test", f"{tap_name}/wright"], brew_env) + prefix = run("Homebrew resolve installed prefix", ["brew", "--prefix", "wright"], brew_env).strip() + native_smoke(Path(prefix) / "bin" / "wright", Path(prefix) / "bin" / "wright-lsp") + finally: + subprocess.run( + ["brew", "uninstall", "--force", "wright"], + cwd=ROOT, + env=brew_env, + check=False, + capture_output=True, + text=True, + ) + if tapped: + subprocess.run( + ["brew", "untap", tap_name], + cwd=ROOT, + env=brew_env, + check=False, + capture_output=True, + text=True, + ) + + +def initialize_bucket(bucket_root: Path, manifest: Path) -> Path: + bucket = bucket_root / "bucket" + bucket.mkdir(parents=True) + shutil.copy2(manifest, bucket / "wright.json") + run("initialize local Scoop bucket", ["git", "init", "--quiet", str(bucket_root)]) + run("configure local Scoop bucket", ["git", "-C", str(bucket_root), "config", "user.name", "wright-ci"]) + run( + "configure local Scoop bucket email", + ["git", "-C", str(bucket_root), "config", "user.email", "wright-ci@example.invalid"], + ) + run("commit local Scoop bucket", ["git", "-C", str(bucket_root), "add", "bucket/wright.json"]) + run( + "commit local Scoop bucket contents", + ["git", "-C", str(bucket_root), "commit", "--quiet", "-m", "test bucket"], + ) + return bucket_root + + +def test_scoop(metadata: Path, work: Path) -> None: + bucket_root = initialize_bucket(work / "scoop-bucket", metadata / "dist" / "scoop" / "wright.json") + added = False + try: + run("Scoop add generated local bucket", ["scoop", "bucket", "add", "wright-local", str(bucket_root)]) + added = True + run("Scoop install from generated local manifest", ["scoop", "install", "wright-local/wright"]) + prefix = run("Scoop resolve installed prefix", ["scoop", "prefix", "wright"]).strip() + native_smoke(Path(prefix) / "wright.exe", Path(prefix) / "wright-lsp.exe") + finally: + subprocess.run(["scoop", "uninstall", "wright"], cwd=ROOT, check=False, capture_output=True, text=True) + if added: + subprocess.run( + ["scoop", "bucket", "rm", "wright-local"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + +def winget_binary(name: str) -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if not local_app_data: + fail("LOCALAPPDATA is not set") + roots = [ + Path(local_app_data) / "Microsoft" / "WinGet" / "Packages", + Path(local_app_data) / "Microsoft" / "WinGet" / "Links", + ] + candidates = [path for root in roots if root.exists() for path in root.rglob(name)] + if not candidates: + fail(f"WinGet installed package does not expose {name}") + return candidates[0] + + +def test_winget(metadata: Path) -> None: + manifest_dir = metadata / "dist" / "winget" / "manifests" / "w" / "WrightKit" / "Wright" / VERSION + try: + run("WinGet validate generated local manifests", ["winget", "validate", "--manifest", str(manifest_dir)]) + run( + "WinGet install from generated local manifests", + [ + "winget", + "install", + "--manifest", + str(manifest_dir), + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + ], + ) + native_smoke(winget_binary("wright.exe"), winget_binary("wright-lsp.exe")) + finally: + subprocess.run( + ["winget", "uninstall", "--id", "WrightKit.Wright", "--silent", "--disable-interactivity"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + +def main() -> None: + global VERSION + VERSION = (ROOT / "version.txt").read_text().strip() + target, extension, exe = target_info() + with tempfile.TemporaryDirectory(prefix="wright-distribution-") as directory: + work = Path(directory) + stage = stage_artifact(work, VERSION, target, extension, exe) + server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + lambda *args, **kwargs: QuietHandler(*args, directory=str(work), **kwargs), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}/releases/download" + metadata = generate_metadata(work, VERSION, target, hashlib.sha256(stage.read_bytes()).hexdigest(), base) + try: + if platform.system() == "Windows": + test_windows_installer(work, base) + test_scoop(metadata, work) + test_winget(metadata) + else: + test_unix_installer(work, base) + if platform.system() == "Darwin": + test_homebrew(metadata, work) + finally: + server.shutdown() + thread.join(timeout=5) + print("distribution channel validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/update-dist-manifests.py b/scripts/update-dist-manifests.py index 219f1f2..2b1315c 100644 --- a/scripts/update-dist-manifests.py +++ b/scripts/update-dist-manifests.py @@ -46,18 +46,18 @@ def valid_hash(value: str) -> bool: return bool(re.fullmatch(r"[0-9a-fA-F]{64}", value)) -def artifact_url(version: str, triple: str) -> str: +def artifact_url(version: str, triple: str, release_base: str = RELEASE_BASE) -> str: ext = ARCHIVE_EXT[triple] - return f"{RELEASE_BASE}/v{version}/wright-{version}-{triple}.{ext}" + return f"{release_base.rstrip('/')}/v{version}/wright-{version}-{triple}.{ext}" def version_from_tag(tag: str) -> str: return tag[1:] if tag.startswith("v") else tag -def homebrew_formula(version: str, hashes: dict) -> str: - arm_url = artifact_url(version, TARGETS["darwin-arm64"]) - x64_url = artifact_url(version, TARGETS["darwin-x64"]) +def homebrew_formula(version: str, hashes: dict, release_base: str = RELEASE_BASE) -> str: + arm_url = artifact_url(version, TARGETS["darwin-arm64"], release_base) + x64_url = artifact_url(version, TARGETS["darwin-x64"], release_base) return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. # Consumes the canonical macOS release archives; do not edit by hand. class Wright < Formula @@ -94,8 +94,10 @@ def install """ -def winget_installer(version: str, windows_hash: str) -> str: - win_url = artifact_url(version, TARGETS["windows-x64"]) +def winget_installer( + version: str, windows_hash: str, release_base: str = RELEASE_BASE +) -> str: + win_url = artifact_url(version, TARGETS["windows-x64"], release_base) payload = f"wright-{version}-x86_64-pc-windows-msvc" return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. # The canonical Windows release ZIP (a zip of portable executables). @@ -155,8 +157,10 @@ def winget_version(version: str) -> str: """ -def scoop_manifest(version: str, windows_hash: str) -> str: - win_url = artifact_url(version, TARGETS["windows-x64"]) +def scoop_manifest( + version: str, windows_hash: str, release_base: str = RELEASE_BASE +) -> str: + win_url = artifact_url(version, TARGETS["windows-x64"], release_base) payload = f"wright-{version}-x86_64-pc-windows-msvc" return f"""{{ "version": "{version}", @@ -189,7 +193,12 @@ def scoop_manifest(version: str, windows_hash: str) -> str: """ -def generate(version: str, hashes: dict, out_dir: Path) -> list: +def generate( + version: str, + hashes: dict, + out_dir: Path, + release_base: str = RELEASE_BASE, +) -> list: """Write every distribution manifest for `version` into `out_dir`. Returns the list of written paths (relative to out_dir). @@ -203,7 +212,7 @@ def generate(version: str, hashes: dict, out_dir: Path) -> list: formula = out_dir / "dist" / "homebrew" / "wright.rb" formula.parent.mkdir(parents=True, exist_ok=True) - formula.write_text(homebrew_formula(version, hashes)) + formula.write_text(homebrew_formula(version, hashes, release_base)) written.append(formula.relative_to(out_dir)) package_dir = out_dir / "dist" / "winget" / "manifests" / "w" / "WrightKit" / "Wright" @@ -214,7 +223,9 @@ def generate(version: str, hashes: dict, out_dir: Path) -> list: version_dir = package_dir / version version_dir.mkdir(parents=True, exist_ok=True) winget_files = { - f"WrightKit.Wright.installer.yaml": winget_installer(version, hashes["windows-x64"]), + f"WrightKit.Wright.installer.yaml": winget_installer( + version, hashes["windows-x64"], release_base + ), "WrightKit.Wright.locale.en-US.yaml": winget_default_locale(version), "WrightKit.Wright.yaml": winget_version(version), } @@ -224,7 +235,7 @@ def generate(version: str, hashes: dict, out_dir: Path) -> list: scoop = out_dir / "dist" / "scoop" / "wright.json" scoop.parent.mkdir(parents=True, exist_ok=True) - scoop.write_text(scoop_manifest(version, hashes["windows-x64"])) + scoop.write_text(scoop_manifest(version, hashes["windows-x64"], release_base)) written.append(scoop.relative_to(out_dir)) return written @@ -236,6 +247,11 @@ def main() -> None: for target in TARGETS: parser.add_argument(f"--{target}-hash", default="", help=f"sha256 of the {target} release archive") parser.add_argument("--out-dir", type=Path, default=REPO_ROOT, help="output root (default: repository root)") + parser.add_argument( + "--release-base", + default=RELEASE_BASE, + help="release artifact base URL (default: canonical GitHub Releases URL)", + ) args = parser.parse_args() version = version_from_tag(args.version) @@ -243,7 +259,7 @@ def main() -> None: raise SystemExit("internal error: placeholder hash must be 64 hex chars") hashes = {target: getattr(args, f"{target.replace('-', '_')}_hash") for target in TARGETS} - written = generate(version, hashes, args.out_dir) + written = generate(version, hashes, args.out_dir, args.release_base) for path in written: print(f"generated {path}") print(f"done: {version}") From f8b6c9b06d64b77f8c7a11fa3085a399f59fb7b3 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:22:10 +0800 Subject: [PATCH 2/6] fix(ci): provision Windows distribution tools Fixes #255 --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306ecc2..1a988ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -634,6 +634,23 @@ jobs: with: python-version: "3.12" + - name: Install Scoop + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $scoopShims = Join-Path $env:USERPROFILE "scoop\shims" + Add-Content -Path $env:GITHUB_PATH -Value $scoopShims + $env:PATH = "$scoopShims;$env:PATH" + scoop --version + + - name: Enable WinGet local manifest installs + if: runner.os == 'Windows' + shell: pwsh + run: winget settings --enable LocalManifestFiles + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: From c2a68adcf21b20421c56f93799f22dabd94e9328 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:39:33 +0800 Subject: [PATCH 3/6] refactor(distribution): isolate channel validation Fixes #255 --- .github/workflows/ci.yml | 50 ++-- docs/release.md | 14 +- scripts/distribution_test_support.py | 166 +++++++++++ scripts/test-distribution-homebrew.py | 74 +++++ scripts/test-distribution-install-sh.py | 59 ++++ scripts/test-distribution-scoop.py | 70 +++++ scripts/test-distribution-winget.py | 69 +++++ scripts/test-distribution.py | 357 ------------------------ scripts/test-install.ps1 | 6 + 9 files changed, 486 insertions(+), 379 deletions(-) create mode 100644 scripts/distribution_test_support.py create mode 100644 scripts/test-distribution-homebrew.py create mode 100644 scripts/test-distribution-install-sh.py create mode 100644 scripts/test-distribution-scoop.py create mode 100644 scripts/test-distribution-winget.py delete mode 100644 scripts/test-distribution.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a988ce..555f0dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -606,7 +606,7 @@ jobs: # explains which gate caused the skip. # ------------------------------------------------------------------------- dist-validation: - name: Distribution validation (${{ matrix.os }}) + name: Distribution validation (${{ matrix.channel }}, ${{ matrix.os }}) needs: - paths - rust-quality @@ -621,10 +621,19 @@ jobs: strategy: fail-fast: false matrix: - os: - - ubuntu-latest - - macos-15 - - windows-latest + include: + - os: ubuntu-latest + channel: install.sh + - os: macos-15 + channel: install.sh + - os: macos-15 + channel: Homebrew + - os: windows-latest + channel: install.ps1 + - os: windows-latest + channel: Scoop + - os: windows-latest + channel: WinGet steps: - name: Check out repository uses: actions/checkout@v7 @@ -634,8 +643,8 @@ jobs: with: python-version: "3.12" - - name: Install Scoop - if: runner.os == 'Windows' + - name: Install Scoop for Scoop validation + if: matrix.channel == 'Scoop' shell: pwsh run: | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser @@ -646,8 +655,8 @@ jobs: $env:PATH = "$scoopShims;$env:PATH" scoop --version - - name: Enable WinGet local manifest installs - if: runner.os == 'Windows' + - name: Enable WinGet local manifests for WinGet validation + if: matrix.channel == 'WinGet' shell: pwsh run: winget settings --enable LocalManifestFiles @@ -672,13 +681,22 @@ jobs: - name: Validate package-manager metadata and install script run: python scripts/verify-dist.py - - name: Run installer functional tests - if: runner.os != 'Windows' + - name: Run install.sh functional tests + if: matrix.channel == 'install.sh' run: scripts/test-install.sh - - name: Run Windows installer functional tests - if: runner.os == 'Windows' + - name: Run install.sh native channel smoke + if: matrix.channel == 'install.sh' + run: python scripts/test-distribution-install-sh.py + - name: Run install.ps1 channel validation + if: matrix.channel == 'install.ps1' shell: pwsh run: ./scripts/test-install.ps1 - - - name: Run native distribution channel smoke tests - run: python scripts/test-distribution.py + - name: Run Homebrew channel validation + if: matrix.channel == 'Homebrew' + run: python scripts/test-distribution-homebrew.py + - name: Run Scoop channel validation + if: matrix.channel == 'Scoop' + run: python scripts/test-distribution-scoop.py + - name: Run WinGet channel validation + if: matrix.channel == 'WinGet' + run: python scripts/test-distribution-winget.py diff --git a/docs/release.md b/docs/release.md index 628f33f..02681e4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -157,12 +157,14 @@ from an empty provider store. The upload job re-verifies that every declared target's archive and checksum are present before attaching them to the draft Release. -The normal CI distribution job separately stages a canonical-shaped local -release archive and generated local metadata. It exercises `install.sh` or -`install.ps1`, Homebrew on macOS, and Scoop plus WinGet on Windows through their -real installation commands; each installed binary then runs the same native -smoke. These channel checks are labelled separately from the native runtime -smoke and do not publish or modify any external package-manager repository. +The normal CI distribution validation uses independent channel legs. It stages +a canonical-shaped local release archive and generated local metadata for +`install.sh` on Linux/macOS, `install.ps1` on Windows, Homebrew on macOS, Scoop +on Windows, and WinGet on Windows. Each leg provisions or configures only its +own package-manager prerequisite, runs that channel's real installation +command, and runs the same native smoke against the installed binaries. These +channel checks are labelled separately from the native runtime smoke and do +not publish or modify any external package-manager repository. ### Repository configuration diff --git a/scripts/distribution_test_support.py b/scripts/distribution_test_support.py new file mode 100644 index 0000000..c40d313 --- /dev/null +++ b/scripts/distribution_test_support.py @@ -0,0 +1,166 @@ +"""Shared local release fixture helpers for channel-specific distribution tests.""" + +from __future__ import annotations + +import hashlib +import http.server +import importlib.util +import shutil +import subprocess +import sys +import tarfile +import tempfile +import threading +import zipfile +from pathlib import Path +from typing import NoReturn + + +ROOT = Path(__file__).resolve().parent.parent +SMOKE = ROOT / "scripts" / "smoke-native.py" + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + pass + + def copyfile(self, source, outputfile) -> None: + try: + super().copyfile(source, outputfile) + except BrokenPipeError: + pass + + +def fail(channel: str, message: str) -> NoReturn: + raise SystemExit(f"{channel} distribution validation failed: {message}") + + +def run(channel: str, label: str, command: list[str], env: dict[str, str] | None = None) -> str: + print(f"==> {channel}: {label}") + try: + result = subprocess.run( + command, + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as error: + fail(channel, f"{label}: missing executable {error.filename}") + except subprocess.CalledProcessError as error: + output = "\n".join(part for part in (error.stdout, error.stderr) if part) + if output: + print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") + fail(channel, f"{label}: command exited with status {error.returncode}") + return result.stdout + + +def native_smoke(channel: str, wright: Path, lsp: Path, version: str) -> None: + try: + subprocess.run( + [ + sys.executable, + str(SMOKE), + "--wright", + str(wright), + "--wright-lsp", + str(lsp), + "--version", + version, + ], + cwd=ROOT, + check=True, + ) + except FileNotFoundError as error: + fail(channel, f"native post-install smoke: missing executable {error.filename}") + except subprocess.CalledProcessError as error: + fail(channel, f"native post-install smoke: command exited with status {error.returncode}") + + +def load_generator(): + spec = importlib.util.spec_from_file_location( + "wright_dist", ROOT / "scripts" / "update-dist-manifests.py" + ) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load scripts/update-dist-manifests.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ReleaseFixture: + def __init__( + self, + channel: str, + version: str, + target: str, + extension: str, + executable_suffix: str, + ) -> None: + self.channel = channel + self.version = version + self.target = target + self.extension = extension + self.executable_suffix = executable_suffix + + def __enter__(self) -> "ReleaseFixture": + self._temporary = tempfile.TemporaryDirectory(prefix="wright-distribution-") + self.work = Path(self._temporary.name) + self.archive = self._stage_artifact() + self.server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + lambda *args, **kwargs: QuietHandler(*args, directory=str(self.work), **kwargs), + ) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base = f"http://127.0.0.1:{self.server.server_port}/releases/download" + self.metadata = self._generate_metadata() + latest = self.work / "repos" / "wrightkit" / "wright" / "releases" / "latest" + latest.parent.mkdir(parents=True, exist_ok=True) + latest.write_text( + f'{{"tag_name":"v{self.version}","draft":false,"prerelease":false}}\n' + ) + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.server.shutdown() + self.thread.join(timeout=5) + self._temporary.cleanup() + + def _stage_artifact(self) -> Path: + source_dir = ROOT / "target" / "debug" + payload_name = f"wright-{self.version}-{self.target}" + release_dir = self.work / "releases" / "download" / f"v{self.version}" + payload = release_dir / payload_name + payload.mkdir(parents=True) + for name in (f"wright{self.executable_suffix}", f"wright-lsp{self.executable_suffix}"): + source = source_dir / name + if not source.is_file(): + fail( + self.channel, + f"native debug binary is missing: {source}; build wright-cli and wright-lsp first", + ) + shutil.copy2(source, payload / name) + (payload / "version.json").write_text(f'{{"version":"{self.version}"}}\n') + + archive = release_dir / f"{payload_name}.{self.extension}" + if self.extension == "zip": + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output: + for path in payload.iterdir(): + output.write(path, f"{payload_name}/{path.name}") + else: + with tarfile.open(archive, "w:gz") as output: + output.add(payload, arcname=payload_name) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + archive.with_name(f"{archive.name}.sha256").write_text(f"{digest} {archive.name}\n") + return archive + + def _generate_metadata(self) -> Path: + generator = load_generator() + hashes = {key: "" for key in generator.TARGETS} + target_key = next(key for key, value in generator.TARGETS.items() if value == self.target) + hashes[target_key] = hashlib.sha256(self.archive.read_bytes()).hexdigest() + metadata = self.work / "metadata" + generator.generate(self.version, hashes, metadata, self.base) + return metadata diff --git a/scripts/test-distribution-homebrew.py b/scripts/test-distribution-homebrew.py new file mode 100644 index 0000000..82103cd --- /dev/null +++ b/scripts/test-distribution-homebrew.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Exercise the native Homebrew channel against a local release artifact.""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +from pathlib import Path + +from distribution_test_support import ROOT, ReleaseFixture, fail, native_smoke, run + + +CHANNEL = "Homebrew" + + +def target_info() -> str: + machine = platform.machine().lower() + if platform.system() != "Darwin": + fail(CHANNEL, f"unsupported validation host {platform.system()}") + if machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin" + if machine in {"x86_64", "amd64"}: + return "x86_64-apple-darwin" + fail(CHANNEL, f"unsupported validation host Darwin/{platform.machine()}") + + +def initialize_tap(fixture: ReleaseFixture): + tap_root = fixture.work / "homebrew-tap" + formula = fixture.metadata / "dist" / "homebrew" / "wright.rb" + tap_formula = tap_root / "Formula" / "wright.rb" + tap_formula.parent.mkdir(parents=True) + shutil.copy2(formula, tap_formula) + run(CHANNEL, "initialize local Homebrew tap", ["git", "init", "--quiet", str(tap_root)]) + run(CHANNEL, "configure local Homebrew tap", ["git", "-C", str(tap_root), "config", "user.name", "wright-ci"]) + run( + CHANNEL, + "configure local Homebrew tap email", + ["git", "-C", str(tap_root), "config", "user.email", "wright-ci@example.invalid"], + ) + run(CHANNEL, "commit local Homebrew tap", ["git", "-C", str(tap_root), "add", "Formula/wright.rb"]) + run(CHANNEL, "commit local Homebrew tap contents", ["git", "-C", str(tap_root), "commit", "--quiet", "-m", "test tap"]) + return tap_root + + +def main() -> None: + version = (ROOT / "version.txt").read_text().strip() + with ReleaseFixture(CHANNEL, version, target_info(), "tar.gz", "") as fixture: + tap_root = initialize_tap(fixture) + tap_name = "wright-ci/local-tap" + brew_env = {**os.environ, "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ENV_HINTS": "1"} + tapped = False + try: + run(CHANNEL, "add generated local tap", ["brew", "tap", tap_name, str(tap_root)], brew_env) + tapped = True + run(CHANNEL, "install from generated local formula", ["brew", "install", f"{tap_name}/wright"], brew_env) + run(CHANNEL, "formula test", ["brew", "test", f"{tap_name}/wright"], brew_env) + prefix = run(CHANNEL, "resolve installed prefix", ["brew", "--prefix", "wright"], brew_env).strip() + native_smoke( + CHANNEL, + Path(prefix) / "bin" / "wright", + Path(prefix) / "bin" / "wright-lsp", + version, + ) + finally: + subprocess.run(["brew", "uninstall", "--force", "wright"], cwd=ROOT, env=brew_env, check=False, capture_output=True, text=True) + if tapped: + subprocess.run(["brew", "untap", tap_name], cwd=ROOT, env=brew_env, check=False, capture_output=True, text=True) + print(f"{CHANNEL} distribution validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-distribution-install-sh.py b/scripts/test-distribution-install-sh.py new file mode 100644 index 0000000..ec082f5 --- /dev/null +++ b/scripts/test-distribution-install-sh.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Exercise the native install.sh channel against a local release artifact.""" + +from __future__ import annotations + +import os +import platform +from pathlib import Path + +from distribution_test_support import ROOT, ReleaseFixture, fail, native_smoke, run + + +CHANNEL = "install.sh" + + +def target_info() -> str: + system = platform.system() + machine = platform.machine().lower() + if system == "Linux" and machine in {"x86_64", "amd64"}: + return "x86_64-unknown-linux-gnu" + if system == "Darwin" and machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin" + if system == "Darwin" and machine in {"x86_64", "amd64"}: + return "x86_64-apple-darwin" + fail(CHANNEL, f"unsupported validation host {system}/{platform.machine()}") + + +def main() -> None: + version = (ROOT / "version.txt").read_text().strip() + with ReleaseFixture(CHANNEL, version, target_info(), "tar.gz", "") as fixture: + install_dir = fixture.work / "install" + home = fixture.work / "home" + home.mkdir() + base_env = { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "WRIGHT_INSTALL_BASE_URL": fixture.base, + "WRIGHT_API_URL": f"{fixture.base}/repos/wrightkit/wright/releases/latest", + } + run( + CHANNEL, + "install.sh installation", + [ + "bash", + str(ROOT / "install.sh"), + "--version", + version, + "--dir", + str(install_dir), + ], + base_env, + ) + native_smoke(CHANNEL, install_dir / "wright", install_dir / "wright-lsp", version) + print(f"{CHANNEL} distribution validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-distribution-scoop.py b/scripts/test-distribution-scoop.py new file mode 100644 index 0000000..d47d35a --- /dev/null +++ b/scripts/test-distribution-scoop.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Exercise the native Scoop channel against a local release artifact.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +from pathlib import Path + +from distribution_test_support import ROOT, ReleaseFixture, fail, native_smoke, run + + +CHANNEL = "Scoop" + + +def powershell_literal(value: object) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def scoop_command(arguments: list[object]) -> list[str]: + expression = "& (Get-Command scoop -ErrorAction Stop).Source " + " ".join( + powershell_literal(argument) for argument in arguments + ) + return ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", expression] + + +def scoop_run(label: str, arguments: list[object]) -> str: + return run(CHANNEL, label, scoop_command(arguments)) + + +def target_info() -> str: + if platform.system() != "Windows" or platform.machine().lower() not in {"x86_64", "amd64"}: + fail(CHANNEL, f"unsupported validation host {platform.system()}/{platform.machine()}") + return "x86_64-pc-windows-msvc" + + +def initialize_bucket(fixture: ReleaseFixture): + bucket_root = fixture.work / "scoop-bucket" + bucket = bucket_root / "bucket" + bucket.mkdir(parents=True) + shutil.copy2(fixture.metadata / "dist" / "scoop" / "wright.json", bucket / "wright.json") + run(CHANNEL, "initialize local bucket", ["git", "init", "--quiet", str(bucket_root)]) + run(CHANNEL, "configure local bucket", ["git", "-C", str(bucket_root), "config", "user.name", "wright-ci"]) + run(CHANNEL, "configure local bucket email", ["git", "-C", str(bucket_root), "config", "user.email", "wright-ci@example.invalid"]) + run(CHANNEL, "commit local bucket", ["git", "-C", str(bucket_root), "add", "bucket/wright.json"]) + run(CHANNEL, "commit local bucket contents", ["git", "-C", str(bucket_root), "commit", "--quiet", "-m", "test bucket"]) + return bucket_root + + +def main() -> None: + version = (ROOT / "version.txt").read_text().strip() + with ReleaseFixture(CHANNEL, version, target_info(), "zip", ".exe") as fixture: + bucket_root = initialize_bucket(fixture) + added = False + try: + scoop_run("add generated local bucket", ["bucket", "add", "wright-local", bucket_root]) + added = True + scoop_run("install from generated local manifest", ["install", "wright-local/wright"]) + prefix = scoop_run("resolve installed prefix", ["prefix", "wright"]).strip() + native_smoke(CHANNEL, Path(prefix) / "wright.exe", Path(prefix) / "wright-lsp.exe", version) + finally: + subprocess.run(scoop_command(["uninstall", "wright"]), cwd=ROOT, check=False, capture_output=True, text=True) + if added: + subprocess.run(scoop_command(["bucket", "rm", "wright-local"]), cwd=ROOT, check=False, capture_output=True, text=True) + print(f"{CHANNEL} distribution validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-distribution-winget.py b/scripts/test-distribution-winget.py new file mode 100644 index 0000000..eb50d41 --- /dev/null +++ b/scripts/test-distribution-winget.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Exercise the native WinGet channel against a local release artifact.""" + +from __future__ import annotations + +import os +import platform +import subprocess +from pathlib import Path + +from distribution_test_support import ROOT, ReleaseFixture, fail, native_smoke, run + + +CHANNEL = "WinGet" + + +def target_info() -> str: + if platform.system() != "Windows" or platform.machine().lower() not in {"x86_64", "amd64"}: + fail(CHANNEL, f"unsupported validation host {platform.system()}/{platform.machine()}") + return "x86_64-pc-windows-msvc" + + +def installed_binary(name: str) -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if not local_app_data: + fail(CHANNEL, "LOCALAPPDATA is not set") + roots = [ + Path(local_app_data) / "Microsoft" / "WinGet" / "Packages", + Path(local_app_data) / "Microsoft" / "WinGet" / "Links", + ] + candidates = [path for root in roots if root.exists() for path in root.rglob(name)] + if not candidates: + fail(CHANNEL, f"installed package does not expose {name}") + return candidates[0] + + +def main() -> None: + version = (ROOT / "version.txt").read_text().strip() + with ReleaseFixture(CHANNEL, version, target_info(), "zip", ".exe") as fixture: + manifest_dir = fixture.metadata / "dist" / "winget" / "manifests" / "w" / "WrightKit" / "Wright" / version + try: + run(CHANNEL, "validate generated local manifests", ["winget", "validate", "--manifest", str(manifest_dir)]) + run( + CHANNEL, + "install from generated local manifests", + [ + "winget", + "install", + "--manifest", + str(manifest_dir), + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + ], + ) + native_smoke(CHANNEL, installed_binary("wright.exe"), installed_binary("wright-lsp.exe"), version) + finally: + subprocess.run( + ["winget", "uninstall", "--id", "WrightKit.Wright", "--silent", "--disable-interactivity"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + print(f"{CHANNEL} distribution validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-distribution.py b/scripts/test-distribution.py deleted file mode 100644 index a6a5b6a..0000000 --- a/scripts/test-distribution.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise native installation channels against a locally staged artifact (#255).""" - -from __future__ import annotations - -import hashlib -import http.server -import importlib.util -import os -import platform -import shutil -import subprocess -import sys -import tarfile -import tempfile -import threading -import zipfile -from pathlib import Path -from typing import NoReturn - - -ROOT = Path(__file__).resolve().parent.parent -SMOKE = ROOT / "scripts" / "smoke-native.py" - - -class QuietHandler(http.server.SimpleHTTPRequestHandler): - def log_message(self, _format: str, *_args: object) -> None: - pass - - def copyfile(self, source, outputfile) -> None: - try: - super().copyfile(source, outputfile) - except BrokenPipeError: - pass - - -def fail(message: str) -> NoReturn: - raise SystemExit(f"distribution channel validation failed: {message}") - - -def run(label: str, command: list[str], env: dict[str, str] | None = None) -> str: - print(f"==> distribution channel: {label}") - try: - result = subprocess.run( - command, - cwd=ROOT, - env=env, - check=True, - capture_output=True, - text=True, - ) - except FileNotFoundError as error: - fail(f"{label}: missing executable {error.filename}") - except subprocess.CalledProcessError as error: - output = "\n".join(part for part in (error.stdout, error.stderr) if part) - if output: - print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") - fail(f"{label}: command exited with status {error.returncode}") - return result.stdout - - -def target_info() -> tuple[str, str, str]: - system = platform.system() - machine = platform.machine().lower() - if system == "Linux" and machine in {"x86_64", "amd64"}: - return "x86_64-unknown-linux-gnu", "tar.gz", "" - if system == "Darwin" and machine in {"arm64", "aarch64"}: - return "aarch64-apple-darwin", "tar.gz", "" - if system == "Darwin" and machine in {"x86_64", "amd64"}: - return "x86_64-apple-darwin", "tar.gz", "" - if system == "Windows" and machine in {"x86_64", "amd64"}: - return "x86_64-pc-windows-msvc", "zip", ".exe" - fail(f"unsupported validation host {system}/{platform.machine()}") - - -def load_generator(): - spec = importlib.util.spec_from_file_location( - "wright_dist", ROOT / "scripts" / "update-dist-manifests.py" - ) - if spec is None or spec.loader is None: - fail("cannot load scripts/update-dist-manifests.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def stage_artifact(work: Path, version: str, target: str, extension: str, exe: str) -> Path: - source_dir = ROOT / "target" / "debug" - source_wright = source_dir / f"wright{exe}" - source_lsp = source_dir / f"wright-lsp{exe}" - if not source_wright.is_file() or not source_lsp.is_file(): - fail( - "native debug binaries are missing; run " - "cargo build --locked -p wright-cli -p wright-lsp first" - ) - - release_dir = work / "releases" / "download" / f"v{version}" - payload_name = f"wright-{version}-{target}" - payload = release_dir / payload_name - payload.mkdir(parents=True) - shutil.copy2(source_wright, payload / f"wright{exe}") - shutil.copy2(source_lsp, payload / f"wright-lsp{exe}") - (payload / "version.json").write_text(f'{{"version":"{version}"}}\n') - - archive = release_dir / f"{payload_name}.{extension}" - if extension == "zip": - with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output: - for path in payload.iterdir(): - output.write(path, f"{payload_name}/{path.name}") - else: - with tarfile.open(archive, "w:gz") as output: - output.add(payload, arcname=payload_name) - digest = hashlib.sha256(archive.read_bytes()).hexdigest() - (Path(f"{archive}.sha256")).write_text(f"{digest} {archive.name}\n") - return archive - - -def generate_metadata(work: Path, version: str, target: str, digest: str, base: str) -> Path: - generator = load_generator() - hashes = {key: "" for key in generator.TARGETS} - target_key = next(key for key, value in generator.TARGETS.items() if value == target) - hashes[target_key] = digest - metadata = work / "metadata" - generator.generate(version, hashes, metadata, base) - return metadata - - -def native_smoke(wright: Path, lsp: Path, provider_bootstrap: bool = False) -> None: - command = [ - sys.executable, - str(SMOKE), - "--wright", - str(wright), - "--wright-lsp", - str(lsp), - "--version", - VERSION, - ] - if provider_bootstrap: - command.append("--provider-bootstrap") - run( - "native post-install smoke", - command, - ) - - -def test_unix_installer(work: Path, base: str) -> None: - install_dir = work / "install-sh" - home = work / "home" - home.mkdir() - run( - "install.sh installation", - [ - "bash", - str(ROOT / "install.sh"), - "--version", - VERSION, - "--dir", - str(install_dir), - ], - { - **os.environ, - "HOME": str(home), - "XDG_CONFIG_HOME": str(home / ".config"), - "WRIGHT_INSTALL_BASE_URL": base, - }, - ) - native_smoke(install_dir / "wright", install_dir / "wright-lsp") - - -def test_windows_installer(work: Path, base: str) -> None: - install_dir = work / "install-ps1" - run( - "install.ps1 installation", - [ - "pwsh", - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-File", - str(ROOT / "install.ps1"), - "-Version", - VERSION, - "-InstallDir", - str(install_dir), - "-BaseUrl", - base, - "-ApiUrl", - f"{base}/unused/latest", - ], - ) - native_smoke(install_dir / "wright.exe", install_dir / "wright-lsp.exe") - - -def initialize_homebrew_tap(tap_root: Path, formula: Path) -> Path: - tap_formula = tap_root / "Formula" / "wright.rb" - tap_formula.parent.mkdir(parents=True) - shutil.copy2(formula, tap_formula) - run("initialize local Homebrew tap", ["git", "init", "--quiet", str(tap_root)]) - run("configure local Homebrew tap", ["git", "-C", str(tap_root), "config", "user.name", "wright-ci"]) - run( - "configure local Homebrew tap email", - ["git", "-C", str(tap_root), "config", "user.email", "wright-ci@example.invalid"], - ) - run("commit local Homebrew tap", ["git", "-C", str(tap_root), "add", "Formula/wright.rb"]) - run( - "commit local Homebrew tap contents", - ["git", "-C", str(tap_root), "commit", "--quiet", "-m", "test tap"], - ) - return tap_root - - -def test_homebrew(metadata: Path, work: Path) -> None: - formula = metadata / "dist" / "homebrew" / "wright.rb" - tap_root = initialize_homebrew_tap(work / "homebrew-tap", formula) - brew_env = {**os.environ, "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ENV_HINTS": "1"} - tap_name = "wright-ci/local-tap" - tapped = False - try: - run("Homebrew add generated local tap", ["brew", "tap", tap_name, str(tap_root)], brew_env) - tapped = True - run("Homebrew install from generated local formula", ["brew", "install", f"{tap_name}/wright"], brew_env) - run("Homebrew formula test", ["brew", "test", f"{tap_name}/wright"], brew_env) - prefix = run("Homebrew resolve installed prefix", ["brew", "--prefix", "wright"], brew_env).strip() - native_smoke(Path(prefix) / "bin" / "wright", Path(prefix) / "bin" / "wright-lsp") - finally: - subprocess.run( - ["brew", "uninstall", "--force", "wright"], - cwd=ROOT, - env=brew_env, - check=False, - capture_output=True, - text=True, - ) - if tapped: - subprocess.run( - ["brew", "untap", tap_name], - cwd=ROOT, - env=brew_env, - check=False, - capture_output=True, - text=True, - ) - - -def initialize_bucket(bucket_root: Path, manifest: Path) -> Path: - bucket = bucket_root / "bucket" - bucket.mkdir(parents=True) - shutil.copy2(manifest, bucket / "wright.json") - run("initialize local Scoop bucket", ["git", "init", "--quiet", str(bucket_root)]) - run("configure local Scoop bucket", ["git", "-C", str(bucket_root), "config", "user.name", "wright-ci"]) - run( - "configure local Scoop bucket email", - ["git", "-C", str(bucket_root), "config", "user.email", "wright-ci@example.invalid"], - ) - run("commit local Scoop bucket", ["git", "-C", str(bucket_root), "add", "bucket/wright.json"]) - run( - "commit local Scoop bucket contents", - ["git", "-C", str(bucket_root), "commit", "--quiet", "-m", "test bucket"], - ) - return bucket_root - - -def test_scoop(metadata: Path, work: Path) -> None: - bucket_root = initialize_bucket(work / "scoop-bucket", metadata / "dist" / "scoop" / "wright.json") - added = False - try: - run("Scoop add generated local bucket", ["scoop", "bucket", "add", "wright-local", str(bucket_root)]) - added = True - run("Scoop install from generated local manifest", ["scoop", "install", "wright-local/wright"]) - prefix = run("Scoop resolve installed prefix", ["scoop", "prefix", "wright"]).strip() - native_smoke(Path(prefix) / "wright.exe", Path(prefix) / "wright-lsp.exe") - finally: - subprocess.run(["scoop", "uninstall", "wright"], cwd=ROOT, check=False, capture_output=True, text=True) - if added: - subprocess.run( - ["scoop", "bucket", "rm", "wright-local"], - cwd=ROOT, - check=False, - capture_output=True, - text=True, - ) - - -def winget_binary(name: str) -> Path: - local_app_data = os.environ.get("LOCALAPPDATA") - if not local_app_data: - fail("LOCALAPPDATA is not set") - roots = [ - Path(local_app_data) / "Microsoft" / "WinGet" / "Packages", - Path(local_app_data) / "Microsoft" / "WinGet" / "Links", - ] - candidates = [path for root in roots if root.exists() for path in root.rglob(name)] - if not candidates: - fail(f"WinGet installed package does not expose {name}") - return candidates[0] - - -def test_winget(metadata: Path) -> None: - manifest_dir = metadata / "dist" / "winget" / "manifests" / "w" / "WrightKit" / "Wright" / VERSION - try: - run("WinGet validate generated local manifests", ["winget", "validate", "--manifest", str(manifest_dir)]) - run( - "WinGet install from generated local manifests", - [ - "winget", - "install", - "--manifest", - str(manifest_dir), - "--accept-source-agreements", - "--accept-package-agreements", - "--disable-interactivity", - ], - ) - native_smoke(winget_binary("wright.exe"), winget_binary("wright-lsp.exe")) - finally: - subprocess.run( - ["winget", "uninstall", "--id", "WrightKit.Wright", "--silent", "--disable-interactivity"], - cwd=ROOT, - check=False, - capture_output=True, - text=True, - ) - - -def main() -> None: - global VERSION - VERSION = (ROOT / "version.txt").read_text().strip() - target, extension, exe = target_info() - with tempfile.TemporaryDirectory(prefix="wright-distribution-") as directory: - work = Path(directory) - stage = stage_artifact(work, VERSION, target, extension, exe) - server = http.server.ThreadingHTTPServer( - ("127.0.0.1", 0), - lambda *args, **kwargs: QuietHandler(*args, directory=str(work), **kwargs), - ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - base = f"http://127.0.0.1:{server.server_port}/releases/download" - metadata = generate_metadata(work, VERSION, target, hashlib.sha256(stage.read_bytes()).hexdigest(), base) - try: - if platform.system() == "Windows": - test_windows_installer(work, base) - test_scoop(metadata, work) - test_winget(metadata) - else: - test_unix_installer(work, base) - if platform.system() == "Darwin": - test_homebrew(metadata, work) - finally: - server.shutdown() - thread.join(timeout=5) - print("distribution channel validation passed") - - -if __name__ == "__main__": - main() diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 1ee79cb..5034f11 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $PSScriptRoot $Installer = Join-Path $Root "install.ps1" +$Smoke = Join-Path $Root "scripts\smoke-native.py" $Version = (Get-Content (Join-Path $Root "version.txt") -Raw).Trim() $Target = "x86_64-pc-windows-msvc" $Work = Join-Path ([IO.Path]::GetTempPath()) ("wright-install-test-" + [Guid]::NewGuid().ToString("N")) @@ -80,6 +81,11 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_ -not (Test-Path -LiteralPath (Join-Path $PinnedDir "wright-lsp.exe"))) { Fail "pinned install did not install both executables" } + & python $Smoke ` + --wright (Join-Path $PinnedDir "wright.exe") ` + --wright-lsp (Join-Path $PinnedDir "wright-lsp.exe") ` + --version $Version + if ($LASTEXITCODE -ne 0) { Fail "native post-install smoke failed" } Write-Host "PASS: pinned install and native smoke check" $LatestDir = Join-Path $Work "latest" From d0f696bb009bae350da0961b8666dd03eee3c28a Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:04:38 +0800 Subject: [PATCH 4/6] fix(distribution): make Scoop and WinGet validation real Fixes #255 --- .../WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml | 2 +- .../Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml | 1 + .../w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml | 1 + scripts/test-distribution-scoop.py | 6 ++++-- scripts/update-dist-manifests.py | 4 +++- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml index 187e38a..4c5cf91 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml @@ -1,4 +1,5 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json # The canonical Windows release ZIP (a zip of portable executables). PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 @@ -13,7 +14,6 @@ Installers: - Architecture: x64 InstallerUrl: https://github.com/wrightkit/wright/releases/download/v0.2.17/wright-0.2.17-x86_64-pc-windows-msvc.zip InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 - Scope: user ElevationRequirement: elevationProhibited ManifestType: installer ManifestVersion: 1.12.0 diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml index e2fec0f..ed6d2db 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml @@ -1,4 +1,5 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 PackageLocale: en-US diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml index 8a6b688..f740882 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml @@ -1,4 +1,5 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 DefaultLocale: en-US diff --git a/scripts/test-distribution-scoop.py b/scripts/test-distribution-scoop.py index d47d35a..def7aa3 100644 --- a/scripts/test-distribution-scoop.py +++ b/scripts/test-distribution-scoop.py @@ -36,7 +36,7 @@ def target_info() -> str: def initialize_bucket(fixture: ReleaseFixture): - bucket_root = fixture.work / "scoop-bucket" + bucket_root = fixture.work / "buckets" / "wright-local" bucket = bucket_root / "bucket" bucket.mkdir(parents=True) shutil.copy2(fixture.metadata / "dist" / "scoop" / "wright.json", bucket / "wright.json") @@ -52,9 +52,11 @@ def main() -> None: version = (ROOT / "version.txt").read_text().strip() with ReleaseFixture(CHANNEL, version, target_info(), "zip", ".exe") as fixture: bucket_root = initialize_bucket(fixture) + bucket_uri = bucket_root.as_uri() + run(CHANNEL, "verify local bucket Git URI", ["git", "ls-remote", bucket_uri]) added = False try: - scoop_run("add generated local bucket", ["bucket", "add", "wright-local", bucket_root]) + scoop_run("add generated local bucket", ["bucket", "add", "wright-local", bucket_uri]) added = True scoop_run("install from generated local manifest", ["install", "wright-local/wright"]) prefix = scoop_run("resolve installed prefix", ["prefix", "wright"]).strip() diff --git a/scripts/update-dist-manifests.py b/scripts/update-dist-manifests.py index 2b1315c..29ef626 100644 --- a/scripts/update-dist-manifests.py +++ b/scripts/update-dist-manifests.py @@ -100,6 +100,7 @@ def winget_installer( win_url = artifact_url(version, TARGETS["windows-x64"], release_base) payload = f"wright-{version}-x86_64-pc-windows-msvc" return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json # The canonical Windows release ZIP (a zip of portable executables). PackageIdentifier: WrightKit.Wright PackageVersion: {version} @@ -114,7 +115,6 @@ def winget_installer( - Architecture: x64 InstallerUrl: {win_url} InstallerSha256: {windows_hash} - Scope: user ElevationRequirement: elevationProhibited ManifestType: installer ManifestVersion: 1.12.0 @@ -123,6 +123,7 @@ def winget_installer( def winget_default_locale(version: str) -> str: return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} PackageLocale: en-US @@ -149,6 +150,7 @@ def winget_default_locale(version: str) -> str: def winget_version(version: str) -> str: return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} DefaultLocale: en-US From 8b1510eae121bd33a15e1fba13605ddcdc17e8d0 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:22:49 +0800 Subject: [PATCH 5/6] fix(distribution): use WinGet-compatible manifest schema Fixes #255 --- .../Wright/0.2.17/WrightKit.Wright.installer.yaml | 4 ++-- .../Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml | 4 ++-- .../w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml | 4 ++-- scripts/update-dist-manifests.py | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml index 4c5cf91..b4135fb 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml @@ -1,5 +1,5 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json # The canonical Windows release ZIP (a zip of portable executables). PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 @@ -16,4 +16,4 @@ Installers: InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 ElevationRequirement: elevationProhibited ManifestType: installer -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml index ed6d2db..b5ddf6c 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.locale.en-US.yaml @@ -1,5 +1,5 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 PackageLocale: en-US @@ -20,4 +20,4 @@ Tags: - cli Moniker: wright ManifestType: defaultLocale -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml index f740882..4080329 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.yaml @@ -1,7 +1,7 @@ # Generated by scripts/update-dist-manifests.py for Wright 0.2.17. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 DefaultLocale: en-US ManifestType: version -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 diff --git a/scripts/update-dist-manifests.py b/scripts/update-dist-manifests.py index 29ef626..888013a 100644 --- a/scripts/update-dist-manifests.py +++ b/scripts/update-dist-manifests.py @@ -100,7 +100,7 @@ def winget_installer( win_url = artifact_url(version, TARGETS["windows-x64"], release_base) payload = f"wright-{version}-x86_64-pc-windows-msvc" return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json # The canonical Windows release ZIP (a zip of portable executables). PackageIdentifier: WrightKit.Wright PackageVersion: {version} @@ -117,13 +117,13 @@ def winget_installer( InstallerSha256: {windows_hash} ElevationRequirement: elevationProhibited ManifestType: installer -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 """ def winget_default_locale(version: str) -> str: return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} PackageLocale: en-US @@ -144,18 +144,18 @@ def winget_default_locale(version: str) -> str: - cli Moniker: wright ManifestType: defaultLocale -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 """ def winget_version(version: str) -> str: return f"""# Generated by scripts/update-dist-manifests.py for Wright {version}. -# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} DefaultLocale: en-US ManifestType: version -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 """ From a9bda8177a07419b766179f9c3fedb29c21a0b75 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:33:18 +0800 Subject: [PATCH 6/6] fix(distribution): allow WinGet runner install Fixes #255 --- .../0.2.17/WrightKit.Wright.installer.yaml | 1 - scripts/test-distribution-winget.py | 21 ++++++++++++------- scripts/update-dist-manifests.py | 1 - 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml index b4135fb..d01e2ba 100644 --- a/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml +++ b/dist/winget/manifests/w/WrightKit/Wright/0.2.17/WrightKit.Wright.installer.yaml @@ -14,6 +14,5 @@ Installers: - Architecture: x64 InstallerUrl: https://github.com/wrightkit/wright/releases/download/v0.2.17/wright-0.2.17-x86_64-pc-windows-msvc.zip InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 - ElevationRequirement: elevationProhibited ManifestType: installer ManifestVersion: 1.10.0 diff --git a/scripts/test-distribution-winget.py b/scripts/test-distribution-winget.py index eb50d41..b55ace7 100644 --- a/scripts/test-distribution-winget.py +++ b/scripts/test-distribution-winget.py @@ -21,13 +21,20 @@ def target_info() -> str: def installed_binary(name: str) -> Path: - local_app_data = os.environ.get("LOCALAPPDATA") - if not local_app_data: - fail(CHANNEL, "LOCALAPPDATA is not set") - roots = [ - Path(local_app_data) / "Microsoft" / "WinGet" / "Packages", - Path(local_app_data) / "Microsoft" / "WinGet" / "Links", - ] + roots = [] + for variable in ("LOCALAPPDATA", "ProgramFiles", "ProgramW6432"): + value = os.environ.get(variable) + if value: + roots.extend( + [ + Path(value) / "Microsoft" / "WinGet" / "Packages", + Path(value) / "Microsoft" / "WinGet" / "Links", + Path(value) / "WinGet" / "Packages", + Path(value) / "WinGet" / "Links", + ] + ) + if not roots: + fail(CHANNEL, "WinGet package roots are not available") candidates = [path for root in roots if root.exists() for path in root.rglob(name)] if not candidates: fail(CHANNEL, f"installed package does not expose {name}") diff --git a/scripts/update-dist-manifests.py b/scripts/update-dist-manifests.py index 888013a..02a2034 100644 --- a/scripts/update-dist-manifests.py +++ b/scripts/update-dist-manifests.py @@ -115,7 +115,6 @@ def winget_installer( - Architecture: x64 InstallerUrl: {win_url} InstallerSha256: {windows_hash} - ElevationRequirement: elevationProhibited ManifestType: installer ManifestVersion: 1.10.0 """