From 5f2e7e5b95022a1a4e87bd84fa20bccc59572c1a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 28 Apr 2026 16:19:14 +0800 Subject: [PATCH 1/4] fix: include version in desktop release artifacts --- .../electron-builder-app-update.test.ts | 2 +- .../electron-builder.config.ts | 2 +- .../scripts/verify-release.test.ts | 182 +++++++++--------- .../scripts/verify-release.ts | 44 +++-- 4 files changed, 122 insertions(+), 108 deletions(-) diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index 94eb43e71..aa573d765 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -45,7 +45,7 @@ describe("electron builder app-update config", () => { const config = createConfig("prod") expect(config.productName).toBe("PawWork") expect(config.appId).toBe("ai.pawwork.desktop") - expect(config.artifactName).toBe("pawwork-${os}-${arch}.${ext}") + expect(config.artifactName).toBe("pawwork-${os}-${arch}-${version}.${ext}") expect(config.publish).toMatchObject({ owner: "Astro-Han", repo: "pawwork" }) expect(createConfig("prod").mac?.extendInfo).toMatchObject({ LSHasLocalizedDisplayName: true, diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index a0f43d591..be2dd3328 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -52,7 +52,7 @@ async function writeLocalizedMacDisplayName(resourcesDir: string, channel: Chann } const getBase = (): Configuration => ({ - artifactName: "pawwork-${os}-${arch}.${ext}", + artifactName: "pawwork-${os}-${arch}-${version}.${ext}", directories: { output: "dist", buildResources: "resources", diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index ac6e6d9d3..c7e824598 100644 --- a/packages/desktop-electron/scripts/verify-release.test.ts +++ b/packages/desktop-electron/scripts/verify-release.test.ts @@ -22,41 +22,41 @@ afterEach(() => { }) const baseRelease: GithubRelease = { - tag_name: "v0.2.6", + tag_name: "v2026.4.28", draft: false, prerelease: false, assets: [ { - name: "pawwork-mac-arm64.dmg", - browser_download_url: "https://example.com/pawwork-mac-arm64.dmg", + name: "pawwork-mac-arm64-2026.4.28.dmg", + browser_download_url: "https://example.com/pawwork-mac-arm64-2026.4.28.dmg", }, { - name: "pawwork-mac-arm64.zip", - browser_download_url: "https://example.com/pawwork-mac-arm64.zip", + name: "pawwork-mac-arm64-2026.4.28.zip", + browser_download_url: "https://example.com/pawwork-mac-arm64-2026.4.28.zip", }, { - name: "pawwork-mac-arm64.zip.blockmap", - browser_download_url: "https://example.com/pawwork-mac-arm64.zip.blockmap", + name: "pawwork-mac-arm64-2026.4.28.zip.blockmap", + browser_download_url: "https://example.com/pawwork-mac-arm64-2026.4.28.zip.blockmap", }, { - name: "pawwork-mac-x64.dmg", - browser_download_url: "https://example.com/pawwork-mac-x64.dmg", + name: "pawwork-mac-x64-2026.4.28.dmg", + browser_download_url: "https://example.com/pawwork-mac-x64-2026.4.28.dmg", }, { - name: "pawwork-mac-x64.zip", - browser_download_url: "https://example.com/pawwork-mac-x64.zip", + name: "pawwork-mac-x64-2026.4.28.zip", + browser_download_url: "https://example.com/pawwork-mac-x64-2026.4.28.zip", }, { - name: "pawwork-mac-x64.zip.blockmap", - browser_download_url: "https://example.com/pawwork-mac-x64.zip.blockmap", + name: "pawwork-mac-x64-2026.4.28.zip.blockmap", + browser_download_url: "https://example.com/pawwork-mac-x64-2026.4.28.zip.blockmap", }, { - name: "pawwork-win-x64.exe", - browser_download_url: "https://example.com/pawwork-win-x64.exe", + name: "pawwork-win-x64-2026.4.28.exe", + browser_download_url: "https://example.com/pawwork-win-x64-2026.4.28.exe", }, { - name: "pawwork-win-x64.exe.blockmap", - browser_download_url: "https://example.com/pawwork-win-x64.exe.blockmap", + name: "pawwork-win-x64-2026.4.28.exe.blockmap", + browser_download_url: "https://example.com/pawwork-win-x64-2026.4.28.exe.blockmap", }, { name: "latest.yml", @@ -71,36 +71,38 @@ const baseRelease: GithubRelease = { describe("verify-release", () => { test("normalizes release tags", () => { - 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("2026.4.28")).toBe("v2026.4.28") + expect(normalizeTag("v2026.4.28")).toBe("v2026.4.28") + expect(() => normalizeTag("vv2026.4.28")).toThrow("Invalid release tag") expect(() => normalizeTag("")).toThrow("Invalid release tag") expect(() => normalizeTag("v")).toThrow("Invalid release tag") expect(() => normalizeTag("abc")).toThrow("Invalid release tag") + expect(() => normalizeTag("2026.4.28.1")).toThrow("Invalid release tag") + expect(() => normalizeTag("2026.4.28-hotfix.1")).toThrow("Invalid release tag") }) test("parses updater file urls and path entries", () => { expect( - parseUpdaterFileUrls(`version: 0.2.6 + parseUpdaterFileUrls(`version: 2026.4.28 files: - - url: pawwork-mac-arm64.zip + - url: pawwork-mac-arm64-2026.4.28.zip size: 1 - - url: pawwork-mac-x64.zip + - url: pawwork-mac-x64-2026.4.28.zip size: 2 -path: pawwork-mac-arm64.zip +path: pawwork-mac-arm64-2026.4.28.zip `), - ).toEqual(["pawwork-mac-arm64.zip", "pawwork-mac-x64.zip", "pawwork-mac-arm64.zip"]) + ).toEqual(["pawwork-mac-arm64-2026.4.28.zip", "pawwork-mac-x64-2026.4.28.zip", "pawwork-mac-arm64-2026.4.28.zip"]) }) test("parses quoted updater file urls and path entries", () => { expect( parseUpdaterFileUrls(`files: - - url: "pawwork-mac-arm64.zip" - - url: 'pawwork-mac-x64.zip' # Intel macOS updater asset + - url: "pawwork-mac-arm64-2026.4.28.zip" + - url: 'pawwork-mac-x64-2026.4.28.zip' # Intel macOS updater asset - url: "pawwork-mac#arm64.zip" - path: "pawwork-win-x64.exe" # Windows updater asset + path: "pawwork-win-x64-2026.4.28.exe" # Windows updater asset `), - ).toEqual(["pawwork-mac-arm64.zip", "pawwork-mac-x64.zip", "pawwork-mac#arm64.zip", "pawwork-win-x64.exe"]) + ).toEqual(["pawwork-mac-arm64-2026.4.28.zip", "pawwork-mac-x64-2026.4.28.zip", "pawwork-mac#arm64.zip", "pawwork-win-x64-2026.4.28.exe"]) }) test("keeps inline comments outside escaped quoted values", () => { @@ -108,17 +110,17 @@ path: pawwork-mac-arm64.zip parseUpdaterFileUrls(String.raw`files: - url: "pawwork-mac\"arm64.zip" # comment - url: "pawwork-mac\\" -path: pawwork-win-x64.exe +path: pawwork-win-x64-2026.4.28.exe `), - ).toEqual([String.raw`pawwork-mac\"arm64.zip`, String.raw`pawwork-mac\\`, "pawwork-win-x64.exe"]) + ).toEqual([String.raw`pawwork-mac\"arm64.zip`, String.raw`pawwork-mac\\`, "pawwork-win-x64-2026.4.28.exe"]) }) test("accepts a stable release with expected assets and updater metadata", () => { 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", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", }), ).toEqual([]) }) @@ -127,9 +129,9 @@ path: pawwork-win-x64.exe expect( verifyReleasePayload({ release: baseRelease, - latestYml: "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v0.2.6/pawwork-win-x64.exe\n", + latestYml: "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-win-x64-2026.4.28.exe\n", latestMacYml: - "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v0.2.6/pawwork-mac-arm64.zip\n - url: https://github.com/Astro-Han/pawwork/releases/download/v0.2.6/pawwork-mac-x64.zip\n", + "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-mac-arm64-2026.4.28.zip\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-mac-x64-2026.4.28.zip\n", }), ).toEqual([]) }) @@ -138,10 +140,10 @@ path: pawwork-win-x64.exe expect( verifyReleasePayload({ release: baseRelease, - latestYml: "files:\n - url: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - url: pawwork-mac-x64.zip\n", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-x64-2026.4.28.zip\n", }), - ).toContain("latest-mac.yml does not include pawwork-mac-arm64.zip") + ).toContain("latest-mac.yml does not include pawwork-mac-arm64-2026.4.28.zip") }) test("reports updater metadata that points to a missing asset", () => { @@ -149,12 +151,12 @@ path: pawwork-win-x64.exe verifyReleasePayload({ release: { ...baseRelease, - assets: baseRelease.assets.filter((asset) => asset.name !== "pawwork-mac-arm64.zip"), + assets: baseRelease.assets.filter((asset) => asset.name !== "pawwork-mac-arm64-2026.4.28.zip"), }, - latestYml: "files:\n - url: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", }), - ).toContain("latest-mac.yml references missing release asset: pawwork-mac-arm64.zip") + ).toContain("latest-mac.yml references missing release asset: pawwork-mac-arm64-2026.4.28.zip") }) test("reports missing installer and updater sidecar assets", () => { @@ -162,15 +164,15 @@ path: pawwork-win-x64.exe release: { ...baseRelease, assets: baseRelease.assets.filter( - (asset) => asset.name !== "pawwork-mac-arm64.dmg" && asset.name !== "pawwork-win-x64.exe.blockmap", + (asset) => asset.name !== "pawwork-mac-arm64-2026.4.28.dmg" && asset.name !== "pawwork-win-x64-2026.4.28.exe.blockmap", ), }, - latestYml: "files:\n - url: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", }) - expect(failures).toContain("Missing release asset: pawwork-mac-arm64.dmg") - expect(failures).toContain("Missing release asset: pawwork-win-x64.exe.blockmap") + expect(failures).toContain("Missing release asset: pawwork-mac-arm64-2026.4.28.dmg") + expect(failures).toContain("Missing release asset: pawwork-win-x64-2026.4.28.exe.blockmap") }) test("reports missing updater metadata assets without requiring metadata downloads", () => { @@ -185,50 +187,50 @@ path: pawwork-win-x64.exe expect(failures).toContain("Missing release asset: latest.yml") expect(failures).toContain("Missing release asset: latest-mac.yml") - expect(failures).toContain("latest.yml does not include pawwork-win-x64.exe") - expect(failures).toContain("latest-mac.yml does not include pawwork-mac-arm64.zip") - expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64.zip") + expect(failures).toContain("latest.yml does not include pawwork-win-x64-2026.4.28.exe") + expect(failures).toContain("latest-mac.yml does not include pawwork-mac-arm64-2026.4.28.zip") + expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64-2026.4.28.zip") }) test("reports draft releases", () => { expect( verifyReleasePayload({ release: { ...baseRelease, draft: true }, - latestYml: "files:\n - url: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", }), - ).toContain("Release v0.2.6 is still a draft") + ).toContain("Release v2026.4.28 is still a draft") }) test("reports prerelease releases", () => { expect( verifyReleasePayload({ release: { ...baseRelease, prerelease: true }, - latestYml: "files:\n - url: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - url: pawwork-mac-arm64.zip\n - url: pawwork-mac-x64.zip\n", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", }), - ).toContain("Release v0.2.6 is marked as a prerelease") + ).toContain("Release v2026.4.28 is marked as a prerelease") }) test("reports malformed updater metadata as missing required updater entries", () => { const failures = verifyReleasePayload({ release: baseRelease, - latestYml: "files:\n - broken: pawwork-win-x64.exe\n", - latestMacYml: "files:\n - broken: pawwork-mac-arm64.zip\n", + latestYml: "files:\n - broken: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - broken: pawwork-mac-arm64-2026.4.28.zip\n", }) - expect(failures).toContain("latest.yml does not include pawwork-win-x64.exe") - expect(failures).toContain("latest-mac.yml does not include pawwork-mac-arm64.zip") - expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64.zip") + expect(failures).toContain("latest.yml does not include pawwork-win-x64-2026.4.28.exe") + expect(failures).toContain("latest-mac.yml does not include pawwork-mac-arm64-2026.4.28.zip") + expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64-2026.4.28.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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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 @@ -241,8 +243,8 @@ path: pawwork-win-x64.exe 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", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", startupLog: "", }), ).toEqual(["Latest startup log does not include any app starting entry"]) @@ -251,9 +253,9 @@ path: pawwork-win-x64.exe 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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 @@ -269,9 +271,9 @@ path: pawwork-win-x64.exe 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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 @@ -284,12 +286,12 @@ path: pawwork-win-x64.exe 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 20:00:00.000] [info] app starting { version: '2026.4.28', 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.088] [info] app starting { version: '2026.4.28', 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' } `, @@ -303,8 +305,8 @@ path: pawwork-win-x64.exe 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", + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.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 @@ -312,15 +314,15 @@ path: pawwork-win-x64.exe `, }) - expect(failures).toContain("Latest startup log version does not match expected 0.2.6") + expect(failures).toContain("Latest startup log version does not match expected 2026.4.28") }) 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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 @@ -333,7 +335,7 @@ path: pawwork-win-x64.exe 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:16.088] [info] app starting { version: '2026.4.28', 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 @@ -346,7 +348,7 @@ path: pawwork-win-x64.exe 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 } + `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', packaged: true } `, "v", ), @@ -361,9 +363,9 @@ path: pawwork-win-x64.exe 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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' } } @@ -377,9 +379,9 @@ path: pawwork-win-x64.exe 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 } + latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", + latestMacYml: "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n", + startupLog: `[2026-04-22 21:26:16.088] [info] app starting { version: '2026.4.28', 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' } } diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index a2bce433d..402f0bdc8 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -21,18 +21,28 @@ type VerificationInput = { const DEFAULT_REPO = "Astro-Han/pawwork" const FETCH_TIMEOUT_MS = 15_000 -const REQUIRED_ASSETS = [ - "pawwork-mac-arm64.dmg", - "pawwork-mac-arm64.zip", - "pawwork-mac-arm64.zip.blockmap", - "pawwork-mac-x64.dmg", - "pawwork-mac-x64.zip", - "pawwork-mac-x64.zip.blockmap", - "pawwork-win-x64.exe", - "pawwork-win-x64.exe.blockmap", - "latest.yml", - "latest-mac.yml", -] +export function releaseAssetNames(version: string) { + return [ + `pawwork-mac-arm64-${version}.dmg`, + `pawwork-mac-arm64-${version}.zip`, + `pawwork-mac-arm64-${version}.zip.blockmap`, + `pawwork-mac-x64-${version}.dmg`, + `pawwork-mac-x64-${version}.zip`, + `pawwork-mac-x64-${version}.zip.blockmap`, + `pawwork-win-x64-${version}.exe`, + `pawwork-win-x64-${version}.exe.blockmap`, + "latest.yml", + "latest-mac.yml", + ] +} + +function windowsUpdaterAsset(version: string) { + return `pawwork-win-x64-${version}.exe` +} + +function macUpdaterAssets(version: string) { + return [`pawwork-mac-arm64-${version}.zip`, `pawwork-mac-x64-${version}.zip`] +} export function parseUpdaterFileUrls(source: string) { const urls: string[] = [] @@ -170,23 +180,25 @@ export function verifyStartupLog(source: string, expectedTag: string) { export function verifyReleasePayload(input: VerificationInput) { const failures: string[] = [] const assetNames = new Set(input.release.assets.map((asset) => asset.name)) + const version = releaseVersion(input.release.tag_name) if (input.release.draft) failures.push(`Release ${input.release.tag_name} is still a draft`) if (input.release.prerelease) failures.push(`Release ${input.release.tag_name} is marked as a prerelease`) - for (const asset of REQUIRED_ASSETS) { + for (const asset of releaseAssetNames(version)) { if (!assetNames.has(asset)) failures.push(`Missing release asset: ${asset}`) } const latestUrls = input.latestYml === undefined ? [] : parseUpdaterFileUrls(input.latestYml) verifyReferencedAssets("latest.yml", latestUrls, assetNames, failures) - if (!hasUpdaterEntry(latestUrls, "pawwork-win-x64.exe")) { - failures.push("latest.yml does not include pawwork-win-x64.exe") + const winUpdaterAsset = windowsUpdaterAsset(version) + if (!hasUpdaterEntry(latestUrls, winUpdaterAsset)) { + failures.push(`latest.yml does not include ${winUpdaterAsset}`) } const latestMacUrls = input.latestMacYml === undefined ? [] : parseUpdaterFileUrls(input.latestMacYml) verifyReferencedAssets("latest-mac.yml", latestMacUrls, assetNames, failures) - for (const asset of ["pawwork-mac-arm64.zip", "pawwork-mac-x64.zip"]) { + for (const asset of macUpdaterAssets(version)) { if (!hasUpdaterEntry(latestMacUrls, asset)) failures.push(`latest-mac.yml does not include ${asset}`) } From 9fcb072c2ecc7835cf8f5e7083ab15fb6792e289 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 28 Apr 2026 16:21:17 +0800 Subject: [PATCH 2/4] chore: bump release version to 2026.4.28 --- bun.lock | 8 ++++---- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/opencode/package.json | 2 +- packages/util/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 052c27d07..225c4e31a 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "0.2.14", + "version": "2026.4.28", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -110,7 +110,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "0.2.14", + "version": "2026.4.28", "dependencies": { "@opencode-ai/util": "workspace:*", "effect": "catalog:", @@ -175,7 +175,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "0.2.14", + "version": "2026.4.28", "bin": { "opencode": "./bin/opencode", }, @@ -402,7 +402,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "0.2.14", + "version": "2026.4.28", "dependencies": { "@opencode-ai/core": "workspace:*", "zod": "catalog:", diff --git a/packages/app/package.json b/packages/app/package.json index 6309e1294..0719a1df4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "0.2.14", + "version": "2026.4.28", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 71652cee3..d5244486b 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "0.2.14", + "version": "2026.4.28", "type": "module", "license": "Apache-2.0", "homepage": "https://pawwork.ai", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 008314a65..3efc1fb25 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.2.14", + "version": "2026.4.28", "name": "opencode", "type": "module", "license": "Apache-2.0", diff --git a/packages/util/package.json b/packages/util/package.json index 78afa0e03..d03f8f59b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "0.2.14", + "version": "2026.4.28", "private": true, "type": "module", "license": "Apache-2.0", From 327fe5a8f86d4c6ba0697c0338482ccc3327ebbe Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 28 Apr 2026 16:46:09 +0800 Subject: [PATCH 3/4] fix: harden release artifact verification --- .../electron-builder-app-update.test.ts | 12 ++- .../scripts/verify-release.test.ts | 48 +++++++++-- .../scripts/verify-release.ts | 86 ++++++++++++------- 3 files changed, 108 insertions(+), 38 deletions(-) diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index aa573d765..ebd053fa5 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -10,7 +10,11 @@ import { serializeAppUpdateConfig } from "./scripts/write-app-update-config" const roots: string[] = [] type AfterPackContext = Parameters, (...args: any[]) => unknown>>[0] -function macAfterPackContext(appOutDir: string, appBundleName: string, electronPlatformName = "darwin"): AfterPackContext { +function macAfterPackContext( + appOutDir: string, + appBundleName: string, + electronPlatformName = "darwin", +): AfterPackContext { return { appOutDir, electronPlatformName, @@ -52,6 +56,12 @@ describe("electron builder app-update config", () => { }) }) + test("all channels share the versioned artifact name", () => { + expect(createConfig("dev").artifactName).toBe("pawwork-${os}-${arch}-${version}.${ext}") + expect(createConfig("beta").artifactName).toBe("pawwork-${os}-${arch}-${version}.${ext}") + expect(createConfig("prod").artifactName).toBe("pawwork-${os}-${arch}-${version}.${ext}") + }) + test("afterPack writes app-update.yml to the packager-reported macOS resources path", async () => { const root = mkdtempSync(join(tmpdir(), "pawwork-builder-config-")) roots.push(root) diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index c7e824598..e3a5b7a2f 100644 --- a/packages/desktop-electron/scripts/verify-release.test.ts +++ b/packages/desktop-electron/scripts/verify-release.test.ts @@ -10,6 +10,8 @@ import { normalizeTag, parseUpdaterFileUrls, readStartupLogFile, + releaseAssetNames, + releaseUpdaterAssetNames, verifyReleasePayload, verifyStartupLog, type GithubRelease, @@ -81,6 +83,25 @@ describe("verify-release", () => { expect(() => normalizeTag("2026.4.28-hotfix.1")).toThrow("Invalid release tag") }) + test("derives release and updater asset names from the CalVer version", () => { + expect(releaseAssetNames("2026.4.28")).toEqual([ + "pawwork-mac-arm64-2026.4.28.dmg", + "pawwork-mac-arm64-2026.4.28.zip", + "pawwork-mac-arm64-2026.4.28.zip.blockmap", + "pawwork-mac-x64-2026.4.28.dmg", + "pawwork-mac-x64-2026.4.28.zip", + "pawwork-mac-x64-2026.4.28.zip.blockmap", + "pawwork-win-x64-2026.4.28.exe", + "pawwork-win-x64-2026.4.28.exe.blockmap", + "latest.yml", + "latest-mac.yml", + ]) + expect(releaseUpdaterAssetNames("2026.4.28")).toEqual({ + "latest.yml": ["pawwork-win-x64-2026.4.28.exe"], + "latest-mac.yml": ["pawwork-mac-arm64-2026.4.28.zip", "pawwork-mac-x64-2026.4.28.zip"], + }) + }) + test("parses updater file urls and path entries", () => { expect( parseUpdaterFileUrls(`version: 2026.4.28 @@ -102,7 +123,12 @@ path: pawwork-mac-arm64-2026.4.28.zip - url: "pawwork-mac#arm64.zip" path: "pawwork-win-x64-2026.4.28.exe" # Windows updater asset `), - ).toEqual(["pawwork-mac-arm64-2026.4.28.zip", "pawwork-mac-x64-2026.4.28.zip", "pawwork-mac#arm64.zip", "pawwork-win-x64-2026.4.28.exe"]) + ).toEqual([ + "pawwork-mac-arm64-2026.4.28.zip", + "pawwork-mac-x64-2026.4.28.zip", + "pawwork-mac#arm64.zip", + "pawwork-win-x64-2026.4.28.exe", + ]) }) test("keeps inline comments outside escaped quoted values", () => { @@ -129,7 +155,8 @@ path: pawwork-win-x64-2026.4.28.exe expect( verifyReleasePayload({ release: baseRelease, - latestYml: "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-win-x64-2026.4.28.exe\n", + latestYml: + "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-win-x64-2026.4.28.exe\n", latestMacYml: "files:\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-mac-arm64-2026.4.28.zip\n - url: https://github.com/Astro-Han/pawwork/releases/download/v2026.4.28/pawwork-mac-x64-2026.4.28.zip\n", }), @@ -164,7 +191,8 @@ path: pawwork-win-x64-2026.4.28.exe release: { ...baseRelease, assets: baseRelease.assets.filter( - (asset) => asset.name !== "pawwork-mac-arm64-2026.4.28.dmg" && asset.name !== "pawwork-win-x64-2026.4.28.exe.blockmap", + (asset) => + asset.name !== "pawwork-mac-arm64-2026.4.28.dmg" && asset.name !== "pawwork-win-x64-2026.4.28.exe.blockmap", ), }, latestYml: "files:\n - url: pawwork-win-x64-2026.4.28.exe\n", @@ -224,6 +252,16 @@ path: pawwork-win-x64-2026.4.28.exe expect(failures).toContain("latest-mac.yml does not include pawwork-mac-x64-2026.4.28.zip") }) + test("reports invalid release tags in release payloads without throwing", () => { + expect( + verifyReleasePayload({ + release: { ...baseRelease, tag_name: "v2026.4.28.1" }, + latestYml: "", + latestMacYml: "", + }), + ).toEqual(["Invalid release tag: v2026.4.28.1. Expected vYYYY.M.D or YYYY.M.D."]) + }) + test("accepts a complete startup log for the release version", () => { expect( verifyReleasePayload({ @@ -342,7 +380,7 @@ path: pawwork-win-x64-2026.4.28.exe `, "v", ), - ).toEqual(["Invalid release tag: v. Expected vX.Y.Z or X.Y.Z."]) + ).toEqual(["Invalid release tag: v. Expected vYYYY.M.D or YYYY.M.D."]) }) test("reports invalid release tags with other startup failures", () => { @@ -353,7 +391,7 @@ path: pawwork-win-x64-2026.4.28.exe "v", ), ).toEqual([ - "Invalid release tag: v. Expected vX.Y.Z or X.Y.Z.", + "Invalid release tag: v. Expected vYYYY.M.D or YYYY.M.D.", "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", diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index 402f0bdc8..846f94be9 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -21,27 +21,41 @@ type VerificationInput = { const DEFAULT_REPO = "Astro-Han/pawwork" const FETCH_TIMEOUT_MS = 15_000 +const RELEASE_TARGETS = [ + { os: "mac", arch: "arm64", installerExt: "dmg", updaterExt: "zip", metadata: "latest-mac.yml" }, + { os: "mac", arch: "x64", installerExt: "dmg", updaterExt: "zip", metadata: "latest-mac.yml" }, + { os: "win", arch: "x64", installerExt: "exe", updaterExt: "exe", metadata: "latest.yml" }, +] as const + +type MetadataFile = (typeof RELEASE_TARGETS)[number]["metadata"] + +function releaseTargetAssetName(target: (typeof RELEASE_TARGETS)[number], version: string, ext: string) { + return `pawwork-${target.os}-${target.arch}-${version}.${ext}` +} + export function releaseAssetNames(version: string) { return [ - `pawwork-mac-arm64-${version}.dmg`, - `pawwork-mac-arm64-${version}.zip`, - `pawwork-mac-arm64-${version}.zip.blockmap`, - `pawwork-mac-x64-${version}.dmg`, - `pawwork-mac-x64-${version}.zip`, - `pawwork-mac-x64-${version}.zip.blockmap`, - `pawwork-win-x64-${version}.exe`, - `pawwork-win-x64-${version}.exe.blockmap`, - "latest.yml", - "latest-mac.yml", + ...new Set([ + ...RELEASE_TARGETS.flatMap((target) => [ + releaseTargetAssetName(target, version, target.installerExt), + releaseTargetAssetName(target, version, target.updaterExt), + `${releaseTargetAssetName(target, version, target.updaterExt)}.blockmap`, + ]), + "latest.yml", + "latest-mac.yml", + ]), ] } -function windowsUpdaterAsset(version: string) { - return `pawwork-win-x64-${version}.exe` -} - -function macUpdaterAssets(version: string) { - return [`pawwork-mac-arm64-${version}.zip`, `pawwork-mac-x64-${version}.zip`] +export function releaseUpdaterAssetNames(version: string): Record { + return { + "latest.yml": RELEASE_TARGETS.filter((target) => target.metadata === "latest.yml").map((target) => + releaseTargetAssetName(target, version, target.updaterExt), + ), + "latest-mac.yml": RELEASE_TARGETS.filter((target) => target.metadata === "latest-mac.yml").map((target) => + releaseTargetAssetName(target, version, target.updaterExt), + ), + } } export function parseUpdaterFileUrls(source: string) { @@ -77,7 +91,7 @@ function stripInlineComment(value: string) { for (let index = 0; index < value.length; index += 1) { const char = value[index] if ((char === `"` || char === `'`) && !isEscaped(value, index)) { - quote = quote === char ? undefined : quote ?? char + quote = quote === char ? undefined : (quote ?? char) continue } @@ -171,7 +185,8 @@ export function verifyStartupLog(source: string, expectedTag: string) { } 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 (!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 @@ -180,26 +195,33 @@ export function verifyStartupLog(source: string, expectedTag: string) { export function verifyReleasePayload(input: VerificationInput) { const failures: string[] = [] const assetNames = new Set(input.release.assets.map((asset) => asset.name)) - const version = releaseVersion(input.release.tag_name) + let version: string | undefined + try { + version = releaseVersion(input.release.tag_name) + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)) + } if (input.release.draft) failures.push(`Release ${input.release.tag_name} is still a draft`) if (input.release.prerelease) failures.push(`Release ${input.release.tag_name} is marked as a prerelease`) - for (const asset of releaseAssetNames(version)) { - if (!assetNames.has(asset)) failures.push(`Missing release asset: ${asset}`) - } - const latestUrls = input.latestYml === undefined ? [] : parseUpdaterFileUrls(input.latestYml) verifyReferencedAssets("latest.yml", latestUrls, assetNames, failures) - const winUpdaterAsset = windowsUpdaterAsset(version) - if (!hasUpdaterEntry(latestUrls, winUpdaterAsset)) { - failures.push(`latest.yml does not include ${winUpdaterAsset}`) - } - const latestMacUrls = input.latestMacYml === undefined ? [] : parseUpdaterFileUrls(input.latestMacYml) verifyReferencedAssets("latest-mac.yml", latestMacUrls, assetNames, failures) - for (const asset of macUpdaterAssets(version)) { - if (!hasUpdaterEntry(latestMacUrls, asset)) failures.push(`latest-mac.yml does not include ${asset}`) + + if (version) { + for (const asset of releaseAssetNames(version)) { + if (!assetNames.has(asset)) failures.push(`Missing release asset: ${asset}`) + } + + const updaterAssets = releaseUpdaterAssetNames(version) + for (const asset of updaterAssets["latest.yml"]) { + if (!hasUpdaterEntry(latestUrls, asset)) failures.push(`latest.yml does not include ${asset}`) + } + for (const asset of updaterAssets["latest-mac.yml"]) { + 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)) @@ -209,8 +231,8 @@ export function verifyReleasePayload(input: VerificationInput) { export function normalizeTag(raw: string) { const normalized = raw.startsWith("v") ? raw : `v${raw}` - if (!/^v\d+\.\d+\.\d+$/.test(normalized)) { - throw new Error(`Invalid release tag: ${raw}. Expected vX.Y.Z or X.Y.Z.`) + if (!/^v\d{4}\.\d{1,2}\.\d{1,2}$/.test(normalized)) { + throw new Error(`Invalid release tag: ${raw}. Expected vYYYY.M.D or YYYY.M.D.`) } return normalized } From 5018ffa9f4fca27a9a8ba90d7dcf8617130f70dc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 28 Apr 2026 16:53:22 +0800 Subject: [PATCH 4/4] fix: isolate stable layout memo helper --- .../src/pages/session/session-layout.test.ts | 2 +- .../app/src/pages/session/session-layout.ts | 18 +----------------- .../src/pages/session/stable-layout-memo.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 packages/app/src/pages/session/stable-layout-memo.ts diff --git a/packages/app/src/pages/session/session-layout.test.ts b/packages/app/src/pages/session/session-layout.test.ts index 8df36c361..bdfda36db 100644 --- a/packages/app/src/pages/session/session-layout.test.ts +++ b/packages/app/src/pages/session/session-layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" -import { createStableLayoutMemo } from "./session-layout" +import { createStableLayoutMemo } from "./stable-layout-memo" describe("createStableLayoutMemo", () => { test("reuses the last value when a disposed memo turns empty", () => { diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts index 7e938c430..1a99173e0 100644 --- a/packages/app/src/pages/session/session-layout.ts +++ b/packages/app/src/pages/session/session-layout.ts @@ -1,6 +1,7 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" +import { createStableLayoutMemo } from "./stable-layout-memo" export const useSessionKey = () => { const params = useParams() @@ -18,20 +19,3 @@ export const useSessionLayout = () => { view: createStableLayoutMemo(() => layout.view(sessionKey)), } } - -export function createStableLayoutMemo(read: () => T) { - let last: { value: T } | undefined - const memo = createMemo(read) - - return () => { - const value = memo() - if (value !== undefined) { - last = { value } - return value - } - - if (last) return last.value - - throw new Error("Stable layout memo read before initialization") - } -} diff --git a/packages/app/src/pages/session/stable-layout-memo.ts b/packages/app/src/pages/session/stable-layout-memo.ts new file mode 100644 index 000000000..9cfc303e1 --- /dev/null +++ b/packages/app/src/pages/session/stable-layout-memo.ts @@ -0,0 +1,18 @@ +import { createMemo } from "solid-js" + +export function createStableLayoutMemo(read: () => T) { + let last: { value: T } | undefined + const memo = createMemo(read) + + return () => { + const value = memo() + if (value !== undefined) { + last = { value } + return value + } + + if (last) return last.value + + throw new Error("Stable layout memo read before initialization") + } +}