From f00bae3bfbf5e235e954e757fb63e272f7bf7e81 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 1 May 2026 02:09:32 -0500 Subject: [PATCH] ci: smoke npm wrapper on windows --- .github/workflows/ci.yml | 33 +---- docs/RELEASE_RUNBOOK.md | 26 ++-- scripts/release/npm-wrapper-smoke.js | 193 +++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 46 deletions(-) create mode 100644 scripts/release/npm-wrapper-smoke.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea21eee..8d802a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -101,35 +101,8 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build wrapper binaries run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui - - name: Prepare local release assets - run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets" - - name: Start local release server - run: | - python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 & - sleep 1 - - name: Pack npm wrapper - working-directory: npm/deepseek-tui - env: - DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" - DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ - run: npm pack - - name: Install packed wrapper - env: - DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" - DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ - run: | - mkdir -p "$RUNNER_TEMP/npm-smoke" - cd "$RUNNER_TEMP/npm-smoke" - npm init -y - npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz - - name: Smoke wrapper entrypoints - env: - DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" - DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ - run: | - cd "$RUNNER_TEMP/npm-smoke" - npx --no-install deepseek --help - npx --no-install deepseek-tui --help + - name: Smoke wrapper install and delegated entrypoints + run: node scripts/release/npm-wrapper-smoke.js # Check documentation builds without warnings docs: diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 4f6883e5..5aaabd18 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -62,26 +62,19 @@ without unpublished workspace dependencies and a packaging preflight for depende workspace crates. That avoids false negatives from crates.io not yet containing the new workspace version while still validating package contents before publish. -For npm wrapper verification: +For npm wrapper verification, build the two shipped binaries and run the +cross-platform smoke harness. This packs the npm wrapper, installs it into a +clean temporary project, serves local release assets over HTTP, and checks both +the dispatcher-to-TUI path (`deepseek doctor --help`) and the direct TUI +entrypoint (`deepseek-tui --help`). ```bash cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui -node scripts/release/prepare-local-release-assets.js -python3 -m http.server 8123 --directory target/npm-release-assets -cd npm/deepseek-tui -DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm pack +node scripts/release/npm-wrapper-smoke.js ``` -Then install the generated tarball in a clean temp directory and smoke the entrypoints: - -```bash -tmpdir="$(mktemp -d)" -cd "${tmpdir}" -npm init -y -DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm install /path/to/deepseek-tui-*.tgz -DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npx --no-install deepseek --help -DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npx --no-install deepseek-tui --help -``` +Set `DEEPSEEK_TUI_KEEP_SMOKE_DIR=1` to keep the temporary pack/install +directory for inspection. To exercise `npm run release:check` locally as well, regenerate the local asset directory with a full asset matrix fixture before starting the server: @@ -94,7 +87,8 @@ DEEPSEEK_TUI_VERSION=X.Y.Z DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ Set `DEEPSEEK_TUI_VERSION` to the npm package version you are verifying for that local run. -The CI workflow runs the same tarball install + smoke test on Linux and macOS. +The CI workflow runs the same tarball install + delegated-entrypoint smoke test +on Linux, macOS, and Windows. ## Rust Crates Release diff --git a/scripts/release/npm-wrapper-smoke.js b/scripts/release/npm-wrapper-smoke.js new file mode 100644 index 00000000..e481b8ca --- /dev/null +++ b/scripts/release/npm-wrapper-smoke.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const fsp = require("fs/promises"); +const http = require("http"); +const os = require("os"); +const path = require("path"); +const { spawn } = require("child_process"); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const packageDir = path.join(repoRoot, "npm", "deepseek-tui"); +const prepareAssetsScript = path.join( + repoRoot, + "scripts", + "release", + "prepare-local-release-assets.js", +); + +function shellQuote(value) { + return /\s/.test(value) ? JSON.stringify(value) : value; +} + +function usesWindowsCommandShim(command) { + return process.platform === "win32" && (command === "npm" || command === "npx"); +} + +function runCommand(command, args, options = {}) { + const cwd = options.cwd || repoRoot; + console.log(`$ ${[command, ...args].map(shellQuote).join(" ")}`); + const child = spawn(command, args, { + cwd, + env: { + ...process.env, + ...(options.env || {}), + }, + encoding: "utf8", + shell: usesWindowsCommandShim(command), + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + windowsHide: true, + }); + + if (!options.capture) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (status) => { + if (status === 0) { + resolve({ stdout: "", stderr: "" }); + } else { + reject(new Error(`${command} exited with status ${status}`)); + } + }); + }); + } + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (status) => { + if (status === 0) { + resolve({ stdout, stderr }); + return; + } + process.stdout.write(stdout); + process.stderr.write(stderr); + reject(new Error(`${command} exited with status ${status}`)); + }); + }); +} + +function serveDirectory(root) { + const server = http.createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + const decodedPath = decodeURIComponent(requestUrl.pathname); + const filePath = path.resolve(root, `.${decodedPath}`); + const relative = path.relative(root, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + response.writeHead(403); + response.end("forbidden"); + return; + } + + const fileStat = await fsp.stat(filePath); + if (!fileStat.isFile()) { + response.writeHead(404); + response.end("not found"); + return; + } + + response.writeHead(200, { + "Content-Length": fileStat.size, + "Content-Type": "application/octet-stream", + }); + fs.createReadStream(filePath).pipe(response); + } catch (error) { + response.writeHead(error && error.code === "ENOENT" ? 404 : 500); + response.end(error && error.message ? error.message : "not found"); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve({ + baseUrl: `http://127.0.0.1:${address.port}/`, + server, + }); + }); + }); +} + +function parsePackJson(stdout) { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error("npm pack did not return package metadata"); + } + const parsed = JSON.parse(trimmed); + const first = Array.isArray(parsed) ? parsed[0] : parsed; + if (!first || !first.filename) { + throw new Error(`npm pack metadata did not include a filename: ${trimmed}`); + } + return first.filename; +} + +async function main() { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "deepseek-npm-smoke-")); + const releaseAssetsDir = path.join(tempRoot, "release-assets"); + const packDir = path.join(tempRoot, "pack"); + const installDir = path.join(tempRoot, "install"); + let keepTemp = process.env.DEEPSEEK_TUI_KEEP_SMOKE_DIR === "1"; + let server; + + try { + await fsp.mkdir(packDir, { recursive: true }); + await fsp.mkdir(installDir, { recursive: true }); + + await runCommand(process.execPath, [prepareAssetsScript, releaseAssetsDir]); + const served = await serveDirectory(releaseAssetsDir); + server = served.server; + + const env = { + DEEPSEEK_TUI_FORCE_DOWNLOAD: "1", + DEEPSEEK_TUI_RELEASE_BASE_URL: served.baseUrl, + }; + const pack = await runCommand( + "npm", + ["pack", "--json", "--pack-destination", packDir], + { + capture: true, + cwd: packageDir, + env, + }, + ); + const tarball = path.join(packDir, parsePackJson(pack.stdout)); + + await runCommand("npm", ["init", "-y"], { cwd: installDir }); + await runCommand("npm", ["install", tarball], { cwd: installDir, env }); + await runCommand("npx", ["--no-install", "deepseek", "doctor", "--help"], { + cwd: installDir, + env, + }); + await runCommand("npx", ["--no-install", "deepseek-tui", "--help"], { + cwd: installDir, + env, + }); + + console.log(`npm wrapper smoke passed with local assets from ${served.baseUrl}`); + } catch (error) { + keepTemp = true; + console.error(`npm wrapper smoke failed: ${error.message}`); + console.error(`Smoke workspace retained at ${tempRoot}`); + process.exitCode = 1; + } finally { + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + if (!keepTemp) { + await fsp.rm(tempRoot, { force: true, recursive: true }); + } + } +} + +main();