// scripts/ci/lib/runtime-feed-gate.mjs // 앱 업데이트(latest.yml)를 게시하기 전에 runtime-latest 가 이 빌드와 맞는지 확인하는 게이트. // // 왜: 앱은 사이드카 엔진을 설치본에 넣지 않고 runtime-latest 에서 받는다. 앱이 // RUNTIME_MIN_VERSION 을 올렸는데 runtime-latest 가 아직 예전 버전이면, 업데이트한 // 사용자는 설치된 엔진도 "낡았다" 고 거부하고 새 엔진도 받지 못해 로컬 STT 가 멈춘다. // 태그 파이프라인(release.yml)과 런타임 파이프라인(portable.yml)은 서로 기다리지 않고, // 로컬 게시(release:updater)도 "런타임 먼저" 가 문서 절차일 뿐이었다. 그래서 두 // publisher 가 latest.yml 을 올리기 전에 이 게이트를 fail-closed 로 돌린다. // // 구성: // 1) parseRuntimeMinVersions — 앱 소스(runtime-index.ts)의 RUNTIME_MIN_VERSION 을 읽는다 (정본은 앱) // 2) evaluateRuntimeFeed — 순수 판정 (문제 목록) // 3) assertRuntimeFeedCompatible — fetch 를 주입받는 IO 어댑터 import { compareSemver, parseSemver } from "./latest-feed-guard.mjs"; /** 앱 쪽 최소 버전 정본 (저장소 루트 기준) */ export const RUNTIME_MIN_VERSION_SOURCE = "apps/desktop/src/main/services/runtime/runtime-index.ts"; /** 자동 업데이트 feed(latest.yml)가 배포하는 데스크톱 빌드의 대상 — electron-builder --win --x64 */ export const WINDOWS_X64_TARGET = Object.freeze({ platform: "win32", arch: "x64" }); /** * runtime-index.ts 소스에서 RUNTIME_MIN_VERSION 객체를 읽는다. * 형식이 바뀌어 읽을 수 없으면 조용히 넘어가지 않고 예외를 던진다. * @param {string} source * @returns {Record} */ export function parseRuntimeMinVersions(source) { const block = /export const RUNTIME_MIN_VERSION\b[^=]*=\s*\{([\s\S]*?)\}/.exec(String(source ?? "")); if (!block) throw new Error("RUNTIME_MIN_VERSION not found in runtime-index.ts"); /** @type {Record} */ const result = {}; for (const match of block[1].matchAll(/^\s*([A-Za-z][\w-]*)\s*:\s*(null|'([^']*)'|"([^"]*)")\s*,?\s*$/gm)) { const value = match[2] === "null" ? null : (match[3] ?? match[4]); if (value !== null && !parseSemver(value)) { throw new Error(`RUNTIME_MIN_VERSION.${match[1]} is not semver: ${value}`); } result[match[1]] = value; } if (Object.keys(result).length === 0) throw new Error("RUNTIME_MIN_VERSION has no components"); return result; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * runtime.json 이 이 빌드의 요구를 만족하는지 판정한다. 비어 있으면 통과. * - version 은 semver 이고, 최소 버전이 있는 모든 구성 요소의 최소 버전 이상이어야 한다. * - 앱이 받는 모든 구성 요소(minVersions 의 키)가 components 에 있어야 한다. * - platform/arch 를 밝혀야 하고, 대상과 같아야 한다 (다른 플랫폼 엔진을 받는 루프 방지). * @param {{ index: unknown, minVersions: Record, target: { platform: string, arch: string } }} input * @returns {string[]} */ export function evaluateRuntimeFeed({ index, minVersions, target }) { if (!isRecord(index)) return ["runtime.json is not an object"]; const problems = []; const version = typeof index.version === "string" ? index.version : null; if (!version || !parseSemver(version)) problems.push(`runtime.json version is not semver: ${String(index.version)}`); if (typeof index.platform !== "string" || typeof index.arch !== "string") { problems.push("runtime.json does not declare platform/arch — republish the runtime with the current build-portable.mjs"); } else if (index.platform !== target.platform || index.arch !== target.arch) { problems.push( `runtime.json targets ${index.platform}-${index.arch}, but this app build is ${target.platform}-${target.arch}`, ); } const components = isRecord(index.components) ? index.components : {}; for (const [component, minimum] of Object.entries(minVersions)) { if (!isRecord(components[component])) { problems.push(`runtime.json has no ${component} component`); continue; } if (minimum !== null && version && parseSemver(version) && compareSemver(version, minimum) < 0) { problems.push( `runtime.json version ${version} is below this build's RUNTIME_MIN_VERSION.${component} ${minimum}`, ); } } return problems; } /** * 게시된 runtime.json 을 읽어 판정하고, 문제가 있으면 예외를 던진다 (fail-closed). * 읽을 수 없어도(404·네트워크·JSON 오류) 통과시키지 않는다. * @param {{ * url: string, * fetchImpl: (url: string, init?: RequestInit) => Promise, * minVersions: Record, * target?: { platform: string, arch: string }, * }} input * @returns {Promise<{ version: string }>} */ export async function assertRuntimeFeedCompatible({ url, fetchImpl, minVersions, target = WINDOWS_X64_TARGET }) { let index; try { const response = await fetchImpl(url, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); index = await response.json(); } catch (error) { throw new Error( `Runtime feed gate: cannot read ${url} (${error instanceof Error ? error.message : String(error)}). ` + "Publish the runtime first (npm run release:portable) — refusing to publish latest.yml.", ); } const problems = evaluateRuntimeFeed({ index, minVersions, target }); if (problems.length > 0) { throw new Error( [ `Runtime feed gate: ${url} cannot serve this app build — refusing to publish latest.yml.`, ...problems.map((problem) => ` - ${problem}`), " Publish the runtime first (npm run release:portable / portable.yml), then rerun this publisher.", ].join("\n"), ); } return { version: /** @type {{ version: string }} */ (index).version }; }