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.
400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
// scripts/ci/lib/portable-publish-policy.mjs
|
|
// 휴대용 배포본(portable-*) · 로컬 AI 런타임(runtime-*) 게시 정책과 유스케이스.
|
|
//
|
|
// Forgejo generic registry 경로는 두 종류다.
|
|
// - 버전 경로 <kind>-<version> : 불변. 한 번 게시된 파일은 절대 지우거나 바꾸지 않는다.
|
|
// runtime-latest/runtime.json 과 커밋된 Scoop 매니페스트(bucket/d3ro-voice.json)가
|
|
// 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다.
|
|
// 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만
|
|
// 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed).
|
|
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 모두 완성된 뒤에만, 그리고
|
|
// 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만
|
|
// 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed).
|
|
// 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다
|
|
// (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다.
|
|
// - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가
|
|
// 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다.
|
|
//
|
|
// 구조
|
|
// 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate
|
|
// + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore)
|
|
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
|
|
// 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>>,
|
|
* 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
|
|
* deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로
|
|
* 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다).
|
|
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec
|
|
* aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload.
|
|
*/
|
|
|
|
export class PortablePublishError extends Error {
|
|
/** @param {string} message */
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "PortablePublishError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {readonly Payload[]} payloads
|
|
* @returns {HashedPayload[]}
|
|
*/
|
|
export function hashPayloads(payloads) {
|
|
return payloads.map((payload) => ({
|
|
...payload,
|
|
sha256: sha256Hex(payload.bytes),
|
|
}));
|
|
}
|
|
|
|
/** @param {Uint8Array} bytes */
|
|
function sha256Hex(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* 불변 버전 경로 게시 계획.
|
|
* - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다)
|
|
* - 로컬 파일이 모두 같은 바이트로 원격에 있음 → skip
|
|
* - 일부만 있음(부분 업로드 재실행) 또는 비어 있음 → 없는 파일만 upload (순서 유지: 인덱스가 마지막)
|
|
*
|
|
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
|
|
* @returns {{ action: "abort" | "skip" | "upload", conflicts: HashedPayload[], uploads: HashedPayload[] }}
|
|
*/
|
|
export function planVersionedPackage({ items, remoteHashes }) {
|
|
const conflicts = items.filter(
|
|
(item) => remoteHashes.has(item.name) && remoteHashes.get(item.name) !== item.sha256,
|
|
);
|
|
if (conflicts.length > 0) return { action: "abort", conflicts, uploads: [] };
|
|
const uploads = items.filter((item) => !remoteHashes.has(item.name));
|
|
if (uploads.length === 0) return { action: "skip", conflicts: [], uploads: [] };
|
|
return { action: "upload", conflicts: [], uploads };
|
|
}
|
|
|
|
/**
|
|
* 별칭 교체 여부 요약(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 { writes } = planAliasFiles({ items, remoteHashes });
|
|
if (writes.length === 0) return { action: "skip", deleteFirst: false };
|
|
return { action: "replace", deleteFirst: remoteHashes.size > 0 };
|
|
}
|
|
|
|
/**
|
|
* 게시된 인덱스(runtime.json / portable.json)의 최상위 version 을 읽는다.
|
|
* @param {string} text
|
|
* @returns {string | null} 파싱할 수 없거나 semver가 아니면 null
|
|
*/
|
|
export function parsePublishedIndexVersion(text) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(String(text ?? ""));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const value = parsed && typeof parsed === "object" ? parsed.version : undefined;
|
|
if (typeof value !== "string" || !parseSemver(value)) return null;
|
|
return value.trim().replace(/^v/, "");
|
|
}
|
|
|
|
/**
|
|
* 별칭 교체 여부. decideLatestFeedUpdate 를 재사용하되, 게시된 인덱스를 읽었는데 버전을
|
|
* 알 수 없으면(null) 교체하지 않고 중단한다 — 스키마가 바뀐 더 새로운 버전을 오래된
|
|
* 버전으로 덮어쓰는 롤백을 막기 위한 fail-closed.
|
|
*
|
|
* @param {{ publishingVersion: string, publishedVersion: string | null | undefined }} input
|
|
* @returns {{ update: boolean, abort: boolean, reason: string }}
|
|
*/
|
|
export function decideAliasUpdate({ publishingVersion, publishedVersion }) {
|
|
if (publishedVersion === null) {
|
|
return { update: false, abort: true, reason: "unreadable-feed" };
|
|
}
|
|
const decision = decideLatestFeedUpdate({ publishingVersion, publishedVersion });
|
|
return { ...decision, abort: false };
|
|
}
|
|
|
|
/**
|
|
* @param {PackageRegistry} registry
|
|
* @param {string} aliasPath
|
|
* @param {string} indexName
|
|
* @returns {Promise<string | null | undefined>} undefined=별칭 없음, null=버전 읽기 불가
|
|
*/
|
|
async function readAliasVersion(registry, aliasPath, indexName) {
|
|
const text = await registry.readTextFile(aliasPath, indexName);
|
|
if (text === undefined) return undefined;
|
|
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`
|
|
: "") +
|
|
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
|
|
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
|
|
*
|
|
* @param {{
|
|
* version: string,
|
|
* packages: readonly PackageSpec[],
|
|
* registry: PackageRegistry,
|
|
* log: (message: string) => void,
|
|
* dryRun?: boolean,
|
|
* describeUrl?: (versionPath: string, name: string) => string,
|
|
* }} input
|
|
* @returns {Promise<{ versioned: Record<string, string>, aliases: Record<string, string> }>}
|
|
*/
|
|
export async function publishPortablePackages({
|
|
version,
|
|
packages,
|
|
registry,
|
|
log,
|
|
dryRun = false,
|
|
describeUrl = (versionPath, name) => `${versionPath}/${name}`,
|
|
}) {
|
|
if (!parseSemver(version)) {
|
|
throw new PortablePublishError(`게시 버전이 semver가 아닙니다: ${version}`);
|
|
}
|
|
const active = packages
|
|
.filter((spec) => spec.payloads.length > 0)
|
|
.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 = {};
|
|
/** @type {Record<string, string>} */
|
|
const aliases = {};
|
|
|
|
if (dryRun) {
|
|
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) 불변 버전 경로 계획 — 어느 하나라도 충돌하면 어떤 버전 경로에도 쓰지 않는다.
|
|
/** @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 }),
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
throw conflictError(aborted, aliases);
|
|
}
|
|
|
|
// 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
|
|
for (const pkg of planned) {
|
|
if (pkg.plan.action === "skip") {
|
|
log(`변경 없음(건너뜀): ${pkg.versionPath}`);
|
|
versioned[pkg.spec.kind] = "unchanged";
|
|
continue;
|
|
}
|
|
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 };
|
|
}
|