diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index bc4df9be1..1fe08b216 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -136,6 +136,80 @@ The helper verifies: - `latest.yml` points to `pawwork-win-x64.exe`. - `latest-mac.yml` includes both `pawwork-mac-arm64.zip` and `pawwork-mac-x64.zip`. +Also verify a fresh packaged startup before closing startup-blocking issues. The command below is for macOS; override `PAWWORK_RELEASE_APP_PATH` and `PAWWORK_RELEASE_STARTUP_LOG` if the app or log is in a custom location. + +```bash +set -euo pipefail +smoke_home=/tmp/pawwork-release-smoke/user-data +smoke_user_data="$smoke_home/ai.pawwork.desktop" +ready_file="$smoke_user_data/ci-smoke-ready.json" +app_path=${PAWWORK_RELEASE_APP_PATH:-/Applications/PawWork.app/Contents/MacOS/PawWork} +startup_log=${PAWWORK_RELEASE_STARTUP_LOG:-$smoke_user_data/logs/main.log} +app_pid="" +cleanup() { + if [ -n "$app_pid" ]; then + kill "$app_pid" 2>/dev/null || true + fi + rm -rf "$smoke_home" +} +trap cleanup EXIT +rm -rf "$smoke_home" +PAWWORK_CI_SMOKE=true PAWWORK_CI_SMOKE_HOME="$smoke_home" "$app_path" & +app_pid=$! +i=0 +while [ "$i" -lt 60 ]; do + test -f "$ready_file" && break + sleep 1 + i=$((i + 1)) +done +if [ ! -f "$ready_file" ]; then + echo "Timed out waiting for $ready_file" + exit 1 +fi +sleep 1 +PAWWORK_RELEASE_STARTUP_LOG="$startup_log" bun packages/desktop-electron/scripts/verify-release.ts vX.Y.Z +``` + +The startup log check reads the latest `app starting` block and verifies it reaches `server ready`, `loading task finished`, and `init step done`. This catches first-launch hangs where the sidecar becomes reachable but the desktop shell never opens the main window. + +For Windows releases, run the same fresh-user-data check from PowerShell: + +```powershell +$ErrorActionPreference = "Stop" +$smokeHome = "$env:TEMP\pawwork-release-smoke\user-data" +$smokeUserData = "$smokeHome\ai.pawwork.desktop" +$readyFile = "$smokeUserData\ci-smoke-ready.json" +$appPath = if ($env:PAWWORK_RELEASE_APP_PATH) { $env:PAWWORK_RELEASE_APP_PATH } else { "$env:LOCALAPPDATA\Programs\PawWork\PawWork.exe" } +$startupLog = if ($env:PAWWORK_RELEASE_STARTUP_LOG) { $env:PAWWORK_RELEASE_STARTUP_LOG } else { "$smokeUserData\logs\main.log" } +Remove-Item -Recurse -Force $smokeHome -ErrorAction SilentlyContinue +$previousCiSmoke = $env:PAWWORK_CI_SMOKE +$previousCiSmokeHome = $env:PAWWORK_CI_SMOKE_HOME +$previousStartupLog = $env:PAWWORK_RELEASE_STARTUP_LOG +$env:PAWWORK_CI_SMOKE = "true" +$env:PAWWORK_CI_SMOKE_HOME = $smokeHome +$app = Start-Process -FilePath $appPath -PassThru +try { + $ready = $false + for ($i = 0; $i -lt 60; $i++) { + if (Test-Path $readyFile) { + $ready = $true + break + } + Start-Sleep -Seconds 1 + } + if (-not $ready) { throw "Timed out waiting for $readyFile" } + Start-Sleep -Seconds 1 + $env:PAWWORK_RELEASE_STARTUP_LOG = $startupLog + bun packages/desktop-electron/scripts/verify-release.ts vX.Y.Z +} finally { + if ($app -and -not $app.HasExited) { Stop-Process -Id $app.Id -Force } + if ($null -eq $previousCiSmoke) { Remove-Item Env:PAWWORK_CI_SMOKE -ErrorAction SilentlyContinue } else { $env:PAWWORK_CI_SMOKE = $previousCiSmoke } + if ($null -eq $previousCiSmokeHome) { Remove-Item Env:PAWWORK_CI_SMOKE_HOME -ErrorAction SilentlyContinue } else { $env:PAWWORK_CI_SMOKE_HOME = $previousCiSmokeHome } + if ($null -eq $previousStartupLog) { Remove-Item Env:PAWWORK_RELEASE_STARTUP_LOG -ErrorAction SilentlyContinue } else { $env:PAWWORK_RELEASE_STARTUP_LOG = $previousStartupLog } + Remove-Item -Recurse -Force $smokeHome -ErrorAction SilentlyContinue +} +``` + Keep `.zip`, `.blockmap`, and `latest*.yml` assets unless updater requirements are proven safe without them. If verification fails, check the reported missing or malformed asset first, rerun only the affected build phase, and publish the release only after the verification helper passes. diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index 9581b9aae..ac6e6d9d3 100644 --- a/packages/desktop-electron/scripts/verify-release.test.ts +++ b/packages/desktop-electron/scripts/verify-release.test.ts @@ -1,11 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { + escapeRegExp, fetchJson, fetchText, normalizeTag, parseUpdaterFileUrls, + readStartupLogFile, verifyReleasePayload, + verifyStartupLog, type GithubRelease, } from "./verify-release" @@ -68,6 +74,8 @@ describe("verify-release", () => { expect(normalizeTag("0.2.6")).toBe("v0.2.6") expect(normalizeTag("v0.2.6")).toBe("v0.2.6") expect(() => normalizeTag("vv0.2.6")).toThrow("Invalid release tag") + expect(() => normalizeTag("")).toThrow("Invalid release tag") + expect(() => normalizeTag("v")).toThrow("Invalid release tag") expect(() => normalizeTag("abc")).toThrow("Invalid release tag") }) @@ -214,6 +222,198 @@ path: pawwork-win-x64.exe expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64.zip") }) + test("accepts a complete startup log for the release version", () => { + expect( + verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init done +`, + }), + ).toEqual([]) + }) + + test("reports an empty startup log", () => { + expect( + verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: "", + }), + ).toEqual(["Latest startup log does not include any app starting entry"]) + }) + + test("reports a fresh startup log stuck after sidecar readiness", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:16.300] [info] spawning sidecar { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:16.767] [info] sidecar connection started { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.129] [info] awaiting server ready +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +`, + }) + + expect(failures).toContain("Latest startup log does not include loading task finished") + expect(failures).toContain("Latest startup log does not include init step done") + expect(failures).toHaveLength(2) + }) + + test("does not accept awaiting server ready as server ready", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:18.129] [info] awaiting server ready +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init done +`, + }) + + expect(failures).toEqual(["Latest startup log does not include server ready"]) + }) + + test("checks the latest startup attempt instead of an older successful launch", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 20:00:00.000] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 20:00:01.000] [info] loading task finished +[2026-04-22 20:00:01.001] [info] init done +[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:16.767] [info] sidecar connection started { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +`, + }) + + expect(failures).toContain("Latest startup log does not include loading task finished") + expect(failures).toContain("Latest startup log does not include init step done") + expect(failures).toHaveLength(2) + }) + + test("reports release version mismatches in the startup log", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.5', packaged: true } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init done +`, + }) + + expect(failures).toContain("Latest startup log version does not match expected 0.2.6") + }) + + test("reports startup logs from unpackaged desktop runs", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: false } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init done +`, + }) + + expect(failures).toEqual(["Latest startup log does not include packaged true"]) + }) + + test("reports invalid release tags during startup log verification", () => { + expect( + verifyStartupLog( + `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init done +`, + "v", + ), + ).toEqual(["Invalid release tag: v. Expected vX.Y.Z or X.Y.Z."]) + }) + + test("reports invalid release tags with other startup failures", () => { + expect( + verifyStartupLog( + `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +`, + "v", + ), + ).toEqual([ + "Invalid release tag: v. Expected vX.Y.Z or X.Y.Z.", + "Latest startup log does not include server ready", + "Latest startup log does not include loading task finished", + "Latest startup log does not include init step done", + ]) + }) + + test("does not accept 'phase: done' in a non-init-step log line", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init step { step: { phase: 'loading' } } +[2026-04-22 21:26:18.132] [info] unrelated message containing phase: 'done' +`, + }) + + expect(failures).toContain("Latest startup log does not include init step done") + }) + + test("does not accept legacy init step done without the dedicated marker", () => { + const failures = verifyReleasePayload({ + release: baseRelease, + latestYml: "files:\n - url: pawwork-win-x64.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '0.2.6', packaged: true } +[2026-04-22 21:26:18.129] [info] server ready { url: 'http://127.0.0.1:59635' } +[2026-04-22 21:26:18.130] [info] loading task finished +[2026-04-22 21:26:18.131] [info] init step { step: { phase: 'done' } } +`, + }) + + expect(failures).toContain("Latest startup log does not include init step done") + }) + + test("reads startup log files", async () => { + const dir = await mkdtemp(join(tmpdir(), "pawwork-release-log-")) + const logPath = join(dir, "main.log") + + try { + await writeFile(logPath, "startup log contents", "utf8") + await expect(readStartupLogFile(logPath)).resolves.toBe("startup log contents") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("reports unreadable startup log files with the path", async () => { + const dir = await mkdtemp(join(tmpdir(), "pawwork-release-log-")) + const missingPath = join(dir, "missing-main.log") + + try { + await expect(readStartupLogFile(missingPath)).rejects.toThrow( + new RegExp(`^Failed to read startup log file ${escapeRegExp(missingPath)}: .+`), + ) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + test("fetchText reports GitHub rate limit headers on HTTP errors", async () => { globalThis.fetch = (() => Promise.resolve( diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index 5decab346..a2bce433d 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -15,6 +15,7 @@ type VerificationInput = { release: GithubRelease latestYml?: string latestMacYml?: string + startupLog?: string } const DEFAULT_REPO = "Astro-Han/pawwork" @@ -103,6 +104,69 @@ function verifyReferencedAssets(sourceName: string, urls: string[], assetNames: } } +function latestStartupAttempt(source: string) { + const marker = "app starting" + const index = source.lastIndexOf(marker) + if (index === -1) return undefined + return source.slice(index) +} + +function firstLine(source: string) { + return source.split(/\r?\n/, 1)[0] ?? "" +} + +function hasInitDone(source: string) { + return source.split(/\r?\n/).some((line) => line.trim().endsWith("init done")) +} + +function hasServerReady(source: string) { + return source.split(/\r?\n/).some((line) => /\bserver ready\b/.test(line) && /\{\s*url:\s*['"]/.test(line)) +} + +function releaseVersion(tag: string) { + return normalizeTag(tag).slice(1) +} + +export function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function hasStartupVersion(startupLine: string, expectedVersion: string) { + return new RegExp(`version:\\s*['"]${escapeRegExp(expectedVersion)}['"]`).test(startupLine) +} + +function hasPackagedStartup(startupLine: string) { + return /packaged:\s*true/.test(startupLine) +} + +export function verifyStartupLog(source: string, expectedTag: string) { + const failures: string[] = [] + const latest = latestStartupAttempt(source) + + if (!latest) { + failures.push("Latest startup log does not include any app starting entry") + return failures + } + + const startupLine = firstLine(latest) + let expectedVersion: string | undefined + try { + expectedVersion = releaseVersion(expectedTag) + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)) + } + + if (expectedVersion && !hasStartupVersion(startupLine, expectedVersion)) { + failures.push(`Latest startup log version does not match expected ${expectedVersion}`) + } + if (!hasPackagedStartup(startupLine)) failures.push("Latest startup log does not include packaged true") + if (!hasServerReady(latest)) failures.push("Latest startup log does not include server ready") + if (!latest.includes("loading task finished")) failures.push("Latest startup log does not include loading task finished") + if (!hasInitDone(latest)) failures.push("Latest startup log does not include init step done") + + return failures +} + export function verifyReleasePayload(input: VerificationInput) { const failures: string[] = [] const assetNames = new Set(input.release.assets.map((asset) => asset.name)) @@ -126,6 +190,8 @@ export function verifyReleasePayload(input: VerificationInput) { if (!hasUpdaterEntry(latestMacUrls, asset)) failures.push(`latest-mac.yml does not include ${asset}`) } + if (input.startupLog !== undefined) failures.push(...verifyStartupLog(input.startupLog, input.release.tag_name)) + return failures } @@ -164,6 +230,15 @@ export async function fetchJson(url: string) { } } +export async function readStartupLogFile(path: string) { + try { + return await Bun.file(path).text() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to read startup log file ${path}: ${message}`) + } +} + async function fetchWithTimeout(url: string) { try { return await fetch(url, { @@ -198,7 +273,9 @@ async function main() { try { const tag = process.argv[2] if (!tag) { - console.error("Usage: bun packages/desktop-electron/scripts/verify-release.ts [owner/repo]") + console.error( + "Usage: bun packages/desktop-electron/scripts/verify-release.ts [owner/repo] [env: PAWWORK_RELEASE_STARTUP_LOG=/path/to/main.log]", + ) process.exit(2) } @@ -212,7 +289,9 @@ async function main() { ) const latestYml = await fetchAssetText(release, "latest.yml") const latestMacYml = await fetchAssetText(release, "latest-mac.yml") - const failures = verifyReleasePayload({ release, latestYml, latestMacYml }) + const startupLogPath = process.env.PAWWORK_RELEASE_STARTUP_LOG + const startupLog = startupLogPath ? await readStartupLogFile(startupLogPath) : undefined + const failures = verifyReleasePayload({ release, latestYml, latestMacYml, startupLog }) if (failures.length) { console.error(`Release verification failed for ${repo} ${normalizedTag}:`) diff --git a/packages/desktop-electron/src/main/index-sidecar-source.test.ts b/packages/desktop-electron/src/main/index-sidecar-source.test.ts index 72e37dc69..0f32530f7 100644 --- a/packages/desktop-electron/src/main/index-sidecar-source.test.ts +++ b/packages/desktop-electron/src/main/index-sidecar-source.test.ts @@ -6,6 +6,9 @@ describe("desktop sidecar source guard", () => { expect(source).toContain("username: PAWWORK_RUNTIME.serverUsername") expect(source).toContain("const needsMigration = false") + expect(source).toContain('app.setPath("logs", join(app.getPath("userData"), "logs"))') + expect(source).toContain('logger.log("server ready", { url: res.url })') + expect(source).toContain('logger.log("init done")') expect(source).not.toContain("sqliteFileExists") expect(source).not.toContain('username: "opencode"') }) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 925227e16..44f57707c 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -39,6 +39,10 @@ if (CI_SMOKE_HOME) { app.setPath("appData", CI_SMOKE_HOME) } app.setPath("userData", join(userDataRoot, app.isPackaged ? APP_IDS[CHANNEL] : "ai.pawwork.desktop.dev")) +if (CI_SMOKE_HOME) { + // Keep smoke logs inside the isolated profile so release checks cannot read stale user logs. + app.setPath("logs", join(app.getPath("userData"), "logs")) +} const CI_SMOKE_READY_FILE = join(app.getPath("userData"), "ci-smoke-ready.json") const { autoUpdater } = pkg @@ -327,6 +331,7 @@ function openMainWindow() { function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) + if (step.phase === "done") logger.log("init done") initEmitter.emit("step", step) }