diff --git a/.github/workflows/voltage-buzz-gpui-release.yml b/.github/workflows/voltage-buzz-gpui-release.yml index c4899a3f41a..96a9fc0714d 100644 --- a/.github/workflows/voltage-buzz-gpui-release.yml +++ b/.github/workflows/voltage-buzz-gpui-release.yml @@ -17,6 +17,10 @@ on: - .github/workflows/voltage-buzz-gpui-release.yml - apps/buzz-gpui/.github/actions/** - apps/buzz-gpui/scripts/package-* + - apps/buzz-gpui/scripts/build-package.sh + - apps/buzz-gpui/scripts/check-remote-provider.mjs + - apps/buzz-gpui/scripts/check-remote-provider.test.mjs + - apps/buzz-gpui/scripts/test-package-build.py - apps/buzz-gpui/scripts/bundle-macos-* - apps/buzz-gpui/scripts/check-macos-* @@ -27,7 +31,22 @@ concurrency: permissions: contents: read +env: + # Ship the Remote workspace UI and its provider on every desktop platform. + BUZZ_GPUI_VOLTAGE: "1" + jobs: + package-contract: + name: Desktop package contract + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: python3 apps/buzz-gpui/scripts/test-package-build.py + - run: node --test apps/buzz-gpui/scripts/check-remote-provider.test.mjs + version: name: GPUI release version runs-on: ubuntu-latest @@ -61,7 +80,7 @@ jobs: linux: name: GPUI Linux x86_64 (.deb) - needs: version + needs: [version, package-contract] runs-on: ubuntu-latest timeout-minutes: 120 env: @@ -81,7 +100,7 @@ jobs: macos: name: GPUI macOS ${{ matrix.arch }} (.dmg) - needs: version + needs: [version, package-contract] runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: @@ -128,7 +147,7 @@ jobs: windows: name: GPUI Windows x86_64 (.zip) - needs: version + needs: [version, package-contract] runs-on: windows-latest timeout-minutes: 120 env: @@ -148,7 +167,7 @@ jobs: with: workspaces: apps/buzz-gpui save-if: ${{ github.ref == 'refs/heads/main' }} - - run: cargo build --locked --release -p buzz-gpui -p buzzd + - run: bash scripts/build-package.sh -p buzzd working-directory: apps/buzz-gpui - run: | python -m pip install pefile @@ -157,6 +176,8 @@ jobs: # The bundled Node runs every bundled plugin. - run: dist/Buzz/lib/buzz-gpui/node/bin/node.exe scripts/check-plugins.mjs dist/Buzz/share/buzz-gpui/plugins working-directory: apps/buzz-gpui + - run: dist/Buzz/lib/buzz-gpui/node/bin/node.exe scripts/check-remote-provider.mjs dist/Buzz/bin/buzz-backend-relay.exe + working-directory: apps/buzz-gpui - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: buzz-gpui-windows-x86_64 diff --git a/apps/buzz-gpui/README.md b/apps/buzz-gpui/README.md index 37b32ce2833..78506feb307 100644 --- a/apps/buzz-gpui/README.md +++ b/apps/buzz-gpui/README.md @@ -91,6 +91,12 @@ The ones you may notice: ### Builds +Voltage's release workflow sets `BUZZ_GPUI_VOLTAGE=1`. These packages enable +Remote workspaces and place `buzz-backend-relay` beside the app executable. +The package checks run the bundled provider's credential-free `info` operation. +For a local Voltage package, set this variable before running its package script. +On Windows, build first with `bash scripts/build-package.sh -p buzzd`. + The Build workflow makes all of these packages. It runs by hand, for pull requests that change it, and for `v*` tags, which also attach the packages to a GitHub release. diff --git a/apps/buzz-gpui/crates/app/Cargo.toml b/apps/buzz-gpui/crates/app/Cargo.toml index be0c2997f2d..5863296f6cb 100644 --- a/apps/buzz-gpui/crates/app/Cargo.toml +++ b/apps/buzz-gpui/crates/app/Cargo.toml @@ -148,3 +148,10 @@ assets = [ ["target/release/bundle/node/LICENSE", "usr/lib/buzz-gpui/node/", "644"], ["target/release/bundle/plugins/**/*", "usr/share/buzz-gpui/plugins/", "644"], ] + +[package.metadata.deb.variants.voltage] +name = "buzz-gpui" +features = ["voltage-sandbox"] +merge-assets.append = [ + ["target/release/buzz-backend-relay", "usr/bin/buzz-backend-relay", "755"], +] diff --git a/apps/buzz-gpui/scripts/build-package.sh b/apps/buzz-gpui/scripts/build-package.sh new file mode 100644 index 00000000000..618efce1e00 --- /dev/null +++ b/apps/buzz-gpui/scripts/build-package.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Build the package's app, and Voltage's provider beside it when requested. +set -euo pipefail +here="$(cd "$(dirname "$0")/.." && pwd)" +repo="$(cd "$here/../.." && pwd)" +cd "$here" +target="${CARGO_TARGET_DIR:-$here/target}" +features=() +if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + features=(--features voltage-sandbox) + cargo build --manifest-path "$repo/Cargo.toml" --locked --release \ + -p buzz-backend-relay --target-dir "$target/voltage-provider" +fi +cargo build --locked --release -p buzz-gpui "${features[@]}" "$@" +if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + suffix="" + if [ "${OS:-}" = Windows_NT ]; then suffix=.exe; fi + cp "$target/voltage-provider/release/buzz-backend-relay$suffix" "$target/release/" +fi diff --git a/apps/buzz-gpui/scripts/check-macos-dmg.sh b/apps/buzz-gpui/scripts/check-macos-dmg.sh index f6a2abc0cd4..fd313c52944 100755 --- a/apps/buzz-gpui/scripts/check-macos-dmg.sh +++ b/apps/buzz-gpui/scripts/check-macos-dmg.sh @@ -42,6 +42,10 @@ app="$work/Buzz.app" # The bundled Node runs every bundled plugin. "$app/Contents/Resources/node/bin/node" "$here/scripts/check-plugins.mjs" \ "$app/Contents/Resources/plugins" +if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + "$app/Contents/Resources/node/bin/node" "$here/scripts/check-remote-provider.mjs" \ + "$app/Contents/MacOS/buzz-backend-relay" +fi du -sh "$app" codesign --verify --deep --strict "$app" diff --git a/apps/buzz-gpui/scripts/check-remote-provider.mjs b/apps/buzz-gpui/scripts/check-remote-provider.mjs new file mode 100644 index 00000000000..28454d1b5c3 --- /dev/null +++ b/apps/buzz-gpui/scripts/check-remote-provider.mjs @@ -0,0 +1,23 @@ +// Exercise the packaged provider without credentials or a relay connection. +import { spawnSync } from "node:child_process"; + +const binary = process.argv[2]; +if (!binary) throw new Error("Expected the packaged buzz-backend-relay path"); +const result = spawnSync(binary, { + input: '{"op":"info"}\n', + encoding: "utf8", + timeout: 10_000, + maxBuffer: 64 * 1024, +}); +if (result.error || result.status !== 0) { + throw new Error("The packaged relay provider could not start"); +} +const info = JSON.parse(result.stdout); +const nonemptyText = (field) => typeof info?.[field] === "string" && info[field].length > 0; +if (info?.ok !== true || info.name !== "relay" || info.protocol_version !== 1 || + !nonemptyText("version") || !nonemptyText("description") || + !info.config_schema || typeof info.config_schema !== "object" || Array.isArray(info.config_schema) || + !Array.isArray(info.ops) || !["deploy", "stop", "recover"].every((op) => info.ops.includes(op))) { + throw new Error("The packaged relay provider has an incompatible protocol"); +} +console.log("Packaged relay provider supports deploy, stop, and recovery"); diff --git a/apps/buzz-gpui/scripts/check-remote-provider.test.mjs b/apps/buzz-gpui/scripts/check-remote-provider.test.mjs new file mode 100644 index 00000000000..af2aceafe2f --- /dev/null +++ b/apps/buzz-gpui/scripts/check-remote-provider.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const check = fileURLToPath(new URL("check-remote-provider.mjs", import.meta.url)); +const valid = { + ok: true, name: "relay", version: "0.1.0", description: "Relay provider", + protocol_version: 1, config_schema: {}, ops: ["info", "deploy", "stop", "recover"], +}; + +function run(info) { + const directory = mkdtempSync(join(tmpdir(), "provider-package-check-")); + try { + const binary = join(directory, "provider"); + writeFileSync(binary, `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(info))});\n`, { mode: 0o755 }); + return spawnSync(process.execPath, [check, binary], { + encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024, + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test("accepts the complete desktop provider response", () => { + const result = run(valid); + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects missing, empty, and wrongly typed desktop metadata", () => { + for (const field of ["version", "description", "config_schema"]) { + const values = field === "config_schema" + ? [undefined, null, [], "schema", 1, false] + : [undefined, null, "", 1, false, {}, []]; + for (const value of values) { + const result = run({ ...valid, [field]: value }); + assert.equal(result.error, undefined); + assert.notEqual(result.status, 0, `${field}=${JSON.stringify(value)} must fail`); + } + } +}); + +test("still requires the supported protocol and lifecycle operations", () => { + for (const patch of [{ protocol_version: 2 }, { ops: ["info", "deploy"] }, { ops: "deploy stop recover" }]) { + assert.notEqual(run({ ...valid, ...patch }).status, 0); + } +}); diff --git a/apps/buzz-gpui/scripts/package-deb.sh b/apps/buzz-gpui/scripts/package-deb.sh index 5b83489def7..183348e7877 100755 --- a/apps/buzz-gpui/scripts/package-deb.sh +++ b/apps/buzz-gpui/scripts/package-deb.sh @@ -13,13 +13,14 @@ set -euo pipefail node_version=22.23.3 node_sha256=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de here="$(cd "$(dirname "$0")/.." && pwd)" +repo="$(cd "$here/../.." && pwd)" cache="$HOME/.cache/buzz-gpui-deb" mkdir -p "$here/dist" "$cache/cargo" "$cache/rustup" "$cache/target" # Never leave an older package around to be mistaken for this build. rm -f "$here"/dist/*.deb docker run --rm \ - -v "$here:$here" \ + -v "$repo:$repo" \ -v "$cache/cargo:/root/.cargo" \ -v "$cache/rustup:/root/.rustup" \ -v "$cache/target:$here/target-deb" \ @@ -28,6 +29,7 @@ docker run --rm \ -e NODE_VERSION="$node_version" \ -e NODE_SHA256="$node_sha256" \ -e BUZZ_GPUI_RELEASE_VERSION \ + -e BUZZ_GPUI_VOLTAGE \ -w "$here" \ ubuntu:26.04 bash -euo pipefail -c ' export DEBIAN_FRONTEND=noninteractive @@ -63,12 +65,22 @@ docker run --rm \ curl -sSf https://sh.rustup.rs | sh -s -- -y -q --profile minimal --no-modify-path fi . /root/.cargo/env - rustup toolchain list | grep -q stable || rustup default stable + # The repository root pins the relay toolchain; GPUI uses its own pin. + rustup toolchain install 1.98.1 --profile minimal --no-self-update + export RUSTUP_TOOLCHAIN=1.98.1 command -v cargo-deb >/dev/null || cargo install -q --locked cargo-deb # cargo-deb maps the target/release asset paths onto CARGO_TARGET_DIR. - cargo build --locked --release -p buzz-gpui - cargo deb --no-build -p buzz-gpui -o dist/ \ + bash scripts/build-package.sh + variant=() + if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then variant=(--variant voltage); fi + cargo deb --no-build -p buzz-gpui "${variant[@]}" -o dist/ \ ${BUZZ_GPUI_RELEASE_VERSION:+--deb-version "$BUZZ_GPUI_RELEASE_VERSION"} + if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + extracted=$(mktemp -d) + dpkg-deb -x dist/*.deb "$extracted" + "$bundle/node/bin/node" scripts/check-remote-provider.mjs "$extracted/usr/bin/buzz-backend-relay" + rm -rf "$extracted" + fi chown -R "$OWNER" dist ' ls -la "$here"/dist/*.deb diff --git a/apps/buzz-gpui/scripts/package-macos.sh b/apps/buzz-gpui/scripts/package-macos.sh index 413e52bb221..353046b14dc 100755 --- a/apps/buzz-gpui/scripts/package-macos.sh +++ b/apps/buzz-gpui/scripts/package-macos.sh @@ -32,12 +32,15 @@ export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,-headerpad_max_install_names" version="$(cargo metadata --no-deps --format-version 1 \ | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "buzz-gpui"))')" arch="$(uname -m)" -cargo build --locked --release -p buzz-gpui +bash scripts/build-package.sh work="$(mktemp -d)" app="$work/Buzz.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" dist cp target/release/buzz-gpui "$app/Contents/MacOS/" +if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + cp target/release/buzz-backend-relay "$app/Contents/MacOS/" +fi iconset="$work/buzz.iconset" mkdir "$iconset" diff --git a/apps/buzz-gpui/scripts/package-windows.py b/apps/buzz-gpui/scripts/package-windows.py index e2ee3a2796c..e1a59706f3f 100644 --- a/apps/buzz-gpui/scripts/package-windows.py +++ b/apps/buzz-gpui/scripts/package-windows.py @@ -111,7 +111,10 @@ def main(): if (system / name).exists(): available[name] = system / name pending = [] - for exe in ("buzz-gpui.exe", "buzzd.exe"): + executables = ["buzz-gpui.exe", "buzzd.exe"] + if os.environ.get("BUZZ_GPUI_VOLTAGE") == "1": + executables.append("buzz-backend-relay.exe") + for exe in executables: shutil.copy2(repo / "target" / "release" / exe, stage / "bin") pending.append(stage / "bin" / exe) for name in PLUGINS: diff --git a/apps/buzz-gpui/scripts/test-package-build.py b/apps/buzz-gpui/scripts/test-package-build.py new file mode 100644 index 00000000000..588946c5f6c --- /dev/null +++ b/apps/buzz-gpui/scripts/test-package-build.py @@ -0,0 +1,77 @@ +"""Exercise the real package builder with a deterministic Cargo subprocess.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class PackageBuild(unittest.TestCase): + def run_build(self, voltage, windows=False, fail=False): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + app = root / "apps" / "buzz-gpui" + scripts = app / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(Path(__file__).with_name("build-package.sh"), scripts) + binaries = root / "bin" + binaries.mkdir() + cargo = binaries / "cargo" + cargo.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, sys +args = sys.argv[1:] +with open(os.environ["CALLS"], "a") as log: + log.write(json.dumps(args) + "\\n") +provider = "buzz-backend-relay" in args +if provider and os.environ.get("FAIL_PROVIDER") == "1": + sys.exit(9) +target = pathlib.Path(args[args.index("--target-dir")+1] if provider else os.environ["CARGO_TARGET_DIR"]) +release = target / "release" +release.mkdir(parents=True, exist_ok=True) +name = "buzz-backend-relay" if provider else "buzz-gpui" +if os.environ.get("OS") == "Windows_NT": + name += ".exe" +(release / name).write_text("fresh " + name) +''') + cargo.chmod(0o755) + env = dict(os.environ, PATH=str(binaries)+os.pathsep+os.environ["PATH"], + CALLS=str(root/"calls.jsonl"), CARGO_TARGET_DIR=str(root/"build"), + BUZZ_GPUI_VOLTAGE="1" if voltage else "0", + OS="Windows_NT" if windows else "Linux", FAIL_PROVIDER="1" if fail else "0") + result = subprocess.run(["bash", str(scripts/"build-package.sh")], + env=env, capture_output=True, text=True, timeout=10) + calls = [json.loads(line) for line in (root/"calls.jsonl").read_text().splitlines()] + return root, result, calls + + def test_voltage_build_enables_remote_ui_and_stages_the_same_builds_provider(self): + for windows in [False, True]: + with self.subTest(windows=windows): + root, result, calls = self.run_build(True, windows) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 2) + self.assertIn(str(root/"Cargo.toml"), calls[0]) + self.assertIn("buzz-backend-relay", calls[0]) + self.assertIn("--features", calls[1]) + self.assertIn("voltage-sandbox", calls[1]) + name = "buzz-backend-relay" + (".exe" if windows else "") + self.assertEqual((root/"build"/"release"/name).read_text(), "fresh "+name) + + def test_generic_packages_keep_their_existing_feature_set(self): + root, result, calls = self.run_build(False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 1) + self.assertNotIn("--features", calls[0]) + self.assertFalse((root/"build"/"release"/"buzz-backend-relay").exists()) + + def test_provider_failure_stops_packaging_before_building_the_app(self): + root, result, calls = self.run_build(True, fail=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(calls), 1) + self.assertFalse((root/"build"/"release"/"buzz-gpui").exists()) + + +if __name__ == "__main__": + unittest.main()