// scripts/ci/lib/latest-feed-guard.mjs // latest feed(electron-updater alias) 보호 정책. // // 버전별 경로(/)는 불변이라 어떤 순서로 게시돼도 안전하지만, latest/ 경로는 // 모든 태그가 공유한다. 오래된 태그의 job이 늦게 끝나거나(재실행·GitLab mirror 재시도) // 하면 latest.yml과 update-policy.json이 이전 버전으로 되돌아간다. 이 모듈은 // 1) 순수 정책 (버전 파싱·비교·게시 여부 결정) // 2) 이미 게시된 latest.yml을 읽는 IO 어댑터 (fetch 주입) // 를 분리해 publisher가 alias를 덮어쓰기 전에 판단할 수 있게 한다. const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; /** * @param {string} value * @returns {{ major: number, minor: number, patch: number, prerelease: string | null } | null} */ export function parseSemver(value) { const match = SEMVER_PATTERN.exec(String(value ?? "").trim().replace(/^v/, "")); if (!match) return null; return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null, }; } /** * SemVer 비교. ab → 양수. * 같은 core면 정식 버전이 prerelease보다 크다. 파싱할 수 없으면 예외. * @param {string} a * @param {string} b */ export function compareSemver(a, b) { const left = parseSemver(a); const right = parseSemver(b); if (!left || !right) throw new Error(`Cannot compare non-semver versions: ${a} vs ${b}`); for (const key of /** @type {const} */ (["major", "minor", "patch"])) { if (left[key] !== right[key]) return left[key] - right[key]; } if (left.prerelease === right.prerelease) return 0; if (left.prerelease === null) return 1; if (right.prerelease === null) return -1; return left.prerelease < right.prerelease ? -1 : 1; } /** * electron-builder update metadata(latest.yml)의 최상위 `version:` 값을 읽는다. * @param {string} yamlText * @returns {string | null} */ export function parseUpdateMetadataVersion(yamlText) { const match = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/m.exec(String(yamlText ?? "")); if (!match) return null; return parseSemver(match[1]) ? match[1].replace(/^v/, "") : null; } /** * latest alias(latest.yml + update-policy.json)를 갱신할지 결정하는 순수 정책. * * - 게시된 feed가 없음(publishedVersion === undefined) → 갱신 (첫 게시) * - 게시된 latest.yml을 파싱할 수 없음(null) → 갱신 (깨진 feed는 복구 대상) * - 게시된 버전 > 이번 버전 → 건너뜀 (이전 태그의 재실행/지연 완료가 되돌리는 것 방지) * - 게시된 버전 = 이번 버전 → 갱신 (같은 태그 재실행: 부분 실패한 alias/policy 복구, 바이트 동일) * - 게시된 버전 < 이번 버전 → 갱신 * * @param {{ publishingVersion: string, publishedVersion: string | null | undefined }} input * @returns {{ update: boolean, reason: "no-feed" | "unreadable-feed" | "newer-published" | "same-version" | "older-published" }} */ export function decideLatestFeedUpdate({ publishingVersion, publishedVersion }) { if (!parseSemver(publishingVersion)) { throw new Error(`Publishing version is not semver: ${publishingVersion}`); } if (publishedVersion === undefined) return { update: true, reason: "no-feed" }; if (publishedVersion === null || !parseSemver(publishedVersion)) { return { update: true, reason: "unreadable-feed" }; } const order = compareSemver(publishedVersion, publishingVersion); if (order > 0) return { update: false, reason: "newer-published" }; if (order === 0) return { update: true, reason: "same-version" }; return { update: true, reason: "older-published" }; } /** * latest alias 갱신 유스케이스. IO는 포트로 주입받는다. * 판단 결과가 "건너뜀"이면 어떤 파일도 업로드하지 않는다. * * @template T * @param {{ * publishingVersion: string, * files: readonly T[], * readPublishedVersion: () => Promise, * upload: (file: T) => Promise, * }} input files는 게시 순서대로 (설치 자산 → metadata → policy) * @returns {Promise<{ update: boolean, reason: string, publishedVersion: string | null | undefined }>} */ export async function publishLatestAlias({ publishingVersion, files, readPublishedVersion, upload }) { const publishedVersion = await readPublishedVersion(); const decision = decideLatestFeedUpdate({ publishingVersion, publishedVersion }); if (decision.update) { for (const file of files) await upload(file); } return { ...decision, publishedVersion }; } /** * 이미 게시된 latest.yml의 버전을 읽는 IO 어댑터. * - 404 → undefined (feed 없음) * - 200 → 파싱된 버전 또는 null (파싱 불가) * - 그 외 HTTP/네트워크 오류 → 예외 (fail-closed: 판단할 수 없으면 alias를 건드리지 않는다) * * @param {{ url: string, fetchImpl: (url: string, init?: RequestInit) => Promise }} deps * @returns {Promise} */ export async function readPublishedFeedVersion({ url, fetchImpl }) { const separator = url.includes("?") ? "&" : "?"; const response = await fetchImpl(`${url}${separator}ts=${Date.now()}`, { cache: "no-store" }); if (response.status === 404) return undefined; if (!response.ok) { throw new Error( `Cannot read the published latest feed (${url}): HTTP ${response.status}. ` + "Refusing to overwrite the latest alias without knowing its version.", ); } return parseUpdateMetadataVersion(await response.text()); }