返回 DeepSeek-Reasonix
release-channels.js
根目录 / site / src / scripts / release-channels.js
1 const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
2 const DESKTOP_DOWNLOAD_PAGE = "https://reasonix.io/?download=desktop#start";
3 const DESKTOP_REQUIRED_ASSETS = [
4 ["platforms", "darwin-arm64", "Reasonix-darwin-arm64.zip"],
5 ["platforms", "darwin-amd64", "Reasonix-darwin-amd64.zip"],
6 ["platforms", "windows-amd64", "Reasonix-windows-amd64-installer.exe"],
7 ["platforms", "windows-arm64", "Reasonix-windows-arm64-installer.exe"],
8 ["platforms", "linux-amd64", "Reasonix-linux-amd64.tar.gz"],
9 ["native_packages", "linux-amd64", "Reasonix-linux-amd64.deb"],
10 ["downloads", "Reasonix-darwin-universal.dmg", "Reasonix-darwin-universal.dmg"],
11 ["downloads", "Reasonix-windows-amd64.zip", "Reasonix-windows-amd64.zip"],
12 ];
13 const DESKTOP_ARCH_DMG_ASSETS = [
14 ["downloads", "Reasonix-darwin-arm64.dmg", "Reasonix-darwin-arm64.dmg"],
15 ["downloads", "Reasonix-darwin-amd64.dmg", "Reasonix-darwin-amd64.dmg"],
16 ];
17 const DESKTOP_ASSETS = [...DESKTOP_REQUIRED_ASSETS, ...DESKTOP_ARCH_DMG_ASSETS];
18 const DESKTOP_ASSET_NAMES = new Set(DESKTOP_ASSETS.map(([, , name]) => name));
19 const OFFICIAL_DESKTOP_RELEASE_TAG = /^(?:desktop-)?(v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))$/;
20 const SHA256 = /^[0-9a-f]{64}$/;
21 const MAX_RELEASE_ASSET_SIZE = 1 << 30;
22
23 // Keep in lockstep with workers/crash-report CLI asset gate.
24 export const CLI_RELEASE_ASSETS = [
25 "reasonix-darwin-amd64.tar.gz",
26 "reasonix-darwin-arm64.tar.gz",
27 "reasonix-linux-amd64.tar.gz",
28 "reasonix-linux-arm64.tar.gz",
29 "reasonix-windows-amd64.zip",
30 "reasonix-windows-arm64.zip",
31 "SHA256SUMS",
32 ];
33 const CLI_RELEASE_ASSET_NAMES = new Set(CLI_RELEASE_ASSETS);
34
35 export const publicReleaseChannels = new Set(["stable"]);
36
37 export function normalizePublicReleaseChannel(_value) {
38 return "stable";
39 }
40
41 export function cliUpgradeCommand(_value) {
42 return "reasonix upgrade";
43 }
44
45 export function releaseVersionLabel(model) {
46 return String(model?.version || "").trim() || "latest";
47 }
48
49 function parsePublicTag(tag) {
50 const value = typeof tag === "string" ? tag : "";
51 let match = value.match(STABLE_TAG);
52 if (match) {
53 return { tag: value, channel: "stable", order: match.slice(1) };
54 }
55 return null;
56 }
57
58 function compareDecimal(left, right) {
59 if (left.length !== right.length) return left.length - right.length;
60 return left === right ? 0 : left > right ? 1 : -1;
61 }
62
63 function compareOrder(left, right) {
64 for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
65 const delta = compareDecimal(left[i] || "0", right[i] || "0");
66 if (delta !== 0) return delta;
67 }
68 return 0;
69 }
70
71 function safeHTTPSURL(value) {
72 if (typeof value !== "string") return null;
73 try {
74 const url = new URL(value);
75 return url.protocol === "https:" &&
76 Boolean(url.hostname) &&
77 !url.username &&
78 !url.password
79 ? url
80 : null;
81 } catch {
82 return null;
83 }
84 }
85
86 function expectedCLIAssetURL(value, tag, name) {
87 if (typeof value !== "string") return null;
88 const url = safeHTTPSURL(value);
89 const path = `/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`;
90 return url &&
91 url.href === value &&
92 url.hostname.toLowerCase() === "github.com" &&
93 !url.port &&
94 url.pathname === path &&
95 !url.search &&
96 !url.hash
97 ? url
98 : null;
99 }
100
101 // Returns the 7 required CLI asset URLs, or null when any required asset is
102 // missing. Never synthesizes download URLs — incomplete releases must be
103 // rejected so the site never advertises 404 links.
104 export function releaseAssetMap(release) {
105 const found = {};
106 const seen = new Set();
107 for (const asset of Array.isArray(release?.assets) ? release.assets : []) {
108 const name = String(asset?.name || "");
109 if (!CLI_RELEASE_ASSET_NAMES.has(name)) continue;
110 if (seen.has(name)) return null;
111 seen.add(name);
112 const url = expectedCLIAssetURL(asset?.browser_download_url, String(release?.tag_name || ""), name);
113 if (
114 name &&
115 url &&
116 Number.isSafeInteger(asset?.size) &&
117 asset.size > 0 &&
118 asset.size <= MAX_RELEASE_ASSET_SIZE
119 ) {
120 found[name] = url.href;
121 }
122 }
123 const assets = {};
124 for (const name of CLI_RELEASE_ASSETS) {
125 if (!found[name]) return null;
126 assets[name] = found[name];
127 }
128 return assets;
129 }
130
131 export function selectCLIRelease(releases, requestedChannel) {
132 const channel = normalizePublicReleaseChannel(requestedChannel);
133 let selected = null;
134 let selectedTag = null;
135 for (const release of Array.isArray(releases) ? releases : []) {
136 const parsed = parsePublicTag(release?.tag_name);
137 if (!parsed || parsed.channel !== channel) continue;
138 if (Boolean(release?.prerelease)) continue;
139 if (!releaseAssetMap(release)) continue;
140 if (!selectedTag || compareOrder(parsed.order, selectedTag.order) > 0) {
141 selected = release;
142 selectedTag = parsed;
143 }
144 }
145 return selected;
146 }
147
148 export function cliReleaseModel(releases, requestedChannel) {
149 const channel = normalizePublicReleaseChannel(requestedChannel);
150 const release = selectCLIRelease(releases, channel);
151 if (!release) return null;
152 const parsed = parsePublicTag(release.tag_name);
153 if (!parsed) return null;
154 const assets = releaseAssetMap(release);
155 if (!assets) return null;
156 const releaseURL = `https://github.com/esengine/DeepSeek-Reasonix/releases/tag/${parsed.tag}`;
157 const exactChangelogURL = `https://reasonix.io/changelog/${parsed.tag}/`;
158 const changelogURL = release.release_notes_url === exactChangelogURL
159 ? exactChangelogURL
160 : "https://reasonix.io/changelog/";
161 return {
162 channel,
163 version: parsed.tag,
164 displayVersion: parsed.tag.slice(1),
165 assets,
166 releaseURL,
167 changelogURL,
168 };
169 }
170
171 function desktopAssetBases(parsed) {
172 const tag = `desktop-${parsed.tag}`;
173 return [
174 `https://dl.reasonix.io/${tag}/`,
175 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/`,
176 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${parsed.tag}/`,
177 ];
178 }
179
180 function normalizeDesktopManifest(manifest, requestedChannel) {
181 const channel = normalizePublicReleaseChannel(requestedChannel);
182 const parsed = parsePublicTag(manifest?.version);
183 if (
184 !parsed ||
185 parsed.channel !== channel ||
186 manifest?.download_page !== DESKTOP_DOWNLOAD_PAGE
187 ) {
188 return null;
189 }
190
191 const allowedBases = desktopAssetBases(parsed);
192 let selectedBase = "";
193 const archDMGCount = DESKTOP_ARCH_DMG_ASSETS.filter(([group, key]) => manifest?.[group]?.[key]).length;
194 if (archDMGCount !== 0 && archDMGCount !== DESKTOP_ARCH_DMG_ASSETS.length) return null;
195 const manifestAssets = archDMGCount === DESKTOP_ARCH_DMG_ASSETS.length
196 ? DESKTOP_ASSETS
197 : DESKTOP_REQUIRED_ASSETS;
198 for (const [group, key, name] of manifestAssets) {
199 const asset = manifest?.[group]?.[key];
200 if (
201 !asset ||
202 typeof asset !== "object" ||
203 Array.isArray(asset) ||
204 !Number.isSafeInteger(asset.size) ||
205 asset.size <= 0 ||
206 asset.size > MAX_RELEASE_ASSET_SIZE ||
207 typeof asset.sha256 !== "string" ||
208 !SHA256.test(asset.sha256)
209 ) {
210 return null;
211 }
212
213 const rawURL = typeof asset.url === "string" ? asset.url : "";
214 const url = safeHTTPSURL(rawURL);
215 const base = allowedBases.find((candidate) => rawURL === candidate + name);
216 if (
217 !url ||
218 !base ||
219 url.href !== rawURL ||
220 asset.sig !== `${rawURL}.minisig` ||
221 (selectedBase && selectedBase !== base)
222 ) {
223 return null;
224 }
225 selectedBase = base;
226 }
227 return selectedBase ? { parsed, manifestAssets } : null;
228 }
229
230 export function desktopReleaseModel(manifest, requestedChannel) {
231 const normalized = normalizeDesktopManifest(manifest, requestedChannel);
232 if (!normalized) return null;
233 const { parsed, manifestAssets } = normalized;
234 const assets = Object.fromEntries(manifestAssets.map(([group, key, name]) => [
235 name,
236 manifest[group][key].url,
237 ]));
238 return {
239 channel: parsed.channel,
240 version: parsed.tag,
241 displayVersion: parsed.tag.slice(1),
242 assets,
243 changelogURL: manifest.release_notes_url === `https://reasonix.io/changelog/${parsed.tag}/`
244 ? manifest.release_notes_url
245 : "https://reasonix.io/changelog/",
246 };
247 }
248
249 // Accept both historical desktop-v* releases and the combined v* release.
250 export function desktopGitHubReleaseModel(release) {
251 const match = typeof release?.tag_name === "string"
252 ? release.tag_name.match(OFFICIAL_DESKTOP_RELEASE_TAG)
253 : null;
254 if (!match || release?.draft !== false || release?.prerelease !== false) return null;
255
256 const tag = release.tag_name;
257 const found = {};
258 const seen = new Set();
259 for (const asset of Array.isArray(release.assets) ? release.assets : []) {
260 const name = String(asset?.name || "");
261 if (!DESKTOP_ASSET_NAMES.has(name)) continue;
262 if (seen.has(name)) return null;
263 seen.add(name);
264 const rawURL = typeof asset?.browser_download_url === "string" ? asset.browser_download_url : "";
265 const url = safeHTTPSURL(rawURL);
266 const expected = `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`;
267 if (
268 !url ||
269 url.href !== rawURL ||
270 rawURL !== expected ||
271 !Number.isSafeInteger(asset?.size) ||
272 asset.size <= 0 ||
273 asset.size > MAX_RELEASE_ASSET_SIZE
274 ) {
275 return null;
276 }
277 found[name] = rawURL;
278 }
279 if (DESKTOP_REQUIRED_ASSETS.some(([, , name]) => !found[name])) return null;
280 const archDMGCount = DESKTOP_ARCH_DMG_ASSETS.filter(([, , name]) => found[name]).length;
281 if (archDMGCount !== 0 && archDMGCount !== DESKTOP_ARCH_DMG_ASSETS.length) return null;
282 const releaseAssets = archDMGCount === DESKTOP_ARCH_DMG_ASSETS.length
283 ? DESKTOP_ASSETS
284 : DESKTOP_REQUIRED_ASSETS;
285
286 return {
287 channel: "stable",
288 version: match[1],
289 displayVersion: match[1].slice(1),
290 assets: Object.fromEntries(releaseAssets.map(([, , name]) => [name, found[name]])),
291 changelogURL: "https://reasonix.io/changelog/",
292 };
293 }
294
295 export async function fetchFirstJSON(urls, fetchImpl = fetch, accept = () => true) {
296 const failures = [];
297 for (const url of urls) {
298 try {
299 const response = await fetchImpl(url, { cache: "no-cache" });
300 if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim());
301 const payload = await response.json();
302 if (!accept(payload)) throw new Error("invalid release data");
303 return payload;
304 } catch (error) {
305 failures.push(`${url}: ${String(error?.message || error)}`);
306 }
307 }
308 throw new Error(`release data unavailable (${failures.join("; ")})`);
309 }
310
311 // Desktop releases published for manual download while the updater stays on its
312 // prior version; scripts/manual-desktop-exception.sh owns the same approval list
313 // for publication. Every entry is probed and the newest one that actually
314 // resolves wins, so adding the next tag before it is published cannot downgrade
315 // the page, and a later signed stable release still supersedes all of them.
316 const MANUAL_DESKTOP_TAGS = ["desktop-v1.38.8", "desktop-v1.38.9"];
317
318 export async function fetchDesktopDownloadModel(fetchImpl = fetch, pinnedVersion = "") {
319 if (pinnedVersion && !parsePublicTag(pinnedVersion)) return null;
320 const acceptsVersion = (model) => Boolean(model && (!pinnedVersion || model.version === pinnedVersion));
321 const load = async (manifestURLs, releaseURL) => {
322 try {
323 return desktopReleaseModel(await fetchFirstJSON(
324 manifestURLs, fetchImpl, (manifest) => acceptsVersion(desktopReleaseModel(manifest)),
325 ));
326 } catch {
327 return desktopGitHubReleaseModel(await fetchFirstJSON(
328 [releaseURL], fetchImpl, (release) => acceptsVersion(desktopGitHubReleaseModel(release)),
329 ));
330 }
331 };
332 if (pinnedVersion) {
333 const tag = `desktop-${pinnedVersion}`;
334 try {
335 return await load(
336 [`https://dl.reasonix.io/${tag}/latest.json`],
337 `https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/tags/${tag}`,
338 );
339 } catch {
340 return null;
341 }
342 }
343 const results = await Promise.allSettled([
344 load([
345 "https://dl.reasonix.io/latest/latest.json",
346 "https://crash.reasonix.io/v1/desktop/releases/stable/latest.json",
347 ], "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/latest"),
348 ...MANUAL_DESKTOP_TAGS.map((tag) => load(
349 [`https://dl.reasonix.io/${tag}/latest.json`],
350 `https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/tags/${tag}`,
351 )),
352 ]);
353 let selected = null;
354 for (const result of results) {
355 if (result.status !== "fulfilled" || !result.value) continue;
356 const model = result.value;
357 if (!selected || compareOrder(parsePublicTag(model.version).order, parsePublicTag(selected.version).order) > 0) {
358 selected = model;
359 }
360 }
361 return selected;
362 }
363
363 lines JAVASCRIPT