diff --git a/.cargo/config.toml b/.cargo/config.toml index 35049cbcb..e460f95cd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,6 @@ [alias] xtask = "run --package xtask --" + +# Release binaries must run on Windows installs without the Visual C++ runtime. +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a7c329ba..dbe77a3e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,16 @@ jobs: os: ubuntu-22.04 - target: aarch64-unknown-linux-gnu os: ubuntu-22.04-arm + - target: x86_64-pc-windows-msvc + os: windows-latest runs-on: ${{ matrix.os }} timeout-minutes: 45 + defaults: + run: + shell: bash + env: + TARGET: ${{ matrix.target }} + PYTHON: ${{ startsWith(matrix.os, 'windows') && 'python' || 'python3' }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -38,28 +46,28 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Build and package env: - TARGET: ${{ matrix.target }} RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | - version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "diffr-cli") | .version') + version=$(cargo metadata --no-deps --format-version 1 | "$PYTHON" -c 'import json, sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "diffr-cli"), end="")') if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "$version" ]; then echo "Tag $RELEASE_TAG does not match Cargo version $version" >&2 exit 1 fi + echo "VERSION=$version" >> "$GITHUB_ENV" cargo xtask install --root "$RUNNER_TEMP/install" cargo install --path . --locked --features all-languages --root "$RUNNER_TEMP/full" - python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist - python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/full" --edition full --output dist + "$PYTHON" scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist + "$PYTHON" scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/full" --edition full --output dist - name: Verify extracted archives without Bun or checkout assets - env: - TARGET: ${{ matrix.target }} run: | - version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) - mkdir -p "$RUNNER_TEMP/extracted/bin" - tar -xzf "dist/diffr-$version-$TARGET.tar.gz" -C "$RUNNER_TEMP/extracted/bin" - python3 xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted" - python3 scripts/smoke_cli_archive.py "dist/diffr-cli-$version-$TARGET.tar.gz" "$version" - python3 scripts/smoke_cli_archive.py "dist/diffr-cli-full-$version-$TARGET.tar.gz" "$version" + # The TUI smoke test drives a Unix PTY. + if [ "$RUNNER_OS" != Windows ]; then + mkdir -p "$RUNNER_TEMP/extracted/bin" + tar -xzf "dist/diffr-$VERSION-$TARGET.tar.gz" -C "$RUNNER_TEMP/extracted/bin" + "$PYTHON" xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted" + fi + "$PYTHON" scripts/smoke_cli_archive.py "dist/diffr-cli-$VERSION-$TARGET.tar.gz" "$VERSION" + "$PYTHON" scripts/smoke_cli_archive.py "dist/diffr-cli-full-$VERSION-$TARGET.tar.gz" "$VERSION" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ matrix.target }} diff --git a/README.md b/README.md index 1b39dc5c3..686d1f579 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ cargo binstall diffr-cli cargo install diffr-cli --locked ``` +Windows x64: download `diffr--.tar.gz` from [Releases](https://github.com/devdotfast/diffr/releases) and extract it with `tar -xzf`, or use `cargo binstall`. + From source: `cargo xtask install` (requires Rust and [Bun](https://bun.sh)). ## History diff --git a/diffr-ts/README.md b/diffr-ts/README.md index 33a05da7d..f343584e1 100644 --- a/diffr-ts/README.md +++ b/diffr-ts/README.md @@ -12,7 +12,7 @@ const event = decodeStructuralDiffEvent(line); npx --package @dev.fast/diffr@0.1.6 diffr-fetch --into ./bin --required ``` -Supports macOS arm64, macOS x64, Linux arm64, and Linux x64. `--check` verifies an existing install without +Supports macOS arm64, macOS x64, Linux arm64, Linux x64, and Windows x64 (`diffr.exe`). `--check` verifies an existing install without network access. Downloads warn on network failure unless `--required`; invalid hashes or archives always fail. diff --git a/diffr-ts/bin/fetch.mjs b/diffr-ts/bin/fetch.mjs index ac9391326..2910e1bd1 100755 --- a/diffr-ts/bin/fetch.mjs +++ b/diffr-ts/bin/fetch.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; +const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu", "win32-x64": "x86_64-pc-windows-msvc" }; try { await main(); @@ -38,13 +38,14 @@ async function main() { throw new Error(`pins.json has no valid sha256 for ${target}`); } - const binary = join(into, "diffr"); + const name = process.platform === "win32" ? "diffr.exe" : "diffr"; + const binary = join(into, name); const stampPath = join(into, "diffr.stamp.json"); let installed = false; try { const stamp = JSON.parse(readFileSync(stampPath, "utf8")); const stat = lstatSync(binary); - installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target + installed = stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0) && stamp.version === version && stamp.target === target && Boolean(stamp.full) === values.full; } catch { /* Missing or damaged installations are fetched again. */ } if (installed) { @@ -73,12 +74,12 @@ async function main() { mkdirSync(into, { recursive: true }); const staging = mkdtempSync(join(into, ".diffr-")); try { - const archive = join(staging, "archive.tar.gz"); - writeFileSync(archive, bytes); - const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" }); - const extracted = join(staging, "diffr"); + writeFileSync(join(staging, "archive.tar.gz"), bytes); + // Relative paths: GNU tar on Windows reads "C:" in an archive path as a remote host. + const tar = spawnSync("tar", ["-xzf", "archive.tar.gz", name], { cwd: staging, encoding: "utf8" }); + const extracted = join(staging, name); if (tar.status !== 0 || !lstatSync(extracted).isFile()) { - throw new Error(`${asset} does not contain a regular diffr file at its root`); + throw new Error(`${asset} does not contain a regular ${name} file at its root`); } chmodSync(extracted, 0o755); const stamp = join(staging, "stamp.json"); diff --git a/diffr-ts/scripts/pin.mjs b/diffr-ts/scripts/pin.mjs index 5e240b3cd..d68e38ffa 100755 --- a/diffr-ts/scripts/pin.mjs +++ b/diffr-ts/scripts/pin.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, ".."); const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; -const targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; +const targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]; async function hashes(artifact) { const sha256 = {}; for (const target of targets) { diff --git a/diffr-ts/src/fetch.test.ts b/diffr-ts/src/fetch.test.ts index 1cf4f9ab6..63eeb8c7a 100644 --- a/diffr-ts/src/fetch.test.ts +++ b/diffr-ts/src/fetch.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; const fetchScript = join(import.meta.dir, "..", "bin", "fetch.mjs"); const { version } = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")); -const targets: Record = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }; +const targets: Record = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu", "win32-x64": "x86_64-pc-windows-msvc" }; const target = targets[`${process.platform}-${process.arch}`]; const dirs: string[] = []; afterEach(() => { @@ -137,6 +137,18 @@ test("Linux ARM64 downloads and stamps the aarch64 release", () => { expect(f.run(["--check"], true).code).toBe(0); }); +test("Windows x64 downloads and stamps diffr.exe", () => { + const f = fixture("diffr.exe", "diffr-cli", "win32", "x64"); + const result = f.run(["--required"]); + expect(result.code, result.err).toBe(0); + expect(readFileSync(join(f.dir, "requested"), "utf8")).toBe( + `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-cli-${version}-x86_64-pc-windows-msvc.tar.gz`, + ); + expect(readdirSync(f.into).sort()).toEqual(["diffr.exe", "diffr.stamp.json"]); + chmodSync(join(f.into, "diffr.exe"), 0o644); + expect(f.run(["--check"], true).code).toBe(0); +}); + test("CLI-only pins download the CLI archive", () => { const f = fixture("diffr", "diffr-cli"); const result = f.run(["--required"]); diff --git a/scripts/release.py b/scripts/release.py index 27f6751a8..94a718862 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -6,7 +6,10 @@ import subprocess import tarfile -TARGETS = ("aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu") +TARGETS = ( + "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +) ROOT = Path(__file__).resolve().parent.parent @@ -15,14 +18,15 @@ def archive_name(version, target, artifact="diffr"): def pack(version, target, install, output, edition="lean"): - actual = subprocess.check_output([install / "bin/diffr", "--version"], text=True).strip() + exe = ".exe" if "windows" in target else "" + actual = subprocess.check_output([install / "bin" / f"diffr{exe}", "--version"], text=True).strip() if actual != f"diffr {version}": raise ValueError(f"Release version mismatch: {actual}") for artifact in ("diffr-cli-full",) if edition == "full" else ("diffr", "diffr-cli"): with tarfile.open(output / archive_name(version, target, artifact), "w:gz") as archive: binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: - archive.add(install / "bin" / name, arcname=name) + archive.add(install / "bin" / f"{name}{exe}", arcname=f"{name}{exe}") for name in ("LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE"): archive.add(ROOT / name, arcname=name) diff --git a/scripts/smoke_cli_archive.py b/scripts/smoke_cli_archive.py index 9a6df44d7..9e35ac9be 100644 --- a/scripts/smoke_cli_archive.py +++ b/scripts/smoke_cli_archive.py @@ -1,5 +1,6 @@ """Verify a CLI-only release archive without Bun, Git, or user configuration.""" import json +import os from pathlib import Path import subprocess import sys @@ -8,18 +9,21 @@ def check(archive_path, version): + exe = ".exe" if os.name == "nt" else "" with tempfile.TemporaryDirectory(prefix="diffr-cli-smoke-") as directory: root = Path(directory) with tarfile.open(archive_path) as archive: assert set(archive.getnames()) == { - "diffr", "LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE", + f"diffr{exe}", "LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE", } archive.extractall(root, filter="data") env = {"PATH": "", "HOME": str(root), "XDG_CONFIG_HOME": str(root / "config")} + if os.name == "nt": + env["SYSTEMROOT"] = os.environ["SYSTEMROOT"] def run(*args): return subprocess.check_output( - [root / "diffr", *args], cwd=root, env=env, text=True, timeout=30, + [root / f"diffr{exe}", *args], cwd=root, env=env, text=True, timeout=30, ) assert run("--version").strip() == f"diffr {version}"