// scripts/ci/publish-forgejo-release.mjs // Canonical release publisher for D3RO Voice. // // Tag 파이프라인의 release 스테이지에서 실행: // 1) apps/desktop/release// 자산을 Forgejo Generic Package Registry에 업로드 // - 버전별 경로: /api/packages//generic/d3ro-voice// // - latest 경로: /api/packages//generic/d3ro-voice/latest/ (electron-updater feed) // 2) release/update-policy.json을 latest feed에 게시 (원격 정책/킬 스위치) // 3) Forgejo Release 생성/갱신 + 설치 자산 첨부 (admin·site 다운로드 허브) // // 배포 순서 보장: 설치파일/blockmap을 먼저 올리고 update metadata(latest.yml)를 // 마지막에 게시한다. 기존 설치본이 배포 도중 404를 받지 않는다. // // 필요 env: // FORGEJO_TOKEN (write:package + write:repository) 또는 FORGEJO_USERNAME/FORGEJO_PASSWORD // 선택: FORGEJO_REPO=git.chanpaca.net/yunchan/d3ro-voice // 선택: FORGEJO_RELEASE_TAG (기본: CI_COMMIT_TAG/GITHUB_REF_NAME/FORGEJO_REF_NAME) // 선택: FORGEJO_PUBLISH_DRY_RUN=1 (업로드 없이 사전점검) import { createHash } from "node:crypto"; import { createReadStream, readFileSync } 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"; import credentialHelpers from "../lib/credentials.cjs"; const { forgejoAuthorization } = credentialHelpers; const DEFAULT_REPO = "git.chanpaca.net/yunchan/d3ro-voice"; const PACKAGE_NAME = "d3ro-voice"; const POLICY_FILENAME = "update-policy.json"; const dryRun = process.env.FORGEJO_PUBLISH_DRY_RUN === "1" || process.argv.includes("--check"); const tag = resolveTag(); if (!tag) throw new Error("Release tag is required (CI_COMMIT_TAG / GITHUB_REF_NAME / FORGEJO_REF_NAME)."); const version = tag.replace(/^v/, ""); if (!/^\d+\.\d+\.\d+$/.test(version)) { throw new Error(`Only stable semver release tags are supported by the canonical publisher: ${tag}`); } const { origin, owner, repo } = parseRepo(resolveRepoUrl()); const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`; const packageLatestUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/latest`; const releasesApiUrl = `${origin}/api/v1/repos/${owner}/${repo}/releases`; const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME ? null : forgejoAuthorization(); const productVersion = JSON.parse( await readFile(fileURLToPath(new URL("../../release/product-version.json", import.meta.url)), "utf8"), ); if (tag !== `v${productVersion.version}`) { throw new Error(`Release tag ${tag} does not match product version v${productVersion.version}.`); } const policyPath = fileURLToPath(new URL("../../release/update-policy.json", import.meta.url)); const policyRaw = readFileSync(policyPath); try { const policy = JSON.parse(policyRaw.toString("utf8")); if (policy.schemaVersion !== 1) throw new Error("unsupported schemaVersion"); } catch (error) { throw new Error(`update-policy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); } const releaseDirectory = process.env.FORGEJO_RELEASE_DIR?.trim() ? process.env.FORGEJO_RELEASE_DIR.trim() : fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url)); const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^(latest|beta|alpha)(-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}`); if (!files.some((file) => file.name.endsWith(".exe"))) { throw new Error(`Windows installer is missing for ${tag}.`); } if (!files.some((file) => file.name === "latest.yml")) { throw new Error(`latest.yml is missing for ${tag} — check electron-builder.yml publish config.`); } if (!files.some((file) => file.name.endsWith(".dmg"))) { process.stdout.write(`WARNING: macOS artifacts missing for ${tag} — Windows-only release.\n`); } const sorted = files.sort((a, b) => a.name.localeCompare(b.name)); const sha256ByFile = new Map(); for (const file of sorted) sha256ByFile.set(file.name, await sha256(file.path)); if (dryRun) { process.stdout.write( `[forgejo] dry-run OK — ${tag}, ${sorted.length} assets, feed ${packageLatestUrl}\n`, ); process.exit(0); } // 1) 버전별(immutable) 패키지 업로드 for (const file of sorted) { await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { immutable: true, }); } // 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막 const latestOrder = [...sorted].sort((a, b) => { const order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name)); return order || a.name.localeCompare(b.name); }); for (const file of latestOrder) { await uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { replace: true, }); } await uploadToRegistry( { name: POLICY_FILENAME, path: policyPath }, `${packageLatestUrl}/${POLICY_FILENAME}`, { replace: true }, ); // 3) metadata 참조 검증 + 공개 URL 재검증 for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) { validateUpdateMetadataReferences(file, latestOrder); } for (const name of ["latest.yml", POLICY_FILENAME]) { await verifyPublicFile(`${packageLatestUrl}/${name}`); } // 4) Forgejo Release 생성/갱신 + 자산 첨부 const description = await buildReleaseDescription(); const releaseId = await upsertRelease(description); for (const file of sorted) { await uploadReleaseAsset(releaseId, file); } process.stdout.write(`Published ${tag} to Forgejo (${sorted.length} assets, release ${releasesApiUrl}/${tag}).\n`); // ── helpers ───────────────────────────────────────────────── function resolveTag() { return ( process.env.CI_COMMIT_TAG?.trim() || process.env.FORGEJO_RELEASE_TAG?.trim() || process.env.FORGEJO_REF_NAME?.trim() || process.env.GITHUB_REF_NAME?.trim() || process.argv.find((arg) => /^v\d+\.\d+\.\d+$/.test(arg)) || "" ); } function resolveRepoUrl() { const configured = process.env.FORGEJO_REPO?.trim(); const value = configured || DEFAULT_REPO; return value.startsWith("http") ? value : `https://${value}`; } function parseRepo(rawUrl) { const parsed = new URL(rawUrl); if (parsed.protocol !== "https:") throw new Error("Forgejo repo URL must use HTTPS"); const segments = parsed.pathname.split("/").filter(Boolean); if (segments.length !== 2) throw new Error("Forgejo repo URL must point to owner/repo"); return { origin: parsed.origin, owner: segments[0], repo: segments[1] }; } function safeAssetName(name) { return basename(name).replace(/[^A-Za-z0-9._-]+/g, "-"); } function isUpdateMetadata(name) { return /(?:\.blockmap$|^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$|^update-policy\.json$)/.test(name); } function isYamlUpdateMetadata(name) { return /^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$/.test(name); } async function sha256(path) { const hash = createHash("sha256"); await new Promise((resolve, reject) => { createReadStream(path) .on("data", (chunk) => hash.update(chunk)) .on("end", resolve) .on("error", reject); }); return hash.digest("hex"); } async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) { const fileStat = await stat(file.path).catch(() => null); if (immutable) { // 버전별 패키지: 이미 동일 크기의 파일이 업로드되어 있다면 중복 전송 방지 const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null); if (head && head.ok) { const remoteLength = head.headers.get("content-length"); if (fileStat && remoteLength && Number(remoteLength) === fileStat.size) { process.stdout.write(` verified existing immutable ${file.name} (${fileStat.size} bytes)\n`); return; } } } if (replace) { // replace 모드: 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한 // Cloudflare 타임아웃(HTTP 524)을 원천 차단한다. await forgejoFetch(url, { method: "DELETE" }).catch(() => null); } const body = await readFile(file.path); const response = await forgejoFetch(url, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body, }); if (response.ok) { process.stdout.write(` uploaded ${file.name}\n`); return; } if (response.status === 409 && (replace || immutable)) { // 재실행/재시도: 기존 파일을 지우고 다시 올린다. await forgejoFetch(url, { method: "DELETE" }); const retry = await forgejoFetch(url, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body, }); if (!retry.ok) { throw new Error(`registry upload failed for ${file.name}: HTTP ${retry.status} ${await retry.text()}`); } process.stdout.write(` replaced ${file.name}\n`); return; } throw new Error(`registry upload failed for ${file.name}: HTTP ${response.status} ${await response.text()}`); } function validateUpdateMetadataReferences(metadataFile, uploadedFiles) { const text = readFileSync(metadataFile.path, "utf8"); 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}.`); } } } async function verifyPublicFile(url) { const response = await fetch(`${url}?release=${encodeURIComponent(tag)}`, { cache: "no-store" }); if (!response.ok) throw new Error(`Public updater verification failed for ${url}: HTTP ${response.status}`); const body = await response.text(); if (!body.trim()) throw new Error(`Public updater file is empty: ${url}`); } async function buildReleaseDescription() { 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.`); const checksums = [...sha256ByFile.entries()] .map(([name, hash]) => `- \`${name}\`: \`${hash}\``) .join("\n"); return `${section}\n\n### SHA-256\n${checksums}`; } function extractChangelogSection(changelog, targetVersion) { const lines = changelog.split("\n"); 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 index = start + 1; index < lines.length; index++) { if (/^##\s+/.test(lines[index])) { end = index; break; } } return lines.slice(start, end).join("\n").trim(); } async function upsertRelease(description) { const byTag = await forgejoFetch(`${releasesApiUrl}/tags/${encodeURIComponent(tag)}`); if (byTag.ok) { const existing = await byTag.json(); const update = await forgejoFetch(`${releasesApiUrl}/${existing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `D3RO Voice ${tag}`, body: description, prerelease: false, draft: false }), }); if (!update.ok) throw new Error(`release update failed: HTTP ${update.status} ${await update.text()}`); return existing.id; } if (byTag.status !== 404) { throw new Error(`release lookup failed: HTTP ${byTag.status} ${await byTag.text()}`); } const create = await forgejoFetch(releasesApiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag_name: tag, target_commitish: "main", name: `D3RO Voice ${tag}`, body: description, draft: false, prerelease: false, }), }); if (!create.ok) throw new Error(`release creation failed: HTTP ${create.status} ${await create.text()}`); const created = await create.json(); return created.id; } async function uploadReleaseAsset(releaseId, file) { const assetName = safeAssetName(file.name); // 재실행 대비: 같은 이름의 기존 asset 제거 const list = await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets`); if (list.ok) { const assets = await list.json(); for (const asset of Array.isArray(assets) ? assets : []) { if (asset?.name === assetName && typeof asset.id === "number") { await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets/${asset.id}`, { method: "DELETE" }); } } } const buffer = await readFile(file.path); const form = new FormData(); form.append("attachment", new Blob([buffer]), assetName); const response = await forgejoFetch( `${releasesApiUrl}/${releaseId}/assets?name=${encodeURIComponent(assetName)}`, { method: "POST", body: form }, ); if (!response.ok) { throw new Error(`release asset upload failed for ${assetName}: HTTP ${response.status} ${await response.text()}`); } process.stdout.write(` attached ${assetName}\n`); } function forgejoFetch(url, init = {}) { return fetch(url, { ...init, headers: { ...(authorization ? { Authorization: authorization } : {}), ...(init.headers ?? {}), }, }); }