diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea08161..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,6 +643,23 @@ jobs: with: python-version: "3.12" + - name: Install Scoop for Scoop validation + if: matrix.channel == 'Scoop' + 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 manifests for WinGet validation + if: matrix.channel == 'WinGet' + shell: pwsh + run: winget settings --enable LocalManifestFiles + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: @@ -655,10 +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 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/.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/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..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 @@ -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.10.0.schema.json # The canonical Windows release ZIP (a zip of portable executables). PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 @@ -13,7 +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 - Scope: user - 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 e2fec0f..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,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.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: 0.2.17 PackageLocale: en-US @@ -19,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 8a6b688..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,6 +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.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/docs/release.md b/docs/release.md index 0b2e649..02681e4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -150,11 +150,21 @@ 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 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/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-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..def7aa3 --- /dev/null +++ b/scripts/test-distribution-scoop.py @@ -0,0 +1,72 @@ +#!/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 / "buckets" / "wright-local" + 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) + 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_uri]) + 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..b55ace7 --- /dev/null +++ b/scripts/test-distribution-winget.py @@ -0,0 +1,76 @@ +#!/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: + 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}") + 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-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" diff --git a/scripts/update-dist-manifests.py b/scripts/update-dist-manifests.py index 219f1f2..02a2034 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,10 +94,13 @@ 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}. +# 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} @@ -112,15 +115,14 @@ def winget_installer(version: str, windows_hash: str) -> str: - Architecture: x64 InstallerUrl: {win_url} InstallerSha256: {windows_hash} - Scope: user - 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.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} PackageLocale: en-US @@ -141,22 +143,25 @@ 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.10.0.schema.json PackageIdentifier: WrightKit.Wright PackageVersion: {version} DefaultLocale: en-US ManifestType: version -ManifestVersion: 1.12.0 +ManifestVersion: 1.10.0 """ -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 +194,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 +213,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 +224,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 +236,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 +248,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 +260,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}")