// scripts/ci/publish-gitlab-release.mjs // 태그 파이프라인의 release 스테이지에서 실행: // 1) package-windows / package-macos artifacts를 GitLab Generic Package Registry에 업로드 // - 버전별 경로: packages/generic/d3ro-voice// // - latest 경로: packages/generic/d3ro-voice/latest/ (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 } from "node:fs"; import { readFile, readdir, stat } from "node:fs/promises"; 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}`); } // electron-builder output: apps/desktop/release// 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 패키지 갱신 — 이전 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) { const registryName = safeAssetName(file.name); await uploadFile(file, `${latestPackageBaseUrl}/${encodeURIComponent(registryName)}`, "latest package"); } // 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()}`); } 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() { // 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"); } 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()}`); } } 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() ?? ""; } 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()}`); } } }