Files
claude-pet/scripts/release.js
T
JianMiauandClaude Fable 5 f1e7aca797 新增發佈到 Gitea Releases 的腳本
摘要:
scripts/release.js 建立 tag v<版本> 的 release 並上傳 dist 的 .exe / .zip,
附帶 --dry-run 與伺服器設定預檢。

根本原因:
產物有 345 MB 且不進版控,需要一個散布管道。Gitea 有 Releases 與附件 API,
但目前伺服器設定會擋下全部三個檔:
  - [attachment] max_size 為 100 MB,portable 101.7 / setup 102.0 / zip 141.3 都超過
  - allowed_types 白名單只有文件與圖片類,沒有 .exe
另外 .npmrc 裡現有的 token 只有套件庫權限,呼叫 repo API 會回 403。

影響:
沒有腳本就得手動上傳三個上百 MB 的檔;直接上傳也會因上述設定而失敗。

修法:
- 新增 scripts/release.js 與 npm script release:
  owner/repo 由 git remote 推導,網址可用 GITEA_URL 覆寫(SSH 埠與網頁埠不同時)。
  release 說明取自上一個 tag 之後的 commit 標題;沒有舊 tag 就取最近 30 筆。
  release 已存在則重用,同名附件先刪再傳,可重複執行。
- 上傳採用手動組裝的 multipart 並以串流送出,不把上百 MB 的檔案讀進記憶體。
- 發佈前先讀 /api/v1/settings/attachment 做預檢,超過上限或副檔名不允許時
  直接列出是哪個檔案卡在哪一條並中止,不會傳到一半才失敗。
- --dry-run 只做讀取,印出將建立或重用的 release 與變更說明。
- git() 關閉 stderr:releaseNotes 會刻意 describe 一個可能不存在的舊 tag,
  否則 git 的 fatal 訊息會被誤認成發佈失敗。
- README 新增「發佈到 Gitea Releases」一節,寫明所需的 app.ini 設定與 token 權限。

驗證:
- 對真實伺服器執行 --dry-run:正確推導出 AI/claude-pet 與 tag v2.0.3,
  並列出三個檔各自違反的限制後中止,全程只有 GET。
- 另寫一個假的 Gitea(本機 http)跑完整流程:建立 release 的 tag、名稱、
  target_commitish 與說明皆正確;三個附件的 Content-Length 與實收位元組相符、
  multipart 邊界與 Content-Disposition 正確、結尾正確,
  且收到的檔案內容 SHA256 與磁碟上的完全一致(共 345 MB)。

未執行:
實際發佈需要伺服器調整設定與一個有 write:repository 權限的 token,兩者都在使用者手上。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 08:30:07 +08:00

229 lines
8.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// 把 dist/ 的產物發佈到 Gitea 的 Releases。
//
// set GITEA_TOKEN=<token> PowerShell$env:GITEA_TOKEN="<token>"
// npm run release 實際發佈
// npm run release -- --dry-run 只檢查,不做任何寫入
//
// token 需要 write:repository 權限。owner/repo 由 git remote 推導,
// 網址預設為 https://<remote 主機>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;
});