新增 isRetryableStatus/deleteWithRetry/runWithConcurrency 功能列表與使用範例, 更新 releases/tags 連結行號與更新時間。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
382 lines
15 KiB
Markdown
382 lines
15 KiB
Markdown
# release-cleanup
|
|
|
|
release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 `entrypoint.sh`。
|
|
|
|
> 更新時間:2026/06/26 15:27:17
|
|
|
|
## 專案列表
|
|
|
|
### 專案描述
|
|
|
|
| 專案名稱 | 專案描述 |
|
|
| --- | --- |
|
|
| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | Gitea Docker Action 的 Node.js 實作。提供主控台輸出、參數驗證、設定載入、Gitea API 客戶端,以及清理舊版本 release 與孤立 tag 的功能。 |
|
|
|
|
### 參考專案
|
|
|
|
| 專案名稱 | 參考專案列表 |
|
|
| --- | --- |
|
|
| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無 |
|
|
|
|
### 相依套件(npm)
|
|
|
|
| 專案名稱 | 套件列表 |
|
|
| --- | --- |
|
|
| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無(僅使用 Node.js 內建模組與 `node:test`) |
|
|
|
|
## 功能列表
|
|
|
|
### release-cleanup
|
|
|
|
| 功能名稱 | 功能描述 |
|
|
| --- | --- |
|
|
| [separator](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L9) | [輸出一條等號分隔線至 stdout。](#separator) |
|
|
| [section](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L17) | [輸出區段標題(分隔線 + 標題 + 虛線)。](#section) |
|
|
| [info](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L27) | [以 `[INFO]` 前綴輸出一般資訊。](#info) |
|
|
| [success](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L35) | [以 `[OK]` 前綴輸出成功訊息。](#success) |
|
|
| [warn](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L43) | [以 `[WARN]` 前綴輸出警告訊息。](#warn) |
|
|
| [fail](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L51) | [以 `[ERR]` 前綴輸出錯誤訊息至 stderr。](#fail) |
|
|
| [failError](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/logger.js#L61) | [輸出錯誤至 stderr,Error 物件含名稱與堆疊。](#failerror) |
|
|
| [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L14) | [判斷值是否為空(空字串、null、undefined 或字串 "null")。](#isemptyornull) |
|
|
| [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L24) | [要求欄位有值,空值時丟出 Error。](#requirevalue) |
|
|
| [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L37) | [要求欄位為非負整數,否則丟出 Error。](#requireinteger) |
|
|
| [requireUrl](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L50) | [要求欄位為合法的 http/https URL。](#requireurl) |
|
|
| [requireRepository](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/validate.js#L69) | [要求欄位為合法的 owner/repo 形式且無路徑穿越。](#requirerepository) |
|
|
| [loadConfig](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/config.js#L26) | [從環境變數載入並驗證設定,回傳設定物件。](#loadconfig) |
|
|
| [GiteaClient](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L26) | [建立帶認證標頭的 Gitea API 客戶端。](#giteaclient) |
|
|
| [GiteaClient.fetchAllPages](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L50) | [逐頁讀取分頁式清單 API 並合併成單一陣列。](#giteaclientfetchallpages) |
|
|
| [GiteaClient.deleteResource](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/gitea-client.js#L112) | [對指定資源發出 DELETE 請求並回傳狀態碼。](#giteaclientdeleteresource) |
|
|
| [selectReleasesToDelete](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L15) | [依時間排序後回傳超出保留數量的舊 release。](#selectreleasestodelete) |
|
|
| [cleanupReleases](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/releases.js#L45) | [讀取並刪除超出保留數量的舊版本 release。](#cleanupreleases) |
|
|
| [categorizeTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L18) | [將 tag 分類為保留、刪除或略過。](#categorizetags) |
|
|
| [cleanupOrphanTags](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/tags.js#L45) | [刪除未被任何 release 指定的孤立 tag。](#cleanuporphantags) |
|
|
| [isRetryableStatus](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/delete-utils.js#L22) | [判斷 HTTP 狀態碼是否為可重試的暫時性錯誤。](#isretryablestatus) |
|
|
| [deleteWithRetry](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/delete-utils.js#L34) | [帶指數退避的刪除,暫時性錯誤重試、永久性錯誤即止。](#deletewithretry) |
|
|
| [runWithConcurrency](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/delete-utils.js#L64) | [以有上限併發逐項執行非同步工作。](#runwithconcurrency) |
|
|
|
|
## 使用範例
|
|
|
|
<a id="separator"></a>
|
|
### separator
|
|
|
|
輸出一條前後換行的等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。
|
|
|
|
```js
|
|
import { separator } from './logger.js'
|
|
|
|
separator()
|
|
// 輸出:
|
|
//
|
|
// ==================================================
|
|
```
|
|
|
|
<a id="section"></a>
|
|
### section
|
|
|
|
先印分隔線,再印標題與一條虛線,用於標示流程進入新階段。
|
|
|
|
```js
|
|
import { section } from './logger.js'
|
|
|
|
section('參數檢查')
|
|
// ==================================================
|
|
// 參數檢查
|
|
// --------------------------------------------------
|
|
```
|
|
|
|
<a id="info"></a>
|
|
### info
|
|
|
|
以 `[INFO]` 前綴輸出一般資訊訊息至 stdout。
|
|
|
|
```js
|
|
import { info } from './logger.js'
|
|
|
|
info('GET https://gitea.example.com/api/v1/repos/owner/repo/releases')
|
|
// [INFO] GET https://gitea.example.com/api/v1/repos/owner/repo/releases
|
|
```
|
|
|
|
<a id="success"></a>
|
|
### success
|
|
|
|
以 `[OK]` 前綴輸出成功訊息(前綴補空白以對齊其他標籤)。
|
|
|
|
```js
|
|
import { success } from './logger.js'
|
|
|
|
success('成功刪除: v1.0.0 (Release 1.0.0)')
|
|
// [OK] 成功刪除: v1.0.0 (Release 1.0.0)
|
|
```
|
|
|
|
<a id="warn"></a>
|
|
### warn
|
|
|
|
以 `[WARN]` 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。
|
|
|
|
```js
|
|
import { warn } from './logger.js'
|
|
|
|
warn('GITEA_TOKEN is empty; release API calls will be anonymous')
|
|
// [WARN] GITEA_TOKEN is empty; release API calls will be anonymous
|
|
```
|
|
|
|
<a id="fail"></a>
|
|
### fail
|
|
|
|
以 `[ERR]` 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。
|
|
|
|
```js
|
|
import { fail } from './logger.js'
|
|
|
|
fail('刪除失敗: v0.9.0 (舊版), HTTP 500')
|
|
// (stderr) [ERR] 刪除失敗: v0.9.0 (舊版), HTTP 500
|
|
```
|
|
|
|
<a id="failerror"></a>
|
|
### failError
|
|
|
|
輸出錯誤至 stderr,並依錯誤型別保留可供除錯的上下文:Error 物件輸出「名稱: 訊息」並附上堆疊,其餘輸出其字串形式。集中錯誤輸出格式,供進入點的 `main().catch` 使用。
|
|
|
|
```js
|
|
import { failError } from './logger.js'
|
|
|
|
failError(new TypeError('boom'))
|
|
// (stderr) [ERR] TypeError: boom
|
|
// (stderr) <堆疊內容>
|
|
|
|
failError('plain failure')
|
|
// (stderr) [ERR] plain failure
|
|
```
|
|
|
|
<a id="isemptyornull"></a>
|
|
### isEmptyOrNull
|
|
|
|
判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空;`undefined`、`null`、空字串與字串 `"null"` 才回傳 `true`。
|
|
|
|
```js
|
|
import { isEmptyOrNull } from './validate.js'
|
|
|
|
isEmptyOrNull('') // true
|
|
isEmptyOrNull('null') // true
|
|
isEmptyOrNull(0) // false
|
|
isEmptyOrNull('v1.0') // false
|
|
```
|
|
|
|
<a id="requirevalue"></a>
|
|
### requireValue
|
|
|
|
要求指定欄位有值,空值時丟出 Error 以中止流程。空值判定委派給 `isEmptyOrNull`。
|
|
|
|
```js
|
|
import { requireValue } from './validate.js'
|
|
|
|
requireValue('GITEA_SERVER_URL', 'https://gitea.example.com') // 通過
|
|
requireValue('GITEA_SERVER_URL', '') // 丟出 Error: GITEA_SERVER_URL is required
|
|
```
|
|
|
|
<a id="requireinteger"></a>
|
|
### requireInteger
|
|
|
|
要求欄位為非負整數(可為數字或純數字字串),拒絕負數、小數、空值與非數字內容。
|
|
|
|
```js
|
|
import { requireInteger } from './validate.js'
|
|
|
|
requireInteger('KEEP_COUNT', '2') // 通過
|
|
requireInteger('KEEP_COUNT', 0) // 通過
|
|
requireInteger('KEEP_COUNT', '-1') // 丟出 Error: KEEP_COUNT must be a non-negative integer
|
|
```
|
|
|
|
<a id="requireurl"></a>
|
|
### requireUrl
|
|
|
|
要求欄位為合法的 http/https URL,避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。
|
|
|
|
```js
|
|
import { requireUrl } from './validate.js'
|
|
|
|
requireUrl('GITEA_SERVER_URL', 'https://gitea.example.com') // 通過
|
|
requireUrl('GITEA_SERVER_URL', 'ftp://x') // 丟出 Error: ... must use http or https protocol
|
|
requireUrl('GITEA_SERVER_URL', 'not a url') // 丟出 Error: ... must be a valid URL
|
|
```
|
|
|
|
<a id="requirerepository"></a>
|
|
### requireRepository
|
|
|
|
要求欄位為合法的 `owner/repo` 形式(僅允許英數字與 `. _ -`、恰好一個 `/`),並拒絕含 `..` 的路徑穿越輸入。
|
|
|
|
```js
|
|
import { requireRepository } from './validate.js'
|
|
|
|
requireRepository('GITEA_REPOSITORY', 'owner/repo') // 通過
|
|
requireRepository('GITEA_REPOSITORY', '../evil') // 丟出 Error: ... owner/repo without path traversal
|
|
```
|
|
|
|
<a id="loadconfig"></a>
|
|
### loadConfig
|
|
|
|
從環境變數載入並驗證設定,回傳供清理流程使用的設定物件;必填項缺漏或 `KEEP_COUNT` 非整數時丟出 Error。
|
|
|
|
```js
|
|
import { loadConfig } from './config.js'
|
|
|
|
// 由 process.env 讀取(GITEA_SERVER_URL / GITEA_REPOSITORY / KEEP_COUNT / GITEA_TOKEN)
|
|
const config = loadConfig()
|
|
|
|
// 測試時可注入假環境變數
|
|
const cfg = loadConfig({
|
|
GITEA_SERVER_URL: 'https://gitea.example.com',
|
|
GITEA_REPOSITORY: 'owner/repo',
|
|
KEEP_COUNT: '2',
|
|
})
|
|
// cfg.releaseApiUrl === 'https://gitea.example.com/api/v1/repos/owner/repo/releases'
|
|
```
|
|
|
|
<a id="giteaclient"></a>
|
|
### GiteaClient
|
|
|
|
建立與 Gitea API 溝通的客戶端;傳入 token 時於後續請求帶上 `Authorization` 標頭,否則以匿名方式呼叫。
|
|
|
|
```js
|
|
import { GiteaClient } from './gitea-client.js'
|
|
|
|
const client = new GiteaClient({ token: process.env.GITEA_TOKEN })
|
|
const anonymous = new GiteaClient() // 無 token,匿名呼叫
|
|
```
|
|
|
|
<a id="giteaclientfetchallpages"></a>
|
|
### GiteaClient.fetchAllPages
|
|
|
|
逐頁讀取分頁式清單 API(每頁以 `?page=N` 遞增),直到回傳空陣列為止,合併成單一陣列;任一頁 HTTP 非 2xx 時丟出 Error。
|
|
|
|
```js
|
|
const client = new GiteaClient({ token: process.env.GITEA_TOKEN })
|
|
const releases = await client.fetchAllPages(
|
|
'https://gitea.example.com/api/v1/repos/owner/repo/releases',
|
|
)
|
|
console.log(`共取得 ${releases.length} 筆 release`)
|
|
```
|
|
|
|
<a id="giteaclientdeleteresource"></a>
|
|
### GiteaClient.deleteResource
|
|
|
|
對指定資源發出 DELETE 請求,回傳 HTTP 狀態碼供呼叫端判斷成敗(成功刪除通常為 204)。
|
|
|
|
```js
|
|
const code = await client.deleteResource(
|
|
'https://gitea.example.com/api/v1/repos/owner/repo/releases/123',
|
|
)
|
|
if (code === 204) {
|
|
console.log('刪除成功')
|
|
}
|
|
```
|
|
|
|
<a id="selectreleasestodelete"></a>
|
|
### selectReleasesToDelete
|
|
|
|
依 `created_at` 由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的 release;為純函式,不改動原陣列。
|
|
|
|
```js
|
|
import { selectReleasesToDelete } from './releases.js'
|
|
|
|
const releases = [
|
|
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
|
|
{ id: 2, tag_name: 'v2.0.0', created_at: '2024-02-01T00:00:00Z' },
|
|
{ id: 3, tag_name: 'v3.0.0', created_at: '2024-03-01T00:00:00Z' },
|
|
]
|
|
selectReleasesToDelete(releases, 2)
|
|
// [{ id: 1, tag_name: 'v1.0.0', ... }] // 僅保留最新兩筆,回傳最舊的一筆
|
|
```
|
|
|
|
<a id="cleanupreleases"></a>
|
|
### cleanupReleases
|
|
|
|
讀取全部 release,刪除超出保留數量的舊版本;總數不超過 `keepCount` 時不做任何刪除。
|
|
|
|
```js
|
|
import { loadConfig } from './config.js'
|
|
import { GiteaClient } from './gitea-client.js'
|
|
import { cleanupReleases } from './releases.js'
|
|
|
|
const config = loadConfig()
|
|
const client = new GiteaClient({ token: config.token })
|
|
await cleanupReleases(client, config)
|
|
```
|
|
|
|
<a id="categorizetags"></a>
|
|
### categorizeTags
|
|
|
|
將每個 tag 分類為 `keep`(仍被 release 指定)、`delete`(孤立 tag)或 `skip`(無名稱);為純函式,方便測試。
|
|
|
|
```js
|
|
import { categorizeTags } from './tags.js'
|
|
|
|
const tags = [{ name: 'v2.0.0' }, { name: 'v1.0.0' }, { name: '' }]
|
|
categorizeTags(tags, ['v2.0.0'])
|
|
// [
|
|
// { tag: { name: 'v2.0.0' }, action: 'keep' },
|
|
// { tag: { name: 'v1.0.0' }, action: 'delete' },
|
|
// { tag: { name: '' }, action: 'skip' },
|
|
// ]
|
|
```
|
|
|
|
<a id="cleanuporphantags"></a>
|
|
### cleanupOrphanTags
|
|
|
|
重新讀取 release 清單取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。
|
|
|
|
```js
|
|
import { loadConfig } from './config.js'
|
|
import { GiteaClient } from './gitea-client.js'
|
|
import { cleanupOrphanTags } from './tags.js'
|
|
|
|
const config = loadConfig()
|
|
const client = new GiteaClient({ token: config.token })
|
|
await cleanupOrphanTags(client, config)
|
|
```
|
|
|
|
<a id="isretryablestatus"></a>
|
|
### isRetryableStatus
|
|
|
|
判斷 HTTP 狀態碼是否為可重試的暫時性錯誤(429/502/503/504);其餘(含 500、401/403/404)視為不可重試。
|
|
|
|
```js
|
|
import { isRetryableStatus } from './delete-utils.js'
|
|
|
|
isRetryableStatus(503) // true
|
|
isRetryableStatus(403) // false
|
|
isRetryableStatus(500) // false(視為確定性錯誤,不重試)
|
|
```
|
|
|
|
<a id="deletewithretry"></a>
|
|
### deleteWithRetry
|
|
|
|
帶指數退避的刪除:204 視為成功;暫時性錯誤(網路例外或 429/502/503/504)會重試;永久性錯誤(如 401/403/404)立即停止。不對外拋例外,以回傳值表示結果。
|
|
|
|
```js
|
|
import { deleteWithRetry } from './delete-utils.js'
|
|
|
|
const { status, error, attempts } = await deleteWithRetry(client, url)
|
|
if (status === 204) {
|
|
// 刪除成功
|
|
} else {
|
|
// status 為最後一次 HTTP 狀態碼;網路例外時 status 為 0 且帶 error
|
|
}
|
|
```
|
|
|
|
<a id="runwithconcurrency"></a>
|
|
### runWithConcurrency
|
|
|
|
以有上限的併發逐項執行非同步 worker,回傳對應輸入順序的結果;用於加速大量刪除同時避免對 API 造成過大壓力。
|
|
|
|
```js
|
|
import { runWithConcurrency } from './delete-utils.js'
|
|
|
|
const urls = ['/a', '/b', '/c', '/d', '/e']
|
|
const results = await runWithConcurrency(
|
|
urls,
|
|
async (url) => deleteWithRetry(client, url),
|
|
4, // 最多同時 4 筆
|
|
)
|
|
```
|