fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
27
scripts/ci/lib/forgejo-generic-file-delete.mjs
Normal file
27
scripts/ci/lib/forgejo-generic-file-delete.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// scripts/ci/lib/forgejo-generic-file-delete.mjs
|
||||
// Forgejo generic registry 어댑터에 파일 단위 삭제(PackageRegistry.deleteFile)를 더한다.
|
||||
//
|
||||
// 별칭(*-latest)을 패키지 버전째 지우면 인덱스가 수백 MB 업로드 내내 404가 된다.
|
||||
// Forgejo generic registry는 파일 하나만 지우는 엔드포인트
|
||||
// DELETE /api/packages/{owner}/generic/{name}/{version}/{file}
|
||||
// 를 제공하므로, 바뀐 파일만 DELETE→PUT 해 404 구간을 파일 하나 교체 시간으로 줄인다.
|
||||
// 404는 이미 없는 것이므로 성공으로 본다. 그 밖의 오류는 예외(fail-closed).
|
||||
|
||||
/**
|
||||
* @template {{ fileUrl: (versionPath: string, name: string) => string }} R
|
||||
* @param {R} registry
|
||||
* @param {{ fetchImpl: (url: string, init?: RequestInit) => Promise<Response> }} deps
|
||||
* @returns {R & { deleteFile: (versionPath: string, name: string) => Promise<void> }}
|
||||
*/
|
||||
export function withFileDeletion(registry, { fetchImpl }) {
|
||||
return {
|
||||
...registry,
|
||||
async deleteFile(versionPath, name) {
|
||||
const url = registry.fileUrl(versionPath, name);
|
||||
const response = await fetchImpl(url, { method: "DELETE" });
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`파일 삭제 실패 (HTTP ${response.status}): ${url}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
110
scripts/ci/lib/portable-alias-plan.mjs
Normal file
110
scripts/ci/lib/portable-alias-plan.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// scripts/ci/lib/portable-alias-plan.mjs
|
||||
// *-latest 별칭 게시의 순수 정책 (IO 없음). 유스케이스는 ./portable-publish-policy.mjs.
|
||||
//
|
||||
// 별칭 교체 원칙
|
||||
// - 패키지 버전 전체를 지우지 않는다. 바뀐 파일만 파일 단위로 DELETE→PUT 한다
|
||||
// (Forgejo는 같은 이름 덮어쓰기를 409로 거부한다). 404 구간은 파일 하나를 바꾸는 수 초뿐이다.
|
||||
// - 인덱스(runtime.json / portable.json)는 항상 마지막에 바꾼다 — 인덱스 교체가 커밋 지점이다.
|
||||
// 새 부품을 올리는 동안에는 옛 인덱스와 옛 부품이 그대로 살아 있다.
|
||||
// - 새 인덱스가 올라간 뒤에만 별칭 집합에 없는 파일(이전 버전 부품, 예전에 별칭에 올리던 볼륨)을 정리한다.
|
||||
// - 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다. 버전 경로 URL로 참조되는 부품은 올리지 않는다.
|
||||
|
||||
/**
|
||||
* @typedef {import("./portable-publish-policy.mjs").HashedPayload} HashedPayload
|
||||
* @typedef {{ item: HashedPayload, replace: boolean }} AliasWrite
|
||||
* @typedef {{ action: "skip" | "update", writes: AliasWrite[], prunes: string[] }} AliasPlan
|
||||
*/
|
||||
|
||||
export class AliasSelectionError extends Error {
|
||||
/** @param {string} message */
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "AliasSelectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 별칭에 올릴 항목을 고른다. aliasNames가 없으면 모든 payload(하위 호환).
|
||||
* payload 순서를 유지하되 인덱스는 맨 뒤로 보낸다(커밋 지점).
|
||||
*
|
||||
* @param {{ items: readonly HashedPayload[], indexName: string, aliasNames?: readonly string[] }} input
|
||||
* @returns {HashedPayload[]}
|
||||
*/
|
||||
export function selectAliasItems({ items, indexName, aliasNames }) {
|
||||
let selected = [...items];
|
||||
if (aliasNames !== undefined) {
|
||||
const wanted = new Set(aliasNames);
|
||||
const known = new Set(items.map((item) => item.name));
|
||||
const unknown = [...wanted].filter((name) => !known.has(name));
|
||||
if (unknown.length > 0) {
|
||||
throw new AliasSelectionError(`별칭 파일이 게시 payload에 없습니다: ${unknown.join(", ")}`);
|
||||
}
|
||||
if (!wanted.has(indexName)) {
|
||||
throw new AliasSelectionError(`별칭에는 인덱스(${indexName})가 있어야 합니다.`);
|
||||
}
|
||||
selected = items.filter((item) => wanted.has(item.name));
|
||||
}
|
||||
return [
|
||||
...selected.filter((item) => item.name !== indexName),
|
||||
...selected.filter((item) => item.name === indexName),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 별칭 파일 단위 교체 계획.
|
||||
* - writes: 원격과 바이트가 다른 항목(항목 순서 유지). replace=true면 같은 이름이 원격에 있어 먼저 지워야 한다.
|
||||
* - prunes: 원격 별칭에만 있는 파일. 모든 writes(인덱스 포함)가 끝난 뒤에 지운다.
|
||||
*
|
||||
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
|
||||
* @returns {AliasPlan}
|
||||
*/
|
||||
export function planAliasFiles({ items, remoteHashes }) {
|
||||
const writes = items
|
||||
.filter((item) => remoteHashes.get(item.name) !== item.sha256)
|
||||
.map((item) => ({ item, replace: remoteHashes.has(item.name) }));
|
||||
const keep = new Set(items.map((item) => item.name));
|
||||
const prunes = [...remoteHashes.keys()].filter((name) => !keep.has(name));
|
||||
const action = writes.length > 0 || prunes.length > 0 ? "update" : "skip";
|
||||
return { action, writes, prunes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 경로가 이미 완성돼 있는데(재빌드 바이트가 달라 버전 단계가 abort) 별칭을 그 게시본으로
|
||||
* 복구할 수 있는지 판단한다. 원격 버전 경로의 바이트가 정본이다.
|
||||
* - 별칭 항목이 버전 경로에 하나라도 없으면 복구 불가(버전 경로 미완성).
|
||||
* - 별칭에 이미 같은 sha256으로 있는 항목은 그대로 둔다(retain).
|
||||
* - 다른 항목은 텍스트(인덱스·설치 스크립트)일 때만 버전 경로에서 읽어 옮길 수 있다(reads).
|
||||
* 이진 부품이 어긋나 있으면 복구 불가.
|
||||
*
|
||||
* @param {{
|
||||
* aliasItems: readonly HashedPayload[],
|
||||
* versionedHashes: ReadonlyMap<string, string>,
|
||||
* aliasHashes: ReadonlyMap<string, string>,
|
||||
* }} input
|
||||
* @returns {{ restorable: false, reason: string } |
|
||||
* { restorable: true, reads: Array<{ name: string, contentType: string, sha256: string }>, prunes: string[] }}
|
||||
*/
|
||||
export function planAliasRestore({ aliasItems, versionedHashes, aliasHashes }) {
|
||||
const missing = aliasItems.filter((item) => !versionedHashes.has(item.name));
|
||||
if (missing.length > 0) {
|
||||
return { restorable: false, reason: `버전 경로에 없음: ${missing.map((item) => item.name).join(", ")}` };
|
||||
}
|
||||
/** @type {Array<{ name: string, contentType: string, sha256: string }>} */
|
||||
const reads = [];
|
||||
for (const item of aliasItems) {
|
||||
const sha256 = /** @type {string} */ (versionedHashes.get(item.name));
|
||||
if (aliasHashes.get(item.name) === sha256) continue;
|
||||
if (!isTextContentType(item.contentType)) {
|
||||
return { restorable: false, reason: `이진 파일은 게시본에서 옮길 수 없음: ${item.name}` };
|
||||
}
|
||||
reads.push({ name: item.name, contentType: item.contentType, sha256 });
|
||||
}
|
||||
const keep = new Set(aliasItems.map((item) => item.name));
|
||||
const prunes = [...aliasHashes.keys()].filter((name) => !keep.has(name));
|
||||
return { restorable: true, reads, prunes };
|
||||
}
|
||||
|
||||
/** @param {string} contentType */
|
||||
export function isTextContentType(contentType) {
|
||||
return contentType === "application/json" || contentType.startsWith("text/");
|
||||
}
|
||||
423
scripts/ci/lib/portable-publish-alias.test.mjs
Normal file
423
scripts/ci/lib/portable-publish-alias.test.mjs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// node --test scripts/ci/lib/portable-publish-alias.test.mjs
|
||||
// *-latest 별칭 교체 회귀: 패키지째 지우고 부품을 다시 올리는 동안 인덱스가 404가 되던 문제,
|
||||
// 중간 실패 후 CI 재실행(재빌드 바이트 → 버전 경로 abort)으로 별칭이 복구되지 않던 문제.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { withFileDeletion } from "./forgejo-generic-file-delete.mjs";
|
||||
import {
|
||||
hashPayloads,
|
||||
planAliasFiles,
|
||||
planAliasRestore,
|
||||
PortablePublishError,
|
||||
publishPortablePackages,
|
||||
selectAliasItems,
|
||||
} from "./portable-publish-policy.mjs";
|
||||
|
||||
const sha = (text) => createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
||||
const payload = (name, text, contentType = "application/octet-stream") => ({
|
||||
name,
|
||||
bytes: Buffer.from(text, "utf8"),
|
||||
contentType,
|
||||
});
|
||||
const runtimeJson = (version, generatedAt = "2026-09-20T00:00:00.000Z") =>
|
||||
JSON.stringify({ schemaVersion: 1, version, generatedAt });
|
||||
const portableJson = (version) => JSON.stringify({ version });
|
||||
const runtimeIndex = (version, generatedAt) =>
|
||||
payload("runtime.json", runtimeJson(version, generatedAt), "application/json");
|
||||
const portableIndex = (version) => payload("portable.json", portableJson(version), "application/json");
|
||||
const installer = (text = "irm") => payload("install-d3ro-voice.ps1", text, "text/plain");
|
||||
|
||||
/**
|
||||
* Forgejo 동작을 흉내 내는 메모리 registry: 같은 이름 PUT은 409, 파일/버전 단위 삭제 지원.
|
||||
* 모든 쓰기 직후 watch 경로의 존재 여부를 timeline 에 기록한다.
|
||||
*/
|
||||
function forgejoLikeRegistry(initial = {}, { watch = [], failUpload } = {}) {
|
||||
const packages = new Map(
|
||||
Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]),
|
||||
);
|
||||
const calls = [];
|
||||
const timeline = [];
|
||||
const snapshot = (op) =>
|
||||
timeline.push({
|
||||
op,
|
||||
present: Object.fromEntries(
|
||||
watch.map((key) => {
|
||||
const [path, name] = key.split("/");
|
||||
return [key, packages.get(path)?.has(name) ?? false];
|
||||
}),
|
||||
),
|
||||
});
|
||||
return {
|
||||
packages,
|
||||
calls,
|
||||
timeline,
|
||||
async listFileHashes(versionPath) {
|
||||
calls.push(["list", versionPath]);
|
||||
const files = packages.get(versionPath);
|
||||
return new Map([...(files ?? new Map())].map(([name, text]) => [name, sha(text)]));
|
||||
},
|
||||
async deleteVersion(versionPath) {
|
||||
calls.push(["deleteVersion", versionPath]);
|
||||
packages.delete(versionPath);
|
||||
snapshot(`deleteVersion ${versionPath}`);
|
||||
},
|
||||
async deleteFile(versionPath, name) {
|
||||
calls.push(["deleteFile", versionPath, name]);
|
||||
const files = packages.get(versionPath);
|
||||
files?.delete(name);
|
||||
if (files && files.size === 0) packages.delete(versionPath);
|
||||
snapshot(`deleteFile ${versionPath}/${name}`);
|
||||
},
|
||||
async uploadFile(versionPath, file) {
|
||||
calls.push(["upload", versionPath, file.name]);
|
||||
if (failUpload?.(versionPath, file.name)) throw new Error(`HTTP 524 ${versionPath}/${file.name}`);
|
||||
const files = packages.get(versionPath) ?? new Map();
|
||||
if (files.has(file.name)) throw new Error(`409 conflict ${versionPath}/${file.name}`);
|
||||
files.set(file.name, Buffer.from(file.bytes).toString("utf8"));
|
||||
packages.set(versionPath, files);
|
||||
snapshot(`upload ${versionPath}/${file.name}`);
|
||||
},
|
||||
async readTextFile(versionPath, name) {
|
||||
calls.push(["read", versionPath, name]);
|
||||
return packages.get(versionPath)?.get(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RUNTIME_PARTS = (tag) => [
|
||||
payload("d3ro-runtime-sidecar.tar.gz.001", `sidecar-1-${tag}`),
|
||||
payload("d3ro-runtime-sidecar.tar.gz.002", `sidecar-2-${tag}`),
|
||||
payload("d3ro-runtime-ffmpeg.tar.gz.001", `ffmpeg-${tag}`),
|
||||
];
|
||||
|
||||
const publishRuntime = (registry, version, runtimePayloads) =>
|
||||
publishPortablePackages({
|
||||
version,
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [
|
||||
{ kind: "runtime", indexName: "runtime.json", payloads: runtimePayloads, aliasNames: ["runtime.json"] },
|
||||
],
|
||||
});
|
||||
|
||||
// ── 순수 정책 ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("selectAliasItems keeps only alias files and moves the index to the end", () => {
|
||||
const items = hashPayloads([portableIndex("1.9.1"), payload("a.zip.001", "z"), payload("a.7z.001", "v"), installer()]);
|
||||
const selected = selectAliasItems({
|
||||
items,
|
||||
indexName: "portable.json",
|
||||
aliasNames: ["portable.json", "a.zip.001", "install-d3ro-voice.ps1"],
|
||||
});
|
||||
assert.deepEqual(
|
||||
selected.map((item) => item.name),
|
||||
["a.zip.001", "install-d3ro-voice.ps1", "portable.json"],
|
||||
);
|
||||
assert.throws(
|
||||
() => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["a.zip.001"] }),
|
||||
/portable\.json/,
|
||||
);
|
||||
assert.throws(
|
||||
() => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["portable.json", "nope"] }),
|
||||
/nope/,
|
||||
);
|
||||
});
|
||||
|
||||
test("planAliasFiles swaps only changed files and prunes stale ones", () => {
|
||||
const items = hashPayloads([payload("new.zip.001", "n"), installer("same"), portableIndex("1.9.1")]);
|
||||
const plan = planAliasFiles({
|
||||
items,
|
||||
remoteHashes: new Map([
|
||||
["old.zip.001", sha("o")],
|
||||
["install-d3ro-voice.ps1", sha("same")],
|
||||
["portable.json", sha(portableJson("1.9.0"))],
|
||||
]),
|
||||
});
|
||||
assert.equal(plan.action, "update");
|
||||
assert.deepEqual(
|
||||
plan.writes.map(({ item, replace }) => [item.name, replace]),
|
||||
[["new.zip.001", false], ["portable.json", true]],
|
||||
);
|
||||
assert.deepEqual(plan.prunes, ["old.zip.001"]);
|
||||
assert.equal(planAliasFiles({ items, remoteHashes: new Map(items.map((i) => [i.name, i.sha256])) }).action, "skip");
|
||||
});
|
||||
|
||||
test("planAliasRestore only restores from a complete versioned package and never copies binaries", () => {
|
||||
const aliasItems = hashPayloads([payload("a.zip.001", "rebuilt-zip"), runtimeIndex("1.9.1", "B")]);
|
||||
const versionedHashes = new Map([
|
||||
["a.zip.001", sha("zip")],
|
||||
["runtime.json", sha(runtimeJson("1.9.1", "A"))],
|
||||
]);
|
||||
// 별칭에 같은 zip이 이미 있으면 인덱스만 옮기면 된다.
|
||||
const ok = planAliasRestore({
|
||||
aliasItems,
|
||||
versionedHashes,
|
||||
aliasHashes: new Map([["a.zip.001", sha("zip")], ["stale", sha("s")]]),
|
||||
});
|
||||
assert.equal(ok.restorable, true);
|
||||
assert.deepEqual(ok.reads.map((read) => [read.name, read.sha256]), [["runtime.json", sha(runtimeJson("1.9.1", "A"))]]);
|
||||
assert.deepEqual(ok.prunes, ["stale"]);
|
||||
// 이진 부품이 어긋나 있으면 복구하지 않는다.
|
||||
assert.equal(planAliasRestore({ aliasItems, versionedHashes, aliasHashes: new Map() }).restorable, false);
|
||||
// 버전 경로 미완성(인덱스 없음)이면 복구하지 않는다.
|
||||
assert.equal(
|
||||
planAliasRestore({
|
||||
aliasItems,
|
||||
versionedHashes: new Map([["a.zip.001", sha("zip")]]),
|
||||
aliasHashes: new Map([["a.zip.001", sha("zip")]]),
|
||||
}).restorable,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ── 유스케이스: 404 구간 ──────────────────────────────────────────────────
|
||||
|
||||
test("a tag bump never deletes runtime-latest nor re-uploads parts; runtime.json is missing only for its own swap", async () => {
|
||||
const registry = forgejoLikeRegistry(
|
||||
{
|
||||
"runtime-latest": {
|
||||
"d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part",
|
||||
"runtime.json": runtimeJson("1.9.0"),
|
||||
},
|
||||
},
|
||||
{ watch: ["runtime-latest/runtime.json"] },
|
||||
);
|
||||
const result = await publishRuntime(registry, "1.9.1", [...RUNTIME_PARTS("191"), runtimeIndex("1.9.1", "2026-09-28T00:00:00.000Z")]);
|
||||
|
||||
assert.deepEqual(result, { versioned: { runtime: "uploaded" }, aliases: { runtime: "replaced" } });
|
||||
assert.equal(registry.calls.some(([op]) => op === "deleteVersion"), false);
|
||||
assert.equal(
|
||||
registry.calls.some(([op, path, name]) => op === "upload" && path === "runtime-latest" && name !== "runtime.json"),
|
||||
false,
|
||||
"parts must not be re-uploaded to the alias — runtime.json points at runtime-<version>",
|
||||
);
|
||||
const gap = registry.timeline.filter((entry) => !entry.present["runtime-latest/runtime.json"]);
|
||||
assert.deepEqual(
|
||||
gap.map((entry) => entry.op),
|
||||
["deleteFile runtime-latest/runtime.json"],
|
||||
"the only write while the index is missing is the index swap itself",
|
||||
);
|
||||
assert.deepEqual([...registry.packages.get("runtime-latest").keys()], ["runtime.json"]);
|
||||
assert.equal(
|
||||
registry.packages.get("runtime-latest").get("runtime.json"),
|
||||
runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
test("portable-latest keeps the old index and installer live while new zip parts upload, prunes after the swap", async () => {
|
||||
const registry = forgejoLikeRegistry(
|
||||
{
|
||||
"portable-latest": {
|
||||
"D3RO-Voice-1.9.0-x64-portable.7z.001": "legacy-volume",
|
||||
"D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190",
|
||||
"install-d3ro-voice.ps1": "irm-old",
|
||||
"portable.json": portableJson("1.9.0"),
|
||||
},
|
||||
},
|
||||
{ watch: ["portable-latest/portable.json", "portable-latest/install-d3ro-voice.ps1"] },
|
||||
);
|
||||
await publishPortablePackages({
|
||||
version: "1.9.1",
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [
|
||||
{
|
||||
kind: "portable",
|
||||
indexName: "portable.json",
|
||||
payloads: [
|
||||
payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "vol191"),
|
||||
payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"),
|
||||
installer("irm-new"),
|
||||
portableIndex("1.9.1"),
|
||||
],
|
||||
aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const aliasOps = registry.timeline.map((entry) => entry.op).filter((op) => op.includes("portable-latest"));
|
||||
assert.deepEqual(aliasOps, [
|
||||
"upload portable-latest/D3RO-Voice-1.9.1-x64-portable.zip.001",
|
||||
"deleteFile portable-latest/install-d3ro-voice.ps1",
|
||||
"upload portable-latest/install-d3ro-voice.ps1",
|
||||
"deleteFile portable-latest/portable.json",
|
||||
"upload portable-latest/portable.json",
|
||||
"deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.7z.001",
|
||||
"deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.zip.001",
|
||||
]);
|
||||
const aliasFiles = Object.fromEntries(registry.packages.get("portable-latest"));
|
||||
assert.deepEqual(aliasFiles, {
|
||||
"D3RO-Voice-1.9.1-x64-portable.zip.001": "zip191",
|
||||
"install-d3ro-voice.ps1": "irm-new",
|
||||
"portable.json": portableJson("1.9.1"),
|
||||
});
|
||||
});
|
||||
|
||||
test("an upload failure mid-alias leaves the previous index serving", async () => {
|
||||
const registry = forgejoLikeRegistry(
|
||||
{
|
||||
"portable-latest": {
|
||||
"D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190",
|
||||
"install-d3ro-voice.ps1": "irm",
|
||||
"portable.json": portableJson("1.9.0"),
|
||||
},
|
||||
},
|
||||
{ failUpload: (path, name) => path === "portable-latest" && name.endsWith(".zip.001") },
|
||||
);
|
||||
await assert.rejects(
|
||||
publishPortablePackages({
|
||||
version: "1.9.1",
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [
|
||||
{
|
||||
kind: "portable",
|
||||
indexName: "portable.json",
|
||||
payloads: [payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), installer("irm"), portableIndex("1.9.1")],
|
||||
aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
/HTTP 524/,
|
||||
);
|
||||
assert.equal(registry.packages.get("portable-latest").get("portable.json"), portableJson("1.9.0"));
|
||||
assert.equal(registry.packages.get("portable-latest").get("D3RO-Voice-1.9.0-x64-portable.zip.001"), "zip190");
|
||||
});
|
||||
|
||||
// ── 유스케이스: 재실행 복구 ────────────────────────────────────────────────
|
||||
|
||||
test("a CI re-run with rebuilt bytes restores an alias left without its index, then still fails closed", async () => {
|
||||
const publishedIndex = runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z");
|
||||
const registry = forgejoLikeRegistry({
|
||||
"runtime-1.9.1": {
|
||||
"d3ro-runtime-sidecar.tar.gz.001": "sidecar-original",
|
||||
"runtime.json": publishedIndex,
|
||||
},
|
||||
// 이전 실행이 runtime.json DELETE 뒤 PUT에서 끊겼다.
|
||||
"runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part" },
|
||||
});
|
||||
await assert.rejects(
|
||||
publishRuntime(registry, "1.9.1", [
|
||||
payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"),
|
||||
runtimeIndex("1.9.1", "2026-09-28T01:00:00.000Z"),
|
||||
]),
|
||||
(error) =>
|
||||
error instanceof PortablePublishError &&
|
||||
/runtime-1\.9\.1/.test(error.message) &&
|
||||
/runtime-latest/.test(error.message),
|
||||
);
|
||||
assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), publishedIndex);
|
||||
assert.equal(registry.packages.get("runtime-latest").has("d3ro-runtime-sidecar.tar.gz.001"), false);
|
||||
assert.equal(
|
||||
registry.calls.some(([op, path]) => op !== "list" && op !== "read" && path === "runtime-1.9.1"),
|
||||
false,
|
||||
"the immutable versioned package is never written",
|
||||
);
|
||||
assert.equal(registry.packages.get("runtime-1.9.1").get("d3ro-runtime-sidecar.tar.gz.001"), "sidecar-original");
|
||||
});
|
||||
|
||||
test("re-run recovery never rolls back a newer alias nor restores from an incomplete versioned package", async () => {
|
||||
const newer = runtimeJson("1.9.2");
|
||||
const rolledForward = forgejoLikeRegistry({
|
||||
"runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p", "runtime.json": runtimeJson("1.9.1", "A") },
|
||||
"runtime-latest": { "runtime.json": newer },
|
||||
});
|
||||
await assert.rejects(
|
||||
publishRuntime(rolledForward, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]),
|
||||
PortablePublishError,
|
||||
);
|
||||
assert.equal(rolledForward.packages.get("runtime-latest").get("runtime.json"), newer);
|
||||
assert.equal(rolledForward.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false);
|
||||
|
||||
const incomplete = forgejoLikeRegistry({
|
||||
"runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p" },
|
||||
"runtime-latest": { "runtime.json": runtimeJson("1.9.0") },
|
||||
});
|
||||
await assert.rejects(
|
||||
publishRuntime(incomplete, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]),
|
||||
PortablePublishError,
|
||||
);
|
||||
assert.equal(incomplete.packages.get("runtime-latest").get("runtime.json"), runtimeJson("1.9.0"));
|
||||
assert.equal(incomplete.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false);
|
||||
});
|
||||
|
||||
test("a conflict in one package blocks every versioned upload (no partial new version)", async () => {
|
||||
const registry = forgejoLikeRegistry({
|
||||
"portable-1.9.1": { "D3RO-Voice-1.9.1-x64-portable.7z.001": "scoop-pinned" },
|
||||
});
|
||||
await assert.rejects(
|
||||
publishPortablePackages({
|
||||
version: "1.9.1",
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [
|
||||
{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] },
|
||||
{ kind: "portable", indexName: "portable.json", payloads: [payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "rebuilt"), portableIndex("1.9.1")] },
|
||||
],
|
||||
}),
|
||||
/portable-1\.9\.1/,
|
||||
);
|
||||
assert.equal(registry.calls.some(([op]) => op === "upload"), false);
|
||||
});
|
||||
|
||||
test("an alias set without the index is rejected before any IO", async () => {
|
||||
const registry = forgejoLikeRegistry();
|
||||
await assert.rejects(
|
||||
publishPortablePackages({
|
||||
version: "1.9.1",
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["p"] }],
|
||||
}),
|
||||
(error) => error instanceof PortablePublishError && /runtime\.json/.test(error.message),
|
||||
);
|
||||
assert.equal(registry.calls.length, 0);
|
||||
});
|
||||
|
||||
test("dry run lists only alias files for the alias path", async () => {
|
||||
const lines = [];
|
||||
await publishPortablePackages({
|
||||
version: "1.9.1",
|
||||
registry: forgejoLikeRegistry(),
|
||||
dryRun: true,
|
||||
log: (line) => lines.push(line),
|
||||
packages: [
|
||||
{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(lines.map((line) => line.split(" ")[2]), ["runtime-1.9.1/p", "runtime-1.9.1/runtime.json", "runtime-latest/runtime.json"]);
|
||||
});
|
||||
|
||||
// ── IO 어댑터 / 조립 ──────────────────────────────────────────────────────
|
||||
|
||||
test("withFileDeletion deletes a single file and fails closed on non-404 errors", async () => {
|
||||
const seen = [];
|
||||
const statuses = { "a.json": 204, "gone.json": 404, "boom.json": 500 };
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
seen.push(`${init.method} ${url}`);
|
||||
return new Response(null, { status: statuses[url.split("/").pop()] });
|
||||
};
|
||||
const base = { fileUrl: (path, name) => `https://feed.test/${path}/${name}`, marker: 1 };
|
||||
const registry = withFileDeletion(base, { fetchImpl });
|
||||
assert.equal(registry.marker, 1);
|
||||
await registry.deleteFile("runtime-latest", "a.json");
|
||||
await registry.deleteFile("runtime-latest", "gone.json");
|
||||
await assert.rejects(registry.deleteFile("runtime-latest", "boom.json"), /HTTP 500/);
|
||||
assert.deepEqual(seen, [
|
||||
"DELETE https://feed.test/runtime-latest/a.json",
|
||||
"DELETE https://feed.test/runtime-latest/gone.json",
|
||||
"DELETE https://feed.test/runtime-latest/boom.json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the portable publisher swaps alias files individually and keeps parts off runtime-latest", () => {
|
||||
const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8");
|
||||
assert.match(source, /withFileDeletion\(/);
|
||||
assert.match(source, /aliasNames:\s*\[\s*'runtime\.json'\s*\]/);
|
||||
assert.match(source, /aliasNames:\s*portableAliasNames/);
|
||||
// 인덱스가 버전 경로에서도 마지막(설치 스크립트 뒤)이어야 재실행 복구가 완성 여부를 판단할 수 있다.
|
||||
assert.ok(source.indexOf("name: 'install-d3ro-voice.ps1'") < source.indexOf("name: 'portable.json'"));
|
||||
assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/);
|
||||
});
|
||||
|
|
@ -7,29 +7,45 @@
|
|||
// 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다.
|
||||
// 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만
|
||||
// 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed).
|
||||
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 완성된 뒤에만, 그리고
|
||||
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 모두 완성된 뒤에만, 그리고
|
||||
// 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만
|
||||
// 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed).
|
||||
// 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다
|
||||
// (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다.
|
||||
// - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가
|
||||
// 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다.
|
||||
//
|
||||
// 구조
|
||||
// 1) 순수 정책: planVersionedPackage / planAliasPackage / parsePublishedIndexVersion /
|
||||
// decideAliasUpdate
|
||||
// 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate
|
||||
// + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore)
|
||||
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
|
||||
// IO 어댑터는 ./forgejo-generic-registry.mjs 에 있다.
|
||||
// IO 어댑터는 ./forgejo-generic-registry.mjs (+ ./forgejo-generic-file-delete.mjs) 에 있다.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.mjs";
|
||||
import {
|
||||
AliasSelectionError,
|
||||
planAliasFiles,
|
||||
planAliasRestore,
|
||||
selectAliasItems,
|
||||
} from "./portable-alias-plan.mjs";
|
||||
|
||||
export { planAliasFiles, planAliasRestore, selectAliasItems } from "./portable-alias-plan.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string, bytes: Uint8Array, contentType: string }} Payload
|
||||
* @typedef {Payload & { sha256: string }} HashedPayload
|
||||
* @typedef {{
|
||||
* listFileHashes: (versionPath: string) => Promise<Map<string, string>>,
|
||||
* deleteVersion: (versionPath: string) => Promise<void>,
|
||||
* uploadFile: (versionPath: string, file: HashedPayload) => Promise<void>,
|
||||
* readTextFile: (versionPath: string, name: string) => Promise<string | undefined>,
|
||||
* deleteFile?: (versionPath: string, name: string) => Promise<void>,
|
||||
* deleteVersion?: (versionPath: string) => Promise<void>,
|
||||
* }} PackageRegistry
|
||||
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[] }} PackageSpec
|
||||
* deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로
|
||||
* 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다).
|
||||
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec
|
||||
* aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload.
|
||||
*/
|
||||
|
||||
export class PortablePublishError extends Error {
|
||||
|
|
@ -47,10 +63,15 @@ export class PortablePublishError extends Error {
|
|||
export function hashPayloads(payloads) {
|
||||
return payloads.map((payload) => ({
|
||||
...payload,
|
||||
sha256: createHash("sha256").update(payload.bytes).digest("hex"),
|
||||
sha256: sha256Hex(payload.bytes),
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param {Uint8Array} bytes */
|
||||
function sha256Hex(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 불변 버전 경로 게시 계획.
|
||||
* - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다)
|
||||
|
|
@ -71,15 +92,16 @@ export function planVersionedPackage({ items, remoteHashes }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 별칭 경로 게시 계획. Forgejo는 파일 단위 덮어쓰기를 거부(409)하므로, 다른 파일이 하나라도
|
||||
* 있으면 버전 전체를 지우고 모든 파일을 다시 올린다(낡은 바이트와 새 바이트가 섞이지 않게).
|
||||
* 별칭 교체 여부 요약(skip/replace). 유스케이스는 planAliasFiles 의 파일 단위 계획을 쓴다.
|
||||
* deleteFirst 는 원격 별칭에 파일이 있어 교체 시 삭제가 필요하다는 뜻이다.
|
||||
*
|
||||
* @deprecated planAliasFiles 를 쓴다. 기존 호출부 호환용 요약이다.
|
||||
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
|
||||
* @returns {{ action: "skip" | "replace", deleteFirst: boolean }}
|
||||
*/
|
||||
export function planAliasPackage({ items, remoteHashes }) {
|
||||
const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256);
|
||||
if (differing.length === 0) return { action: "skip", deleteFirst: false };
|
||||
const { writes } = planAliasFiles({ items, remoteHashes });
|
||||
if (writes.length === 0) return { action: "skip", deleteFirst: false };
|
||||
return { action: "replace", deleteFirst: remoteHashes.size > 0 };
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +150,150 @@ async function readAliasVersion(registry, aliasPath, indexName) {
|
|||
return parsePublishedIndexVersion(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {HashedPayload[]} AliasItems
|
||||
* @typedef {{
|
||||
* spec: PackageSpec & { items: HashedPayload[] },
|
||||
* versionPath: string,
|
||||
* aliasPath: string,
|
||||
* aliasItems: AliasItems,
|
||||
* remoteHashes: Map<string, string>,
|
||||
* plan: ReturnType<typeof planVersionedPackage>,
|
||||
* }} PlannedPackage
|
||||
*/
|
||||
|
||||
/**
|
||||
* 롤백 방지 판정. 교체해도 되면 true, 더 새로운 버전이 있으면 false, 읽을 수 없으면 예외.
|
||||
* @param {PackageRegistry} registry
|
||||
* @param {PlannedPackage} pkg
|
||||
* @param {string} version
|
||||
* @param {(message: string) => void} log
|
||||
* @param {Record<string, string>} aliases
|
||||
*/
|
||||
async function aliasMayAdvance(registry, pkg, version, log, aliases) {
|
||||
const { aliasPath, spec } = pkg;
|
||||
const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName);
|
||||
const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion });
|
||||
if (decision.abort) {
|
||||
throw new PortablePublishError(
|
||||
`${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` +
|
||||
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
|
||||
);
|
||||
}
|
||||
if (!decision.update) {
|
||||
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
|
||||
aliases[spec.kind] = decision.reason;
|
||||
}
|
||||
return decision.update;
|
||||
}
|
||||
|
||||
/**
|
||||
* 별칭 계획을 실행한다. deleteFile 포트가 있으면 파일 단위 교체(인덱스가 마지막, 정리는 그 뒤),
|
||||
* 없으면 예전 방식(별칭 전체 삭제 후 전부 업로드)으로 대체한다.
|
||||
*
|
||||
* @param {PackageRegistry} registry
|
||||
* @param {string} aliasPath
|
||||
* @param {AliasItems} aliasItems
|
||||
* @param {{ writes: Array<{ item: HashedPayload, replace: boolean }>, prunes: readonly string[] }} plan
|
||||
*/
|
||||
async function applyAliasPlan(registry, aliasPath, aliasItems, plan) {
|
||||
if (typeof registry.deleteFile === "function") {
|
||||
for (const { item, replace } of plan.writes) {
|
||||
if (replace) await registry.deleteFile(aliasPath, item.name);
|
||||
await registry.uploadFile(aliasPath, item);
|
||||
}
|
||||
for (const name of plan.prunes) await registry.deleteFile(aliasPath, name);
|
||||
return;
|
||||
}
|
||||
const needsDelete = plan.prunes.length > 0 || plan.writes.some((write) => write.replace);
|
||||
if (!needsDelete) {
|
||||
for (const { item } of plan.writes) await registry.uploadFile(aliasPath, item);
|
||||
return;
|
||||
}
|
||||
if (typeof registry.deleteVersion !== "function") {
|
||||
throw new PortablePublishError(`${aliasPath} 를 교체할 삭제 수단(deleteFile/deleteVersion)이 registry에 없습니다.`);
|
||||
}
|
||||
await registry.deleteVersion(aliasPath);
|
||||
for (const item of aliasItems) await registry.uploadFile(aliasPath, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 빌드로 별칭을 갱신한다(버전 경로가 완성된 뒤에만 호출).
|
||||
* @param {PackageRegistry} registry
|
||||
* @param {PlannedPackage} pkg
|
||||
* @returns {Promise<"unchanged" | "replaced">}
|
||||
*/
|
||||
async function publishAlias(registry, pkg) {
|
||||
const plan = planAliasFiles({
|
||||
items: pkg.aliasItems,
|
||||
remoteHashes: await registry.listFileHashes(pkg.aliasPath),
|
||||
});
|
||||
if (plan.action === "skip") return "unchanged";
|
||||
await applyAliasPlan(registry, pkg.aliasPath, pkg.aliasItems, plan);
|
||||
return "replaced";
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 경로가 이미 완성돼 있는데 로컬 재빌드가 달라 게시를 중단하는 경우, 별칭을 원격 버전 경로의
|
||||
* 게시본(정본)으로 맞춘다 — 이전 실행이 별칭 교체 도중 끊겼다면 CI 재실행이 이를 복구한다.
|
||||
* 파일 단위 삭제 포트가 없거나 복구할 수 없으면 아무것도 쓰지 않는다.
|
||||
*
|
||||
* @param {PackageRegistry} registry
|
||||
* @param {PlannedPackage} pkg
|
||||
* @returns {Promise<"restored" | "unchanged" | "unrestorable">}
|
||||
*/
|
||||
async function restoreAliasFromPublished(registry, pkg) {
|
||||
if (typeof registry.deleteFile !== "function") return "unrestorable";
|
||||
const aliasHashes = await registry.listFileHashes(pkg.aliasPath);
|
||||
const restore = planAliasRestore({
|
||||
aliasItems: pkg.aliasItems,
|
||||
versionedHashes: pkg.remoteHashes,
|
||||
aliasHashes,
|
||||
});
|
||||
if (!restore.restorable) return "unrestorable";
|
||||
if (restore.reads.length === 0 && restore.prunes.length === 0) return "unchanged";
|
||||
|
||||
/** @type {Array<{ item: HashedPayload, replace: boolean }>} */
|
||||
const writes = [];
|
||||
for (const read of restore.reads) {
|
||||
const text = await registry.readTextFile(pkg.versionPath, read.name);
|
||||
if (text === undefined) return "unrestorable";
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
// 원격 바이트와 정확히 같은지 확인한다(인코딩 손실이 있으면 옮기지 않는다).
|
||||
if (sha256Hex(bytes) !== read.sha256) return "unrestorable";
|
||||
writes.push({
|
||||
item: { name: read.name, bytes, contentType: read.contentType, sha256: read.sha256 },
|
||||
replace: aliasHashes.has(read.name),
|
||||
});
|
||||
}
|
||||
await applyAliasPlan(registry, pkg.aliasPath, [], { writes, prunes: restore.prunes });
|
||||
return "restored";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PlannedPackage[]} aborted
|
||||
* @param {Record<string, string>} aliases
|
||||
*/
|
||||
function conflictError(aborted, aliases) {
|
||||
const lines = aborted.map(
|
||||
(pkg) =>
|
||||
`${pkg.versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` +
|
||||
pkg.plan.conflicts.map((item) => item.name).join(", "),
|
||||
);
|
||||
const restored = Object.entries(aliases)
|
||||
.filter(([, status]) => status === "restored")
|
||||
.map(([kind]) => `${kind}-latest`);
|
||||
return new PortablePublishError(
|
||||
`${lines.join("\n")}\n` +
|
||||
" 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" +
|
||||
(restored.length > 0
|
||||
? ` (중단된 별칭은 게시된 버전 경로의 인덱스로 복구했습니다: ${restored.join(", ")})\n`
|
||||
: "") +
|
||||
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
|
||||
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
|
||||
*
|
||||
|
|
@ -154,7 +320,22 @@ export async function publishPortablePackages({
|
|||
}
|
||||
const active = packages
|
||||
.filter((spec) => spec.payloads.length > 0)
|
||||
.map((spec) => ({ ...spec, items: hashPayloads(spec.payloads) }));
|
||||
.map((spec) => {
|
||||
const items = hashPayloads(spec.payloads);
|
||||
let aliasItems;
|
||||
try {
|
||||
aliasItems = selectAliasItems({ items, indexName: spec.indexName, aliasNames: spec.aliasNames });
|
||||
} catch (error) {
|
||||
if (error instanceof AliasSelectionError) throw new PortablePublishError(`${spec.kind}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
spec: { ...spec, items },
|
||||
versionPath: `${spec.kind}-${version}`,
|
||||
aliasPath: `${spec.kind}-latest`,
|
||||
aliasItems,
|
||||
};
|
||||
});
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const versioned = {};
|
||||
|
|
@ -162,69 +343,57 @@ export async function publishPortablePackages({
|
|||
const aliases = {};
|
||||
|
||||
if (dryRun) {
|
||||
for (const spec of active) {
|
||||
for (const path of [`${spec.kind}-${version}`, `${spec.kind}-latest`]) {
|
||||
for (const item of spec.items) {
|
||||
log(`(check) PUT ${describeUrl(path, item.name)} (${item.bytes.length} bytes)`);
|
||||
}
|
||||
for (const pkg of active) {
|
||||
for (const item of pkg.spec.items) {
|
||||
log(`(check) PUT ${describeUrl(pkg.versionPath, item.name)} (${item.bytes.length} bytes)`);
|
||||
}
|
||||
for (const item of pkg.aliasItems) {
|
||||
log(`(check) PUT ${describeUrl(pkg.aliasPath, item.name)} (${item.bytes.length} bytes)`);
|
||||
}
|
||||
}
|
||||
return { versioned, aliases };
|
||||
}
|
||||
|
||||
// 1) 불변 버전 경로 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
|
||||
for (const spec of active) {
|
||||
const versionPath = `${spec.kind}-${version}`;
|
||||
const plan = planVersionedPackage({
|
||||
items: spec.items,
|
||||
remoteHashes: await registry.listFileHashes(versionPath),
|
||||
// 1) 불변 버전 경로 계획 — 어느 하나라도 충돌하면 어떤 버전 경로에도 쓰지 않는다.
|
||||
/** @type {PlannedPackage[]} */
|
||||
const planned = [];
|
||||
for (const pkg of active) {
|
||||
const remoteHashes = await registry.listFileHashes(pkg.versionPath);
|
||||
planned.push({
|
||||
...pkg,
|
||||
remoteHashes,
|
||||
plan: planVersionedPackage({ items: pkg.spec.items, remoteHashes }),
|
||||
});
|
||||
if (plan.action === "abort") {
|
||||
throw new PortablePublishError(
|
||||
`${versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` +
|
||||
`${plan.conflicts.map((item) => item.name).join(", ")}\n` +
|
||||
" 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" +
|
||||
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
|
||||
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
|
||||
);
|
||||
}
|
||||
if (plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${versionPath}`);
|
||||
versioned[spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
for (const item of plan.uploads) await registry.uploadFile(versionPath, item);
|
||||
versioned[spec.kind] = plan.uploads.length === spec.items.length ? "uploaded" : "resumed";
|
||||
}
|
||||
|
||||
// 2) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다.
|
||||
for (const spec of active) {
|
||||
const aliasPath = `${spec.kind}-latest`;
|
||||
const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName);
|
||||
const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion });
|
||||
if (decision.abort) {
|
||||
throw new PortablePublishError(
|
||||
`${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` +
|
||||
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
|
||||
);
|
||||
const aborted = planned.filter((pkg) => pkg.plan.action === "abort");
|
||||
if (aborted.length > 0) {
|
||||
// 재실행 복구: 이미 완성된 버전 경로(게시본)로 끊긴 별칭을 맞춘 뒤 중단한다.
|
||||
for (const pkg of aborted) {
|
||||
if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue;
|
||||
aliases[pkg.spec.kind] = await restoreAliasFromPublished(registry, pkg);
|
||||
}
|
||||
if (!decision.update) {
|
||||
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
|
||||
aliases[spec.kind] = decision.reason;
|
||||
throw conflictError(aborted, aliases);
|
||||
}
|
||||
|
||||
// 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
|
||||
for (const pkg of planned) {
|
||||
if (pkg.plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${pkg.versionPath}`);
|
||||
versioned[pkg.spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
const plan = planAliasPackage({
|
||||
items: spec.items,
|
||||
remoteHashes: await registry.listFileHashes(aliasPath),
|
||||
});
|
||||
if (plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${aliasPath}`);
|
||||
aliases[spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
if (plan.deleteFirst) await registry.deleteVersion(aliasPath);
|
||||
for (const item of spec.items) await registry.uploadFile(aliasPath, item);
|
||||
aliases[spec.kind] = "replaced";
|
||||
for (const item of pkg.plan.uploads) await registry.uploadFile(pkg.versionPath, item);
|
||||
versioned[pkg.spec.kind] =
|
||||
pkg.plan.uploads.length === pkg.spec.items.length ? "uploaded" : "resumed";
|
||||
}
|
||||
|
||||
// 3) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다. 파일 단위, 인덱스가 마지막.
|
||||
for (const pkg of planned) {
|
||||
if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue;
|
||||
const status = await publishAlias(registry, pkg);
|
||||
if (status === "unchanged") log(`변경 없음(건너뜀): ${pkg.aliasPath}`);
|
||||
aliases[pkg.spec.kind] = status;
|
||||
}
|
||||
|
||||
return { versioned, aliases };
|
||||
|
|
|
|||
121
scripts/ci/lib/runtime-feed-gate.mjs
Normal file
121
scripts/ci/lib/runtime-feed-gate.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// 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<string, string | null>}
|
||||
*/
|
||||
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<string, string | null>} */
|
||||
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<string, string | null>, 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<Response>,
|
||||
* minVersions: Record<string, string | null>,
|
||||
* 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 };
|
||||
}
|
||||
129
scripts/ci/lib/runtime-feed-gate.test.mjs
Normal file
129
scripts/ci/lib/runtime-feed-gate.test.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// node --test scripts/ci/lib/runtime-feed-gate.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import {
|
||||
RUNTIME_MIN_VERSION_SOURCE,
|
||||
WINDOWS_X64_TARGET,
|
||||
assertRuntimeFeedCompatible,
|
||||
evaluateRuntimeFeed,
|
||||
parseRuntimeMinVersions,
|
||||
} from "./runtime-feed-gate.mjs";
|
||||
import { buildRuntimeIndex } from "./runtime-index-builder.mjs";
|
||||
|
||||
const SHA = "a".repeat(64);
|
||||
const MIN = { sidecar: "1.7.0", ffmpeg: null };
|
||||
|
||||
function index({ version = "1.9.0", platform = "win32", arch = "x64", components = ["sidecar", "ffmpeg"] } = {}) {
|
||||
return buildRuntimeIndex({
|
||||
version,
|
||||
generatedAt: "2026-09-28T00:00:00.000Z",
|
||||
platform,
|
||||
arch,
|
||||
partBaseUrl: `https://feed.test/runtime-${version}`,
|
||||
components: Object.fromEntries(
|
||||
components.map((name) => [
|
||||
name,
|
||||
{
|
||||
archive: `d3ro-runtime-${name}.tar.gz`,
|
||||
sha256: SHA,
|
||||
totalSize: 10,
|
||||
parts: [{ name: `d3ro-runtime-${name}.tar.gz.001`, size: 10, sha256: SHA }],
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function jsonFetch(body, status = 200) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
test("parseRuntimeMinVersions reads the app's real RUNTIME_MIN_VERSION (drift guard)", () => {
|
||||
const source = readFileSync(fileURLToPath(new URL(`../../../${RUNTIME_MIN_VERSION_SOURCE}`, import.meta.url)), "utf8");
|
||||
const parsed = parseRuntimeMinVersions(source);
|
||||
assert.deepEqual(Object.keys(parsed).sort(), ["ffmpeg", "sidecar"]);
|
||||
assert.match(parsed.sidecar, /^\d+\.\d+\.\d+$/);
|
||||
assert.equal(parsed.ffmpeg, null);
|
||||
});
|
||||
|
||||
test("parseRuntimeMinVersions fails loudly when the constant cannot be read", () => {
|
||||
assert.throws(() => parseRuntimeMinVersions("export const OTHER = {}"), /RUNTIME_MIN_VERSION not found/);
|
||||
assert.throws(
|
||||
() => parseRuntimeMinVersions("export const RUNTIME_MIN_VERSION = {\n sidecar: '1.7',\n}"),
|
||||
/not semver/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a runtime feed that satisfies the minimum and target passes", () => {
|
||||
assert.deepEqual(evaluateRuntimeFeed({ index: index(), minVersions: MIN, target: WINDOWS_X64_TARGET }), []);
|
||||
});
|
||||
|
||||
test("runtime-latest older than this build's minimum blocks latest.yml (regression)", () => {
|
||||
const problems = evaluateRuntimeFeed({
|
||||
index: index({ version: "1.6.0" }),
|
||||
minVersions: MIN,
|
||||
target: WINDOWS_X64_TARGET,
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /1\.6\.0 is below .*sidecar 1\.7\.0/);
|
||||
});
|
||||
|
||||
test("a legacy index without platform/arch or for another platform is rejected", () => {
|
||||
const legacy = index();
|
||||
delete legacy.platform;
|
||||
delete legacy.arch;
|
||||
assert.match(
|
||||
evaluateRuntimeFeed({ index: legacy, minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"),
|
||||
/does not declare platform\/arch/,
|
||||
);
|
||||
assert.match(
|
||||
evaluateRuntimeFeed({ index: index({ platform: "darwin", arch: "arm64" }), minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"),
|
||||
/targets darwin-arm64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a missing component is rejected even without a minimum version", () => {
|
||||
const problems = evaluateRuntimeFeed({
|
||||
index: index({ components: ["sidecar"] }),
|
||||
minVersions: MIN,
|
||||
target: WINDOWS_X64_TARGET,
|
||||
});
|
||||
assert.deepEqual(problems, ["runtime.json has no ffmpeg component"]);
|
||||
});
|
||||
|
||||
test("assertRuntimeFeedCompatible fails closed when the feed is unreadable", async () => {
|
||||
const missing = jsonFetch({}, 404);
|
||||
await assert.rejects(
|
||||
assertRuntimeFeedCompatible({ url: "https://feed.test/runtime-latest/runtime.json", fetchImpl: missing.fetchImpl, minVersions: MIN }),
|
||||
/cannot read .*HTTP 404/,
|
||||
);
|
||||
const failing = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
await assert.rejects(
|
||||
assertRuntimeFeedCompatible({ url: "https://feed.test/x", fetchImpl: failing, minVersions: MIN }),
|
||||
/cannot read .*offline/,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertRuntimeFeedCompatible returns the feed version when compatible and throws when stale", async () => {
|
||||
const ok = jsonFetch(index({ version: "1.9.0" }));
|
||||
assert.deepEqual(
|
||||
await assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: ok.fetchImpl, minVersions: MIN }),
|
||||
{ version: "1.9.0" },
|
||||
);
|
||||
assert.deepEqual(ok.calls, ["https://feed.test/runtime.json"]);
|
||||
|
||||
const stale = jsonFetch(index({ version: "1.6.0" }));
|
||||
await assert.rejects(
|
||||
assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: stale.fetchImpl, minVersions: MIN }),
|
||||
/refusing to publish latest\.yml[\s\S]*1\.6\.0 is below/,
|
||||
);
|
||||
});
|
||||
52
scripts/ci/lib/runtime-index-builder.mjs
Normal file
52
scripts/ci/lib/runtime-index-builder.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// scripts/ci/lib/runtime-index-builder.mjs
|
||||
// 로컬 AI 런타임 인덱스(runtime.json)를 만드는 순수 함수.
|
||||
//
|
||||
// 왜 분리하나: build-portable.mjs 가 인덱스를 즉석 객체로 만들면서 platform/arch 를
|
||||
// 빠뜨렸다. 사이드카(PyInstaller)와 ffmpeg 는 빌드 호스트의 네이티브 실행 파일인데,
|
||||
// 인덱스가 그 사실을 밝히지 않아 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고
|
||||
// 검증에서 떨어진 뒤 매번 ~160MB 를 다시 받았다. 인덱스 모양은 여기 한 곳에서 정하고,
|
||||
// 클라이언트(apps/desktop/src/main/services/runtime/runtime-index.ts)는 platform/arch 가
|
||||
// 다르면 부품을 받기 전에 거부한다.
|
||||
|
||||
export const RUNTIME_INDEX_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string, size: number, sha256: string }} RuntimePackedPart
|
||||
* @typedef {{ archive: string, sha256: string, totalSize: number, parts: RuntimePackedPart[] }} RuntimePackedComponent
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* version: string,
|
||||
* generatedAt: string,
|
||||
* platform: string,
|
||||
* arch: string,
|
||||
* partBaseUrl: string,
|
||||
* components: Record<string, RuntimePackedComponent>,
|
||||
* }} input
|
||||
*/
|
||||
export function buildRuntimeIndex({ version, generatedAt, platform, arch, partBaseUrl, components }) {
|
||||
if (typeof platform !== "string" || platform.length === 0) {
|
||||
throw new Error("runtime index requires a build platform (process.platform)");
|
||||
}
|
||||
if (typeof arch !== "string" || arch.length === 0) {
|
||||
throw new Error("runtime index requires a build arch (process.arch)");
|
||||
}
|
||||
const base = String(partBaseUrl).replace(/\/+$/, "");
|
||||
return {
|
||||
schemaVersion: RUNTIME_INDEX_SCHEMA_VERSION,
|
||||
version,
|
||||
platform,
|
||||
arch,
|
||||
generatedAt,
|
||||
components: Object.fromEntries(
|
||||
Object.entries(components).map(([name, entry]) => [
|
||||
name,
|
||||
{
|
||||
...entry,
|
||||
parts: entry.parts.map((part) => ({ ...part, url: `${base}/${part.name}` })),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
48
scripts/ci/lib/runtime-index-builder.test.mjs
Normal file
48
scripts/ci/lib/runtime-index-builder.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// node --test scripts/ci/lib/runtime-index-builder.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { RUNTIME_INDEX_SCHEMA_VERSION, buildRuntimeIndex } from "./runtime-index-builder.mjs";
|
||||
|
||||
const SHA = "b".repeat(64);
|
||||
const components = {
|
||||
sidecar: {
|
||||
component: "sidecar",
|
||||
archive: "d3ro-runtime-sidecar.tar.gz",
|
||||
sha256: SHA,
|
||||
totalSize: 20,
|
||||
parts: [
|
||||
{ name: "d3ro-runtime-sidecar.tar.gz.001", size: 10, sha256: SHA },
|
||||
{ name: "d3ro-runtime-sidecar.tar.gz.002", size: 10, sha256: SHA },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test("runtime index declares the build platform and arch (regression: macOS got the Windows engine)", () => {
|
||||
const index = buildRuntimeIndex({
|
||||
version: "1.9.0",
|
||||
generatedAt: "2026-09-28T00:00:00.000Z",
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
partBaseUrl: "https://feed.test/runtime-1.9.0/",
|
||||
components,
|
||||
});
|
||||
assert.equal(index.schemaVersion, RUNTIME_INDEX_SCHEMA_VERSION);
|
||||
assert.equal(index.platform, "win32");
|
||||
assert.equal(index.arch, "x64");
|
||||
assert.equal(index.version, "1.9.0");
|
||||
assert.deepEqual(
|
||||
index.components.sidecar.parts.map((part) => part.url),
|
||||
[
|
||||
"https://feed.test/runtime-1.9.0/d3ro-runtime-sidecar.tar.gz.001",
|
||||
"https://feed.test/runtime-1.9.0/d3ro-runtime-sidecar.tar.gz.002",
|
||||
],
|
||||
);
|
||||
// 입력은 바꾸지 않는다
|
||||
assert.equal("url" in components.sidecar.parts[0], false);
|
||||
});
|
||||
|
||||
test("runtime index refuses to be built without a platform or arch", () => {
|
||||
const base = { version: "1.9.0", generatedAt: "x", partBaseUrl: "https://feed.test", components };
|
||||
assert.throws(() => buildRuntimeIndex({ ...base, platform: "", arch: "x64" }), /platform/);
|
||||
assert.throws(() => buildRuntimeIndex({ ...base, platform: "win32", arch: undefined }), /arch/);
|
||||
});
|
||||
97
scripts/ci/lib/update-policy-schema.mjs
Normal file
97
scripts/ci/lib/update-policy-schema.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// 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;
|
||||
}
|
||||
55
scripts/ci/lib/update-policy-schema.test.mjs
Normal file
55
scripts/ci/lib/update-policy-schema.test.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// node --test scripts/ci/lib/update-policy-schema.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { assertValidUpdatePolicy, validateUpdatePolicyDocument } from "./update-policy-schema.mjs";
|
||||
|
||||
const committedRaw = readFileSync(fileURLToPath(new URL("../../../release/update-policy.json", import.meta.url)), "utf8");
|
||||
const committed = JSON.parse(committedRaw);
|
||||
|
||||
test("the committed release/update-policy.json passes the strict schema", () => {
|
||||
assert.deepEqual(validateUpdatePolicyDocument(committed), []);
|
||||
assert.deepEqual(assertValidUpdatePolicy(committedRaw), committed);
|
||||
});
|
||||
|
||||
for (const [field, value, pattern] of [
|
||||
["stagingPercentage", 5.5, /stagingPercentage/],
|
||||
["stagingPercentage", "5", /stagingPercentage/],
|
||||
["stagingPercentage", 101, /stagingPercentage/],
|
||||
["killSwitch", "true", /killSwitch/],
|
||||
["killSwitch", 1, /killSwitch/],
|
||||
["minimumSupportedVersion", "1.9", /minimumSupportedVersion/],
|
||||
["forceInstallBelow", "v1.0.0", /forceInstallBelow/],
|
||||
["fullInstallVersionGap", -1, /fullInstallVersionGap/],
|
||||
["fullInstallOnMajorChange", "yes", /fullInstallOnMajorChange/],
|
||||
["defaultChannel", "stable", /defaultChannel/],
|
||||
["schemaVersion", 2, /schemaVersion/],
|
||||
]) {
|
||||
test(`rejects ${field}=${JSON.stringify(value)} instead of publishing a silently permissive policy`, () => {
|
||||
const errors = validateUpdatePolicyDocument({ ...committed, [field]: value });
|
||||
assert.equal(errors.length, 1, errors.join("\n"));
|
||||
assert.match(errors[0], pattern);
|
||||
assert.throws(() => assertValidUpdatePolicy(JSON.stringify({ ...committed, [field]: value })), /refusing to publish/);
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects missing safety fields, unknown fields and malformed channels", () => {
|
||||
const { killSwitch: _omit, ...withoutKillSwitch } = committed;
|
||||
assert.match(validateUpdatePolicyDocument(withoutKillSwitch).join("\n"), /killSwitch/);
|
||||
assert.match(validateUpdatePolicyDocument({ ...committed, killswitch: true }).join("\n"), /unknown field "killswitch"/);
|
||||
assert.match(
|
||||
validateUpdatePolicyDocument({ ...committed, channels: { ...committed.channels, beta: { allowPrerelease: "true" } } }).join("\n"),
|
||||
/channels\.beta/,
|
||||
);
|
||||
assert.match(
|
||||
validateUpdatePolicyDocument({ ...committed, channels: { ...committed.channels, rogue: { allowPrerelease: true } } }).join("\n"),
|
||||
/unknown channel "rogue"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects non-object and non-JSON input", () => {
|
||||
assert.deepEqual(validateUpdatePolicyDocument([]), ["update-policy.json must be a JSON object"]);
|
||||
assert.throws(() => assertValidUpdatePolicy("{"), /not valid JSON/);
|
||||
assert.throws(() => assertValidUpdatePolicy(Buffer.from("null")), /must be a JSON object/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue