feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue