Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
16 changes: 10 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,4 +20,4 @@ Tags:
- cli
Moniker: wright
ManifestType: defaultLocale
ManifestVersion: 1.12.0
ManifestVersion: 1.10.0
Original file line number Diff line number Diff line change
@@ -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
20 changes: 15 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
166 changes: 166 additions & 0 deletions scripts/distribution_test_support.py
Original file line number Diff line number Diff line change
@@ -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
Loading