fix(cleanup-release): 修正刪除失敗未回報、輸入驗證與 URL 遮罩並重用連線
- tag 刪除失敗後補上 hadFailure 檢查,確保以非 0 結束 - GITEA_SERVER_URL 的 log 改為遮罩後 origin,API URL 清除 userinfo/query - release id 驗證為正整數才組 DELETE URL - isEmptyOrNull 不再把字串 'null' 當空值,改在環境變數來源正規化 - 共用 keep-alive https.Agent 減少重複建連線 - 分頁抓取加入 MAX_PAGES 上限避免無限迴圈 - 批次大小抽成 DELETE_CONCURRENCY 常數 - 錯誤處理保留 currentStage 階段上下文 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fb26de18d0
commit
45588ee96e
+71
-20
@@ -1,5 +1,10 @@
|
||||
const https = require('https');
|
||||
|
||||
const DELETE_CONCURRENCY = 4;
|
||||
const MAX_PAGES = 1000;
|
||||
|
||||
const keepAliveAgent = new https.Agent({ keepAlive: true });
|
||||
|
||||
const taipeiFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
@@ -142,7 +147,31 @@ function fail(message) {
|
||||
* @returns {boolean} 如果是空值則回傳 `true`。
|
||||
*/
|
||||
function isEmptyOrNull(value) {
|
||||
return value === undefined || value === null || value === '' || value === 'null';
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化環境變數值;workflow 模板缺值時可能代入字面值 `'null'`,一律視為未提供。
|
||||
*
|
||||
* @param {string | undefined} value 環境變數原始值。
|
||||
* @returns {string | undefined} 正規化後的值。
|
||||
*/
|
||||
function normalizeEnvValue(value) {
|
||||
return value === 'null' ? undefined : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 URL 遮罩成只含 origin 的文字,供 log 使用。
|
||||
*
|
||||
* @param {*} value 原始 URL 值。
|
||||
* @returns {string} 遮罩後的 origin,無法解析時回傳提示文字。
|
||||
*/
|
||||
function maskUrlForLog(value) {
|
||||
try {
|
||||
return new URL(String(value)).origin;
|
||||
} catch (error) {
|
||||
return '[invalid URL]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,9 +179,10 @@ function isEmptyOrNull(value) {
|
||||
*
|
||||
* @param {string} name 參數名稱。
|
||||
* @param {*} value 參數值。
|
||||
* @param {*} [displayValue=value] 寫進 log 的顯示值,敏感內容可先遮罩。
|
||||
*/
|
||||
function requireValue(name, value) {
|
||||
info(`${name}=${value}`);
|
||||
function requireValue(name, value, displayValue = value) {
|
||||
info(`${name}=${displayValue}`);
|
||||
|
||||
if (isEmptyOrNull(value)) {
|
||||
fail(`${name} is required`);
|
||||
@@ -193,6 +223,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
agent: keepAliveAgent,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
@@ -216,7 +247,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止。
|
||||
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止;超過 `MAX_PAGES` 即中止並回報異常。
|
||||
*
|
||||
* @param {string} baseUrl 不含 page 參數的 API URL。
|
||||
* @param {Record<string, string>} headers request 標頭。
|
||||
@@ -226,6 +257,10 @@ async function fetchAllPages(baseUrl, headers) {
|
||||
const all = [];
|
||||
|
||||
for (let page = 1; ; page += 1) {
|
||||
if (page > MAX_PAGES) {
|
||||
throw new Error(`GET ${baseUrl} 分頁超過 ${MAX_PAGES} 頁上限,中止抓取以避免無限迴圈`);
|
||||
}
|
||||
|
||||
const pageUrl = `${baseUrl}?page=${page}`;
|
||||
const { statusCode, body } = await request(pageUrl, { headers });
|
||||
|
||||
@@ -286,11 +321,13 @@ async function processInBatches(items, batchSize, handler) {
|
||||
* 執行 release 與 tag 清理流程。
|
||||
*/
|
||||
async function main() {
|
||||
const { GITEA_SERVER_URL, GITEA_REPOSITORY, RUNNER_TOKEN = '', KEEP_COUNT = '' } =
|
||||
process.env;
|
||||
const GITEA_SERVER_URL = normalizeEnvValue(process.env.GITEA_SERVER_URL);
|
||||
const GITEA_REPOSITORY = normalizeEnvValue(process.env.GITEA_REPOSITORY);
|
||||
const RUNNER_TOKEN = normalizeEnvValue(process.env.RUNNER_TOKEN) ?? '';
|
||||
const KEEP_COUNT = normalizeEnvValue(process.env.KEEP_COUNT) ?? '';
|
||||
|
||||
section('參數檢查');
|
||||
requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL);
|
||||
requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL, maskUrlForLog(GITEA_SERVER_URL));
|
||||
requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY);
|
||||
requireValue('KEEP_COUNT', KEEP_COUNT);
|
||||
requireInteger('KEEP_COUNT', KEEP_COUNT);
|
||||
@@ -304,7 +341,14 @@ async function main() {
|
||||
authHeaders.Authorization = `token ${RUNNER_TOKEN}`;
|
||||
}
|
||||
|
||||
const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`;
|
||||
const serverBase = new URL(GITEA_SERVER_URL);
|
||||
serverBase.username = '';
|
||||
serverBase.password = '';
|
||||
serverBase.search = '';
|
||||
serverBase.hash = '';
|
||||
const serverBaseUrl = serverBase.toString().replace(/\/+$/, '');
|
||||
|
||||
const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/releases`;
|
||||
|
||||
section('取得成品資訊');
|
||||
info(`GET ${releaseApiUrl}`);
|
||||
@@ -334,17 +378,18 @@ async function main() {
|
||||
section('刪除舊版本成品');
|
||||
|
||||
const releaseToDelete = releaseJson.slice(keepCount);
|
||||
await processInBatches(releaseToDelete, 4, async (releaseItem) => {
|
||||
if (!releaseItem || isEmptyOrNull(releaseItem.id)) {
|
||||
await processInBatches(releaseToDelete, DELETE_CONCURRENCY, async (releaseItem) => {
|
||||
const releaseId = releaseItem?.id;
|
||||
if (!Number.isSafeInteger(releaseId) || releaseId <= 0) {
|
||||
warn(
|
||||
`略過沒有 id 的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
|
||||
`略過 id 不是正整數的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseTag = sanitizeLogText(releaseItem.tag_name || '');
|
||||
const releaseName = sanitizeLogText(releaseItem.name || '');
|
||||
const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`;
|
||||
const deleteUrl = `${releaseApiUrl}/${releaseId}`;
|
||||
info(`DELETE ${releaseTag} (${releaseName})`);
|
||||
|
||||
const { statusCode } = await deleteResource(deleteUrl, authHeaders);
|
||||
@@ -358,7 +403,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (hadFailure) {
|
||||
throw new Error('至少有一筆 release 或 tag 刪除失敗');
|
||||
throw new Error('至少有一筆 release 刪除失敗');
|
||||
}
|
||||
|
||||
section('刪除未指定 release 的 tag');
|
||||
@@ -370,13 +415,13 @@ async function main() {
|
||||
.filter((tag) => !isEmptyOrNull(tag)),
|
||||
);
|
||||
|
||||
const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`;
|
||||
const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/tags`;
|
||||
info(`GET ${tagApiUrl}`);
|
||||
|
||||
const tagJson = await fetchAllPages(tagApiUrl, authHeaders);
|
||||
info(`TAG_COUNT=${tagJson.length}`);
|
||||
|
||||
await processInBatches(tagJson, 4, async (tagItem) => {
|
||||
await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => {
|
||||
const tagName = tagItem?.name;
|
||||
if (isEmptyOrNull(tagName)) {
|
||||
warn('略過沒有名稱的 tag');
|
||||
@@ -401,10 +446,16 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
if (hadFailure) {
|
||||
throw new Error('至少有一筆 tag 刪除失敗');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
currentStage = '';
|
||||
fail(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
main()
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
keepAliveAgent.destroy();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user