Files
clean-old-release/src/index.js
T
JefferyandClaude Fable 5 597fa4119b
docker-actions/template: CI / BUILD (pull_request) Successful in 5s
feat: 實作 Clean Old Release action
- 依建立時間將 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>
2026-07-17 08:38:21 +08:00

224 lines
10 KiB
JavaScript
Raw 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.
/**
* Clean Old Release 主程式:
* 依建立時間將 release 分為正式版與 beta 版(tag 名稱含 beta),
* 各自保留最新指定筆數,其餘舊 release 連同對應 git tag 一併刪除。
*/
const logger = require('./logger');
// 未捕捉例外一律以 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();