fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback
This commit is contained in:
parent
88f3dbcd69
commit
5f12ab4772
4 changed files with 658 additions and 88 deletions
76
scripts/ci/lib/forgejo-generic-registry.mjs
Normal file
76
scripts/ci/lib/forgejo-generic-registry.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// scripts/ci/lib/forgejo-generic-registry.mjs
|
||||
// Forgejo generic package registry IO 어댑터 (portable-publish-policy.mjs 의 PackageRegistry 포트).
|
||||
//
|
||||
// - Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시를 헤더로 주지 않는다.
|
||||
// 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격 바이트가
|
||||
// 로컬과 같은지 정확히 판단할 수 있다. (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가
|
||||
// 우연히 같을 때 다른 바이트를 "동일"로 오판한다 — 실측 사고.)
|
||||
// - 404 외의 오류는 모두 예외로 올린다(fail-closed). 원격 상태를 모르면 게시 판단을 할 수 없다.
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* feedUrl: string,
|
||||
* packageApiUrl: string,
|
||||
* fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
|
||||
* onUploaded?: (url: string) => void,
|
||||
* }} deps
|
||||
* @returns {import("./portable-publish-policy.mjs").PackageRegistry & { fileUrl: (versionPath: string, name: string) => string }}
|
||||
*/
|
||||
export function createForgejoGenericRegistry({ feedUrl, packageApiUrl, fetchImpl, onUploaded }) {
|
||||
const versionApi = (versionPath) => `${packageApiUrl}/${encodeURIComponent(versionPath)}`;
|
||||
const fileUrl = (versionPath, name) =>
|
||||
`${feedUrl}/${encodeURIComponent(versionPath)}/${encodeURIComponent(name)}`;
|
||||
|
||||
return {
|
||||
fileUrl,
|
||||
|
||||
async listFileHashes(versionPath) {
|
||||
const response = await fetchImpl(`${versionApi(versionPath)}/files`);
|
||||
if (response.status === 404) return new Map();
|
||||
if (!response.ok) {
|
||||
throw new Error(`파일 목록 조회 실패 (HTTP ${response.status}): ${versionPath}`);
|
||||
}
|
||||
const files = await response.json();
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error(`파일 목록 응답 형식이 올바르지 않습니다: ${versionPath}`);
|
||||
}
|
||||
return new Map(files.map((file) => [String(file.name), String(file.sha256)]));
|
||||
},
|
||||
|
||||
async deleteVersion(versionPath) {
|
||||
const response = await fetchImpl(versionApi(versionPath), { method: "DELETE" });
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`);
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(versionPath, file) {
|
||||
const url = fileUrl(versionPath, file.name);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.contentType },
|
||||
body: file.bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
||||
" HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.",
|
||||
);
|
||||
}
|
||||
onUploaded?.(url);
|
||||
},
|
||||
|
||||
async readTextFile(versionPath, name) {
|
||||
const response = await fetchImpl(`${fileUrl(versionPath, name)}?ts=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`게시된 파일을 읽을 수 없습니다 (HTTP ${response.status}): ${versionPath}/${name}`,
|
||||
);
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
};
|
||||
}
|
||||
231
scripts/ci/lib/portable-publish-policy.mjs
Normal file
231
scripts/ci/lib/portable-publish-policy.mjs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// 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).
|
||||
//
|
||||
// 구조
|
||||
// 1) 순수 정책: planVersionedPackage / planAliasPackage / parsePublishedIndexVersion /
|
||||
// decideAliasUpdate
|
||||
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
|
||||
// IO 어댑터는 ./forgejo-generic-registry.mjs 에 있다.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.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>,
|
||||
* }} PackageRegistry
|
||||
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[] }} PackageSpec
|
||||
*/
|
||||
|
||||
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: createHash("sha256").update(payload.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 별칭 경로 게시 계획. Forgejo는 파일 단위 덮어쓰기를 거부(409)하므로, 다른 파일이 하나라도
|
||||
* 있으면 버전 전체를 지우고 모든 파일을 다시 올린다(낡은 바이트와 새 바이트가 섞이지 않게).
|
||||
*
|
||||
* @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 };
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
|
||||
*
|
||||
* @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) => ({ ...spec, items: hashPayloads(spec.payloads) }));
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const versioned = {};
|
||||
/** @type {Record<string, string>} */
|
||||
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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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),
|
||||
});
|
||||
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} 의 게시 버전을 읽을 수 없습니다. ` +
|
||||
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
|
||||
);
|
||||
}
|
||||
if (!decision.update) {
|
||||
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
|
||||
aliases[spec.kind] = decision.reason;
|
||||
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";
|
||||
}
|
||||
|
||||
return { versioned, aliases };
|
||||
}
|
||||
322
scripts/ci/lib/portable-publish-policy.test.mjs
Normal file
322
scripts/ci/lib/portable-publish-policy.test.mjs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// node --test scripts/ci/lib/portable-publish-policy.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { createForgejoGenericRegistry } from "./forgejo-generic-registry.mjs";
|
||||
import {
|
||||
decideAliasUpdate,
|
||||
hashPayloads,
|
||||
parsePublishedIndexVersion,
|
||||
planAliasPackage,
|
||||
planVersionedPackage,
|
||||
publishPortablePackages,
|
||||
} 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 runtimeIndex = (version, generatedAt = "2026-09-20T00:00:00.000Z") =>
|
||||
payload(
|
||||
"runtime.json",
|
||||
JSON.stringify({ schemaVersion: 1, version, generatedAt }),
|
||||
"application/json",
|
||||
);
|
||||
const portableIndex = (version) =>
|
||||
payload("portable.json", JSON.stringify({ version }), "application/json");
|
||||
|
||||
/** 메모리 registry. packages: Map<versionPath, Map<name, text>> */
|
||||
function fakeRegistry(initial = {}) {
|
||||
const packages = new Map(
|
||||
Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]),
|
||||
);
|
||||
const calls = [];
|
||||
return {
|
||||
packages,
|
||||
calls,
|
||||
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(["delete", versionPath]);
|
||||
packages.delete(versionPath);
|
||||
},
|
||||
async uploadFile(versionPath, file) {
|
||||
calls.push(["upload", 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);
|
||||
},
|
||||
async readTextFile(versionPath, name) {
|
||||
calls.push(["read", versionPath, name]);
|
||||
return packages.get(versionPath)?.get(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const publish = (registry, version, runtime, portable) =>
|
||||
publishPortablePackages({
|
||||
version,
|
||||
registry,
|
||||
log: () => {},
|
||||
packages: [
|
||||
{ kind: "runtime", indexName: "runtime.json", payloads: runtime },
|
||||
{ kind: "portable", indexName: "portable.json", payloads: portable },
|
||||
],
|
||||
});
|
||||
|
||||
// ── 순수 정책 ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("planVersionedPackage aborts when a published file has different bytes", () => {
|
||||
const items = hashPayloads([payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt")]);
|
||||
const plan = planVersionedPackage({
|
||||
items,
|
||||
remoteHashes: new Map([["d3ro-runtime-sidecar.tar.gz.001", sha("original")]]),
|
||||
});
|
||||
assert.equal(plan.action, "abort");
|
||||
assert.deepEqual(
|
||||
plan.conflicts.map((item) => item.name),
|
||||
["d3ro-runtime-sidecar.tar.gz.001"],
|
||||
);
|
||||
});
|
||||
|
||||
test("planVersionedPackage resumes a partial upload with only the missing files, in order", () => {
|
||||
const items = hashPayloads([
|
||||
payload("a.7z.001", "one"),
|
||||
payload("a.7z.002", "two"),
|
||||
portableIndex("1.9.0"),
|
||||
]);
|
||||
const plan = planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) });
|
||||
assert.equal(plan.action, "upload");
|
||||
assert.deepEqual(
|
||||
plan.uploads.map((item) => item.name),
|
||||
["a.7z.002", "portable.json"],
|
||||
);
|
||||
});
|
||||
|
||||
test("planVersionedPackage skips when every file already matches", () => {
|
||||
const items = hashPayloads([payload("a.7z.001", "one")]);
|
||||
assert.equal(
|
||||
planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) }).action,
|
||||
"skip",
|
||||
);
|
||||
});
|
||||
|
||||
test("planAliasPackage replaces the whole alias when any file differs", () => {
|
||||
const items = hashPayloads([payload("x", "new")]);
|
||||
assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map([["x", sha("old")]]) }), {
|
||||
action: "replace",
|
||||
deleteFirst: true,
|
||||
});
|
||||
assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map() }), {
|
||||
action: "replace",
|
||||
deleteFirst: false,
|
||||
});
|
||||
assert.equal(planAliasPackage({ items, remoteHashes: new Map([["x", sha("new")]]) }).action, "skip");
|
||||
});
|
||||
|
||||
test("parsePublishedIndexVersion reads a semver version or returns null", () => {
|
||||
assert.equal(parsePublishedIndexVersion('{"version":"1.9.1"}'), "1.9.1");
|
||||
assert.equal(parsePublishedIndexVersion('{"version":"v2.0.0-beta.1"}'), "2.0.0-beta.1");
|
||||
assert.equal(parsePublishedIndexVersion('{"schemaVersion":2}'), null);
|
||||
assert.equal(parsePublishedIndexVersion("<html>"), null);
|
||||
assert.equal(parsePublishedIndexVersion("null"), null);
|
||||
});
|
||||
|
||||
test("decideAliasUpdate skips older versions and fails closed on an unreadable version", () => {
|
||||
assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), {
|
||||
update: false,
|
||||
reason: "newer-published",
|
||||
abort: false,
|
||||
});
|
||||
assert.equal(decideAliasUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }).update, true);
|
||||
assert.equal(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: undefined }).update, true);
|
||||
assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: null }), {
|
||||
update: false,
|
||||
abort: true,
|
||||
reason: "unreadable-feed",
|
||||
});
|
||||
});
|
||||
|
||||
// ── 유스케이스: 레드팀 회귀 ────────────────────────────────────────────────
|
||||
|
||||
test("re-run with rebuilt runtime bytes never deletes runtime-<version> nor touches runtime-latest", async () => {
|
||||
const published = {
|
||||
"runtime-1.9.0": {
|
||||
"d3ro-runtime-sidecar.tar.gz.001": "sidecar-original",
|
||||
"runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"),
|
||||
},
|
||||
"runtime-latest": {
|
||||
"d3ro-runtime-sidecar.tar.gz.001": "sidecar-original",
|
||||
"runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"),
|
||||
},
|
||||
};
|
||||
const registry = fakeRegistry(published);
|
||||
await assert.rejects(
|
||||
publish(
|
||||
registry,
|
||||
"1.9.0",
|
||||
[payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"), runtimeIndex("1.9.0", "2026-09-21T00:00:00.000Z")],
|
||||
[payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")],
|
||||
),
|
||||
/runtime-1\.9\.0/,
|
||||
);
|
||||
assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false);
|
||||
assert.equal(
|
||||
registry.packages.get("runtime-1.9.0").get("d3ro-runtime-sidecar.tar.gz.001"),
|
||||
"sidecar-original",
|
||||
);
|
||||
});
|
||||
|
||||
test("re-run with rebuilt portable volumes never replaces portable-<version>", async () => {
|
||||
const registry = fakeRegistry({
|
||||
"portable-1.9.0": { "D3RO-Voice-1.9.0.7z.001": "scoop-pinned" },
|
||||
});
|
||||
await assert.rejects(
|
||||
publish(registry, "1.9.0", [], [payload("D3RO-Voice-1.9.0.7z.001", "rebuilt"), portableIndex("1.9.0")]),
|
||||
/portable-1\.9\.0/,
|
||||
);
|
||||
assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false);
|
||||
});
|
||||
|
||||
test("re-run after a partial upload resumes the versioned package, then publishes the aliases", async () => {
|
||||
const registry = fakeRegistry({
|
||||
"runtime-1.9.0": { "d3ro-runtime-sidecar.tar.gz.001": "p1" },
|
||||
});
|
||||
const result = await publish(
|
||||
registry,
|
||||
"1.9.0",
|
||||
[payload("d3ro-runtime-sidecar.tar.gz.001", "p1"), payload("d3ro-runtime-sidecar.tar.gz.002", "p2"), runtimeIndex("1.9.0")],
|
||||
[payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")],
|
||||
);
|
||||
assert.deepEqual(result.versioned, { runtime: "resumed", portable: "uploaded" });
|
||||
assert.deepEqual(result.aliases, { runtime: "replaced", portable: "replaced" });
|
||||
assert.equal(registry.calls.some(([op, path]) => op === "delete" && path.endsWith("-1.9.0")), false);
|
||||
assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), runtimeIndex("1.9.0").bytes.toString("utf8"));
|
||||
});
|
||||
|
||||
test("versioned packages are all complete before any alias is touched", async () => {
|
||||
const registry = fakeRegistry();
|
||||
await publish(
|
||||
registry,
|
||||
"1.9.1",
|
||||
[payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.1")],
|
||||
[payload("D3RO-Voice-1.9.1.7z.001", "v"), portableIndex("1.9.1")],
|
||||
);
|
||||
const firstAliasCall = registry.calls.findIndex(([, path]) => path.endsWith("-latest"));
|
||||
const lastVersionedUpload = registry.calls.findLastIndex(
|
||||
([op, path]) => op === "upload" && path.endsWith("-1.9.1"),
|
||||
);
|
||||
assert.ok(firstAliasCall > lastVersionedUpload);
|
||||
});
|
||||
|
||||
test("an older tag re-run does not roll runtime-latest / portable-latest back", async () => {
|
||||
const newerRuntime = runtimeIndex("1.9.1").bytes.toString("utf8");
|
||||
const newerPortable = portableIndex("1.9.1").bytes.toString("utf8");
|
||||
const registry = fakeRegistry({
|
||||
"runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p191", "runtime.json": newerRuntime },
|
||||
"portable-latest": { "D3RO-Voice-1.9.1.7z.001": "v191", "portable.json": newerPortable },
|
||||
});
|
||||
const result = await publish(
|
||||
registry,
|
||||
"1.9.0",
|
||||
[payload("d3ro-runtime-sidecar.tar.gz.001", "p190"), runtimeIndex("1.9.0")],
|
||||
[payload("D3RO-Voice-1.9.0.7z.001", "v190"), portableIndex("1.9.0")],
|
||||
);
|
||||
assert.deepEqual(result.aliases, { runtime: "newer-published", portable: "newer-published" });
|
||||
assert.equal(registry.calls.some(([op, path]) => op !== "read" && op !== "list" && path.endsWith("-latest")), false);
|
||||
assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), newerRuntime);
|
||||
assert.equal(registry.packages.get("portable-latest").get("portable.json"), newerPortable);
|
||||
});
|
||||
|
||||
test("an alias whose published version cannot be read is left untouched (fail-closed)", async () => {
|
||||
const registry = fakeRegistry({
|
||||
"runtime-latest": { "runtime.json": '{"schemaVersion":2,"release":"2.0.0"}' },
|
||||
});
|
||||
await assert.rejects(
|
||||
publish(registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.0")], []),
|
||||
/runtime-latest\/runtime\.json/,
|
||||
);
|
||||
assert.equal(registry.calls.some(([op, path]) => op === "delete" && path === "runtime-latest"), false);
|
||||
});
|
||||
|
||||
test("a newer version replaces the alias atomically (delete then full upload)", async () => {
|
||||
const registry = fakeRegistry({
|
||||
"runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p190", "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8") },
|
||||
});
|
||||
const result = await publish(
|
||||
registry,
|
||||
"1.9.1",
|
||||
[payload("d3ro-runtime-sidecar.tar.gz.001", "p191"), runtimeIndex("1.9.1")],
|
||||
[],
|
||||
);
|
||||
assert.equal(result.aliases.runtime, "replaced");
|
||||
assert.equal(registry.packages.get("runtime-latest").get("d3ro-runtime-sidecar.tar.gz.001"), "p191");
|
||||
});
|
||||
|
||||
test("dry run performs no registry IO", async () => {
|
||||
const registry = fakeRegistry();
|
||||
const lines = [];
|
||||
await publishPortablePackages({
|
||||
version: "1.9.0",
|
||||
registry,
|
||||
dryRun: true,
|
||||
log: (line) => lines.push(line),
|
||||
packages: [{ kind: "portable", indexName: "portable.json", payloads: [portableIndex("1.9.0")] }],
|
||||
});
|
||||
assert.equal(registry.calls.length, 0);
|
||||
assert.equal(lines.length, 2);
|
||||
});
|
||||
|
||||
// ── IO 어댑터 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function fakeFetch(routes) {
|
||||
const seen = [];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
seen.push({ url, method: init.method ?? "GET" });
|
||||
const key = `${init.method ?? "GET"} ${url.replace(/\?ts=\d+$/, "")}`;
|
||||
const route = routes[key];
|
||||
if (!route) return new Response("not found", { status: 404 });
|
||||
return new Response(route.body ?? "", { status: route.status ?? 200 });
|
||||
};
|
||||
return { fetchImpl, seen };
|
||||
}
|
||||
|
||||
const FEED = "https://feed.test/generic/d3ro-voice";
|
||||
const API = "https://feed.test/api/v1/packages/d3ro-voice";
|
||||
|
||||
test("registry adapter maps 404 to empty/undefined and fails closed on other errors", async () => {
|
||||
const { fetchImpl } = fakeFetch({
|
||||
[`GET ${API}/runtime-1.9.0/files`]: { body: JSON.stringify([{ name: "a", sha256: "h" }]) },
|
||||
[`GET ${API}/runtime-latest/files`]: { status: 500 },
|
||||
[`GET ${FEED}/runtime-latest/runtime.json`]: { status: 524 },
|
||||
});
|
||||
const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl });
|
||||
assert.deepEqual([...(await registry.listFileHashes("runtime-1.9.0"))], [["a", "h"]]);
|
||||
assert.equal((await registry.listFileHashes("portable-1.9.0")).size, 0);
|
||||
await assert.rejects(registry.listFileHashes("runtime-latest"), /HTTP 500/);
|
||||
assert.equal(await registry.readTextFile("portable-latest", "portable.json"), undefined);
|
||||
await assert.rejects(registry.readTextFile("runtime-latest", "runtime.json"), /HTTP 524/);
|
||||
});
|
||||
|
||||
test("registry adapter throws on a failed upload instead of exiting", async () => {
|
||||
const { fetchImpl } = fakeFetch({ [`PUT ${FEED}/portable-1.9.0/a.7z.001`]: { status: 413 } });
|
||||
const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl });
|
||||
await assert.rejects(
|
||||
registry.uploadFile("portable-1.9.0", { ...payload("a.7z.001", "x"), sha256: sha("x") }),
|
||||
/HTTP 413/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the portable publisher routes every registry write through the policy use case", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8");
|
||||
assert.match(source, /publishPortablePackages\(/);
|
||||
assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/);
|
||||
assert.doesNotMatch(source, /async function publishBase/);
|
||||
});
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
// .../generic/d3ro-voice/portable-<version>/portable.json
|
||||
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
|
||||
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
|
||||
// .../generic/d3ro-voice/runtime-<version>/... (로컬 AI 런타임 부품 + runtime.json)
|
||||
// .../generic/d3ro-voice/runtime-latest/... (동일 파일 alias)
|
||||
//
|
||||
// 버전 경로는 불변(다른 바이트면 중단, 부분 업로드는 이어 올림), *-latest 별칭은 버전 경로가
|
||||
// 모두 완성된 뒤 더 오래된 버전으로 되돌리지 않을 때만 교체한다 — lib/portable-publish-policy.mjs.
|
||||
//
|
||||
// 사용:
|
||||
// node scripts/ci/build-portable.mjs
|
||||
|
|
@ -21,6 +26,8 @@ import { existsSync, readFileSync } from 'node:fs'
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createForgejoGenericRegistry } from './lib/forgejo-generic-registry.mjs'
|
||||
import { publishPortablePackages } from './lib/portable-publish-policy.mjs'
|
||||
|
||||
const { forgejoAuthorization } = credentialHelpers
|
||||
|
||||
|
|
@ -134,98 +141,32 @@ async function forgejoFetch(url, init = {}) {
|
|||
|
||||
const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice'
|
||||
|
||||
/**
|
||||
* Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다.
|
||||
* 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격
|
||||
* 바이트가 로컬과 같은지 정확히 판단할 수 있다.
|
||||
* (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를
|
||||
* "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.)
|
||||
*/
|
||||
async function remoteFileHashes(versionPath) {
|
||||
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}/files`)
|
||||
if (!response.ok) return new Map()
|
||||
const files = await response.json()
|
||||
return new Map(files.map((file) => [file.name, file.sha256]))
|
||||
}
|
||||
const registry = createForgejoGenericRegistry({
|
||||
feedUrl: FEED,
|
||||
packageApiUrl: PACKAGE_API,
|
||||
fetchImpl: forgejoFetch,
|
||||
onUploaded: (url) => console.log(`[portable] uploaded ${url}`),
|
||||
})
|
||||
|
||||
async function deletePackageVersion(versionPath) {
|
||||
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, {
|
||||
method: 'DELETE',
|
||||
// 정책(불변 버전 경로 · 별칭 롤백 방지)은 lib/portable-publish-policy.mjs 에 있다.
|
||||
// 버전 경로(runtime-<v>, portable-<v>)를 모두 완성한 뒤에만 *-latest 별칭을 갱신한다.
|
||||
try {
|
||||
await publishPortablePackages({
|
||||
version,
|
||||
packages: [
|
||||
{ kind: 'runtime', indexName: 'runtime.json', payloads: runtimePayloads },
|
||||
{ kind: 'portable', indexName: 'portable.json', payloads },
|
||||
],
|
||||
registry,
|
||||
dryRun: check,
|
||||
describeUrl: registry.fileUrl,
|
||||
log: (message) => console.log(`[portable] ${message}`),
|
||||
})
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`)
|
||||
} catch (error) {
|
||||
console.error(`[portable] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function put(url, body, contentType) {
|
||||
const response = await forgejoFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
||||
' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[portable] uploaded ${url}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 버전 경로를 원자적으로 게시한다.
|
||||
* Forgejo generic registry는 파일 단위 덮어쓰기를 거부(409)하므로, 내용이 다른 파일이
|
||||
* 하나라도 있으면 버전 전체를 지우고 모든 파일을 다시 올린다. 이렇게 해야
|
||||
* `runtime-latest`/`portable-latest` 같은 별칭이 낡은 바이트와 새 바이트가 섞이지 않는다.
|
||||
*/
|
||||
async function publishBase(base, basePayloads) {
|
||||
const versionPath = base.split('/').pop()
|
||||
const items = basePayloads.map((payload) => ({
|
||||
...payload,
|
||||
sha256: createHash('sha256').update(payload.bytes).digest('hex'),
|
||||
}))
|
||||
|
||||
if (check) {
|
||||
for (const item of items) {
|
||||
console.log(`[portable] (check) PUT ${base}/${item.name} (${item.bytes.length} bytes)`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const remoteHashes = await remoteFileHashes(versionPath)
|
||||
const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256)
|
||||
if (differing.length === 0) {
|
||||
console.log(`[portable] 변경 없음(건너): ${base}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
||||
const isImmutableAsset =
|
||||
base.includes(`/portable-${version}/`) &&
|
||||
differing.some((item) => item.name.includes('.7z.') || item.name.includes('.zip.'))
|
||||
if (isImmutableAsset) {
|
||||
console.error(
|
||||
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${base}\n` +
|
||||
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (remoteHashes.size > 0) {
|
||||
await deletePackageVersion(versionPath)
|
||||
}
|
||||
for (const item of items) {
|
||||
await put(`${base}/${encodeURIComponent(item.name)}`, item.bytes, item.contentType)
|
||||
}
|
||||
}
|
||||
|
||||
await publishBase(`${FEED}/runtime-${version}`, runtimePayloads)
|
||||
await publishBase(`${FEED}/runtime-latest`, runtimePayloads)
|
||||
await publishBase(`${FEED}/portable-${version}`, payloads)
|
||||
await publishBase(`${FEED}/portable-latest`, payloads)
|
||||
|
||||
console.log(
|
||||
[
|
||||
'',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue