diff --git a/CLAUDE.md b/CLAUDE.md index 0e899702e..d42314dbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,21 +150,22 @@ Run `npx expo-doctor` to diagnose version mismatches and native module issues. ## Release playbook -Use the scripted release flow from repo root. Avoid manual version bumps or publish commands unless debugging. +Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging. ```bash -# 1) bump all workspaces and refresh workspace links -npm run version:all:patch +# Recommended: full patch release (bump, check, publish, push branch+tag) +npm run release:patch -# 2) run release gate checks (typecheck, build, pack dry-run) +# Manual, step-by-step fallback: +npm run version:all:patch # npm version across all workspaces (creates commit + local tag) npm run release:check - -# 3) publish relay/server/cli packages npm run release:publish +npm run release:push # pushes HEAD and current version tag (triggers desktop release) ``` Notes: -- `release:prepare` is part of the flow and refreshes workspace `node_modules` links to prevent stale local package types during release checks. +- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created. +- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks. - If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible. ## Orchestrator Mode diff --git a/README.md b/README.md index 8da6815f9..c0eedb329 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,15 @@ See [paseo.sh/docs](https://paseo.sh/docs) for full documentation. Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`). ```bash -git tag v0.1.0 -git push origin v0.1.0 +npm run version:all:patch +npm run release:push ``` -If you prefer, `npm version` can be used to create and push a version tag. +For the full package release flow, use: + +```bash +npm run release:patch +``` This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`). diff --git a/package.json b/package.json index ca970ee7b..e586e23b6 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,19 @@ "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "build:desktop": "npm run build --workspace=@getpaseo/desktop", "cli": "npx tsx packages/cli/src/index.js", + "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", "release:prepare": "npm install --workspaces --include-workspace-root", - "version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare", - "version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare", - "version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare", + "version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"", + "version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"", + "version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", - "release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public" + "release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", + "release:push": "node scripts/push-current-release-tag.mjs", + "release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push", + "release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push", + "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3aa78150f..5d4480af4 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -52,7 +52,6 @@ import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { - buildOfferEndpoints, createConnectionOfferV2, encodeOfferToFragmentUrl, } from "./connection-offer.js"; @@ -524,20 +523,6 @@ export async function createPaseoDaemon( const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh"; - const directEndpoints = buildOfferEndpoints({ - listenHost: listenTarget.host, - port: listenTarget.port, - }); - - logger.info( - { - serverId, - endpoints: directEndpoints, - wsUrls: directEndpoints.map((endpoint) => `ws://${endpoint}/ws`), - }, - "direct_connect" - ); - if (relayEnabled) { const offer = await createConnectionOfferV2({ serverId, @@ -547,8 +532,6 @@ export async function createPaseoDaemon( const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); logger.info({ url }, "pairing_offer"); - } else { - logger.info("relay_disabled"); } if (relayEnabled) { diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 6913d0c88..eff0746a7 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -197,10 +197,10 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { }); try { - const directConnect = await new Promise<{ endpoints: string[] }>((resolve, reject) => { + const sawListeningLog = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { proc.kill(); - reject(new Error("timed out waiting for direct_connect log")); + reject(new Error("timed out waiting for server listening log")); }, 15000); const onData = (data: Buffer) => { @@ -213,16 +213,12 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { reject(new Error("unexpected pairing_offer log when --no-relay is set")); return; } - if (!line.includes("direct_connect")) continue; + try { - const parsed = JSON.parse(line) as { msg?: string; endpoints?: unknown }; - if (parsed.msg !== "direct_connect") continue; - const endpoints = Array.isArray(parsed.endpoints) - ? parsed.endpoints.filter((v) => typeof v === "string") - : []; - if (endpoints.length === 0) continue; + const parsed = JSON.parse(line) as { msg?: string }; + if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue; clearTimeout(timeout); - resolve({ endpoints }); + resolve(true); return; } catch { // ignore @@ -243,8 +239,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { }); }); - expect(directConnect.endpoints).toContain(`localhost:${port}`); - expect(directConnect.endpoints).toContain(`192.168.1.12:${port}`); + expect(sawListeningLog).toBe(true); } catch (err) { throw new Error( `failed; stdout so far:\\n${stdoutLines.join("")}\\n\\n${String(err)}` diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index dc7127e34..6152a5236 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -54,15 +54,15 @@ describe("sherpa model downloader", () => { ).rejects.toThrow(/auto-download/i); }); - test("ensureSherpaOnnxModel logs artifact download progress", async () => { + test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => { const modelsDir = makeTmpDir(); - const progressLogs: Array> = []; + const infoMessages: string[] = []; const loggerWithSpy = { child: () => loggerWithSpy, - info: (obj?: unknown, msg?: string) => { - if (msg === "Downloading model artifact" && obj && typeof obj === "object") { - progressLogs.push(obj as Record); + info: (_obj?: unknown, msg?: string) => { + if (typeof msg === "string") { + infoMessages.push(msg); } }, error: () => undefined, @@ -90,9 +90,8 @@ describe("sherpa model downloader", () => { } expect(fetchMock).toHaveBeenCalled(); - expect(progressLogs.length).toBeGreaterThan(0); - const final = progressLogs.at(-1); - expect(final?.modelId).toBe("pocket-tts-onnx-int8"); - expect(final?.pct).toBe(100); + expect(infoMessages).toContain("Starting model download"); + expect(infoMessages).toContain("Model download completed"); + expect(infoMessages).not.toContain("Downloading model artifact"); }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 1f0e3ac83..4ef2c11bc 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -1,7 +1,7 @@ import { createWriteStream } from "node:fs"; import { mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; -import { Readable, Transform } from "node:stream"; +import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import type pino from "pino"; @@ -39,31 +39,13 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom return true; } -const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024; -const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000; - type DownloadToFileOptions = { url: string; outputPath: string; - logger: pino.Logger; - modelId: SherpaOnnxModelId; - artifact: string; }; -function parseContentLength(res: Response): number | null { - const raw = res.headers.get("content-length"); - if (!raw) { - return null; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return null; - } - return parsed; -} - async function downloadToFile(options: DownloadToFileOptions): Promise { - const { url, outputPath, logger, modelId, artifact } = options; + const { url, outputPath } = options; const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); @@ -76,62 +58,9 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { await mkdir(path.dirname(outputPath), { recursive: true }); const nodeStream = Readable.fromWeb(res.body as any); - const totalBytes = parseContentLength(res); - - let downloadedBytes = 0; - let lastLoggedPct = -1; - let lastLoggedBytes = 0; - let lastLoggedAt = 0; - - const logProgress = (force: boolean): void => { - const pct = - totalBytes && totalBytes > 0 - ? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes)) - : null; - - if (!force) { - if (pct !== null) { - if (pct <= lastLoggedPct) { - return; - } - } else { - const now = Date.now(); - const advancedBytes = downloadedBytes - lastLoggedBytes; - if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) { - return; - } - } - } - - logger.info( - { - modelId, - artifact, - url, - downloadedBytes, - totalBytes, - pct, - }, - "Downloading model artifact" - ); - lastLoggedPct = pct ?? -1; - lastLoggedBytes = downloadedBytes; - lastLoggedAt = Date.now(); - }; - - logProgress(false); - - const progressStream = new Transform({ - transform(chunk: Buffer, _encoding, callback) { - downloadedBytes += chunk.length; - logProgress(false); - callback(null, chunk); - }, - }); try { - await pipeline(nodeStream, progressStream, createWriteStream(tmpPath)); - logProgress(true); + await pipeline(nodeStream, createWriteStream(tmpPath)); await rename(tmpPath, outputPath); } catch (error) { await rm(tmpPath, { force: true }).catch(() => undefined); @@ -194,9 +123,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption await downloadToFile({ url: spec.archiveUrl, outputPath: archivePath, - logger, - modelId: options.modelId, - artifact: archiveFilename, }); } @@ -251,9 +177,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption await downloadToFile({ url: file.url, outputPath: dst, - logger, - modelId: options.modelId, - artifact: file.relPath, }); } diff --git a/scripts/push-current-release-tag.mjs b/scripts/push-current-release-tag.mjs new file mode 100644 index 000000000..98595c5a3 --- /dev/null +++ b/scripts/push-current-release-tag.mjs @@ -0,0 +1,58 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const rootPackagePath = path.join(rootDir, "package.json"); + +function run(cmd, args) { + execFileSync(cmd, args, { cwd: rootDir, stdio: "inherit" }); +} + +function runQuiet(cmd, args) { + return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim(); +} + +const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")); +const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : ""; +if (!version) { + throw new Error('Root package.json must contain a valid "version"'); +} + +const tag = `v${version}`; +const headCommit = runQuiet("git", ["rev-parse", "HEAD"]); + +let localTagCommit = ""; +try { + localTagCommit = runQuiet("git", ["rev-list", "-n", "1", tag]); +} catch { + run("git", ["tag", "-a", tag, "-m", tag]); + localTagCommit = runQuiet("git", ["rev-list", "-n", "1", tag]); +} + +if (localTagCommit !== headCommit) { + throw new Error( + `Local tag ${tag} points to ${localTagCommit}, but HEAD is ${headCommit}. ` + + "Create a new release commit before pushing this tag." + ); +} + +run("git", ["push", "origin", "HEAD"]); + +let remoteTagExists = false; +try { + runQuiet("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`]); + remoteTagExists = true; +} catch { + remoteTagExists = false; +} + +if (remoteTagExists) { + console.log(`Tag ${tag} already exists on origin`); +} else { + run("git", ["push", "origin", tag]); +} + +console.log(`Release push complete: branch HEAD and tag ${tag}`);