feat: 實作 Clean Old Release action
docker-actions/template: CI / BUILD (pull_request) Successful in 5s

- 依建立時間將 release 分為正式版與 beta 版(tag 含 beta),各自保留最新指定筆數(keep_count 預設 5、keep_count_beta 預設 10),其餘舊 release 連同對應 git tag 一併刪除
- Node.js 主程式置於 src/(index.js 主流程、logger.js 統一訊息格式 [yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息)
- 多階段建置 dockerfile(NODE_VERSION=latest / NODE_RUNTIME=slim),entrypoint.sh 輸出啟動訊息後 exec 主程式
- 補齊全部 JSDoc 與指令檔逐行註解,重建 readme.md
- 移除範本的 .gitea/scoped_workflows/ci.yaml

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-17 08:38:21 +08:00
co-authored by Claude Fable 5
parent fd96e217fc
commit 597fa4119b
7 changed files with 460 additions and 404 deletions
+217 -9
View File
@@ -1,15 +1,223 @@
const fs = require('fs');
/**
* Clean Old Release 主程式:
* 依建立時間將 release 分為正式版與 beta 版(tag 名稱含 beta),
* 各自保留最新指定筆數,其餘舊 release 連同對應 git tag 一併刪除。
*/
function main() {
const message = process.env.INPUT_MESSAGE || '';
const outputPath = process.env.GITHUB_OUTPUT;
const line = `message=${message}\n`;
const logger = require('./logger');
if (outputPath) {
fs.appendFileSync(outputPath, line);
} else {
process.stdout.write(line);
// 未捕捉例外一律以 ERR 格式輸出後非零結束
process.on('uncaughtException', (error) => {
logger.err(`未捕捉例外:${error.stack || error.message}`);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
logger.err(`未處理的 Promise 拒絕:${reason instanceof Error ? reason.stack : reason}`);
process.exit(1);
});
/**
* 讀取並驗證保留筆數輸入(須為 0 以上整數);未提供時採用預設值,
* 驗證失敗會輸出 ERR 訊息並以 exit code 1 終止程序(不拋出例外)。
* @param {string} name 輸入參數名稱(log 顯示用)
* @param {string|undefined} raw 環境變數原始值(INPUT_*);undefined 或空字串視為未提供
* @param {number} fallback 未提供時的預設值(本身不做驗證,呼叫端須傳入合法值)
* @returns {number} 驗證後的保留筆數(0 為合法值,代表該群全部刪除)
*/
function parseKeepCount(name, raw, fallback) {
if (raw === undefined || raw === '') {
logger.inf(`輸入 ${name} 未提供,採用預設值 ${fallback}`, '讀取輸入');
return fallback;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
logger.err(`輸入 ${name} 的值「${raw}」不是 0 以上的整數`, '讀取輸入');
process.exit(1);
}
logger.inf(`輸入 ${name} = ${value}`, '讀取輸入');
return value;
}
/**
* 讀取執行所需的環境設定(repo、API URL、token),任一缺漏即輸出 ERR 並以 exit code 1 結束。
* repo 取自 GITHUB_REPOSITORYAPI URL 優先取 GITHUB_API_URL,否則由 GITHUB_SERVER_URL 推得 /api/v1
* token 依序取 GITHUB_TOKEN、GITEA_TOKEN,內容不輸出至 log。
* @returns {{repository: string, apiUrl: string, token: string}} 環境設定;apiUrl 已去除尾端斜線
*/
function readEnvironment() {
const repository = process.env.GITHUB_REPOSITORY;
if (!repository) {
logger.err('找不到環境變數 GITHUB_REPOSITORY,無法判斷目標 repo', '讀取輸入');
process.exit(1);
}
let apiUrl = process.env.GITHUB_API_URL;
if (!apiUrl && process.env.GITHUB_SERVER_URL) {
apiUrl = `${process.env.GITHUB_SERVER_URL.replace(/\/+$/, '')}/api/v1`;
logger.wrn(`環境變數 GITHUB_API_URL 未提供,改由 GITHUB_SERVER_URL 推得:${apiUrl}`, '讀取輸入');
}
if (!apiUrl) {
logger.err('找不到環境變數 GITHUB_API_URL 或 GITHUB_SERVER_URL,無法組出 Gitea API 位址', '讀取輸入');
process.exit(1);
}
apiUrl = apiUrl.replace(/\/+$/, '');
const token = process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN;
if (!token) {
logger.err('找不到環境變數 GITHUB_TOKENGITEA_TOKEN,請於 workflow 以 env 傳入(例如 GITHUB_TOKEN: ${{ gitea.token }}', '讀取輸入');
process.exit(1);
}
logger.inf(`目標 repo${repository}`, '讀取輸入');
logger.inf(`Gitea API 位址:${apiUrl}`, '讀取輸入');
logger.inf('已取得 API token(內容不輸出)', '讀取輸入');
return { repository, apiUrl, token };
}
/**
* 呼叫 Gitea API:組合 URL、附加 token 認證標頭後發出請求。
* 不檢查 HTTP 狀態碼,一律回傳整包 Response 由呼叫端判斷;
* 網路層例外(連線失敗等)不在此攔截,會沿 Promise 鏈向外拋。
* @param {{apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值)
* @param {string} method HTTP 方法(如 GET、DELETE
* @param {string} path API 路徑(/repos/... 開頭,須自行完成 URL 編碼)
* @returns {Promise<Response>} fetch 回應
*/
async function callApi(env, method, path) {
const url = `${env.apiUrl}${path}`;
logger.trc(`${method} ${url}`, '呼叫 API');
return fetch(url, {
method,
headers: {
Authorization: `token ${env.token}`,
'Content-Type': 'application/json',
},
});
}
/**
* 逐頁(每頁 50 筆)取得 repo 全部 release,直到單頁回傳不足 50 筆為止;
* 任一頁查詢失敗即輸出 ERR 並以 exit code 1 終止(fail-fast,不做部分清理)。
* @param {{repository: string, apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值)
* @returns {Promise<Array<{id: number, tag_name: string, created_at: string}>>} release 清單(僅列出實際使用的欄位,API 回傳物件另有其他欄位)
*/
async function fetchAllReleases(env) {
const releases = [];
const pageSize = 50;
for (let page = 1; ; page += 1) {
const response = await callApi(env, 'GET', `/repos/${env.repository}/releases?page=${page}&limit=${pageSize}`);
if (!response.ok) {
logger.err(`查詢 release 失敗(第 ${page} 頁):HTTP ${response.status} ${await response.text()}`, '查詢 release');
process.exit(1);
}
const batch = await response.json();
logger.trc(`${page} 頁取得 ${batch.length} 筆 release`, '查詢 release');
releases.push(...batch);
if (batch.length < pageSize) {
break;
}
}
logger.inf(`共取得 ${releases.length} 筆 release`, '查詢 release');
return releases;
}
/**
* 依保留筆數挑出某群組要刪除的舊 release(依 created_at 新→舊排序後保留前 N 筆)。
* 不修改傳入的陣列(排序作用於複本),僅輸出 log,無其他副作用。
* @param {string} groupName 群組名稱(log 顯示用,如「正式版」「beta 版」)
* @param {Array<{id: number, tag_name: string, created_at: string}>} releases 該群組的 release 清單
* @param {number} keepCount 要保留的最新筆數;大於清單長度時不刪除任何項目
* @returns {Array<{id: number, tag_name: string, created_at: string}>} 要刪除的 release 清單(新→舊排序)
*/
function pickDeletions(groupName, releases, keepCount) {
const sorted = [...releases].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const kept = sorted.slice(0, keepCount);
const toDelete = sorted.slice(keepCount);
logger.inf(`${groupName}${sorted.length} 筆,保留最新 ${kept.length} 筆,待刪除 ${toDelete.length}`, '分群');
for (const release of kept) {
logger.dbg(`${groupName}保留:${release.tag_name}(建立於 ${release.created_at}`, '分群');
}
return toDelete;
}
/**
* 透過 Gitea API 刪除一筆 release,成功後再刪除其對應的 git tag;
* tag 已不存在(404)視為成功並輸出警告,其餘任一步失敗即記錄 ERR 並回報失敗。
* 注意:release 刪除成功但 tag 刪除失敗時屬部分完成,殘留 tag 需人工清理。
* @param {{repository: string, apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值,token 須具刪除 releasetag 權限)
* @param {{id: number, tag_name: string, created_at: string}} release 要刪除的 releasetag_name 由本函式做 URL 編碼,呼叫端不需預先編碼
* @returns {Promise<boolean>} release 與 tag 皆刪除成功(或 tag 已不存在)為 true;任一 DELETE 非 2xxtag 404 除外)為 false。HTTP 錯誤不拋例外,網路層例外會向外拋
*/
async function deleteRelease(env, release) {
logger.inf(`刪除 release${release.tag_name}id=${release.id},建立於 ${release.created_at}`, '刪除');
const releaseResponse = await callApi(env, 'DELETE', `/repos/${env.repository}/releases/${release.id}`);
if (!releaseResponse.ok) {
logger.err(`刪除 release ${release.tag_name} 失敗:HTTP ${releaseResponse.status} ${await releaseResponse.text()}`, '刪除');
return false;
}
logger.inf(`release ${release.tag_name} 已刪除`, '刪除');
const tagResponse = await callApi(env, 'DELETE', `/repos/${env.repository}/tags/${encodeURIComponent(release.tag_name)}`);
if (tagResponse.status === 404) {
logger.wrn(`tag ${release.tag_name} 不存在(可能已被刪除),略過`, '刪除');
return true;
}
if (!tagResponse.ok) {
logger.err(`刪除 tag ${release.tag_name} 失敗:HTTP ${tagResponse.status} ${await tagResponse.text()}`, '刪除');
return false;
}
logger.inf(`tag ${release.tag_name} 已刪除`, '刪除');
return true;
}
/**
* 主流程:讀取輸入 → 逐頁查詢 release → 以 tag 名稱含 beta 分群 → 依保留筆數挑選待刪 → 逐筆循序刪除 → 總結。
* 無 release 或兩群皆在保留筆數內時提前結束;任一筆刪除失敗仍繼續刪其餘,最後以 exit code 1 結束。
* 前置條件:runner 須注入 GITHUB_REPOSITORY 與 GITHUB_API_URL(或 GITHUB_SERVER_URL),並提供 GITHUB_TOKENGITEA_TOKEN。
*/
async function main() {
logger.inf('開始執行 Clean Old Release');
const keepCount = parseKeepCount('keep_count', process.env.INPUT_KEEP_COUNT, 5);
const keepCountBeta = parseKeepCount('keep_count_beta', process.env.INPUT_KEEP_COUNT_BETA, 10);
const env = readEnvironment();
const releases = await fetchAllReleases(env);
if (releases.length === 0) {
logger.inf('repo 沒有任何 release,無需清理', '完成');
return;
}
const betaReleases = [];
const stableReleases = [];
for (const release of releases) {
(release.tag_name.toLowerCase().includes('beta') ? betaReleases : stableReleases).push(release);
}
logger.inf(`分群結果:正式版 ${stableReleases.length} 筆、beta 版 ${betaReleases.length}`, '分群');
const toDelete = [
...pickDeletions('正式版', stableReleases, keepCount),
...pickDeletions('beta 版', betaReleases, keepCountBeta),
];
if (toDelete.length === 0) {
logger.inf('兩群皆在保留筆數內,無需刪除任何 release', '完成');
return;
}
let failureCount = 0;
for (const release of toDelete) {
const success = await deleteRelease(env, release);
if (!success) {
failureCount += 1;
}
}
if (failureCount > 0) {
logger.err(`清理完成,但有 ${failureCount}/${toDelete.length} 筆刪除失敗`, '完成');
process.exit(1);
}
logger.inf(`清理完成,共刪除 ${toDelete.length} 筆舊 release(含對應 tag`, '完成');
}
main();