Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Astro-Han marked this conversation as resolved.
Comment thread
Astro-Han marked this conversation as resolved.
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}
Comment thread
Astro-Han marked this conversation as resolved.
startup_log=${PAWWORK_RELEASE_STARTUP_LOG:-$smoke_user_data/logs/main.log}
Comment thread
Astro-Han marked this conversation as resolved.
app_pid=""
cleanup() {
Comment thread
Astro-Han marked this conversation as resolved.
if [ -n "$app_pid" ]; then
kill "$app_pid" 2>/dev/null || true
fi
rm -rf "$smoke_home"
}
Comment thread
Astro-Han marked this conversation as resolved.
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
Comment thread
Astro-Han marked this conversation as resolved.
i=$((i + 1))
Comment thread
Astro-Han marked this conversation as resolved.
done
Comment thread
Astro-Han marked this conversation as resolved.
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
```
Comment thread
Astro-Han marked this conversation as resolved.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
```

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.
Expand Down
200 changes: 200 additions & 0 deletions packages/desktop-electron/scripts/verify-release.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Comment thread
Astro-Han marked this conversation as resolved.
import {
escapeRegExp,
fetchJson,
fetchText,
normalizeTag,
parseUpdaterFileUrls,
readStartupLogFile,
Comment thread
Astro-Han marked this conversation as resolved.
verifyReleasePayload,
verifyStartupLog,
type GithubRelease,
} from "./verify-release"

Expand Down Expand Up @@ -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")
})

Expand Down Expand Up @@ -214,6 +222,198 @@ path: pawwork-win-x64.exe
expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64.zip")
Comment thread
Astro-Han marked this conversation as resolved.
})

test("accepts a complete startup log for the release version", () => {
Comment thread
Astro-Han marked this conversation as resolved.
Comment thread
Astro-Han marked this conversation as resolved.
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")
Comment thread
Astro-Han marked this conversation as resolved.
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' }
Comment thread
Astro-Han marked this conversation as resolved.
`,
})

expect(failures).toContain("Latest startup log does not include loading task finished")
expect(failures).toContain("Latest startup log does not include init step done")
Comment thread
Astro-Han marked this conversation as resolved.
expect(failures).toHaveLength(2)
})
Comment thread
Astro-Han marked this conversation as resolved.

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",
Comment thread
Astro-Han marked this conversation as resolved.
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' } }
Comment thread
Astro-Han marked this conversation as resolved.
[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' }
Comment thread
Astro-Han marked this conversation as resolved.
[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(
Expand Down
Loading
Loading