From db5c8f2e20e0bb244736389e0cb75b8dbc4b4b44 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 15 Jul 2026 10:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(cleanup-release):=20=E5=BC=B7=E5=8C=96?= =?UTF-8?q?=E5=8F=83=E6=95=B8=E9=A9=97=E8=AD=89=E8=88=87=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E8=99=95=E7=90=86=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E8=B7=AF=E5=BE=91=E6=B3=A8=E5=85=A5=E8=88=87=E6=95=B4=E6=89=B9?= =?UTF-8?q?=E4=B8=AD=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processInBatches 改用 Promise.allSettled 並回傳結果集合,單筆例外不再中止同批,移除共享 hadFailure 狀態 - GITEA_REPOSITORY 嚴格驗證 owner/repo 格式並逐段 encodeURIComponent,拒絕 . / .. 路徑片段 - GITEA_SERVER_URL 於參數檢查階段驗證為合法 HTTPS 絕對 URL,錯誤不再留到中途才拋出 - KEEP_COUNT 下限改為 1 並限制在安全整數範圍,避免設 0 清空所有 release 或大數失真 - DELETE 請求改以 collectBody: false 丟棄回應串流,只保留狀態碼 Co-Authored-By: Claude Fable 5 --- src/index.js | 141 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/src/index.js b/src/index.js index 9f7738c..8cc01bd 100644 --- a/src/index.js +++ b/src/index.js @@ -191,14 +191,52 @@ function requireValue(name, value, displayValue = value) { } /** - * 驗證字串是否為非負整數。 + * 驗證字串是否為正整數,且落在安全整數範圍內;下限 1 可避免把保留數設成 0 而清空所有 release。 * * @param {string} name 參數名稱。 * @param {string} value 參數值。 */ -function requireInteger(name, value) { - if (!/^[0-9]+$/.test(value)) { - fail(`${name} must be a non-negative integer`); +function requirePositiveInteger(name, value) { + if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) { + fail(`${name} must be a positive integer within the safe integer range`); + process.exit(1); + } +} + +/** + * 驗證值是否為合法的 HTTPS 絕對 URL,避免 URL 解析失敗留到主流程中途才拋出。 + * + * @param {string} name 參數名稱。 + * @param {*} value 參數值。 + */ +function requireHttpsUrl(name, value) { + let parsed; + try { + parsed = new URL(String(value)); + } catch (error) { + fail(`${name} must be a valid absolute URL`); + process.exit(1); + } + + if (parsed.protocol !== 'https:') { + fail(`${name} must use HTTPS`); + process.exit(1); + } +} + +/** + * 驗證 repository 是否為 `owner/repo` 格式且僅含安全字元,拒絕 `.`、`..` 等路徑片段。 + * + * @param {string} name 參數名稱。 + * @param {*} value 參數值。 + */ +function requireRepository(name, value) { + const segments = String(value).split('/'); + const isValidSegment = (segment) => + /^[A-Za-z0-9_.-]+$/.test(segment) && segment !== '.' && segment !== '..'; + + if (segments.length !== 2 || !segments.every(isValidSegment)) { + fail(`${name} must be in owner/repo format`); process.exit(1); } } @@ -207,10 +245,10 @@ function requireInteger(name, value) { * 對指定 URL 發送 request,回傳狀態碼與 body。 * * @param {string} url 完整目標網址。 - * @param {{ method?: string, headers?: Record }} [options] request 設定。 + * @param {{ method?: string, headers?: Record, collectBody?: boolean }} [options] request 設定;`collectBody` 為 `false` 時丟棄回應內容、只保留狀態碼。 * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 */ -function request(url, { method = 'GET', headers = {} } = {}) { +function request(url, { method = 'GET', headers = {}, collectBody = true } = {}) { return new Promise((resolve, reject) => { const target = new URL(url); if (target.protocol !== 'https:') { @@ -226,6 +264,16 @@ function request(url, { method = 'GET', headers = {} } = {}) { agent: keepAliveAgent, }, (res) => { + const statusCode = res.statusCode || 0; + + if (!collectBody) { + res.resume(); + res.on('end', () => { + resolve({ statusCode, body: '' }); + }); + return; + } + const chunks = []; res.setEncoding('utf8'); @@ -234,7 +282,7 @@ function request(url, { method = 'GET', headers = {} } = {}) { }); res.on('end', () => { resolve({ - statusCode: res.statusCode || 0, + statusCode, body: chunks.join(''), }); }); @@ -300,21 +348,49 @@ async function deleteResource(url, headers) { return request(url, { method: 'DELETE', headers, + collectBody: false, }); } /** - * 以固定批次大小處理項目,降低逐筆等待造成的延遲。 + * 以固定批次大小處理項目,降低逐筆等待造成的延遲;單筆例外不會中止同批其他項目。 * * @param {any[]} items 要處理的項目。 * @param {number} batchSize 每批同時處理的數量。 - * @param {(item: any) => Promise} handler 單筆處理函式。 + * @param {(item: any) => Promise} handler 單筆處理函式,回傳該筆是否成功。 + * @returns {Promise[]>} 依原始順序排列的處理結果。 */ async function processInBatches(items, batchSize, handler) { + const results = []; + for (let index = 0; index < items.length; index += batchSize) { const batch = items.slice(index, index + batchSize); - await Promise.all(batch.map((item) => handler(item))); + results.push(...(await Promise.allSettled(batch.map((item) => handler(item))))); } + + return results; +} + +/** + * 彙總批次結果;記錄被 reject 的例外,並回傳是否有任何一筆失敗。 + * + * @param {PromiseSettledResult[]} results 批次處理結果。 + * @returns {boolean} 只要有任一筆失敗即回傳 `true`。 + */ +function hasBatchFailure(results) { + let failed = false; + + for (const result of results) { + if (result.status === 'rejected') { + failed = true; + const reason = result.reason; + fail(`批次處理發生例外: ${reason instanceof Error ? reason.message : String(reason)}`); + } else if (result.value === false) { + failed = true; + } + } + + return failed; } /** @@ -328,9 +404,11 @@ async function main() { section('參數檢查'); requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL, maskUrlForLog(GITEA_SERVER_URL)); + requireHttpsUrl('GITEA_SERVER_URL', GITEA_SERVER_URL); requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); + requireRepository('GITEA_REPOSITORY', GITEA_REPOSITORY); requireValue('KEEP_COUNT', KEEP_COUNT); - requireInteger('KEEP_COUNT', KEEP_COUNT); + requirePositiveInteger('KEEP_COUNT', KEEP_COUNT); const keepCount = Number(KEEP_COUNT); const authHeaders = {}; @@ -348,7 +426,8 @@ async function main() { serverBase.hash = ''; const serverBaseUrl = serverBase.toString().replace(/\/+$/, ''); - const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/releases`; + const repositoryPath = GITEA_REPOSITORY.split('/').map(encodeURIComponent).join('/'); + const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${repositoryPath}/releases`; section('取得成品資訊'); info(`GET ${releaseApiUrl}`); @@ -370,21 +449,19 @@ async function main() { info(`RELEASE_COUNT=${releaseCount}`); info(`KEEP_COUNT=${KEEP_COUNT}`); - let hadFailure = false; - if (releaseCount <= keepCount) { info('沒有需要清理的舊版本成品'); } else { section('刪除舊版本成品'); const releaseToDelete = releaseJson.slice(keepCount); - await processInBatches(releaseToDelete, DELETE_CONCURRENCY, async (releaseItem) => { + const releaseResults = 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 || '')})`, ); - return; + return true; } const releaseTag = sanitizeLogText(releaseItem.tag_name || ''); @@ -395,15 +472,16 @@ async function main() { const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { info(`成功刪除: ${releaseTag} (${releaseName})`); - } else { - hadFailure = true; - fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); + return true; } - }); - } - if (hadFailure) { - throw new Error('至少有一筆 release 刪除失敗'); + fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); + return false; + }); + + if (hasBatchFailure(releaseResults)) { + throw new Error('至少有一筆 release 刪除失敗'); + } } section('刪除未指定 release 的 tag'); @@ -415,23 +493,23 @@ async function main() { .filter((tag) => !isEmptyOrNull(tag)), ); - const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/tags`; + const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${repositoryPath}/tags`; info(`GET ${tagApiUrl}`); const tagJson = await fetchAllPages(tagApiUrl, authHeaders); info(`TAG_COUNT=${tagJson.length}`); - await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => { + const tagResults = await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => { const tagName = tagItem?.name; if (isEmptyOrNull(tagName)) { warn('略過沒有名稱的 tag'); - return; + return true; } const safeTagName = sanitizeLogText(tagName); if (releaseTags.has(tagName)) { info(`保留指定 release 的 tag: ${safeTagName}`); - return; + return true; } const deleteUrl = `${tagApiUrl}/${encodeURIComponent(tagName)}`; @@ -440,13 +518,14 @@ async function main() { const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { info(`成功刪除未指定 release 的 tag: ${safeTagName}`); - } else { - hadFailure = true; - fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`); + return true; } + + fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`); + return false; }); - if (hadFailure) { + if (hasBatchFailure(tagResults)) { throw new Error('至少有一筆 tag 刪除失敗'); } }