feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -10,8 +10,9 @@
//
// 필요 env: CI_API_V4_URL, CI_PROJECT_ID, CI_JOB_TOKEN, CI_COMMIT_TAG
import { createReadStream } from "node:fs";
import { createReadStream, readFileSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { timingSafeEqual } from "node:crypto";
import { basename, join } from "node:path";
import process from "node:process";
import { fileURLToPath, URL } from "node:url";
@ -27,6 +28,13 @@ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Unsupported release tag: ${tag}`);
}
const productVersion = JSON.parse(
await readFile(fileURLToPath(new URL("../../release/product-version.json", import.meta.url)), "utf8"),
);
if (tag !== `v${productVersion.version}` || !/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Release tag ${tag} does not match stable product version v${productVersion.version}.`);
}
// electron-builder output: apps/desktop/release/<version>/
const releaseDirectory = fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
@ -79,41 +87,28 @@ for (const file of sortedFiles) {
});
}
// 2) latest 패키지 갱신 — 이전 latest 삭제 후 현재 버전 재업로드.
// 삭제는 best-effort — JOB-TOKEN에 패키지 삭제 권한이 없는 GitLab 설정에서도
// 릴리스가 막히지 않게 한다 (동일 파일명 재업로드 시 다운로드는 최신 파일 우선).
try {
await deletePackagesForVersion("latest");
} catch (err) {
process.stdout.write(
`WARNING: stale latest package cleanup failed (continuing): ${err instanceof Error ? err.message : String(err)}\n`,
);
}
for (const file of sortedFiles) {
// 2) latest 패키지 갱신. 설치파일과 blockmap을 먼저 올리고, update metadata를
// 마지막에 게시한다. 기존 latest를 선삭제하지 않으므로 배포 중에도 이전
// 설치본이 404를 받지 않는다. 중복 업로드가 금지된 인스턴스라면 metadata
// 전환 전에 실패해 기존 feed가 그대로 보존된다.
const latestFiles = [...sortedFiles].sort((a, b) => {
const metadataOrder = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
return metadataOrder || a.name.localeCompare(b.name);
});
for (const file of latestFiles) {
const registryName = safeAssetName(file.name);
await uploadFile(file, `${latestPackageBaseUrl}/${encodeURIComponent(registryName)}`, "latest package");
}
// 3) GitLab Release 생성
for (const file of latestFiles.filter((candidate) => isUpdateMetadata(candidate.name))) {
validateUpdateMetadataReferences(file, latestFiles);
await verifyPublicLatestFile(file, latestPackageBaseUrl);
}
// 3) GitLab Release 생성 또는 재시도 시 안전하게 갱신
const description = await buildDescription();
const releaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/releases`;
const releaseResponse = await globalThis.fetch(releaseUrl, {
method: "POST",
headers: {
"JOB-TOKEN": jobToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `D3RO Voice ${tag}`,
tag_name: tag,
description,
assets: { links },
}),
});
if (!releaseResponse.ok) {
throw new Error(`Release creation failed: HTTP ${releaseResponse.status} ${await releaseResponse.text()}`);
}
await upsertRelease(releaseUrl, description, links);
process.stdout.write(`Created ${tag} with ${links.length} release assets.\n`);
@ -134,27 +129,13 @@ function isUpdateMetadata(name) {
}
async function buildDescription() {
// CHANGELOG.md의 해당 버전 섹션이 있으면 사용, 없으면 기본 안내문
try {
const changelog = await readFile(fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)), "utf8");
const section = extractChangelogSection(changelog, version);
if (section) return section;
} catch {
// CHANGELOG 없음 — 기본 안내문 사용
}
const lines = [
`## D3RO Voice ${tag}`,
"",
"아래 Assets에서 플랫폼별 설치 파일을 받으세요.",
"",
"- Windows: `D3RO Voice Setup *.exe`",
hasMac ? "- macOS (Apple Silicon): `*.dmg` — 무서명 빌드는 우클릭 → 열기로 실행" : null,
"",
"첫 실행 시 온보딩에서 AI 모델을 자동 다운로드합니다:",
"- LLM: `gemma4:e4b` (~9.6GB)",
"- Whisper: `large-v3-turbo` (~1.6GB)",
].filter((line) => line !== null);
return lines.join("\n");
const changelog = await readFile(
fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const section = extractChangelogSection(changelog, version);
if (!section) throw new Error(`CHANGELOG.md is missing a ${version} release section.`);
return section;
}
function extractChangelogSection(changelog, targetVersion) {
@ -187,44 +168,86 @@ async function uploadFile(file, uploadUrl, target) {
}
}
async function listPackagesForVersion(packageVersion) {
const packages = [];
let page = "1";
while (page) {
const listUrl = new URL(`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages`);
listUrl.searchParams.set("package_type", "generic");
listUrl.searchParams.set("package_name", packageName);
listUrl.searchParams.set("package_version", packageVersion);
listUrl.searchParams.set("per_page", "100");
listUrl.searchParams.set("page", page);
const response = await globalThis.fetch(listUrl, { headers: { "JOB-TOKEN": jobToken } });
if (!response.ok) {
throw new Error(`Package lookup failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
}
const rows = await response.json();
if (!Array.isArray(rows)) throw new Error(`Package lookup returned an invalid response for ${packageVersion}.`);
packages.push(
...rows.filter(
(row) => row?.package_type === "generic" && row?.name === packageName && row?.version === packageVersion,
),
);
page = response.headers.get("x-next-page")?.trim() ?? "";
function validateUpdateMetadataReferences(metadataFile, uploadedFiles) {
const text = readFileSyncUtf8(metadataFile.path);
const uploadedNames = new Set(uploadedFiles.map((file) => safeAssetName(file.name)));
const references = [...text.matchAll(/^\s*(?:-\s+url:|path:)\s*["']?([^"'\r\n]+?)["']?\s*$/gm)]
.map((match) => basename(match[1].trim()));
if (references.length === 0) {
throw new Error(`${metadataFile.name} does not reference a release artifact.`);
}
return packages;
}
async function deletePackagesForVersion(packageVersion) {
const packages = await listPackagesForVersion(packageVersion);
for (const packageEntry of packages) {
process.stdout.write(`Deleting stale ${packageName}/${packageVersion} package ${packageEntry.id}...\n`);
const response = await globalThis.fetch(
`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/${encodeURIComponent(packageEntry.id)}`,
{ method: "DELETE", headers: { "JOB-TOKEN": jobToken } },
);
if (!response.ok) {
throw new Error(`Package deletion failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
for (const reference of references) {
if (!uploadedNames.has(reference)) {
throw new Error(`${metadataFile.name} references missing release artifact ${reference}.`);
}
}
}
function readFileSyncUtf8(path) {
return readFileSync(path, "utf8");
}
async function verifyPublicLatestFile(file, packageBaseUrl) {
const publicUrl = `${packageBaseUrl}/${encodeURIComponent(safeAssetName(file.name))}?release=${encodeURIComponent(tag)}`;
const response = await globalThis.fetch(publicUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Public updater verification failed for ${file.name}: HTTP ${response.status}`);
}
const expected = await readFile(file.path);
const actual = Buffer.from(await response.arrayBuffer());
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error(`Public updater verification returned stale or altered ${file.name}.`);
}
process.stdout.write(`Verified public latest metadata ${file.name}.\n`);
}
async function upsertRelease(releaseUrl, description, desiredLinks) {
const headers = { "JOB-TOKEN": jobToken, "Content-Type": "application/json" };
const existingUrl = `${releaseUrl}/${encodeURIComponent(tag)}`;
const existingResponse = await globalThis.fetch(existingUrl, { headers });
if (existingResponse.status === 404) {
const createResponse = await globalThis.fetch(releaseUrl, {
method: "POST",
headers,
body: JSON.stringify({
name: `D3RO Voice ${tag}`,
tag_name: tag,
description,
assets: { links: desiredLinks },
}),
});
if (!createResponse.ok) {
throw new Error(`Release creation failed: HTTP ${createResponse.status} ${await createResponse.text()}`);
}
return;
}
if (!existingResponse.ok) {
throw new Error(`Release lookup failed: HTTP ${existingResponse.status} ${await existingResponse.text()}`);
}
const existing = await existingResponse.json();
const updateResponse = await globalThis.fetch(existingUrl, {
method: "PUT",
headers,
body: JSON.stringify({ name: `D3RO Voice ${tag}`, description }),
});
if (!updateResponse.ok) {
throw new Error(`Release update failed: HTTP ${updateResponse.status} ${await updateResponse.text()}`);
}
const existingLinks = Array.isArray(existing?.assets?.links) ? existing.assets.links : [];
for (const desired of desiredLinks) {
const match = existingLinks.find((link) => link?.name === desired.name);
const linksBase = `${existingUrl}/assets/links`;
const response = await globalThis.fetch(match ? `${linksBase}/${encodeURIComponent(match.id)}` : linksBase, {
method: match ? "PUT" : "POST",
headers,
body: JSON.stringify(desired),
});
if (!response.ok) {
throw new Error(`Release link upsert failed for ${desired.name}: HTTP ${response.status} ${await response.text()}`);
}
}
}