diff --git a/docs/RELEASING.md b/docs/RELEASING.md index bb48f9e..f221920 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -23,7 +23,9 @@ bun run sync:locked The synchronization command: -1. reads the official updater manifest; +1. reads the public stable-channel manifest used by ZCode Desktop, with the + static CDN manifest as a fallback only when the service response is + unavailable or invalid; 2. downloads the matching installer; 3. verifies its SHA-512 from the manifest; 4. extracts `resources/glm`; @@ -89,6 +91,13 @@ workflows. Its updater URL and SHA-512 are committed in downloads the committed URL and verifies the locked SHA-512, so a later upstream update cannot silently change a reviewed release. +The scheduled workflow follows the Desktop stable channel (`channel=1`) without +credentials or a personal `device_mid`. Static `latest-*.yml` files can lag the +service and are therefore recovery inputs, not the primary update signal. If +upstream rolls its stable channel back, synchronization keeps a newer committed +lock instead of silently downgrading it; adopting a rollback requires an +explicit maintainer review of `zcode-runtime.lock.json`. + The preparation workflow checks upstream once per day at 01:30 in the `Asia/Shanghai` timezone, in `upstream` mode. [GitHub documents scheduled triggers as best effort](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule): diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index eb760d3..91dee27 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -12,6 +12,10 @@ import { syncedReleaseVersion } from "./release-version.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const cdnRoot = "https://cdn-zcode.z.ai/zcode/electron/releases"; +const updateServiceRoot = "https://zcode.z.ai"; +const updateServiceManifestPath = "/api/v1/releases/electron/manifest"; +const stableReleaseChannel = "1"; +const updateManifestAccept = "application/x-yaml,text/yaml,text/plain,*/*"; export interface SyncOptions { platform: "darwin" | "linux" | "win32"; @@ -47,6 +51,14 @@ export interface RuntimeLock { sha512: string; } +export interface RuntimeManifestResolution { + lock: RuntimeLock; + source: "service" | "static"; + url: string; +} + +type ManifestFetcher = (url: string, init?: RequestInit) => Promise; + export function parseArgs(argv: string[]): SyncOptions { const result: SyncOptions = { platform: "linux", arch: "x64" }; for (let index = 0; index < argv.length; index += 1) { @@ -140,6 +152,23 @@ export function manifestUrl(platform: SyncOptions["platform"], arch: string): st return `${cdnRoot}/update/win/${arch}/latest.yml`; } +export function serviceReleasePlatform(platform: SyncOptions["platform"], arch: string): string { + const releasePlatform = platform === "darwin" + ? "darwin" + : platform === "win32" ? "windows" : "linux"; + const releaseArch = arch === "arm64" + ? "aarch64" + : arch === "x64" ? "x86_64" : arch === "ia32" ? "x86" : arch; + return `${releasePlatform}-${releaseArch}`; +} + +export function serviceManifestUrl(platform: SyncOptions["platform"], arch: string): string { + const url = new URL(updateServiceManifestPath, updateServiceRoot); + url.searchParams.set("platform", serviceReleasePlatform(platform, arch)); + url.searchParams.set("channel", stableReleaseChannel); + return url.href; +} + export function resolveArtifactUrl(manifestHref: string, artifactHref: string): string { return new URL(artifactHref, manifestHref).href; } @@ -154,6 +183,64 @@ export function chooseArtifact(manifest: UpdateManifest, platform: SyncOptions[" return artifact; } +function parseUpdateManifest(contents: string, url: string): UpdateManifest { + const manifest: unknown = parse(contents); + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error(`The update manifest at ${url} is not an object.`); + } + return manifest as UpdateManifest; +} + +async function runtimeLockFromManifest( + options: Pick, + url: string, + artifactBaseUrl: string, + fetcher: ManifestFetcher, + init?: RequestInit +): Promise { + const manifest = parseUpdateManifest(await fetcher(url, init), url); + const artifact = chooseArtifact(manifest, options.platform); + if (manifest.version === undefined) throw new Error("The update manifest does not contain a version."); + return parseRuntimeLock({ + schemaVersion: 1, + appVersion: String(manifest.version), + platform: options.platform, + arch: options.arch, + url: resolveArtifactUrl(artifactBaseUrl, artifact.url), + sha512: artifact.sha512 + }); +} + +export async function resolveLatestRuntimeLock( + options: Pick, + fetcher: ManifestFetcher = fetchText +): Promise { + const serviceUrl = serviceManifestUrl(options.platform, options.arch); + const releasePlatform = serviceReleasePlatform(options.platform, options.arch); + try { + const lock = await runtimeLockFromManifest( + options, + serviceUrl, + new URL("/", serviceUrl).href, + fetcher, + { + headers: { + Accept: updateManifestAccept, + "X-Platform": releasePlatform, + "X-Release-Channel": stableReleaseChannel + } + } + ); + return { lock, source: "service", url: serviceUrl }; + } catch (error) { + const fallbackUrl = manifestUrl(options.platform, options.arch); + const reason = error instanceof Error ? error.message : String(error); + console.warn(`Stable update service manifest failed (${reason}); falling back to ${fallbackUrl}.`); + const lock = await runtimeLockFromManifest(options, fallbackUrl, fallbackUrl, fetcher); + return { lock, source: "static", url: fallbackUrl }; + } +} + export function supportsMultiMessageFileRewind(runtime: string): boolean { return /(?:Array\.isArray\([A-Za-z_$][\w$]*\.targetMessageIds\)|[A-Za-z_$][\w$]*\.targetMessageIds&&[A-Za-z_$][\w$]*\.targetMessageIds\.length>0)/u .test(runtime); @@ -459,8 +546,8 @@ export function patchRuntimeZaiDesktopOAuth(runtime: string): string { return `${runtime.slice(0, insertionPoint)}${branch}${runtime.slice(insertionPoint)}`; } -async function fetchText(url: string): Promise { - const response = await fetch(url, { redirect: "follow" }); +async function fetchText(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { ...init, redirect: "follow" }); if (!response.ok) throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`); return response.text(); } @@ -621,19 +708,11 @@ async function resolveSource(options: SyncOptions, temporaryDirectory: string): return resolveLockedSource(lock, temporaryDirectory); } - const url = manifestUrl(options.platform, options.arch); - const manifest = parse(await fetchText(url)) as UpdateManifest; - const artifact = chooseArtifact(manifest, options.platform); - const artifactUrl = resolveArtifactUrl(url, artifact.url); - if (manifest.version === undefined) throw new Error("The update manifest does not contain a version."); - const candidate = parseRuntimeLock({ - schemaVersion: 1, - appVersion: String(manifest.version), - platform: options.platform, - arch: options.arch, - url: artifactUrl, - sha512: artifact.sha512 - }); + const resolved = await resolveLatestRuntimeLock(options); + const candidate = resolved.lock; + console.log( + `Resolved stable ZCode App ${candidate.appVersion} from the ${resolved.source} manifest ${resolved.url}.` + ); const currentLockPath = join(root, "zcode-runtime.lock.json"); const current = existsSync(currentLockPath) ? parseRuntimeLock(JSON.parse(await readFile(currentLockPath, "utf8"))) diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index 69ceb0d..30ae7fd 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -9,7 +9,10 @@ import { patchRuntimeTuiBridge, patchRuntimeZaiDesktopOAuth, resolveArtifactUrl, + resolveLatestRuntimeLock, selectRuntimeLock, + serviceManifestUrl, + serviceReleasePlatform, supportsMultiMessageFileRewind } from "../scripts/sync-runtime.ts"; import { @@ -196,12 +199,94 @@ describe("runtime synchronization", () => { }); }); - test("manifestUrl maps supported updater channels", () => { + test("maps supported static updater manifests", () => { expect(manifestUrl("linux", "x64")).toMatch(/update\/linux\/x64\/latest-linux\.yml$/); expect(manifestUrl("darwin", "arm64")).toMatch(/update\/mac\/arm64\/latest-mac\.yml$/); expect(manifestUrl("win32", "x64")).toMatch(/update\/win\/x64\/latest\.yml$/); }); + test("maps platforms to the Desktop stable update service", () => { + expect(serviceReleasePlatform("linux", "x64")).toBe("linux-x86_64"); + expect(serviceReleasePlatform("darwin", "arm64")).toBe("darwin-aarch64"); + expect(serviceReleasePlatform("win32", "ia32")).toBe("windows-x86"); + expect(serviceManifestUrl("linux", "x64")).toBe( + "https://zcode.z.ai/api/v1/releases/electron/manifest?platform=linux-x86_64&channel=1" + ); + }); + + test("resolves the latest runtime from the Desktop stable update service", async () => { + const calls: Array<{ init?: RequestInit; url: string }> = []; + const sha512 = Buffer.alloc(64, 7).toString("base64"); + const result = await resolveLatestRuntimeLock( + { platform: "linux", arch: "x64" }, + async (url, init) => { + calls.push({ url, init }); + return JSON.stringify({ + version: "3.7.3", + files: [{ + url: "/zcode/electron/releases/3.7.3/linux-x64/ZCode-3.7.3-linux-x64.deb", + sha512 + }] + }); + } + ); + + expect(result).toEqual({ + source: "service", + url: serviceManifestUrl("linux", "x64"), + lock: { + schemaVersion: 1, + appVersion: "3.7.3", + platform: "linux", + arch: "x64", + url: "https://zcode.z.ai/zcode/electron/releases/3.7.3/linux-x64/ZCode-3.7.3-linux-x64.deb", + sha512 + } + }); + expect(calls).toHaveLength(1); + const headers = new Headers(calls[0]!.init?.headers); + expect(headers.get("Accept")).toContain("application/x-yaml"); + expect(headers.get("X-Platform")).toBe("linux-x86_64"); + expect(headers.get("X-Release-Channel")).toBe("1"); + expect(headers.get("X-Device-Mid")).toBeNull(); + }); + + test("falls back to the static manifest when the service manifest is unusable", async () => { + const calls: string[] = []; + const sha512 = Buffer.alloc(64, 6).toString("base64"); + const result = await resolveLatestRuntimeLock( + { platform: "linux", arch: "x64" }, + async (url) => { + calls.push(url); + if (calls.length === 1) { + return JSON.stringify({ + version: "3.7.3", + files: [{ url: "ZCode.AppImage", sha512 }] + }); + } + return JSON.stringify({ + version: "3.6.5", + files: [{ url: "ZCode-3.6.5-linux-x64.deb", sha512 }] + }); + } + ); + + const fallbackUrl = manifestUrl("linux", "x64"); + expect(calls).toEqual([serviceManifestUrl("linux", "x64"), fallbackUrl]); + expect(result).toEqual({ + source: "static", + url: fallbackUrl, + lock: { + schemaVersion: 1, + appVersion: "3.6.5", + platform: "linux", + arch: "x64", + url: "https://cdn-zcode.z.ai/zcode/electron/releases/update/linux/x64/ZCode-3.6.5-linux-x64.deb", + sha512 + } + }); + }); + test("resolves relative and absolute updater artifact URLs", () => { const manifest = manifestUrl("linux", "x64"); const absolute = "https://cdn-zcode.z.ai/zcode/electron/releases/3.3.6/linux-x64/ZCode.deb";