diff --git a/src/index.js b/src/index.js index 5fad040..9f7738c 100644 --- a/src/index.js +++ b/src/index.js @@ -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} 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(); + });