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>
224 lines
10 KiB
JavaScript
224 lines
10 KiB
JavaScript
/**
|
||
* 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_REPOSITORY;API 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_TOKEN/GITEA_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 須具刪除 release/tag 權限)
|
||
* @param {{id: number, tag_name: string, created_at: string}} release 要刪除的 release;tag_name 由本函式做 URL 編碼,呼叫端不需預先編碼
|
||
* @returns {Promise<boolean>} release 與 tag 皆刪除成功(或 tag 已不存在)為 true;任一 DELETE 非 2xx(tag 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_TOKEN/GITEA_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();
|