"use strict"; // 把 dist/ 的產物發佈到 Gitea 的 Releases。 // // set GITEA_TOKEN= (PowerShell:$env:GITEA_TOKEN="") // npm run release 實際發佈 // npm run release -- --dry-run 只檢查,不做任何寫入 // // token 需要 write:repository 權限。owner/repo 由 git remote 推導, // 網址預設為 https://;SSH 埠與網頁埠不同時用 GITEA_URL 覆寫。 const fs = require("node:fs"); const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const APP_DIR = path.resolve(__dirname, ".."); const DIST_DIR = path.join(APP_DIR, "dist"); const DRY_RUN = process.argv.includes("--dry-run"); const TOKEN = process.env.GITEA_TOKEN || ""; const pkg = JSON.parse(fs.readFileSync(path.join(APP_DIR, "package.json"), "utf8")); const TAG = `v${pkg.version}`; // 要上傳的產物;.blockmap 只有 electron-updater 的差分更新才需要,目前沒用到 const ASSET_PATTERNS = [/\.exe$/i, /\.zip$/i]; function git(...args) { // stderr 丟掉:releaseNotes 會刻意去 describe 一個可能不存在的舊 tag, // 讓 git 的 fatal 訊息印出來會被誤認成發佈失敗 return execFileSync("git", args, { cwd: APP_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } function remoteInfo() { const url = git("remote", "get-url", "origin"); const m = url.match(/^(?:ssh:\/\/(?:[^@]+@)?|(?:https?:\/\/)(?:[^@]+@)?|(?:[^@]+@))([^:/]+)(?::\d+)?[:/](.+?)\/([^/]+?)(?:\.git)?$/); if (!m) throw new Error(`看不懂 remote 網址:${url}`); return { host: m[1], owner: m[2], repo: m[3] }; } // stream 模式:先送 head,再串流檔案,最後以 tail 收尾。 // 產物上百 MB,不能整份讀進記憶體。 function request(method, url, { headers = {}, body = null, head = null, tail = null, stream = null, size = 0 } = {}) { return new Promise((resolve, reject) => { const u = new URL(url); const lib = u.protocol === "https:" ? https : http; const req = lib.request(u, { method, headers }, (res) => { let text = ""; res.setEncoding("utf8"); res.on("data", (c) => { text += c; }); res.on("end", () => { let json = null; try { json = text ? JSON.parse(text) : null; } catch { /* 非 JSON 就留 null */ } resolve({ status: res.statusCode, json, text }); }); }); req.on("error", reject); if (!stream) { req.end(body); return; } req.write(head); let sent = 0; let lastPct = -1; stream.on("data", (chunk) => { sent += chunk.length; const pct = Math.floor((sent / size) * 100); if (pct >= lastPct + 10) { lastPct = pct; process.stdout.write(`\r 上傳中 ${pct}% `); } }); stream.on("error", reject); stream.pipe(req, { end: false }); stream.on("end", () => req.end(tail)); }); } function apiUrl(base, owner, repo, suffix) { return `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${suffix}`; } async function api(method, url, payload) { const headers = { Accept: "application/json" }; if (TOKEN) headers.Authorization = `token ${TOKEN}`; let body = null; if (payload !== undefined) { body = Buffer.from(JSON.stringify(payload), "utf8"); headers["Content-Type"] = "application/json"; headers["Content-Length"] = body.length; } return request(method, url, { headers, body }); } async function uploadAsset(base, owner, repo, releaseId, filePath) { const name = path.basename(filePath); const size = fs.statSync(filePath).size; const boundary = `----claudepet${Date.now().toString(16)}`; const head = Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="attachment"; filename="${name}"\r\n` + "Content-Type: application/octet-stream\r\n\r\n", "utf8", ); const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); const url = `${apiUrl(base, owner, repo, `releases/${releaseId}/assets`)}?name=${encodeURIComponent(name)}`; const res = await request("POST", url, { headers: { Authorization: `token ${TOKEN}`, Accept: "application/json", "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": head.length + size + tail.length, }, head, tail, stream: fs.createReadStream(filePath), size, }); process.stdout.write("\r"); return res; } function releaseNotes() { let range = ""; try { const prev = git("describe", "--tags", "--abbrev=0", `${TAG}^`); range = `${prev}..HEAD`; } catch { try { git("rev-parse", "HEAD"); range = "HEAD"; } catch { range = ""; } } try { const log = range === "HEAD" ? git("log", "-n", "30", "--pretty=- %s") // 沒有舊 tag(第一次發佈)就取最近 30 筆 : git("log", range, "--pretty=- %s"); return log || `Claude Pet ${TAG}`; } catch { return `Claude Pet ${TAG}`; } } async function main() { const { host, owner, repo } = remoteInfo(); const base = (process.env.GITEA_URL || `https://${host}`).replace(/\/+$/, ""); console.log(`Gitea : ${base}`); console.log(`Repo : ${owner}/${repo}`); console.log(`Tag : ${TAG}`); if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`); const files = fs.readdirSync(DIST_DIR) .filter((f) => ASSET_PATTERNS.some((re) => re.test(f))) .map((f) => path.join(DIST_DIR, f)) .filter((f) => fs.statSync(f).isFile()); if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`); console.log("產物 :"); for (const f of files) console.log(` ${path.basename(f)} ${(fs.statSync(f).size / 1048576).toFixed(1)} MB`); // 附件限制:超過就先講清楚,不要傳到一半才失敗 const settings = await request("GET", `${base}/api/v1/settings/attachment`, { headers: { Accept: "application/json" } }); if (settings.json) { const maxMB = Number(settings.json.max_size); const types = String(settings.json.allowed_types || ""); const anyType = types.includes("*/*") || types.trim() === ""; const problems = []; for (const f of files) { const sizeMB = fs.statSync(f).size / 1048576; if (maxMB && sizeMB > maxMB) problems.push(`${path.basename(f)} 為 ${sizeMB.toFixed(1)} MB,超過上限 ${maxMB} MB`); const ext = path.extname(f).toLowerCase(); if (!anyType && !types.split(",").map((t) => t.trim().toLowerCase()).includes(ext)) { problems.push(`${path.basename(f)} 的副檔名 ${ext} 不在 allowed_types 內`); } } if (problems.length) { console.error("\n伺服器的附件設定擋住這次發佈:"); for (const p of problems) console.error(` - ${p}`); console.error("\n請在 Gitea 的 app.ini 調整後重啟:"); console.error(" [attachment]\n MAX_SIZE = 300\n ALLOWED_TYPES = */*"); process.exitCode = 1; return; } } const existing = await api("GET", apiUrl(base, owner, repo, `releases/tags/${encodeURIComponent(TAG)}`)); if (DRY_RUN) { console.log(`\n[dry-run] release ${TAG} ${existing.status === 200 ? "已存在,會重用並覆蓋同名附件" : "不存在,會新建"}`); console.log("[dry-run] 變更說明:"); console.log(releaseNotes().split("\n").map((l) => ` ${l}`).join("\n")); console.log("\n[dry-run] 沒有做任何寫入。"); return; } if (!TOKEN) throw new Error("缺少 GITEA_TOKEN 環境變數(需要 write:repository 權限)"); let release = existing.json; if (existing.status !== 200) { const created = await api("POST", apiUrl(base, owner, repo, "releases"), { tag_name: TAG, target_commitish: git("rev-parse", "--abbrev-ref", "HEAD"), name: `Claude Pet ${TAG}`, body: releaseNotes(), draft: false, prerelease: false, }); if (created.status !== 201) throw new Error(`建立 release 失敗(HTTP ${created.status}):${created.text}`); release = created.json; console.log(`\n已建立 release ${TAG}`); } else { console.log(`\nrelease ${TAG} 已存在,重用`); } for (const f of files) { const name = path.basename(f); const dup = (release.assets || []).find((a) => a.name === name); if (dup) { const del = await api("DELETE", apiUrl(base, owner, repo, `releases/${release.id}/assets/${dup.id}`)); console.log(` 移除舊的 ${name}(HTTP ${del.status})`); } console.log(` 上傳 ${name}`); const res = await uploadAsset(base, owner, repo, release.id, f); if (res.status !== 201) throw new Error(`上傳 ${name} 失敗(HTTP ${res.status}):${res.text}`); console.log(` 完成 ${name}`); } console.log(`\n發佈完成:${base}/${owner}/${repo}/releases/tag/${TAG}`); } main().catch((err) => { console.error(err.message || err); process.exitCode = 1; });