From 348d3f5e020a7b6e463874fd319bc10d4fc3c2ea Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 8 Oct 2026 21:38:39 +0000 Subject: [PATCH 1/2] feat(release): publish Windows binaries and npm packages Build x86_64 and aarch64 Windows (MSVC) release binaries on native runners, with a statically linked C runtime and Ghostty pinned to its baseline CPU via a new OPENCODE_ZIG_CPU build option (the Unix Zig wrapper is a shell script). Each build smoke-tests a real daemon over a named pipe. The npm dispatcher gains @opencode-ai/pty-win32-x64 and -win32-arm64, resolving opencode-pty.exe. The release workflow also runs on pull requests that change the release pipeline; publishing remains tag-only. --- .github/workflows/publish.yml | 101 +++++++++++++++++++++++++++++++++- README.md | 14 +++-- build.rs | 6 ++ npm/index.js | 8 ++- npm/package.json | 4 +- script/npm-packages.mjs | 9 ++- 6 files changed, 128 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 60aeaa3..1e0b60e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,13 @@ on: - master tags: - "v*" + # Validate release-pipeline changes before merge. Publishing remains tag-only. + pull_request: + paths: + - .github/workflows/publish.yml + - build.rs + - npm/** + - script/npm-packages.mjs permissions: contents: read @@ -133,8 +140,98 @@ jobs: path: dist/*.tar.gz if-no-files-found: error + build-windows: + needs: checks + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2025 + target: x86_64-pc-windows-msvc + arch: x64 + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + arch: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: pwsh + env: + CARGO_BUILD_TARGET: ${{ matrix.target }} + RUSTUP_TOOLCHAIN: 1.90.0-x86_64-pc-windows-msvc + # Portable CPU for Ghostty (see build.rs) and no Visual C++ runtime dependency. + OPENCODE_ZIG_CPU: baseline + RUSTFLAGS: -C target-feature=+crt-static + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Enable long dependency paths + run: git config --global core.longpaths true + - uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5 + if: matrix.arch == 'x64' + with: + version: ${{ env.ZIG_VERSION }} + cache-key: release-${{ matrix.target }} + # Zig 0.16 crashes when building Ghostty on the native ARM64 host, so use + # the x64-hosted compiler there (matching windows.yml). + - name: Install x64 Zig for ARM64 cross-compilation + if: matrix.arch == 'arm64' + run: | + $archive = Join-Path $env:RUNNER_TEMP 'zig-x64.zip' + Invoke-WebRequest 'https://ziglang.org/download/0.16.0/zig-x86_64-windows-0.16.0.zip' -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne '68659eb5f1e4eb1437a722f1dd889c5a322c9954607f5edcf337bc3684a75a7e') { throw 'Zig archive checksum mismatch' } + Expand-Archive $archive -DestinationPath $env:RUNNER_TEMP + Join-Path $env:RUNNER_TEMP 'zig-x86_64-windows-0.16.0' >> $env:GITHUB_PATH + - name: Fix Zig 0.16 ARM64 function-pointer alignment + if: matrix.arch == 'arm64' + run: | + $file = Join-Path $env:RUNNER_TEMP 'zig-x86_64-windows-0.16.0/lib/std/debug/SelfInfo/Windows.zig' + $source = [System.IO.File]::ReadAllText($file) + $old = '@ptrCast(try si.loadNtdllProc(@tagName(proc)))' + if (!$source.Contains($old)) { throw 'Zig alignment workaround no longer matches; re-evaluate it' } + [System.IO.File]::WriteAllText($file, $source.Replace($old, '@ptrCast(@alignCast(try si.loadNtdllProc(@tagName(proc))))')) + - name: Install Rust toolchain and target + run: rustup toolchain install $env:RUSTUP_TOOLCHAIN --force-non-host --profile minimal --target $env:CARGO_BUILD_TARGET + - name: Build + run: cargo build --locked --release + - name: Smoke test + run: | + $metadata = cargo metadata --locked --no-deps --format-version 1 | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $version = ($metadata.packages | Where-Object name -eq 'opencode-pty').version + $binary = "target/$env:CARGO_BUILD_TARGET/release/opencode-pty.exe" + $output = & $binary --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($output -ne "opencode-pty $version (protocol $env:PROTOCOL_VERSION)") { throw "Unexpected version output: $output" } + # Start a real daemon over a named pipe; it stops when nobody claims it. + $runtime = Join-Path $env:RUNNER_TEMP 'opencode-pty-release-smoke' + $daemon = Start-Process $binary -ArgumentList @('daemon', '--runtime-dir', $runtime, '--name', 'smoke') -PassThru -NoNewWindow + $deadline = (Get-Date).AddSeconds(10) + while (!(Test-Path (Join-Path $runtime 'smoke/service.json'))) { + if ($daemon.HasExited -or (Get-Date) -gt $deadline) { throw 'daemon did not register' } + Start-Sleep -Milliseconds 50 + } + if (!$daemon.WaitForExit(15000)) { $daemon.Kill(); throw 'unclaimed daemon did not stop' } + if (Test-Path (Join-Path $runtime 'smoke')) { throw 'runtime directory was not removed' } + - name: Package + run: | + $metadata = cargo metadata --locked --no-deps --format-version 1 | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $version = ($metadata.packages | Where-Object name -eq 'opencode-pty').version + $name = "opencode-pty-$version-$env:CARGO_BUILD_TARGET" + New-Item -ItemType Directory -Force "dist/$name" | Out-Null + Copy-Item "target/$env:CARGO_BUILD_TARGET/release/opencode-pty.exe" "dist/$name/" + Copy-Item LICENSE, README.md "dist/$name/" + tar -C dist -czf "dist/$name.tar.gz" $name + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-pty-${{ matrix.target }} + path: dist/*.tar.gz + if-no-files-found: error + release: - needs: build + needs: [build, build-windows] runs-on: ubuntu-22.04 permissions: contents: write @@ -156,7 +253,7 @@ jobs: - name: Create checksums and manifest working-directory: dist run: | - version="${GITHUB_REF_NAME#v}" + version="$(node -p 'require("../npm/package.json").version')" sha256sum opencode-pty-*.tar.gz > SHA256SUMS artifacts='[]' while read -r sha256 archive; do diff --git a/README.md b/README.md index 17106d7..f6c8481 100644 --- a/README.md +++ b/README.md @@ -343,15 +343,18 @@ gh run view RUN_ID --repo anomalyco/opencode-pty --log-failed Pushing a `vX.Y.Z` tag matching the version in `Cargo.toml` creates an unsigned GitHub release. The release contains x86-64 and arm64 binaries for Linux GNU, -Linux musl, and macOS, plus `SHA256SUMS`, a machine-readable +Linux musl, macOS, and Windows (MSVC, statically linked C runtime), plus +`SHA256SUMS`, a machine-readable `release-manifest.json`, and GitHub build-provenance attestations. Release builds force Ghostty's Zig code generation to its baseline CPU target so -artifacts do not inherit instruction-set extensions from CI runners. Linux GNU -artifacts support glibc 2.30 and newer. +artifacts do not inherit instruction-set extensions from CI runners (Windows +builds set `OPENCODE_ZIG_CPU=baseline`, since the Unix Zig wrapper is a shell +script). Linux GNU artifacts support glibc 2.30 and newer. Tagged releases also publish `@opencode-ai/pty` to npm with optional, platform-specific binary packages. Installing the npm package selects the native -binary for the current platform and exposes its path as `binaryPath`: +binary for the current platform (`opencode-pty.exe` on Windows) and exposes +its path as `binaryPath`: ```js import { binaryPath } from "@opencode-ai/pty" @@ -362,8 +365,7 @@ git tag v0.1.0 push origin v0.1.0 ``` -Release artifacts do not yet include Windows builds. Platform signing will be -added later. +Platform signing will be added later. ## Current Limits diff --git a/build.rs b/build.rs index 27dfb57..1dbf842 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,7 @@ fn main() { println!("cargo:rerun-if-changed=ghostty-revision"); println!("cargo:rerun-if-changed=src/ghostty/ffi.rs"); println!("cargo:rerun-if-env-changed=GHOSTTY_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=OPENCODE_ZIG_CPU"); let revision = include_str!("ghostty-revision").trim(); assert_eq!( @@ -96,6 +97,11 @@ fn main() { } else { "-Doptimize=ReleaseFast" }); + // Release builds pin a portable CPU. Unix releases use script/release-bin/zig; + // Windows cannot run that shell wrapper, so it sets this instead. + if let Ok(cpu) = env::var("OPENCODE_ZIG_CPU") { + build.arg(format!("-Dcpu={cpu}")); + } build.arg("--prefix").arg(&install); build.arg("--cache-dir").arg(out.join("zig-cache")); if env::var("HOST").as_deref() != Ok(target.as_str()) { diff --git a/npm/index.js b/npm/index.js index 54cb919..5e018fd 100644 --- a/npm/index.js +++ b/npm/index.js @@ -2,12 +2,16 @@ import { createRequire } from "node:module" import path from "node:path" const require = createRequire(import.meta.url) -const supported = process.platform === "darwin" || process.platform === "linux" +const supported = process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" const arch = process.arch === "arm64" || process.arch === "x64" ? process.arch : undefined const libc = process.platform === "linux" ? (process.report.getReport().header.glibcVersionRuntime ? "gnu" : "musl") : "" const suffix = [process.platform, arch, libc].filter(Boolean).join("-") export const binaryPath = supported && arch - ? path.join(path.dirname(require.resolve(`@opencode-ai/pty-${suffix}/package.json`)), "bin", "opencode-pty") + ? path.join( + path.dirname(require.resolve(`@opencode-ai/pty-${suffix}/package.json`)), + "bin", + process.platform === "win32" ? "opencode-pty.exe" : "opencode-pty", + ) : undefined diff --git a/npm/package.json b/npm/package.json index e8eea31..2013288 100644 --- a/npm/package.json +++ b/npm/package.json @@ -29,7 +29,9 @@ "@opencode-ai/pty-linux-arm64-gnu": "0.3.0", "@opencode-ai/pty-linux-arm64-musl": "0.3.0", "@opencode-ai/pty-linux-x64-gnu": "0.3.0", - "@opencode-ai/pty-linux-x64-musl": "0.3.0" + "@opencode-ai/pty-linux-x64-musl": "0.3.0", + "@opencode-ai/pty-win32-arm64": "0.3.0", + "@opencode-ai/pty-win32-x64": "0.3.0" }, "publishConfig": { "access": "public" diff --git a/script/npm-packages.mjs b/script/npm-packages.mjs index 0714400..ab2afaa 100644 --- a/script/npm-packages.mjs +++ b/script/npm-packages.mjs @@ -17,6 +17,8 @@ const targets = [ { target: "aarch64-unknown-linux-musl", platform: "linux", arch: "arm64", libc: "musl", suffix: "musl" }, { target: "x86_64-unknown-linux-gnu", platform: "linux", arch: "x64", libc: "glibc", suffix: "gnu" }, { target: "x86_64-unknown-linux-musl", platform: "linux", arch: "x64", libc: "musl", suffix: "musl" }, + { target: "aarch64-pc-windows-msvc", platform: "win32", arch: "arm64" }, + { target: "x86_64-pc-windows-msvc", platform: "win32", arch: "x64" }, ] await rm(output, { recursive: true, force: true }) @@ -27,12 +29,13 @@ for (const target of targets) { const name = `@opencode-ai/pty-${suffix}` const directory = path.join(output, `opencode-pty-${suffix}`) const archive = path.join(dist, `opencode-pty-${manifest.version}-${target.target}.tar.gz`) + const binary = target.platform === "win32" ? "opencode-pty.exe" : "opencode-pty" const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-npm-")) try { execFileSync("tar", ["-xzf", archive, "-C", temporary]) - const executable = path.join(temporary, `opencode-pty-${manifest.version}-${target.target}`, "opencode-pty") + const executable = path.join(temporary, `opencode-pty-${manifest.version}-${target.target}`, binary) await mkdir(path.join(directory, "bin"), { recursive: true }) - await copyFile(executable, path.join(directory, "bin", "opencode-pty")) + await copyFile(executable, path.join(directory, "bin", binary)) await copyFile(path.join(root, "LICENSE"), path.join(directory, "LICENSE")) await writeFile( path.join(directory, "package.json"), @@ -46,7 +49,7 @@ for (const target of targets) { os: [target.platform], cpu: [target.arch], ...(target.libc ? { libc: [target.libc] } : {}), - exports: { "./package.json": "./package.json", "./bin/opencode-pty": "./bin/opencode-pty" }, + exports: { "./package.json": "./package.json", [`./bin/${binary}`]: `./bin/${binary}` }, files: ["bin"], publishConfig: { access: "public" }, }, From 97d569f2be12e182699bc88e6c939149a672cbe1 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 8 Oct 2026 21:38:39 +0000 Subject: [PATCH 2/2] chore(pty): bump version to 0.3.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- npm/package.json | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90b2772..0ee2114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,7 @@ dependencies = [ [[package]] name = "opencode-pty" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index d55b148..a1b01a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencode-pty" -version = "0.3.0" +version = "0.3.1" edition = "2024" rust-version = "1.90" description = "Persistent PTY service for OpenCode" diff --git a/npm/package.json b/npm/package.json index 2013288..387904b 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/pty", - "version": "0.3.0", + "version": "0.3.1", "description": "Persistent PTY service for OpenCode", "type": "module", "license": "MIT", @@ -24,14 +24,14 @@ "index.js" ], "optionalDependencies": { - "@opencode-ai/pty-darwin-arm64": "0.3.0", - "@opencode-ai/pty-darwin-x64": "0.3.0", - "@opencode-ai/pty-linux-arm64-gnu": "0.3.0", - "@opencode-ai/pty-linux-arm64-musl": "0.3.0", - "@opencode-ai/pty-linux-x64-gnu": "0.3.0", - "@opencode-ai/pty-linux-x64-musl": "0.3.0", - "@opencode-ai/pty-win32-arm64": "0.3.0", - "@opencode-ai/pty-win32-x64": "0.3.0" + "@opencode-ai/pty-darwin-arm64": "0.3.1", + "@opencode-ai/pty-darwin-x64": "0.3.1", + "@opencode-ai/pty-linux-arm64-gnu": "0.3.1", + "@opencode-ai/pty-linux-arm64-musl": "0.3.1", + "@opencode-ai/pty-linux-x64-gnu": "0.3.1", + "@opencode-ai/pty-linux-x64-musl": "0.3.1", + "@opencode-ai/pty-win32-arm64": "0.3.1", + "@opencode-ai/pty-win32-x64": "0.3.1" }, "publishConfig": { "access": "public"