feat(release): switchboard 릴리스 체계 이식 — Mac 배포 + Registry 게시 + 자동 업데이트
- package-macos CI job: arm64 무서명(ad-hoc) dmg+zip, D3RO_MAC_RUNNER 변수 게이트 - sync-version.mjs: 태그 → package.json 버전 동기화 - publish-gitlab-release.mjs: Generic Package Registry(버전별+latest) 업로드 + Release 생성 - electron-builder: publish generic(latest.yml 생성), 공백 없는 artifactName, mac arm64 단일화 - UpdateService: electron-updater 4h 주기 체크 + 재시작 다이얼로그 (update-feed.ts SSOT) - @rollup/rollup-win32-x64-msvc → optionalDependencies (mac npm ci EBADPLATFORM 해소) - docs/deployment/release-guide.md: runner 등록·feed 설정·파일명 규칙 가이드 리뷰 워크플로(3관점 적대적 검증)로 확정된 결함 8건 반영: publish 부재로 latest.yml 미생성, 파일명 공백 vs 레지스트리 404, mac EBADPLATFORM, needs optional의 실패 미커버, DELETE 권한 의존, CHANGELOG regex 경계, --arm64 무시, 다이얼로그 parent 부재
This commit is contained in:
parent
8f7d300b89
commit
3beba99668
11 changed files with 776 additions and 2291 deletions
230
scripts/ci/publish-gitlab-release.mjs
Normal file
230
scripts/ci/publish-gitlab-release.mjs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// 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 } 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/<version>/
|
||||
const releaseDirectory = fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
|
||||
|
||||
// 업로드 대상 수집 — 설치파일 + 업데이터 메타데이터만 (unpacked 디렉토리 제외)
|
||||
const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^latest(-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(?:-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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
scripts/ci/sync-version.mjs
Normal file
34
scripts/ci/sync-version.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// scripts/ci/sync-version.mjs
|
||||
// CI_COMMIT_TAG(v0.1.0-alpha 등)에서 버전을 추출해 apps/desktop/package.json에 기록한다.
|
||||
// electron-builder가 package.json version으로 설치파일명/latest.yml을 만들기 때문에
|
||||
// 태그와 산출물 버전이 어긋나지 않게 패키징 전에 실행한다.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const tag = process.env.CI_COMMIT_TAG ?? process.argv[2]
|
||||
if (!tag) {
|
||||
console.log('[sync-version] CI_COMMIT_TAG 없음 — 버전 동기화 건너뜀')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const version = tag.replace(/^v/, '')
|
||||
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
console.error(`[sync-version] 유효하지 않은 semver: ${version} (tag: ${tag})`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const pkgPath = join(root, 'apps', 'desktop', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
|
||||
if (pkg.version === version) {
|
||||
console.log(`[sync-version] 이미 동기화됨: ${version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const prev = pkg.version
|
||||
pkg.version = version
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
||||
console.log(`[sync-version] apps/desktop version: ${prev} → ${version}`)
|
||||
Loading…
Add table
Add a link
Reference in a new issue