253 lines
10 KiB
JavaScript
253 lines
10 KiB
JavaScript
// scripts/ci/publish-gitlab-release.mjs
|
|
// 태그 파이프라인의 release 스테이지에서 실행:
|
|
// 1) package-windows / package-macos artifacts를 GitLab Generic Package Registry에 업로드
|
|
// - 버전별 경로: packages/generic/d3ro-voice/<version>/<file>
|
|
// - latest 경로: packages/generic/d3ro-voice/latest/<file> (electron-updater feed)
|
|
// 2) GitLab Release 생성 + asset 링크 연결
|
|
//
|
|
// agent-switchboard-client의 publish-gitlab-release.mjs 패턴을 D3RO에 맞게 단순화.
|
|
// (Ed25519 update-policy 서명 체계는 후속 단계 — 현재는 latest.yml 기반)
|
|
//
|
|
// 필요 env: CI_API_V4_URL, CI_PROJECT_ID, CI_JOB_TOKEN, CI_COMMIT_TAG
|
|
|
|
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";
|
|
|
|
const apiUrl = required("CI_API_V4_URL").replace(/\/+$/, "");
|
|
const projectId = required("CI_PROJECT_ID");
|
|
const jobToken = required("CI_JOB_TOKEN");
|
|
const tag = required("CI_COMMIT_TAG");
|
|
const version = tag.replace(/^v/, "");
|
|
const packageName = "d3ro-voice";
|
|
|
|
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));
|
|
|
|
// 업로드 대상 수집 — 설치파일 + 업데이터 메타데이터만 (unpacked 디렉토리 제외)
|
|
const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^(latest|alpha|beta)(-mac|-linux)?\.yml)$/;
|
|
|
|
const files = [];
|
|
for (const name of await readdir(releaseDirectory)) {
|
|
if (!ASSET_PATTERN.test(name)) continue;
|
|
const path = join(releaseDirectory, name);
|
|
if ((await stat(path)).isFile()) files.push({ name, path });
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
throw new Error(`No release assets found in ${releaseDirectory}`);
|
|
}
|
|
|
|
const hasWindows = files.some((file) => file.name.endsWith(".exe"));
|
|
if (!hasWindows) {
|
|
throw new Error(`Windows installer is missing for ${tag}.`);
|
|
}
|
|
// latest.yml은 자동 업데이트 feed의 핵심 — 없으면 electron-builder publish 설정 회귀
|
|
if (!files.some((file) => file.name === "latest.yml")) {
|
|
throw new Error(
|
|
`latest.yml is missing for ${tag} — check electron-builder.yml publish config.`,
|
|
);
|
|
}
|
|
const hasMac = files.some((file) => file.name.endsWith(".dmg"));
|
|
if (!hasMac) {
|
|
// mac runner 미등록/실패 시에도 Windows 단독 릴리스는 진행 (경고만)
|
|
process.stdout.write(`WARNING: macOS artifacts missing for ${tag} — Windows-only release.\n`);
|
|
}
|
|
|
|
const packageBaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/generic/${packageName}/${version}`;
|
|
const latestPackageBaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/generic/${packageName}/latest`;
|
|
|
|
const links = [];
|
|
const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// 1) 버전별 패키지 업로드
|
|
for (const file of sortedFiles) {
|
|
const registryName = safeAssetName(file.name);
|
|
const uploadUrl = `${packageBaseUrl}/${encodeURIComponent(registryName)}`;
|
|
await uploadFile(file, uploadUrl, "versioned package");
|
|
links.push({
|
|
name: file.name,
|
|
url: uploadUrl,
|
|
direct_asset_path: `/${registryName}`,
|
|
link_type: isUpdateMetadata(file.name) ? "other" : "package",
|
|
});
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
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`;
|
|
await upsertRelease(releaseUrl, description, links);
|
|
|
|
process.stdout.write(`Created ${tag} with ${links.length} release assets.\n`);
|
|
|
|
// ── 헬퍼 ────────────────────────────────────────────────────
|
|
|
|
function required(name) {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) throw new Error(`${name} is required.`);
|
|
return value;
|
|
}
|
|
|
|
function safeAssetName(name) {
|
|
return basename(name).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
}
|
|
|
|
function isUpdateMetadata(name) {
|
|
return /(?:\.blockmap$|^(?:latest|alpha|beta)(?:-mac|-linux)?\.yml$)/.test(name);
|
|
}
|
|
|
|
async function buildDescription() {
|
|
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) {
|
|
const lines = changelog.split("\n");
|
|
// 버전 뒤 경계 필수 — "0.1.0"이 "0.1.0-alpha" 섹션에 오매칭되지 않게
|
|
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const headerPattern = new RegExp(`^##\\s+\\[?v?${escaped}(?:\\]|\\s|$)`);
|
|
const start = lines.findIndex((line) => headerPattern.test(line));
|
|
if (start === -1) return null;
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (/^##\s+/.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
return lines.slice(start, end).join("\n").trim();
|
|
}
|
|
|
|
async function uploadFile(file, uploadUrl, target) {
|
|
process.stdout.write(`Uploading ${file.name} to ${target}...\n`);
|
|
const response = await globalThis.fetch(uploadUrl, {
|
|
method: "PUT",
|
|
headers: { "JOB-TOKEN": jobToken },
|
|
body: createReadStream(file.path),
|
|
duplex: "half",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${target} upload failed for ${file.name}: HTTP ${response.status} ${await response.text()}`);
|
|
}
|
|
}
|
|
|
|
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.`);
|
|
}
|
|
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()}`);
|
|
}
|
|
}
|
|
}
|