// scripts/ci/lib/update-policy-schema.mjs // release/update-policy.json 의 엄격한 스키마 검증 (게시 직전 게이트). // // 왜: 클라이언트(apps/desktop/src/main/update-policy.ts)는 모르는 형식의 필드를 버린다. // 예전에는 `"killSwitch": "true"`, `"stagingPercentage": "5"` 같은 오타가 가장 허용적인 // 기본값(킬 스위치 꺼짐, 100% 배포)으로 조용히 바뀌었고, publisher 는 schemaVersion 만 // 보거나(forgejo) 아무것도 보지 않고(updater) 그대로 올렸다. 게시 경로마다 같은 규칙으로 // 막도록 규칙을 여기 한 곳에 둔다 (verify-release-metadata.mjs 의 CI 검사와 같은 규칙). const CHANNELS = ["latest", "beta", "alpha"]; const STABLE_SEMVER = /^\d+\.\d+\.\d+$/; const KNOWN_KEYS = new Set([ "schemaVersion", "defaultChannel", "channels", "minimumSupportedVersion", "forceInstallBelow", "fullInstallOnMajorChange", "fullInstallVersionGap", "stagingPercentage", "killSwitch", ]); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * 모든 필드가 있고 형식이 맞는지 본다. 모르는 필드도 오류로 본다(오타 방지). * @param {unknown} doc * @returns {string[]} 문제 목록 (비면 통과) */ export function validateUpdatePolicyDocument(doc) { if (!isRecord(doc)) return ["update-policy.json must be a JSON object"]; const errors = []; const fail = (condition, message) => { if (!condition) errors.push(message); }; for (const key of Object.keys(doc)) fail(KNOWN_KEYS.has(key), `unknown field "${key}"`); fail(doc.schemaVersion === 1, "schemaVersion must be 1"); fail(CHANNELS.includes(doc.defaultChannel), `defaultChannel must be one of ${CHANNELS.join("/")}`); if (!isRecord(doc.channels)) { errors.push("channels must be an object"); } else { for (const key of Object.keys(doc.channels)) fail(CHANNELS.includes(key), `unknown channel "${key}"`); for (const channel of CHANNELS) { const entry = doc.channels[channel]; fail( isRecord(entry) && typeof entry.allowPrerelease === "boolean" && Object.keys(entry).length === 1, `channels.${channel} must be { "allowPrerelease": boolean }`, ); } } fail( typeof doc.minimumSupportedVersion === "string" && STABLE_SEMVER.test(doc.minimumSupportedVersion), "minimumSupportedVersion must be a stable semver string (x.y.z)", ); fail( doc.forceInstallBelow === null || (typeof doc.forceInstallBelow === "string" && STABLE_SEMVER.test(doc.forceInstallBelow)), "forceInstallBelow must be null or a stable semver string (x.y.z)", ); fail(typeof doc.fullInstallOnMajorChange === "boolean", "fullInstallOnMajorChange must be a boolean"); fail( Number.isSafeInteger(doc.fullInstallVersionGap) && doc.fullInstallVersionGap >= 0, "fullInstallVersionGap must be a non-negative integer", ); fail( Number.isSafeInteger(doc.stagingPercentage) && doc.stagingPercentage >= 0 && doc.stagingPercentage <= 100, "stagingPercentage must be an integer between 0 and 100", ); fail(typeof doc.killSwitch === "boolean", "killSwitch must be a boolean (true/false, not a string)"); return errors; } /** * 원문 바이트/문자열을 파싱해 검증한다. 문제가 있으면 모두 모아 예외를 던진다. * @param {string | Buffer} raw * @param {string} [label] */ export function assertValidUpdatePolicy(raw, label = "update-policy.json") { let doc; try { doc = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw)); } catch (error) { throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } const errors = validateUpdatePolicyDocument(doc); if (errors.length > 0) { throw new Error(`${label} is invalid — refusing to publish:\n${errors.map((error) => ` - ${error}`).join("\n")}`); } return doc; }