新增發佈到 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>
This commit is contained in:
2026-08-24 08:30:07 +08:00
co-authored by Claude Fable 5
parent d78f99fcc7
commit f1e7aca797
3 changed files with 266 additions and 1 deletions
+37 -1
View File
@@ -23,6 +23,7 @@
- [與 Codex 的對照](#與-codex-的對照)
- [HTTP API](#http-api)
- [打包成 exe](#打包成-exe)
- [發佈到 Gitea Releases](#發佈到-gitea-releases)
- [疑難排解](#疑難排解)
- [移除](#移除)
@@ -229,7 +230,7 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
| `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) |
| `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值;開發時指向 `electron.exe + 專案路徑`,打包後直接指向那顆 exe |
| `lib/paths.js` | 開發/安裝版/portable 三種情境的路徑解析(`app.asar``app.asar.unpacked`、是否打包、portable 的真實 exe 位置) |
| `scripts/*.js` | 上述兩者的 CLI 包裝 |
| `scripts/*.js` | 上述兩者的 CLI 包裝,以及 `release.js`(發佈到 Gitea Releases |
| `pets/` | 內附寵物 |
| `docs/` | README 用圖 |
| `build/icon.ico` | exe 與安裝檔的圖示,取自小念 spritesheet 第 0 列第 6 欄(中立正面)的頭肩方形裁切 |
@@ -370,6 +371,41 @@ NSIS 的 portable 外殼**每次啟動**都會把整包解壓到 `%TEMP%\ClaudeP
換圖示就換掉 `build/icon.ico`(要含 256×256)。目前這顆是從 `pets/xiao-nian/spritesheet.webp` 第 0 列第 6 欄(中立正面)取頭肩方形裁切產生的。
## 發佈到 Gitea Releases
```powershell
$env:GITEA_TOKEN = "<token>"
npm run release -- --dry-run # 只檢查:產物、附件限制、release 是否已存在,不做任何寫入
npm run release # 實際發佈
```
會建立 tag `v<版本>` 的 release(說明自動取自上一個 tag 之後的 commit 標題),
並上傳 `dist\` 裡的 `.exe``.zip`。同名附件會先刪再傳,所以可以重跑。
`.blockmap` 不上傳——那是 electron-updater 的差分更新才需要,本專案沒有用到。
**前置一:伺服器設定。** Gitea 預設的附件上限是 100 MB,而且 `ALLOWED_TYPES` 白名單沒有 `.exe`
三個產物全部會被擋下。在 `app.ini` 調整後重啟 Gitea
```ini
[attachment]
MAX_SIZE = 300
ALLOWED_TYPES = */*
```
腳本會先讀 `/api/v1/settings/attachment` 做預檢,設定不足時會直接列出哪個檔案卡在哪一條、
不會傳到一半才失敗。
**前置二:token。** 到 Gitea 的「設定 → 應用程式 → 產生新的權杖」,
勾選 `write:repository`(只有套件庫權限的 token 不行,repo API 會回 403)。
放環境變數就好,不要寫進檔案。
**其他環境變數**
| 變數 | 用途 |
|---|---|
| `GITEA_TOKEN` | 必填,需 `write:repository` |
| `GITEA_URL` | 網頁網址與 remote 主機不同時覆寫(預設 `https://<remote 主機>`)。SSH 走 8022 埠不影響,腳本只取主機名 |
## 疑難排解
### Electron 裝完沒有 `electron.exe`
+1
View File
@@ -14,6 +14,7 @@
"predist": "npm version patch --no-git-tag-version",
"dist": "electron-builder --win",
"pack": "electron-builder --win --dir",
"release": "node scripts/release.js",
"predist:portable": "npm version patch --no-git-tag-version",
"dist:portable": "electron-builder --win portable"
},
+228
View File
@@ -0,0 +1,228 @@
"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;
});