The feed publisher overwrote whatever version-specific assets it found, so a re-run of an old release tag could quietly replace the installer that customers already downloaded under that version number. Publication now compares the bytes already in the version-specific registry path and stops when they differ, while still allowing an identical re-run to finish. The metadata verifier gained a negative case so the guard cannot be removed unnoticed.
382 lines
15 KiB
JavaScript
382 lines
15 KiB
JavaScript
// scripts/ci/publish-forgejo-release.mjs
|
|
// Canonical release publisher for D3RO Voice.
|
|
//
|
|
// Tag 파이프라인의 release 스테이지에서 실행:
|
|
// 1) apps/desktop/release/<version>/ 자산을 Forgejo Generic Package Registry에 업로드
|
|
// - 버전별 경로: /api/packages/<owner>/generic/d3ro-voice/<version>/<file>
|
|
// - latest 경로: /api/packages/<owner>/generic/d3ro-voice/latest/<file> (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);
|
|
}
|
|
|
|
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
|
// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고,
|
|
// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다.
|
|
await assertVersionNotRepublished(sorted);
|
|
|
|
// 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 assertVersionNotRepublished(localFiles) {
|
|
for (const file of localFiles) {
|
|
const url = `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`;
|
|
const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null);
|
|
if (!head || !head.ok) continue;
|
|
|
|
const remoteLength = Number(head.headers.get("content-length"));
|
|
const localLength = (await stat(file.path)).size;
|
|
if (!Number.isFinite(remoteLength) || remoteLength === localLength) continue;
|
|
|
|
throw new Error(
|
|
`${tag} is already published with different bytes (${safeAssetName(file.name)}: ` +
|
|
`remote ${remoteLength} bytes, local ${localLength} bytes). Releases are immutable — ` +
|
|
"lift the version and publish a new tag instead of re-publishing this one.",
|
|
);
|
|
}
|
|
}
|
|
|
|
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 ?? {}),
|
|
},
|
|
});
|
|
}
|