fix(release-cleanup): 清理改用重試+有上限併發+best-effort 續行

cleanupReleases/cleanupOrphanTags 改用 delete-utils:暫時性錯誤(429/5xx、
網路例外)重試、永久性錯誤(401/403/404)即止、單筆失敗只記錄不中斷並彙報失敗數,
並以有上限併發加速。解決 AI review 的重試/錯誤分流/併發/部分失敗續行系列建議。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-06-26 15:28:40 +08:00
co-authored by Claude Opus 4.8
parent 7d34ceb439
commit 9e88f5a9d5
2 changed files with 39 additions and 23 deletions
+21 -14
View File
@@ -3,6 +3,7 @@
import { section, info, success, fail, warn } from './logger.js' import { section, info, success, fail, warn } from './logger.js'
import { isEmptyOrNull } from './validate.js' import { isEmptyOrNull } from './validate.js'
import { deleteWithRetry, runWithConcurrency } from './delete-utils.js'
/** /**
* 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。 * 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。
@@ -34,8 +35,8 @@ export function selectReleasesToDelete(releases, keepCount) {
* 讀取全部成品清單,刪除超出保留數量的舊版本成品。 * 讀取全部成品清單,刪除超出保留數量的舊版本成品。
* *
* 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→ * 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→
* 否則以 [[selectReleasesToDelete]] 取出待刪除清單,逐筆刪除(略過沒有 `id` 的項目), * 否則以 [[selectReleasesToDelete]] 取出待刪除清單,過濾結構異常/無效 id 的項目,
* 依回應狀態碼 204 判定成功與否並輸出結果 * 以有上限併發、暫時性錯誤重試的方式逐筆刪除(best-effort:單筆失敗會記錄並繼續),最後彙報失敗筆數
* *
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端 * @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount` * @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount`
@@ -55,32 +56,38 @@ export async function cleanupReleases(client, config) {
} }
section('刪除舊版本成品') section('刪除舊版本成品')
const toDelete = selectReleasesToDelete(releases, config.keepCount)
for (const release of toDelete) { // 先過濾出可安全刪除的項目:略過 null/非物件結構,以及非正整數 id(Gitea release id 本即正整數)。
// 防禦非預期結構(null/非物件),避免解構時拋出未捕捉例外。 const targets = selectReleasesToDelete(releases, config.keepCount).filter((release) => {
if (release === null || typeof release !== 'object') { if (release === null || typeof release !== 'object') {
warn('略過格式異常的成品項目') warn('略過格式異常的成品項目')
continue return false
} }
const { id, tag_name: tag, name } = release const { id, tag_name: tag, name } = release
// 要求 id 為正整數(Gitea release id 本即正整數);非整數一律略過,不僅依賴 URL 編碼防護。
if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) { if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) {
warn(`略過沒有有效 id 的成品: ${tag} (${name})`) warn(`略過沒有有效 id 的成品: ${tag} (${name})`)
continue return false
} }
return true
})
// id 已驗證為正整數;仍對其編碼作為縱深防禦。 let failures = 0
// 有上限併發 + 暫時性錯誤重試;單筆刪除失敗(含網路例外)只記錄不中斷,確保其餘成品仍被嘗試清理。
await runWithConcurrency(targets, async (release) => {
const { id, tag_name: tag, name } = release
const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}` const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}`
info(`DELETE ${tag} (${name})`) info(`DELETE ${tag} (${name})`)
const code = await client.deleteResource(url) const { status, error } = await deleteWithRetry(client, url)
if (code === 204) { if (status === 204) {
success(`成功刪除: ${tag} (${name})`) success(`成功刪除: ${tag} (${name})`)
} else { } else {
fail(`刪除失敗: ${tag} (${name}), HTTP ${code}`) failures += 1
fail(`刪除失敗: ${tag} (${name}), ${error ? error.message : `HTTP ${status}`}`)
} }
})
if (failures > 0) {
warn(`${failures} 個成品刪除失敗(已記錄;清理為冪等,將於下次執行重試)`)
} }
} }
+18 -9
View File
@@ -3,6 +3,7 @@
import { section, info, success, fail, warn } from './logger.js' import { section, info, success, fail, warn } from './logger.js'
import { isEmptyOrNull } from './validate.js' import { isEmptyOrNull } from './validate.js'
import { deleteWithRetry, runWithConcurrency } from './delete-utils.js'
/** /**
* 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。 * 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。
@@ -55,26 +56,34 @@ export async function cleanupOrphanTags(client, config) {
const tags = await client.fetchAllPages(config.tagApiUrl) const tags = await client.fetchAllPages(config.tagApiUrl)
info(`TAG_COUNT=${tags.length}`) info(`TAG_COUNT=${tags.length}`)
for (const { tag, action } of categorizeTags(tags, releaseTagNames)) { const categorized = categorizeTags(tags, releaseTagNames)
for (const { tag, action } of categorized) {
if (action === 'skip') { if (action === 'skip') {
warn('略過沒有名稱的 tag') warn('略過沒有名稱的 tag')
continue } else if (action === 'keep') {
}
if (action === 'keep') {
info(`保留指定 release 的 tag: ${tag.name}`) info(`保留指定 release 的 tag: ${tag.name}`)
continue
} }
}
// 取出孤立 tag,以有上限併發 + 暫時性錯誤重試刪除;單筆失敗只記錄不中斷,確保其餘 tag 仍被嘗試清理。
const orphans = categorized.filter(({ action }) => action === 'delete').map(({ tag }) => tag)
let failures = 0
await runWithConcurrency(orphans, async (tag) => {
// 對 tag 名稱做編碼,避免名稱中的特殊字元被拼接進 URL(防路徑穿越);一般 tag 名編碼後不變。 // 對 tag 名稱做編碼,避免名稱中的特殊字元被拼接進 URL(防路徑穿越);一般 tag 名編碼後不變。
const url = `${config.tagApiUrl}/${encodeURIComponent(tag.name)}` const url = `${config.tagApiUrl}/${encodeURIComponent(tag.name)}`
info(`DELETE tag ${tag.name}`) info(`DELETE tag ${tag.name}`)
const code = await client.deleteResource(url) const { status, error } = await deleteWithRetry(client, url)
if (code === 204) { if (status === 204) {
success(`成功刪除未指定 release 的 tag: ${tag.name}`) success(`成功刪除未指定 release 的 tag: ${tag.name}`)
} else { } else {
fail(`刪除 tag 失敗: ${tag.name}, HTTP ${code}`) failures += 1
fail(`刪除 tag 失敗: ${tag.name}, ${error ? error.message : `HTTP ${status}`}`)
} }
})
if (failures > 0) {
warn(`${failures} 個 tag 刪除失敗(已記錄;清理為冪等,將於下次執行重試)`)
} }
} }