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
11 changes: 10 additions & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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):
Expand Down
109 changes: 94 additions & 15 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>;

export function parseArgs(argv: string[]): SyncOptions {
const result: SyncOptions = { platform: "linux", arch: "x64" };
for (let index = 0; index < argv.length; index += 1) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<SyncOptions, "platform" | "arch">,
url: string,
artifactBaseUrl: string,
fetcher: ManifestFetcher,
init?: RequestInit
): Promise<RuntimeLock> {
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<SyncOptions, "platform" | "arch">,
fetcher: ManifestFetcher = fetchText
): Promise<RuntimeManifestResolution> {
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);
Expand Down Expand Up @@ -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<string> {
const response = await fetch(url, { redirect: "follow" });
async function fetchText(url: string, init?: RequestInit): Promise<string> {
const response = await fetch(url, { ...init, redirect: "follow" });
if (!response.ok) throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
return response.text();
}
Expand Down Expand Up @@ -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")))
Expand Down
87 changes: 86 additions & 1 deletion test/sync-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
patchRuntimeTuiBridge,
patchRuntimeZaiDesktopOAuth,
resolveArtifactUrl,
resolveLatestRuntimeLock,
selectRuntimeLock,
serviceManifestUrl,
serviceReleasePlatform,
supportsMultiMessageFileRewind
} from "../scripts/sync-runtime.ts";
import {
Expand Down Expand Up @@ -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";
Expand Down