refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #6
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"location": "app/config.js:31",
|
||||
"role": "Bard",
|
||||
"original_finding": "日志信息的键值对缺少空格,排版不够整齐统一。建议加上空格以提升易读性:`info('GITEA_TOKEN = [redacted]')`",
|
||||
"reason": "誤報。loadConfig 中所有參數記錄皆採一致的 `KEY=value` 格式(如 GITEA_SERVER_URL=...、KEEP_COUNT=...);僅將 GITEA_TOKEN 一行改為 `KEY = value` 反而破壞整體一致性,故不採納。"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile:13",
|
||||
"role": "Bard",
|
||||
"original_finding": "请在冒号后面加上一个空格,让注释读起来更舒畅:`# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。`",
|
||||
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile:16",
|
||||
"role": "Bard",
|
||||
"original_finding": "在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`",
|
||||
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||
},
|
||||
{
|
||||
"location": "app/logger.js:5",
|
||||
"role": "Bard",
|
||||
"original_finding": "分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。建議將分隔線管理集中化,並考慮提供動態產生方法。",
|
||||
"reason": "不適用。分隔線已是模組頂層的具名常數(LINE/SUBLINE),本即集中管理且為通用良好實務;固定寬度分隔線改為動態產生方法只會徒增複雜度,無實質效益,故不採納。"
|
||||
},
|
||||
{
|
||||
"location": "app/gitea-client.js:63",
|
||||
"role": "Mage",
|
||||
"original_finding": "while 迴圈中使用 AbortSignal.timeout,若 Node.js 版本低於 16 會崩潰。建議改用 AbortController 搭配 setTimeout。",
|
||||
"reason": "不適用。本專案以 node:20-alpine 為基底且 package.json engines 限定 >=18,AbortSignal.timeout 自 Node 17.3 起即支援,執行環境保證可用,無向下相容需求。"
|
||||
},
|
||||
{
|
||||
"location": "app/gitea-client.js:101",
|
||||
"role": "Rogue",
|
||||
"original_finding": "處理分頁資料用 for...of 逐一 all.push(item) 效率不佳,建議用 Array.prototype.push.apply(all, items)。",
|
||||
"reason": "不採納且會造成回歸。先前依 Bard(gitea-client.js:59)建議刻意改用 for...of 以規避展開/apply 在大量項目時的呼叫堆疊上限風險;push.apply 等同展開,會重新引入該風險。逐頁項目數量有限,迴圈開銷可忽略。"
|
||||
},
|
||||
{
|
||||
"location": "app/validate.js:14",
|
||||
"role": "Mage",
|
||||
"original_finding": "isEmptyOrNull 判斷式包含 value === 'null',導致字串 'null' 被判定為空,破壞語義一致性。建議移除對 'null' 字串的特殊判定。",
|
||||
"reason": "不採納。此為刻意保留原 bash 版本 is_empty_or_null 的語義:Gitea/jq 等來源可能將未設定值輸出為字串 'null',視其為空可避免把字面 'null' 當成有效設定。移除會改變既有行為。"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:46",
|
||||
"role": "Rogue",
|
||||
"original_finding": "cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,複雜度 O(tags*releases),建議改用 Set。",
|
||||
"reason": "誤報。cleanupOrphanTags 已將 releaseTagNames 交給 categorizeTags,後者於進入迴圈前即建立 Set 進行 O(1) 查找;並無 O(tags*releases) 的重複掃描。"
|
||||
},
|
||||
{
|
||||
"location": "app/config.js:33",
|
||||
"role": "Leo",
|
||||
"original_finding": "直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用,建議抽離為 Provider 或 ConfigFactory。",
|
||||
"reason": "不適用。loadConfig 已將 env 設為可注入參數(預設 process.env),測試即透過傳入假 env 驗證,已具良好可測性與隔離;對此規模的 Action 引入 Provider/Factory 屬過度設計。"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:21",
|
||||
"role": "Leo",
|
||||
"original_finding": "categorizeTags 內部頻繁轉換 Set,若被頻繁呼叫可能造成效能浪費,建議改為接收已轉好的 Set。",
|
||||
"reason": "不適用。categorizeTags 每次清理流程僅被呼叫一次,單次建立 Set 成本可忽略;讓純函式自行建立 Set 可保持介面單純,將轉換責任外推反而增加耦合。"
|
||||
},
|
||||
{
|
||||
"location": "app/gitea-client.js:25",
|
||||
"role": "Bard",
|
||||
"original_finding": "GiteaClient 建構子的 header 初始化結構死板,建議抽離為內部 private 函式 _getHeaders() 以增加未來彈性。",
|
||||
"reason": "YAGNI / 不適用。目前僅單一固定標頭,建構子直接設定清楚直觀;為尚不存在的需求預先抽象屬過度設計,待真有多標頭需求時再重構。"
|
||||
},
|
||||
{
|
||||
"location": "app/releases.js:23",
|
||||
"role": "Rogue",
|
||||
"original_finding": "為排序而 map 成物件陣列再 map 回原物件,造成多次 O(n) 陣列分配與 GC 壓力,建議減少中間狀態或原地排序。",
|
||||
"reason": "不採納且與前次建議衝突。此 map/sort/map(Schwartzian transform)正是前一輪 Rogue(releases.js:17)要求「排序前預先計算時間戳」的實作,用以避免比較器重複呼叫 Date.parse;release 數量有限,中間陣列開銷可忽略,可讀性更佳。"
|
||||
},
|
||||
{
|
||||
"location": "app/config.js:35",
|
||||
"role": "Assassin",
|
||||
"original_finding": "requireUrl 僅驗證 URL 格式,未阻擋指向內部網路/loopback(localhost、169.254.169.254 等)的位址,容器環境恐 SSRF。建議加入內部 IP 黑名單。",
|
||||
"reason": "不適用且會破壞正常部署。GITEA_SERVER_URL 來自 CI 平台提供的 gitea.server_url,即本 Action 欲操作的 Gitea 實例本身;自架 Gitea 經常部署於內網/私有位址,封鎖內部 IP 會使合法部署無法運作。此非攻擊者可控的任意外連目標。"
|
||||
},
|
||||
{
|
||||
"location": "app/releases.js:46",
|
||||
"role": "Bard",
|
||||
"original_finding": "區段標題層級不一致:『取得成品資訊』內含 info 輸出,『刪除舊版本成品』直接開始邏輯,視覺節奏稍不一致。",
|
||||
"reason": "主觀排版意見。現行 section() 用於標示每個主要階段,語意清楚且符合原 bash 版本結構;不構成功能或可讀性問題。"
|
||||
},
|
||||
{
|
||||
"location": "app/releases.js:20",
|
||||
"role": "Leo",
|
||||
"original_finding": "selectReleasesToDelete 對無法解析的日期視為 0 是隱晦行為,維護者可能不解,建議記錄 warning 或明確處理。",
|
||||
"reason": "已於程式碼註解明確記錄『格式無效或缺漏時視為最舊(0)』;Gitea API 一向回傳有效 created_at,對每筆無效日期發出 runtime warning 將是不會觸發的死碼與雜訊,不採納。"
|
||||
},
|
||||
{
|
||||
"location": "app/logger.js:53",
|
||||
"role": "Assassin",
|
||||
"original_finding": "failError 直接輸出 error.stack 可能洩漏專案目錄結構、內部函式名稱等敏感路徑;建議生產環境隱藏堆疊或僅 debug 模式輸出。",
|
||||
"reason": "不適用。本專案為 CI/CD 清理 Action,堆疊輸出至僅儲存庫維護者可見的 Action 日誌以利除錯,非公開環境;容器內路徑為 /app,不含敏感資訊。隱藏堆疊反而降低可維運性。"
|
||||
},
|
||||
{
|
||||
"location": "app/gitea-client.js:10",
|
||||
"role": "Leo",
|
||||
"original_finding": "MAX_PAGES 寫死於程式碼,建議改為環境變數傳入以增加部署彈性。",
|
||||
"reason": "不採納。MAX_PAGES 為防止無限迴圈的安全上限(backstop),非調校參數;1000 頁遠超任何真實儲存庫的 release/tag 規模,改為環境變數只會增加不必要的設定面與誤設風險。"
|
||||
},
|
||||
{
|
||||
"location": "app/logger.js:46",
|
||||
"role": "Leo",
|
||||
"original_finding": "建議在 logger.js 建立通用 log(level, prefix, stream) 函式,讓 info/warn/fail 等共用以降低重複。",
|
||||
"reason": "不採納。六個輸出函式皆為單行、語意直觀;為此抽象通用 dispatcher 反而增加間接層與閱讀成本,去重效益微小。"
|
||||
},
|
||||
{
|
||||
"location": "app/gitea-client.js:63",
|
||||
"role": "Assassin",
|
||||
"original_finding": "baseUrl 直接拼接到 URL,若來自惡意環境變數恐 SSRF;建議確保 baseUrl 只能以預期的 GITEA_SERVER_URL 開頭。",
|
||||
"reason": "不適用。fetchAllPages 的 baseUrl 並非外部輸入,而是由 config 的 releaseApiUrl/tagApiUrl 提供,二者皆以已通過 requireUrl 驗證的 GITEA_SERVER_URL 與通過 requireRepository 驗證的 repository 組成 `${serverUrl}/api/v1/repos/${repository}/...`,本即保證以 GITEA_SERVER_URL 開頭,無額外白名單必要。"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:62",
|
||||
"role": "Assassin",
|
||||
"original_finding": "tag 名稱拼接至 URL 雖用 encodeURIComponent,仍建議以白名單格式驗證(限英數字、點、破折號,禁 .. 或 /)以更安全。",
|
||||
"reason": "不採納。encodeURIComponent 會將路徑分隔字元 / 編碼為 %2F,已消除 URL 路徑穿越;且 git ref 命名規則本即禁止 tag 含 '..'、控制字元與多數特殊字元。額外白名單反而可能誤拒合法 tag 名,且非 URL 安全所必需。"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:19",
|
||||
"role": "Bard",
|
||||
"original_finding": "categorizeTags 內部判斷邏輯稍複雜,建議拆分為更小的判斷函式以提高可讀性。",
|
||||
"reason": "不採納。categorizeTags 僅為 skip/keep/delete 三分支的單層 map,語意已清楚且具完整測試;為三個簡單條件再抽出微函式只會增加跳轉與閱讀成本,可讀性無實質提升。"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:19",
|
||||
"role": "Bard",
|
||||
"original_finding": "建議將邏輯拆分為更小的判斷函式,提高可讀性。",
|
||||
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||
},
|
||||
{
|
||||
"location": "app/tags.js:62",
|
||||
"role": "Assassin",
|
||||
"original_finding": "除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。",
|
||||
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||
},
|
||||
{
|
||||
"location": "app/config.js:33",
|
||||
"role": "Mage",
|
||||
"original_finding": "loadConfig 對 GITEA_SERVER_URL 的 replace 假設輸入為 string,非字串恐拋 TypeError。",
|
||||
"reason": "不成立。該行已以 `typeof rawServerUrl === 'string' ? rawServerUrl.replace(...) : rawServerUrl` 做型別防護,非字串時不呼叫 replace;finding 內文亦自承「已經做了檢查」。env 變數本即為字串或 undefined,不會出現陣列/物件。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"level": "warning",
|
||||
"role": "Mage",
|
||||
"location": "app/tags.js:52",
|
||||
"problem": "cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,過濾過程未保證原子性;期間 Gitea 狀態變更可能誤刪非孤立 tag。",
|
||||
"suggestion": "考量原子性需求,或在刪除前增加確認機制;並明確文件化此風險。",
|
||||
"status": "deferred",
|
||||
"defer_reason": "Gitea API 無交易機制,無法在應用層做到真正原子;已於 cleanupOrphanTags 以註解明確文件化此 TOCTOU 殘餘競態,並於流程開頭重新抓取最新 release 作為主要緩解。此屬排程清理任務可接受的殘餘風險(下次執行會自我修正),完整原子性需 Gitea 端支援,保留待人工評估。"
|
||||
}
|
||||
]
|
||||
@@ -1,12 +1,27 @@
|
||||
# =============================================================================
|
||||
# 用途: release-cleanup 專案的 CD workflow。
|
||||
# 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。
|
||||
# 更新日期: 2026/06/26 10:28:36
|
||||
# =============================================================================
|
||||
|
||||
# workflow 名稱,顯示於 Gitea Actions 介面。
|
||||
name: CD
|
||||
# 觸發條件設定。
|
||||
on:
|
||||
# 於 push 事件觸發。
|
||||
push:
|
||||
# 僅在推送至 master 分支時觸發。
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
# job 識別碼:釋出並標註版本。
|
||||
release-tag-version:
|
||||
# job 顯示名稱。
|
||||
name: Release Tag Version
|
||||
# 指定執行於 ubuntu runner。
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
# 呼叫共用的 release-tag-version composite action,自動產生版本標籤並釋出。
|
||||
- name: 釋出並標註成品版本
|
||||
# 版本以變數 ACTION_RELEASE_TAG_VERSION 控制,便於統一升級。
|
||||
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
# =============================================================================
|
||||
# 用途: release-cleanup 專案的 CI workflow。
|
||||
# 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。
|
||||
# 更新日期: 2026/06/26 10:28:36
|
||||
# =============================================================================
|
||||
|
||||
# workflow 名稱,顯示於 Gitea Actions 介面。
|
||||
name: CI
|
||||
# 觸發條件設定。
|
||||
on:
|
||||
# 於 Pull Request 事件觸發。
|
||||
pull_request:
|
||||
# 忽略目標分支為 master 的 PR (master 走 CD 流程,不在此審查)。
|
||||
branches-ignore:
|
||||
- master
|
||||
# 僅在 PR 開啟 (opened) 或有新 commit 推送 (synchronize) 時觸發。
|
||||
types: [opened, synchronize]
|
||||
jobs:
|
||||
# job 識別碼:AI 程式碼審查。
|
||||
ai-code-review:
|
||||
# job 顯示名稱。
|
||||
name: AI Code Review
|
||||
# 指定執行於 ubuntu runner。
|
||||
runs-on: ubuntu
|
||||
# 此 job 所需的權限 (供 AI 寫回審查結果)。
|
||||
permissions:
|
||||
# 可寫入儲存庫內容。
|
||||
contents: write
|
||||
# 可在 PR 上留言/變更狀態。
|
||||
pull-requests: write
|
||||
# 可建立/更新 issue。
|
||||
issues: write
|
||||
steps:
|
||||
# 呼叫共用的 OpenCode 程式碼審查 composite action。
|
||||
- name: AI 程式碼審查 by OpenCode
|
||||
# 版本以變數 ACTION_OPENCODE_CODE_REVIEW_VERSION 控制,便於統一升級。
|
||||
uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }}
|
||||
with:
|
||||
comment_token: ${{ secrets.COMMENT_TOKEN }}
|
||||
# 傳入留言用 token (secret),供 action 在 PR 上發表審查留言。
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
FROM alpine:latest
|
||||
# =============================================================================
|
||||
# 用途: 建置 release-cleanup Action 的容器映像。
|
||||
# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js),
|
||||
# 並透過 entrypoint.sh 啟動。
|
||||
# 更新日期: 2026/06/26 10:28:36
|
||||
# =============================================================================
|
||||
|
||||
# 安裝必要的工具
|
||||
RUN apk add --no-cache --no-check-certificate bash curl jq
|
||||
# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。
|
||||
FROM node:20-alpine
|
||||
|
||||
# 複製 Node.js 應用程式 (不含測試)
|
||||
# 先單獨複製 package.json,再複製所有 *.js;有利於 Docker layer 快取。
|
||||
COPY app/package.json /app/package.json
|
||||
COPY app/*.js /app/
|
||||
|
||||
# 複製容器進入點腳本至根目錄。
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# 設定容器啟動時執行的進入點 (內部會 exec node /app/index.js)。
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# 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 筆
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
// 讀取並驗證環境變數,組出後續流程所需的設定物件。
|
||||
// 對應原本 entrypoint.sh 的「參數檢查」區段。
|
||||
|
||||
import {
|
||||
isEmptyOrNull,
|
||||
requireValue,
|
||||
requireInteger,
|
||||
requireUrl,
|
||||
requireRepository,
|
||||
} from './validate.js'
|
||||
import { section, info, warn } from './logger.js'
|
||||
|
||||
/**
|
||||
* 從環境變數載入設定並完成驗證,作為整個清理流程的設定來源。
|
||||
*
|
||||
* 讀取 `GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`KEEP_COUNT`、`GITEA_TOKEN`,
|
||||
* 其中前三者為必填(`KEEP_COUNT` 另須為非負整數);`GITEA_TOKEN` 為選填,
|
||||
* 為空時改以匿名方式呼叫 API。驗證過程會將參數印出(token 以 `[redacted]` 遮蔽)。
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 `process.env`,可於測試時注入假值
|
||||
* @returns {{serverUrl: string, repository: string, token: string|null, keepCount: number, releaseApiUrl: string, tagApiUrl: string}}
|
||||
* 設定物件:`token` 在環境變數為空時為 `null`;`keepCount` 為數值;
|
||||
* `releaseApiUrl`/`tagApiUrl` 為依 server 與 repository 組出的 API 位址。
|
||||
* @throws {Error} 任一必填項缺漏,或 `KEEP_COUNT` 非非負整數時拋出(由 [[validate]] 的檢查函式丟出)
|
||||
*/
|
||||
export function loadConfig(env = process.env) {
|
||||
section('參數檢查')
|
||||
|
||||
// 去除結尾多餘的 /,避免後續拼接出 `https://host//api/v1/...` 這類錯誤路徑。
|
||||
const rawServerUrl = env.GITEA_SERVER_URL
|
||||
const serverUrl =
|
||||
typeof rawServerUrl === 'string' ? rawServerUrl.replace(/\/+$/, '') : rawServerUrl
|
||||
const repository = env.GITEA_REPOSITORY
|
||||
const keepCountRaw = env.KEEP_COUNT
|
||||
const token = env.GITEA_TOKEN
|
||||
|
||||
info(`GITEA_SERVER_URL=${serverUrl}`)
|
||||
requireValue('GITEA_SERVER_URL', serverUrl)
|
||||
requireUrl('GITEA_SERVER_URL', serverUrl)
|
||||
|
||||
info(`GITEA_REPOSITORY=${repository}`)
|
||||
requireValue('GITEA_REPOSITORY', repository)
|
||||
requireRepository('GITEA_REPOSITORY', repository)
|
||||
|
||||
info(`KEEP_COUNT=${keepCountRaw}`)
|
||||
requireValue('KEEP_COUNT', keepCountRaw)
|
||||
requireInteger('KEEP_COUNT', keepCountRaw)
|
||||
|
||||
if (isEmptyOrNull(token)) {
|
||||
warn('GITEA_TOKEN is empty; release API calls will be anonymous')
|
||||
} else {
|
||||
info('GITEA_TOKEN=[redacted]')
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
repository,
|
||||
token: isEmptyOrNull(token) ? null : token,
|
||||
keepCount: Number(keepCountRaw),
|
||||
releaseApiUrl: `${serverUrl}/api/v1/repos/${repository}/releases`,
|
||||
tagApiUrl: `${serverUrl}/api/v1/repos/${repository}/tags`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 刪除作業共用工具:暫時性錯誤的指數退避重試、有上限併發,以及 best-effort 續行所需的結果型別。
|
||||
// 對應 AI review 對 release/tag 刪除「重試、錯誤分流、併發、部分失敗續行」的多項建議。
|
||||
|
||||
// 視為可重試的暫時性 HTTP 狀態碼(限流與閘道層暫時性錯誤)。
|
||||
// 刻意不含 500:500 多為確定性錯誤,重試通常無益。
|
||||
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
||||
|
||||
// 單筆刪除的最大嘗試次數與退避基準。
|
||||
const MAX_ATTEMPTS = 3
|
||||
const BASE_BACKOFF_MS = 200
|
||||
|
||||
// 預設併發上限:加速大量刪除,同時避免對 Gitea API 造成過大壓力或觸發速率限制。
|
||||
const DEFAULT_CONCURRENCY = 4
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* 判斷 HTTP 狀態碼是否為可重試的暫時性錯誤(429/502/503/504)。
|
||||
* @param {number} status HTTP 狀態碼
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRetryableStatus(status) {
|
||||
return RETRYABLE_STATUS.has(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 帶指數退避的刪除:204 視為成功;暫時性錯誤(網路例外或 429/502/503/504)會重試;
|
||||
* 永久性錯誤(如 401/403/404)立即停止重試。本函式不對外拋例外,失敗以回傳值表示。
|
||||
* @param {{ deleteResource: (url: string) => Promise<number> }} client 具 deleteResource 的客戶端
|
||||
* @param {string} url 目標資源位址
|
||||
* @param {{ attempts?: number, sleep?: (ms: number) => Promise<void> }} [options] 可注入嘗試次數與 sleep(測試用)
|
||||
* @returns {Promise<{ status: number, error?: Error, attempts: number }>} 最終狀態(204 成功);網路例外時 status 為 0 並帶 error
|
||||
*/
|
||||
export async function deleteWithRetry(client, url, options = {}) {
|
||||
const { attempts = MAX_ATTEMPTS, sleep = wait } = options
|
||||
let last = { status: 0, attempts: 0 }
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const status = await client.deleteResource(url)
|
||||
if (status === 204 || !isRetryableStatus(status)) {
|
||||
return { status, attempts: attempt } // 成功或永久性錯誤,不再重試
|
||||
}
|
||||
last = { status, attempts: attempt }
|
||||
} catch (error) {
|
||||
last = { status: 0, error, attempts: attempt }
|
||||
}
|
||||
if (attempt < attempts) {
|
||||
await sleep(BASE_BACKOFF_MS * 2 ** (attempt - 1)) // 200ms、400ms…
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* 以有上限的併發逐項執行非同步 worker,回傳對應輸入順序的結果陣列。
|
||||
* 控制併發數可加速處理,同時避免一次送出過多請求。worker 不應拋例外(由呼叫端自行吞例外)。
|
||||
* @template T, R
|
||||
* @param {T[]} items 待處理項目
|
||||
* @param {(item: T, index: number) => Promise<R>} worker 處理單一項目
|
||||
* @param {number} [concurrency] 併發上限,預設 4
|
||||
* @returns {Promise<R[]>}
|
||||
*/
|
||||
export async function runWithConcurrency(items, worker, concurrency = DEFAULT_CONCURRENCY) {
|
||||
const results = new Array(items.length)
|
||||
let next = 0
|
||||
|
||||
async function runner() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
const size = Math.max(1, Math.min(concurrency, items.length))
|
||||
await Promise.all(Array.from({ length: size }, () => runner()))
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
|
||||
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
|
||||
|
||||
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
|
||||
const REQUEST_TIMEOUT_MS = 30000
|
||||
|
||||
// 分頁讀取的最大頁數上限,作為安全斷點:即使 API 異常未以空陣列結尾,也不致無限迴圈耗盡資源。
|
||||
const MAX_PAGES = 1000
|
||||
|
||||
/**
|
||||
* 將回應內容整理成可安全寫入錯誤訊息的片段:移除控制字元(避免換行等造成的 log 注入)並限制長度。
|
||||
* @param {string} text 原始回應文字
|
||||
* @returns {string} 清理後、最長 200 字元的片段
|
||||
*/
|
||||
function sanitizeBody(text) {
|
||||
return (text || '')
|
||||
.replace(/[\u0000-\u001F\u007F]+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
|
||||
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
|
||||
*/
|
||||
export class GiteaClient {
|
||||
/**
|
||||
* 建立客戶端;有 token 時於後續所有請求帶上 `Authorization: token <token>` 標頭,
|
||||
* 無 token(或為 `null`)時以匿名方式呼叫。
|
||||
* @param {{ token?: string | null }} [options] 認證設定
|
||||
*/
|
||||
constructor({ token = null } = {}) {
|
||||
this.headers = {}
|
||||
if (token) {
|
||||
this.headers.Authorization = `token ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列為止,
|
||||
* 將所有頁面項目合併成單一陣列回傳。
|
||||
*
|
||||
* 在 `res.ok` 為真的前提下嚴格要求回應為 JSON 陣列:非 JSON、JSON 解析失敗或非陣列內容
|
||||
* 都會拋出帶 URL 上下文的錯誤,而非被誤判為最後一頁而靜默結束。
|
||||
*
|
||||
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
|
||||
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
|
||||
* @throws {Error} HTTP 非 2xx、回應非 JSON、JSON 無法解析或非陣列、或請求逾時(AbortError)時拋出
|
||||
*/
|
||||
async fetchAllPages(baseUrl) {
|
||||
const all = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
if (page > MAX_PAGES) {
|
||||
throw new Error(`GET ${baseUrl} exceeded MAX_PAGES (${MAX_PAGES}); aborting to avoid an unbounded loop`)
|
||||
}
|
||||
|
||||
const url = `${baseUrl}?page=${page}`
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = sanitizeBody(await res.text().catch(() => ''))
|
||||
throw new Error(
|
||||
`GET ${url} failed: HTTP ${res.status}${body ? ` - ${body}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
const body = sanitizeBody(await res.text().catch(() => ''))
|
||||
throw new Error(
|
||||
`GET ${url} returned non-JSON content-type "${contentType}"${body ? `: ${body}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 即使 content-type 正確,內容仍可能格式錯誤;明確攔截 SyntaxError 並補上 URL 上下文。
|
||||
let items
|
||||
try {
|
||||
items = await res.json()
|
||||
} catch (error) {
|
||||
throw new Error(`GET ${url} returned invalid JSON: ${error.message}`)
|
||||
}
|
||||
|
||||
// res.ok 為真時嚴格要求陣列:非陣列代表非預期回應(如錯誤物件),應報錯而非視為最後一頁。
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error(`GET ${url} returned a non-array JSON payload`)
|
||||
}
|
||||
if (items.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// 逐一加入(不使用展開運算子),避免大量項目時觸發呼叫堆疊上限。
|
||||
for (const item of items) {
|
||||
all.push(item)
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)。
|
||||
* @param {string} url 目標資源位址
|
||||
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
|
||||
*/
|
||||
async deleteResource(url) {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
|
Ghost marked this conversation as resolved
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
return res.status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 進入點:載入設定、建立 Gitea 客戶端,依序清理舊成品與孤立 tag。
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { loadConfig } from './config.js'
|
||||
import { GiteaClient } from './gitea-client.js'
|
||||
import { cleanupReleases } from './releases.js'
|
||||
import { cleanupOrphanTags } from './tags.js'
|
||||
import { separator, failError } from './logger.js'
|
||||
|
||||
/**
|
||||
* Action 主流程:讀取並驗證環境設定、建立帶認證的 Gitea 客戶端,
|
||||
* 先清理超出保留數量的舊 release,再清理未被任何 release 指定的孤立 tag,最後輸出結尾分隔線。
|
||||
*
|
||||
* 任一步驟拋出的例外不在此攔截,改由模組層級的 `main().catch(...)` 統一處理並以非零狀態結束。
|
||||
*
|
||||
* @returns {Promise<void>} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。
|
||||
*/
|
||||
export async function main() {
|
||||
const config = loadConfig()
|
||||
const client = new GiteaClient({ token: config.token })
|
||||
|
||||
await cleanupReleases(client, config)
|
||||
await cleanupOrphanTags(client, config)
|
||||
|
||||
separator()
|
||||
}
|
||||
|
||||
// 僅在直接以 `node index.js` 執行時啟動主流程;被測試 import 時不自動執行,方便撰寫整合測試。
|
||||
// 以 pathToFileURL 正確處理含空白/特殊字元的路徑與跨平台差異,避免手動拼接 file:// 的脆弱性。
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Bard
**問題**:手動拼接 import.meta.url 字串來判斷執行入口,寫法原始且脆弱。
**建議**:引用 node:url 中的 pathToFileURL,使用 import.meta.url === pathToFileURL(process.argv[1]).href 進行比較。
|
||||
main().catch((error) => {
|
||||
failError(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 統一的主控台輸出格式,對應原本 entrypoint.sh 的 separator/section/info/... 等函式。
|
||||
|
||||
const LINE = '=================================================='
|
||||
const SUBLINE = '--------------------------------------------------'
|
||||
|
||||
/**
|
||||
* 在前後換行的情況下輸出一條等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。
|
||||
*/
|
||||
export function separator() {
|
||||
process.stdout.write(`\n${LINE}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出一個區段標題:先印分隔線,再印標題文字與一條虛線,用於標示流程進入新階段。
|
||||
* @param {string} title 區段標題文字
|
||||
*/
|
||||
export function section(title) {
|
||||
separator()
|
||||
process.stdout.write(`${title}\n`)
|
||||
process.stdout.write(`${SUBLINE}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 `[INFO]` 前綴輸出一般資訊訊息至 stdout。
|
||||
* @param {string} message 訊息內容
|
||||
*/
|
||||
export function info(message) {
|
||||
process.stdout.write(`[INFO] ${message}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 `[OK]` 前綴輸出成功訊息至 stdout(前綴補空白以與其他標籤對齊)。
|
||||
* @param {string} message 訊息內容
|
||||
*/
|
||||
export function success(message) {
|
||||
process.stdout.write(`[OK] ${message}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 `[WARN]` 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。
|
||||
* @param {string} message 訊息內容
|
||||
*/
|
||||
export function warn(message) {
|
||||
process.stdout.write(`[WARN] ${message}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 `[ERR]` 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。
|
||||
* @param {string} message 訊息內容
|
||||
*/
|
||||
export function fail(message) {
|
||||
process.stderr.write(`[ERR] ${message}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出錯誤至 stderr,並依錯誤型別保留可供除錯的上下文:
|
||||
* 若為 Error 物件,輸出「名稱: 訊息」並附上堆疊;否則輸出其字串形式。
|
||||
* 將「如何輸出錯誤」的邏輯集中於此,呼叫端毋須自行操作 stderr 或判斷型別。
|
||||
* @param {unknown} error 待輸出的錯誤
|
||||
*/
|
||||
export function failError(error) {
|
||||
if (error instanceof Error) {
|
||||
fail(`${error.name}: ${error.message}`)
|
||||
if (error.stack) {
|
||||
process.stderr.write(`${error.stack}\n`)
|
||||
}
|
||||
} else {
|
||||
fail(String(error))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "release-cleanup",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "清理 Gitea 舊版本成品與未指定 release 的 tag",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 清理舊版本成品的功能模組。
|
||||
// 對應原本 entrypoint.sh 的「取得成品資訊」與「刪除舊版本成品」區段。
|
||||
|
||||
import { section, info, success, fail, warn } from './logger.js'
|
||||
import { isEmptyOrNull } from './validate.js'
|
||||
import { deleteWithRetry, runWithConcurrency } from './delete-utils.js'
|
||||
|
||||
/**
|
||||
* 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。
|
||||
* 以展開運算子複製陣列後再排序,不會改動傳入的原陣列。為純函式,方便單元測試。
|
||||
* @param {any[]} releases 成品清單,每個元素預期含有 `created_at` 欄位
|
||||
* @param {number} keepCount 要保留的最新筆數;為 0 時代表全部刪除
|
||||
* @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序
|
||||
*/
|
||||
export function selectReleasesToDelete(releases, keepCount) {
|
||||
// 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。
|
||||
const createdTime = (release) => {
|
||||
const time = Date.parse(release?.created_at)
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
// 先一次性計算每筆的時間戳,避免在排序比較中重複呼叫 Date.parse。
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 selectReleasesToDelete 函式中,若 releases 陣列為空,雖不拋錯但邏輯上應考慮空值邊界。且雖然處理了 Date.parse 的 NaN,但對空物件或缺失 created_at 屬性的項目的處理依賴屬性存取鏈,可能在特定結構下產生非預期行為。
**建議**:建議增加對釋出項目結構的預防性檢查,確保 created_at 存在且有效。
|
||||
// 時間相同時以 id(數值,大者為新)作為次要鍵,確保排序結果具決定性。
|
||||
return releases
|
||||
.map((release) => ({
|
||||
release,
|
||||
time: createdTime(release),
|
||||
id: Number(release?.id) || 0,
|
||||
}))
|
||||
.sort((a, b) => b.time - a.time || b.id - a.id)
|
||||
.map((entry) => entry.release)
|
||||
.slice(keepCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取全部成品清單,刪除超出保留數量的舊版本成品。
|
||||
*
|
||||
* 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→
|
||||
* 否則以 [[selectReleasesToDelete]] 取出待刪除清單,過濾結構異常/無效 id 的項目後,
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,無法確保後續清理的一致性與部分成功重試。
**建議**:引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略。
|
||||
* 以有上限併發、暫時性錯誤重試的方式逐筆刪除(best-effort:單筆失敗會記錄並繼續),最後彙報失敗筆數。
|
||||
*
|
||||
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
|
||||
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount`
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanupReleases(client, config) {
|
||||
section('取得成品資訊')
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:清理舊版本成品時,若刪除過程拋出例外或 API 回傳異常(除 204 外),未作完善處理,無法保證一致性且未針對『部分失敗』設計重試或完整性檢查機制。(合併:包含 app/releases.js:46 例外中止問題)
**建議**:將逐筆刪除包在 try/catch,加入錯誤計數;若失敗率過高或發生特定非預期錯誤(如 403),應明確拋出例外讓流程終止。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:清理舊版本成品時,若刪除過程拋出例外或 API 回傳異常(除 204 外),未作完善處理,導致無法保證一致性且未針對「部分失敗」設計重試或完整性檢查機制。
**建議**:將逐筆刪除包在 try/catch,加入錯誤計數;若失敗率過高或發生特定非預期錯誤(如 403),應明確拋出例外讓流程終止。
|
||||
info(`GET ${config.releaseApiUrl}`)
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:cleanupReleases 迴圈逐筆 await deleteResource,release 眾多時依序刪除耗時,且 API 負載高時易逾時。
**建議**:若 API 允許,採有上限的併發刪除(如限流 Promise.all),或增加進度日誌與重試。
|
||||
|
||||
const releases = await client.fetchAllPages(config.releaseApiUrl)
|
||||
info(`RELEASE_COUNT=${releases.length}`)
|
||||
info(`KEEP_COUNT=${config.keepCount}`)
|
||||
|
||||
if (releases.length <= config.keepCount) {
|
||||
success('沒有需要清理的舊版本成品')
|
||||
return
|
||||
}
|
||||
|
||||
section('刪除舊版本成品')
|
||||
|
||||
// 先過濾出可安全刪除的項目:略過 null/非物件結構,以及非正整數 id(Gitea release id 本即正整數)。
|
||||
const targets = selectReleasesToDelete(releases, config.keepCount).filter((release) => {
|
||||
if (release === null || typeof release !== 'object') {
|
||||
warn('略過格式異常的成品項目')
|
||||
return false
|
||||
}
|
||||
const { id, tag_name: tag, name } = release
|
||||
if (isEmptyOrNull(id) || !Number.isInteger(Number(id)) || Number(id) <= 0) {
|
||||
warn(`略過沒有有效 id 的成品: ${tag} (${name})`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
let failures = 0
|
||||
// 有上限併發 + 暫時性錯誤重試;單筆刪除失敗(含網路例外)只記錄不中斷,確保其餘成品仍被嘗試清理。
|
||||
await runWithConcurrency(targets, async (release) => {
|
||||
const { id, tag_name: tag, name } = release
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:儘管使用了 encodeURIComponent(id),若後續處理中 id 被錯誤地解碼或拼接,仍存在目錄穿越風險(API 路徑穿越)。
**建議**:除了 encodeURIComponent,需確保 cleanupReleases 迴圈中對 id 為正整數的驗證邏輯不能被繞過,且移除 id 上的任何類型轉換風險。
|
||||
const url = `${config.releaseApiUrl}/${encodeURIComponent(id)}`
|
||||
info(`DELETE ${tag} (${name})`)
|
||||
|
||||
const { status, error } = await deleteWithRetry(client, url)
|
||||
if (status === 204) {
|
||||
success(`成功刪除: ${tag} (${name})`)
|
||||
} else {
|
||||
failures += 1
|
||||
fail(`刪除失敗: ${tag} (${name}), ${error ? error.message : `HTTP ${status}`}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (failures > 0) {
|
||||
warn(`有 ${failures} 個成品刪除失敗(已記錄;清理為冪等,將於下次執行重試)`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// 清理未指定 release 的 tag 的功能模組。
|
||||
// 對應原本 entrypoint.sh 的「刪除未指定 release 的 tag」區段。
|
||||
|
||||
import { section, info, success, fail, warn } from './logger.js'
|
||||
import { isEmptyOrNull } from './validate.js'
|
||||
import { deleteWithRetry, runWithConcurrency } from './delete-utils.js'
|
||||
|
||||
/**
|
||||
* 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。
|
||||
*
|
||||
* 先把 `releaseTagNames` 收進 `Set` 以利 O(1) 比對;名稱經 [[isEmptyOrNull]] 判定為空者標為 `skip`,
|
||||
* 仍被任一 release 指定者標為 `keep`,其餘視為孤立 tag 標為 `delete`。為純函式,無副作用,方便單元測試。
|
||||
*
|
||||
* @param {any[]} tags tag 清單,每個元素預期含有 `name` 欄位
|
||||
* @param {Iterable<string>} releaseTagNames 仍被 release 指定的 tag 名稱集合
|
||||
* @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} 與輸入等長且保持原順序的分類結果
|
||||
*/
|
||||
export function categorizeTags(tags, releaseTagNames) {
|
||||
const keep = new Set(releaseTagNames)
|
||||
|
||||
return tags.map((tag) => {
|
||||
// 防禦 null/非物件 tag 與非字串 name:一律歸為 skip,避免後續存取或比對時出錯。
|
||||
const name = tag?.name
|
||||
if (typeof name !== 'string' || isEmptyOrNull(name)) {
|
||||
return { tag, action: 'skip' }
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:categorizeTags 函式中直接呼叫 keep.has(tag.name),若 API 回傳的 tag 物件缺少 name 欄位或 name 為非字串,可能導致非預期行為。
**建議**:增加明確的型別檢查,確保 tag.name 為 string 後再進行比對。
|
||||
}
|
||||
if (keep.has(name)) {
|
||||
return { tag, action: 'keep' }
|
||||
}
|
||||
return { tag, action: 'delete' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。
|
||||
*
|
||||
* 流程:重新抓取全部 release(反映刪除舊版本後的最新狀態)並取出其 `tag_name` 集合 →
|
||||
* 抓取全部 tag → 以 [[categorizeTags]] 分類,對 `skip` 警告略過、`keep` 保留,
|
||||
* 對 `delete` 發出刪除並依狀態碼 204 判定成功與否。
|
||||
*
|
||||
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
|
||||
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `tagApiUrl`
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanupOrphanTags(client, config) {
|
||||
section('刪除未指定 release 的 tag')
|
||||
|
||||
// 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品。
|
||||
// 已知限制(TOCTOU):release 與 tag 為兩次獨立 API 呼叫,Gitea 無交易機制;
|
||||
// 若兩次呼叫之間有新 release 關聯到某 tag,該 tag 仍可能基於過時快照被誤判為孤立。
|
||||
// 此為排程清理任務可接受的殘餘競態(下次執行會自我修正),完整原子性留待人工評估。
|
||||
const currentReleases = await client.fetchAllPages(config.releaseApiUrl)
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,過濾過程未保證原子性;期間 Gitea 狀態變更可能誤刪非孤立 tag。(合併:包含 app/tags.js:47, 45 之類似 TOCTOU 風險指控)
**建議**:考量原子性需求,或在刪除前增加確認機制;並明確文件化此風險。建議增加最終防護機制,或在測試中模擬競爭條件。
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:cleanupOrphanTags 分兩次 API 呼叫取得 releases 與 tags,過濾過程未保證原子性,期間 Gitea 狀態變更可能誤刪非孤立 tag。
**建議**:考量原子性需求,或在刪除前增加確認機制,並明確文件化此風險。
|
||||
const releaseTagNames = currentReleases.map((release) => release.tag_name)
|
||||
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:刪除 tag 的迴圈缺少對 deleteResource 拋例外的防禦,網路錯誤會中止後續 tag 清理。
**建議**:在 for 迴圈內加 try/catch,記錄錯誤並 continue;比照 releases 補測試。
|
||||
info(`GET ${config.tagApiUrl}`)
|
||||
const tags = await client.fetchAllPages(config.tagApiUrl)
|
||||
info(`TAG_COUNT=${tags.length}`)
|
||||
|
||||
const categorized = categorizeTags(tags, releaseTagNames)
|
||||
for (const { tag, action } of categorized) {
|
||||
if (action === 'skip') {
|
||||
warn('略過沒有名稱的 tag')
|
||||
} else if (action === 'keep') {
|
||||
info(`保留指定 release 的 tag: ${tag.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 取出孤立 tag,以有上限併發 + 暫時性錯誤重試刪除;單筆失敗只記錄不中斷,確保其餘 tag 仍被嘗試清理。
|
||||
const orphans = categorized.filter(({ action }) => action === 'delete').map(({ tag }) => tag)
|
||||
|
||||
let failures = 0
|
||||
await runWithConcurrency(orphans, async (tag) => {
|
||||
// 對 tag 名稱做編碼,避免名稱中的特殊字元被拼接進 URL(防路徑穿越);一般 tag 名編碼後不變。
|
||||
const url = `${config.tagApiUrl}/${encodeURIComponent(tag.name)}`
|
||||
info(`DELETE tag ${tag.name}`)
|
||||
|
||||
const { status, error } = await deleteWithRetry(client, url)
|
||||
if (status === 204) {
|
||||
success(`成功刪除未指定 release 的 tag: ${tag.name}`)
|
||||
} else {
|
||||
failures += 1
|
||||
fail(`刪除 tag 失敗: ${tag.name}, ${error ? error.message : `HTTP ${status}`}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (failures > 0) {
|
||||
warn(`有 ${failures} 個 tag 刪除失敗(已記錄;清理為冪等,將於下次執行重試)`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadConfig } from '../config.js'
|
||||
|
||||
const base = {
|
||||
GITEA_SERVER_URL: 'https://gitea.example.com',
|
||||
GITEA_REPOSITORY: 'owner/repo',
|
||||
KEEP_COUNT: '2',
|
||||
}
|
||||
|
||||
test('loadConfig 在缺少 GITEA_SERVER_URL 時丟出', () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ GITEA_REPOSITORY: 'owner/repo', KEEP_COUNT: '2' }),
|
||||
/GITEA_SERVER_URL is required/,
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 在 KEEP_COUNT 非整數時丟出', () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ ...base, KEEP_COUNT: 'abc' }),
|
||||
/non-negative integer/,
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 在 GITEA_SERVER_URL 非合法 URL 時丟出', () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ ...base, GITEA_SERVER_URL: 'not a url' }),
|
||||
/must be a valid URL/,
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 在 GITEA_REPOSITORY 含路徑穿越時丟出', () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ ...base, GITEA_REPOSITORY: '../evil' }),
|
||||
/owner\/repo without path traversal/,
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 在有效輸入時回傳完整設定物件', () => {
|
||||
const cfg = loadConfig(base)
|
||||
assert.equal(cfg.serverUrl, 'https://gitea.example.com')
|
||||
assert.equal(cfg.repository, 'owner/repo')
|
||||
assert.equal(cfg.token, null)
|
||||
assert.equal(cfg.keepCount, 2)
|
||||
assert.equal(
|
||||
cfg.releaseApiUrl,
|
||||
'https://gitea.example.com/api/v1/repos/owner/repo/releases',
|
||||
)
|
||||
assert.equal(
|
||||
cfg.tagApiUrl,
|
||||
'https://gitea.example.com/api/v1/repos/owner/repo/tags',
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 去除 GITEA_SERVER_URL 結尾斜線,避免 API 路徑出現雙斜線', () => {
|
||||
const cfg = loadConfig({ ...base, GITEA_SERVER_URL: 'https://gitea.example.com/' })
|
||||
assert.equal(cfg.serverUrl, 'https://gitea.example.com')
|
||||
assert.equal(
|
||||
cfg.releaseApiUrl,
|
||||
'https://gitea.example.com/api/v1/repos/owner/repo/releases',
|
||||
)
|
||||
})
|
||||
|
||||
test('loadConfig 在提供 token 時保留 token 值', () => {
|
||||
const cfg = loadConfig({ ...base, GITEA_TOKEN: 'secret' })
|
||||
assert.equal(cfg.token, 'secret')
|
||||
})
|
||||
|
||||
test('loadConfig 在 GITEA_TOKEN 為空字串時視為匿名(token 為 null)', () => {
|
||||
const cfg = loadConfig({ ...base, GITEA_TOKEN: '' })
|
||||
assert.equal(cfg.token, null)
|
||||
})
|
||||
|
||||
test('loadConfig 接受極短與極長的 GITEA_TOKEN', () => {
|
||||
const short = loadConfig({ ...base, GITEA_TOKEN: 'x' })
|
||||
assert.equal(short.token, 'x')
|
||||
|
||||
const longToken = 'a'.repeat(1000)
|
||||
const long = loadConfig({ ...base, GITEA_TOKEN: longToken })
|
||||
assert.equal(long.token, longToken)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
isRetryableStatus,
|
||||
deleteWithRetry,
|
||||
runWithConcurrency,
|
||||
} from '../delete-utils.js'
|
||||
|
||||
const noSleep = () => Promise.resolve()
|
||||
|
||||
test('isRetryableStatus 僅對暫時性狀態碼回傳 true', () => {
|
||||
for (const s of [429, 502, 503, 504]) assert.equal(isRetryableStatus(s), true)
|
||||
for (const s of [200, 204, 400, 401, 403, 404, 500]) {
|
||||
assert.equal(isRetryableStatus(s), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleteWithRetry 成功(204)時不重試', async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
async deleteResource() {
|
||||
calls += 1
|
||||
return 204
|
||||
},
|
||||
}
|
||||
const result = await deleteWithRetry(client, 'u', { sleep: noSleep })
|
||||
assert.equal(result.status, 204)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('deleteWithRetry 對暫時性錯誤重試後成功', async () => {
|
||||
const statuses = [503, 503, 204]
|
||||
let i = 0
|
||||
const client = {
|
||||
async deleteResource() {
|
||||
return statuses[i++]
|
||||
},
|
||||
}
|
||||
const result = await deleteWithRetry(client, 'u', { sleep: noSleep })
|
||||
assert.equal(result.status, 204)
|
||||
assert.equal(result.attempts, 3)
|
||||
})
|
||||
|
||||
test('deleteWithRetry 對永久性錯誤(403)立即停止重試', async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
async deleteResource() {
|
||||
calls += 1
|
||||
return 403
|
||||
},
|
||||
}
|
||||
const result = await deleteWithRetry(client, 'u', { sleep: noSleep })
|
||||
assert.equal(result.status, 403)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('deleteWithRetry 對網路例外重試至次數用盡並回傳 error', async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
async deleteResource() {
|
||||
calls += 1
|
||||
throw new Error('network down')
|
||||
},
|
||||
}
|
||||
const result = await deleteWithRetry(client, 'u', { attempts: 3, sleep: noSleep })
|
||||
assert.equal(result.status, 0)
|
||||
assert.match(result.error.message, /network down/)
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('runWithConcurrency 回傳對應輸入順序的結果', async () => {
|
||||
const items = [1, 2, 3, 4, 5]
|
||||
const out = await runWithConcurrency(items, async (n) => n * 2, 2)
|
||||
assert.deepEqual(out, [2, 4, 6, 8, 10])
|
||||
})
|
||||
|
||||
test('runWithConcurrency 不超過併發上限', async () => {
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const items = Array.from({ length: 10 }, (_, i) => i)
|
||||
await runWithConcurrency(
|
||||
items,
|
||||
async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await Promise.resolve()
|
||||
active -= 1
|
||||
},
|
||||
3,
|
||||
)
|
||||
assert.ok(maxActive <= 3, `maxActive=${maxActive} 應 <= 3`)
|
||||
})
|
||||
|
||||
test('runWithConcurrency 對空陣列回傳空陣列', async () => {
|
||||
const out = await runWithConcurrency([], async () => 1, 4)
|
||||
assert.deepEqual(out, [])
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { GiteaClient } from '../gitea-client.js'
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
// 建立模擬的 JSON 回應(含 content-type 標頭)
|
||||
function jsonResponse(data) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
test('建構子在有 token 時帶上 Authorization 標頭', () => {
|
||||
const client = new GiteaClient({ token: 'abc' })
|
||||
assert.equal(client.headers.Authorization, 'token abc')
|
||||
})
|
||||
|
||||
test('建構子在無 token 時不帶 Authorization 標頭', () => {
|
||||
const client = new GiteaClient({})
|
||||
assert.equal(client.headers.Authorization, undefined)
|
||||
})
|
||||
|
||||
test('fetchAllPages 逐頁讀取直到空陣列', async () => {
|
||||
const pages = {
|
||||
1: [{ id: 1 }, { id: 2 }],
|
||||
2: [{ id: 3 }],
|
||||
3: [],
|
||||
}
|
||||
const requested = []
|
||||
globalThis.fetch = async (url) => {
|
||||
const page = new URL(url).searchParams.get('page')
|
||||
requested.push(page)
|
||||
return jsonResponse(pages[page])
|
||||
}
|
||||
|
||||
const client = new GiteaClient({})
|
||||
const items = await client.fetchAllPages('https://example.com/api')
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.id),
|
||||
[1, 2, 3],
|
||||
)
|
||||
assert.deepEqual(requested, ['1', '2', '3'])
|
||||
})
|
||||
|
||||
test('fetchAllPages 帶上逾時 signal', async () => {
|
||||
let sawSignal = false
|
||||
globalThis.fetch = async (_url, opts) => {
|
||||
sawSignal = opts && typeof opts.signal === 'object' && opts.signal !== null
|
||||
return jsonResponse([])
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
await client.fetchAllPages('https://example.com/api')
|
||||
assert.equal(sawSignal, true)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'boom',
|
||||
})
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/HTTP 500/,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在回應非 JSON 時丟出例外', async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'text/html' },
|
||||
text: async () => '<html>error</html>',
|
||||
})
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/non-JSON content-type/,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在回應為非陣列 JSON 時丟出例外', async () => {
|
||||
globalThis.fetch = async () => jsonResponse({ message: 'error object' })
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/non-array JSON payload/,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在 fetch 因逾時拋出 AbortError 時向外拋出', async () => {
|
||||
globalThis.fetch = async () => {
|
||||
const err = new Error('The operation was aborted due to timeout')
|
||||
err.name = 'AbortError'
|
||||
throw err
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
(err) => err.name === 'AbortError',
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在 fetch 拋出一般網路錯誤時向外拋出', async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new TypeError('fetch failed: ECONNREFUSED')
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/fetch failed|ECONNREFUSED/,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在頁數超過 MAX_PAGES 時中止以避免無限迴圈', async () => {
|
||||
// 永遠回傳非空陣列,模擬 API 不以空陣列結尾的異常情形
|
||||
globalThis.fetch = async () => jsonResponse([{ id: 1 }])
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/exceeded MAX_PAGES/,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchAllPages 的錯誤訊息會清理控制字元並限制長度(sanitizeBody)', async () => {
|
||||
const nasty = 'line1\nline2\r\tinjected' + 'x'.repeat(500)
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => nasty,
|
||||
})
|
||||
const client = new GiteaClient({})
|
||||
let message = ''
|
||||
try {
|
||||
await client.fetchAllPages('https://example.com/api')
|
||||
} catch (error) {
|
||||
message = error.message
|
||||
}
|
||||
// 控制字元(換行、Tab 等)已被移除
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:fetchAllPages 目前缺乏測試當 fetch 自身發生網路錯誤時的情境。
**建議**:補上一個測試案例,模擬 fetch 拋出非 AbortError 的一般網路錯誤,以驗證客戶端能妥善捕捉並拋出具備 URL 上下文的錯誤。
|
||||
assert.doesNotMatch(message, /[\u0000-\u001F]/)
|
||||
// 回應內容片段受 200 字元上限約束(原始有 500 個 x)
|
||||
assert.ok((message.match(/x/g) || []).length <= 200)
|
||||
})
|
||||
|
||||
test('fetchAllPages 在第 MAX_PAGES 頁回傳空陣列時正常結束(不拋例外)', async () => {
|
||||
let page = 0
|
||||
globalThis.fetch = async () => {
|
||||
page += 1
|
||||
// 前 999 頁有資料,第 1000 頁(MAX_PAGES)回空陣列,應正常結束
|
||||
return jsonResponse(page < 1000 ? [{ id: page }] : [])
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
const items = await client.fetchAllPages('https://example.com/api')
|
||||
assert.equal(items.length, 999)
|
||||
})
|
||||
|
||||
test('deleteResource 回傳 HTTP 狀態碼', async () => {
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
assert.equal(opts.method, 'DELETE')
|
||||
return { status: 204 }
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
assert.equal(await client.deleteResource('https://example.com/api/1'), 204)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { main } from '../index.js'
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
const savedEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
process.env = { ...savedEnv }
|
||||
})
|
||||
|
||||
function jsonResponse(data) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
// 端對端驗證:列出 release → 刪除舊版本 → 重新列出 release → 列出 tag → 刪除孤立 tag。
|
||||
test('main 完整清理流程:刪除舊 release 與孤立 tag,呼叫順序符合預期', async () => {
|
||||
for (const k of ['GITEA_SERVER_URL', 'GITEA_REPOSITORY', 'KEEP_COUNT', 'GITEA_TOKEN']) {
|
||||
delete process.env[k]
|
||||
}
|
||||
Object.assign(process.env, {
|
||||
GITEA_SERVER_URL: 'https://gitea.example.com',
|
||||
GITEA_REPOSITORY: 'owner/repo',
|
||||
KEEP_COUNT: '2',
|
||||
})
|
||||
|
||||
const releases = [
|
||||
{ id: 3, tag_name: 'v3', name: 'r3', created_at: '2024-03-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2', name: 'r2', created_at: '2024-02-01T00:00:00Z' },
|
||||
{ id: 1, tag_name: 'v1', name: 'r1', created_at: '2024-01-01T00:00:00Z' },
|
||||
]
|
||||
const tags = [{ name: 'v3' }, { name: 'v2' }, { name: 'v1' }, { name: 'orphan' }]
|
||||
|
||||
const calls = { get: [], delete: [] }
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
const u = new URL(url)
|
||||
const method = (opts && opts.method) || 'GET'
|
||||
if (method === 'DELETE') {
|
||||
calls.delete.push(u.pathname)
|
||||
return { status: 204 }
|
||||
}
|
||||
const page = u.searchParams.get('page')
|
||||
calls.get.push(`${u.pathname}?page=${page}`)
|
||||
if (u.pathname.endsWith('/releases')) {
|
||||
return jsonResponse(page === '1' ? releases : [])
|
||||
}
|
||||
if (u.pathname.endsWith('/tags')) {
|
||||
return jsonResponse(page === '1' ? tags : [])
|
||||
}
|
||||
return jsonResponse([])
|
||||
}
|
||||
|
||||
await main()
|
||||
|
||||
// 保留最新 2 筆(v3、v2),刪除最舊 v1(id=1);孤立 tag 'orphan' 被刪除
|
||||
assert.deepEqual(calls.delete, [
|
||||
'/api/v1/repos/owner/repo/releases/1',
|
||||
'/api/v1/repos/owner/repo/tags/orphan',
|
||||
])
|
||||
// release 清單被讀取兩次(清理舊版本一次、清理 tag 前重新讀取一次)
|
||||
const releaseListGets = calls.get.filter((c) => c.includes('/releases?page=1'))
|
||||
assert.equal(releaseListGets.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
separator,
|
||||
section,
|
||||
info,
|
||||
success,
|
||||
warn,
|
||||
fail,
|
||||
failError,
|
||||
} from '../logger.js'
|
||||
|
||||
const realWrite = process.stderr.write
|
||||
const realStdoutWrite = process.stdout.write
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = realWrite
|
||||
process.stdout.write = realStdoutWrite
|
||||
})
|
||||
|
||||
// 攔截寫入 stdout 的內容
|
||||
function captureStdout(fn) {
|
||||
const chunks = []
|
||||
process.stdout.write = (chunk) => {
|
||||
chunks.push(String(chunk))
|
||||
return true
|
||||
}
|
||||
try {
|
||||
fn()
|
||||
} finally {
|
||||
process.stdout.write = realStdoutWrite
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
test('info/success/warn 以正確前綴寫入 stdout', () => {
|
||||
assert.equal(captureStdout(() => info('hi')), '[INFO] hi\n')
|
||||
assert.equal(captureStdout(() => success('ok')), '[OK] ok\n')
|
||||
assert.equal(captureStdout(() => warn('careful')), '[WARN] careful\n')
|
||||
})
|
||||
|
||||
test('separator 輸出等號分隔線', () => {
|
||||
const out = captureStdout(() => separator())
|
||||
assert.match(out, /^\n={50}\n$/)
|
||||
})
|
||||
|
||||
test('section 輸出分隔線、標題與虛線', () => {
|
||||
const out = captureStdout(() => section('參數檢查'))
|
||||
assert.match(out, /={50}\n參數檢查\n-{50}\n/)
|
||||
})
|
||||
|
||||
test('fail 寫入 stderr 而非 stdout', () => {
|
||||
const stdout = captureStdout(() => {
|
||||
const chunks = []
|
||||
process.stderr.write = (chunk) => {
|
||||
chunks.push(String(chunk))
|
||||
return true
|
||||
}
|
||||
fail('boom')
|
||||
assert.equal(chunks.join(''), '[ERR] boom\n')
|
||||
})
|
||||
assert.equal(stdout, '') // fail 不應寫入 stdout
|
||||
})
|
||||
|
||||
// 攔截寫入 stderr 的內容
|
||||
function captureStderr(fn) {
|
||||
const chunks = []
|
||||
process.stderr.write = (chunk) => {
|
||||
chunks.push(String(chunk))
|
||||
return true
|
||||
}
|
||||
try {
|
||||
fn()
|
||||
} finally {
|
||||
process.stderr.write = realWrite
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
test('failError 對 Error 物件輸出名稱、訊息與堆疊', () => {
|
||||
const out = captureStderr(() => failError(new TypeError('boom')))
|
||||
assert.match(out, /\[ERR\] TypeError: boom/)
|
||||
assert.match(out, /at /) // 堆疊內容
|
||||
})
|
||||
|
||||
test('failError 對非 Error 值輸出其字串形式', () => {
|
||||
const out = captureStderr(() => failError('plain failure'))
|
||||
assert.match(out, /\[ERR\] plain failure/)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { main } from '../index.js'
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
const savedEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
process.env = { ...savedEnv }
|
||||
})
|
||||
|
||||
function setEnv(env) {
|
||||
for (const key of ['GITEA_SERVER_URL', 'GITEA_REPOSITORY', 'KEEP_COUNT', 'GITEA_TOKEN']) {
|
||||
delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
test('main 在環境變數有效且 API 正常時順利完成', async () => {
|
||||
setEnv({
|
||||
GITEA_SERVER_URL: 'https://gitea.example.com',
|
||||
GITEA_REPOSITORY: 'owner/repo',
|
||||
KEEP_COUNT: '2',
|
||||
})
|
||||
// 所有清單端點皆回空陣列(無 release、無 tag)
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => [],
|
||||
})
|
||||
|
||||
await main() // 不應拋出
|
||||
})
|
||||
|
||||
test('main 在必填環境變數缺失時 reject(供整合層級驗證錯誤處理)', async () => {
|
||||
setEnv({
|
||||
GITEA_REPOSITORY: 'owner/repo',
|
||||
KEEP_COUNT: '2',
|
||||
})
|
||||
await assert.rejects(() => main(), /GITEA_SERVER_URL is required/)
|
||||
})
|
||||
|
||||
test('直接執行 index.js 在設定錯誤時以非零狀態碼結束', () => {
|
||||
const indexPath = fileURLToPath(new URL('../index.js', import.meta.url))
|
||||
// 僅帶 PATH,不提供任何必填環境變數,使 main 拋錯並觸發 process.exit(1)
|
||||
const result = spawnSync(process.execPath, [indexPath], {
|
||||
env: { PATH: process.env.PATH },
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /GITEA_SERVER_URL is required/)
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { selectReleasesToDelete, cleanupReleases } from '../releases.js'
|
||||
|
||||
const releases = [
|
||||
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 3, tag_name: 'v3.0.0', created_at: '2024-03-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2.0.0', created_at: '2024-02-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
test('selectReleasesToDelete 保留最新的 keepCount 筆,刪除其餘', () => {
|
||||
const toDelete = selectReleasesToDelete(releases, 2)
|
||||
assert.equal(toDelete.length, 1)
|
||||
assert.equal(toDelete[0].tag_name, 'v1.0.0')
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 在數量不超過保留數時回傳空陣列', () => {
|
||||
assert.deepEqual(selectReleasesToDelete(releases, 3), [])
|
||||
assert.deepEqual(selectReleasesToDelete(releases, 5), [])
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 對空陣列回傳空陣列', () => {
|
||||
assert.deepEqual(selectReleasesToDelete([], 1), [])
|
||||
assert.deepEqual(selectReleasesToDelete([], 0), [])
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 在 keepCount 為 0 時刪除全部(由新到舊)', () => {
|
||||
const toDelete = selectReleasesToDelete(releases, 0)
|
||||
assert.deepEqual(
|
||||
toDelete.map((r) => r.tag_name),
|
||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:selectReleasesToDelete 尚未明確測試傳入空陣列 [] 時的行為。
**建議**:增加測試案例:assert.deepEqual(selectReleasesToDelete([], 1), []),驗證穩健性。
|
||||
['v3.0.0', 'v2.0.0', 'v1.0.0'],
|
||||
)
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 在 created_at 相同時以 id 決定順序(具決定性)', () => {
|
||||
const same = '2024-01-01T00:00:00Z'
|
||||
const list = [
|
||||
{ id: 1, created_at: same },
|
||||
{ id: 3, created_at: same },
|
||||
{ id: 2, created_at: same },
|
||||
]
|
||||
const toDelete = selectReleasesToDelete(list, 1)
|
||||
// 保留 id 最大者(3),刪除 id 2 與 1(順序固定)
|
||||
assert.deepEqual(
|
||||
toDelete.map((r) => r.id),
|
||||
[2, 1],
|
||||
)
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 不改動原陣列', () => {
|
||||
const original = [...releases]
|
||||
selectReleasesToDelete(releases, 1)
|
||||
assert.deepEqual(releases, original)
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 對無效 created_at 視為最舊,不產生 NaN 排序', () => {
|
||||
const list = [
|
||||
{ id: 1, tag_name: 'good', created_at: '2024-05-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'bad', created_at: 'not-a-date' },
|
||||
{ id: 3, tag_name: 'empty' },
|
||||
]
|
||||
const toDelete = selectReleasesToDelete(list, 1)
|
||||
// 最新的 good 被保留;兩筆無效日期視為最舊,落入待刪除
|
||||
assert.equal(toDelete.length, 2)
|
||||
assert.ok(!toDelete.some((r) => r.tag_name === 'good'))
|
||||
})
|
||||
|
||||
// 建立可記錄刪除呼叫的假 client
|
||||
function fakeClient(releases, statusFor = () => 204) {
|
||||
const deleted = []
|
||||
return {
|
||||
deleted,
|
||||
async fetchAllPages() {
|
||||
return releases
|
||||
},
|
||||
async deleteResource(url) {
|
||||
deleted.push(url)
|
||||
return statusFor(url)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('cleanupReleases 刪除超出保留數的舊 release', async () => {
|
||||
const client = fakeClient([
|
||||
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
|
||||
{ id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
|
||||
])
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
|
||||
// 保留最新 v3,刪除 v2 與 v1
|
||||
assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1'])
|
||||
})
|
||||
|
||||
test('cleanupReleases 在未超過保留數時不刪除', async () => {
|
||||
const client = fakeClient([
|
||||
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
])
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 2 })
|
||||
assert.equal(client.deleted.length, 0)
|
||||
})
|
||||
|
||||
test('cleanupReleases 在 deleteResource 回傳非 204 時仍繼續處理其餘項目', async () => {
|
||||
const client = fakeClient(
|
||||
[
|
||||
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
|
||||
{ id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
|
||||
],
|
||||
(url) => (url.endsWith('/2') ? 500 : 204), // v2 刪除失敗
|
||||
)
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
|
||||
// 即使 v2 失敗(500),v1 仍被嘗試刪除
|
||||
assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1'])
|
||||
})
|
||||
|
||||
test('cleanupReleases 略過非整數(惡意)id,不發出刪除請求', async () => {
|
||||
const client = fakeClient([
|
||||
{ id: 1, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
|
||||
{ id: '../evil', tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
])
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
|
||||
// 保留最新(id=1);待刪除者 id 非整數 → 被略過,不會送出任何刪除
|
||||
assert.deepEqual(client.deleted, [])
|
||||
})
|
||||
|
||||
test('cleanupReleases 在刪除回傳非 204 時記錄錯誤至 stderr', async () => {
|
||||
const client = fakeClient(
|
||||
[
|
||||
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
|
||||
],
|
||||
() => 500,
|
||||
)
|
||||
const realWrite = process.stderr.write
|
||||
const chunks = []
|
||||
process.stderr.write = (chunk) => {
|
||||
chunks.push(String(chunk))
|
||||
return true
|
||||
}
|
||||
try {
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
|
||||
} finally {
|
||||
process.stderr.write = realWrite
|
||||
}
|
||||
assert.match(chunks.join(''), /\[ERR\]/)
|
||||
})
|
||||
|
||||
test('cleanupReleases 對含 null/異常結構的 release 不拋例外且照常處理正常項目', async () => {
|
||||
const client = fakeClient([
|
||||
{ id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
|
||||
null,
|
||||
{ created_at: '2024-02-01T00:00:00Z' }, // 缺 id/tag_name
|
||||
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
])
|
||||
await assert.doesNotReject(() =>
|
||||
cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }),
|
||||
)
|
||||
// 保留最新(id=3);待刪除者中僅 id=1 為有效正整數 id,其餘(null、缺 id)被略過
|
||||
assert.deepEqual(client.deleted, ['http://x/releases/1'])
|
||||
})
|
||||
|
||||
test('cleanupReleases 略過沒有 id 的 release', async () => {
|
||||
const client = fakeClient([
|
||||
{ id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
|
||||
{ id: null, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
|
||||
])
|
||||
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
|
||||
// 保留最新 v3;待刪除為 v2 與 (無 id 的) v1 → 無 id 者被略過
|
||||
assert.deepEqual(client.deleted, ['http://x/releases/2'])
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { categorizeTags, cleanupOrphanTags } from '../tags.js'
|
||||
|
||||
const tags = [
|
||||
{ name: 'v3.0.0' },
|
||||
{ name: 'v2.0.0' },
|
||||
{ name: 'v1.0.0' },
|
||||
{ name: '' },
|
||||
]
|
||||
|
||||
test('categorizeTags 保留仍被 release 指定的 tag', () => {
|
||||
const result = categorizeTags(tags, ['v3.0.0', 'v2.0.0'])
|
||||
const byName = Object.fromEntries(
|
||||
result.map(({ tag, action }) => [tag.name, action]),
|
||||
)
|
||||
assert.equal(byName['v3.0.0'], 'keep')
|
||||
assert.equal(byName['v2.0.0'], 'keep')
|
||||
})
|
||||
|
||||
test('categorizeTags 將未指定 release 的 tag 標記為刪除', () => {
|
||||
const result = categorizeTags(tags, ['v3.0.0'])
|
||||
const v1 = result.find(({ tag }) => tag.name === 'v1.0.0')
|
||||
assert.equal(v1.action, 'delete')
|
||||
})
|
||||
|
||||
test('categorizeTags 將無名稱的 tag 標記為略過', () => {
|
||||
const result = categorizeTags(tags, [])
|
||||
const empty = result.find(({ tag }) => tag.name === '')
|
||||
assert.equal(empty.action, 'skip')
|
||||
})
|
||||
|
||||
test('categorizeTags 對 null/非字串 name 的項目標記為略過(不拋例外)', () => {
|
||||
const odd = [null, { name: 123 }, {}, { name: 'v1' }]
|
||||
let result
|
||||
assert.doesNotThrow(() => {
|
||||
result = categorizeTags(odd, ['v1'])
|
||||
})
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.action),
|
||||
['skip', 'skip', 'skip', 'keep'],
|
||||
)
|
||||
})
|
||||
|
||||
test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', () => {
|
||||
const result = categorizeTags(tags, [])
|
||||
const deletes = result.filter(({ action }) => action === 'delete')
|
||||
assert.deepEqual(
|
||||
deletes.map(({ tag }) => tag.name),
|
||||
['v3.0.0', 'v2.0.0', 'v1.0.0'],
|
||||
)
|
||||
})
|
||||
|
||||
// 依 URL 回傳對應清單的假 client(releases 與 tags 兩種端點)
|
||||
function fakeClient({ releases, tagList }) {
|
||||
const deleted = []
|
||||
return {
|
||||
deleted,
|
||||
async fetchAllPages(url) {
|
||||
return url.includes('/releases') ? releases : tagList
|
||||
},
|
||||
async deleteResource(url) {
|
||||
deleted.push(url)
|
||||
return 204
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
releaseApiUrl: 'http://x/releases',
|
||||
tagApiUrl: 'http://x/tags',
|
||||
}
|
||||
|
||||
test('cleanupOrphanTags 刪除孤立 tag、保留被指定的、略過無名稱', async () => {
|
||||
const client = fakeClient({
|
||||
releases: [{ tag_name: 'v2.0.0' }],
|
||||
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }, { name: '' }],
|
||||
})
|
||||
await cleanupOrphanTags(client, config)
|
||||
// v2.0.0 仍被 release 指定 → 保留;v1.0.0 孤立 → 刪除;'' 無名稱 → 略過
|
||||
assert.deepEqual(client.deleted, ['http://x/tags/v1.0.0'])
|
||||
})
|
||||
|
||||
test('cleanupOrphanTags 在所有 tag 都被指定時不刪除', async () => {
|
||||
const client = fakeClient({
|
||||
releases: [{ tag_name: 'v2.0.0' }, { tag_name: 'v1.0.0' }],
|
||||
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }],
|
||||
})
|
||||
await cleanupOrphanTags(client, config)
|
||||
assert.equal(client.deleted.length, 0)
|
||||
})
|
||||
|
||||
test('cleanupOrphanTags 在沒有任何 release 時刪除所有具名 tag', async () => {
|
||||
const client = fakeClient({
|
||||
releases: [],
|
||||
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }],
|
||||
})
|
||||
await cleanupOrphanTags(client, config)
|
||||
assert.deepEqual(client.deleted, ['http://x/tags/v2.0.0', 'http://x/tags/v1.0.0'])
|
||||
})
|
||||
|
||||
test('cleanupOrphanTags 在刪除回傳非 204 時繼續處理其餘 tag 並記錄錯誤', async () => {
|
||||
const deleted = []
|
||||
const client = {
|
||||
async fetchAllPages(url) {
|
||||
return url.includes('/releases') ? [] : [{ name: 'v1' }, { name: 'v2' }]
|
||||
},
|
||||
async deleteResource(url) {
|
||||
deleted.push(url)
|
||||
return 500
|
||||
},
|
||||
}
|
||||
const realWrite = process.stderr.write
|
||||
const chunks = []
|
||||
process.stderr.write = (chunk) => {
|
||||
chunks.push(String(chunk))
|
||||
return true
|
||||
}
|
||||
try {
|
||||
await cleanupOrphanTags(client, config)
|
||||
} finally {
|
||||
process.stderr.write = realWrite
|
||||
}
|
||||
// 兩個孤立 tag 都被嘗試刪除(失敗不中斷),且有錯誤記錄
|
||||
assert.equal(deleted.length, 2)
|
||||
assert.match(chunks.join(''), /\[ERR\]/)
|
||||
})
|
||||
|
||||
test('cleanupOrphanTags 在 fetchAllPages 拋錯時向外拋出', async () => {
|
||||
const client = {
|
||||
async fetchAllPages() {
|
||||
throw new Error('GET http://x/releases?page=1 failed: HTTP 500')
|
||||
},
|
||||
async deleteResource() {
|
||||
return 204
|
||||
},
|
||||
}
|
||||
await assert.rejects(() => cleanupOrphanTags(client, config), /HTTP 500/)
|
||||
})
|
||||
|
||||
test('cleanupOrphanTags 對含特殊字元的 tag 名稱進行 URL 編碼(防路徑穿越)', async () => {
|
||||
const client = fakeClient({
|
||||
releases: [],
|
||||
tagList: [{ name: '../evil' }],
|
||||
})
|
||||
await cleanupOrphanTags(client, config)
|
||||
// 名稱中的 ../ 應被編碼,不會形成可穿越的路徑
|
||||
assert.deepEqual(client.deleted, ['http://x/tags/..%2Fevil'])
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
isEmptyOrNull,
|
||||
requireValue,
|
||||
requireInteger,
|
||||
requireUrl,
|
||||
requireRepository,
|
||||
} from '../validate.js'
|
||||
|
||||
test('isEmptyOrNull 對空值回傳 true', () => {
|
||||
assert.equal(isEmptyOrNull(''), true)
|
||||
assert.equal(isEmptyOrNull(null), true)
|
||||
assert.equal(isEmptyOrNull(undefined), true)
|
||||
assert.equal(isEmptyOrNull('null'), true)
|
||||
})
|
||||
|
||||
test('isEmptyOrNull 對有效值回傳 false', () => {
|
||||
assert.equal(isEmptyOrNull('value'), false)
|
||||
assert.equal(isEmptyOrNull('0'), false)
|
||||
assert.equal(isEmptyOrNull(0), false)
|
||||
})
|
||||
|
||||
test('requireValue 對空值丟出錯誤', () => {
|
||||
assert.throws(() => requireValue('NAME', ''), /NAME is required/)
|
||||
assert.throws(() => requireValue('NAME', 'null'), /NAME is required/)
|
||||
})
|
||||
|
||||
test('requireValue 對有效值不丟出錯誤', () => {
|
||||
assert.doesNotThrow(() => requireValue('NAME', 'value'))
|
||||
})
|
||||
|
||||
test('requireInteger 接受非負整數', () => {
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '0'))
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '10'))
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', 5))
|
||||
})
|
||||
|
||||
test('requireInteger 拒絕非整數或負數', () => {
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', '-1'), /non-negative integer/)
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', '1.5'), /non-negative integer/)
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /non-negative integer/)
|
||||
})
|
||||
|
||||
test('requireUrl 接受 http/https URL', () => {
|
||||
assert.doesNotThrow(() => requireUrl('URL', 'https://gitea.example.com'))
|
||||
assert.doesNotThrow(() => requireUrl('URL', 'http://localhost:3000'))
|
||||
})
|
||||
|
||||
test('requireUrl 拒絕非法 URL 或非 http(s) 協定', () => {
|
||||
assert.throws(() => requireUrl('URL', 'not a url'), /must be a valid URL/)
|
||||
assert.throws(() => requireUrl('URL', 'ftp://example.com'), /http or https/)
|
||||
assert.throws(() => requireUrl('URL', 'file:///etc/passwd'), /http or https/)
|
||||
})
|
||||
|
||||
test('requireRepository 接受合法 owner/repo', () => {
|
||||
assert.doesNotThrow(() => requireRepository('REPO', 'owner/repo'))
|
||||
assert.doesNotThrow(() => requireRepository('REPO', 'my-org/my_repo.git'))
|
||||
})
|
||||
|
||||
test('requireRepository 拒絕路徑穿越或格式錯誤', () => {
|
||||
assert.throws(() => requireRepository('REPO', '../evil'), /owner\/repo/)
|
||||
assert.throws(() => requireRepository('REPO', 'owner/repo/extra'), /owner\/repo/)
|
||||
assert.throws(() => requireRepository('REPO', 'noslash'), /owner\/repo/)
|
||||
assert.throws(() => requireRepository('REPO', 'owner/..'), /owner\/repo/)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// 參數驗證,對應原本 entrypoint.sh 的 is_empty_or_null/require_value/require_integer。
|
||||
// 驗證失敗時丟出 Error,由進入點統一捕捉後以非零狀態結束。
|
||||
|
||||
// 允許的 URL 協定;集中為常數,方便檢視系統允許的協定與日後擴充。
|
||||
const ALLOWED_URL_PROTOCOLS = ['http:', 'https:']
|
||||
// 合法 repository 形式:owner/repo,僅允許英數字與 . _ -,且恰好一個 /。
|
||||
const REPOSITORY_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
|
||||
|
||||
/**
|
||||
* 判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空。
|
||||
* @param {*} value 待判斷的值
|
||||
* @returns {boolean} 當值為 `undefined`、`null`、空字串或字串 `"null"` 時回傳 true
|
||||
*/
|
||||
export function isEmptyOrNull(value) {
|
||||
return value === undefined || value === null || value === '' || value === 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求指定欄位有值,空值時丟出 Error 以中止流程。
|
||||
* @param {string} name 欄位名稱,用於組出錯誤訊息
|
||||
* @param {*} value 待檢查的值,空值判定委派給 [[isEmptyOrNull]]
|
||||
* @throws {Error} 當 value 為空時丟出 `${name} is required`
|
||||
*/
|
||||
export function requireValue(name, value) {
|
||||
if (isEmptyOrNull(value)) {
|
||||
throw new Error(`${name} is required`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求指定欄位為非負整數。會先轉成字串再以 `/^[0-9]+$/` 比對,因此可接受數字或純數字字串,
|
||||
* 但拒絕負數、小數、空值與非數字內容。
|
||||
* @param {string} name 欄位名稱,用於組出錯誤訊息
|
||||
* @param {string|number} value 待檢查的值
|
||||
* @throws {Error} 當 value 不是非負整數時丟出 `${name} must be a non-negative integer`
|
||||
*/
|
||||
export function requireInteger(name, value) {
|
||||
if (!/^[0-9]+$/.test(String(value))) {
|
||||
throw new Error(`${name} must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求值為合法的 http/https URL,藉此避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。
|
||||
* @param {string} name 欄位名稱,用於組出錯誤訊息
|
||||
* @param {string} value 待檢查的 URL 字串
|
||||
* @throws {Error} 當 value 無法解析為 URL 時丟出 `${name} must be a valid URL`;
|
||||
* 協定非 http/https 時丟出 `${name} must use http or https protocol`
|
||||
*/
|
||||
export function requireUrl(name, value) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new Error(`${name} must be a valid URL`)
|
||||
}
|
||||
if (!ALLOWED_URL_PROTOCOLS.includes(url.protocol)) {
|
||||
throw new Error(`${name} must use http or https protocol`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求值為合法的 `owner/repo` 形式:僅允許英數字與 `. _ -`、恰好一個 `/`,且不可含路徑穿越片段 `..`。
|
||||
* 用於防止 GITEA_REPOSITORY 被竄改造成 API 目標被導向其他儲存庫。
|
||||
* @param {string} name 欄位名稱,用於組出錯誤訊息
|
||||
* @param {string} value 待檢查的 repository 字串
|
||||
* @throws {Error} 格式不符或含 `..` 時丟出 `${name} must be in the form owner/repo without path traversal`
|
||||
*/
|
||||
export function requireRepository(name, value) {
|
||||
if (String(value).includes('..') || !REPOSITORY_PATTERN.test(value)) {
|
||||
throw new Error(`${name} must be in the form owner/repo without path traversal`)
|
||||
}
|
||||
}
|
||||
@@ -1,177 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
#!/usr/bin/env sh
|
||||
# =============================================================================
|
||||
# 用途: release-cleanup Action 的容器進入點 (entrypoint)。
|
||||
# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js,
|
||||
# 由 Node.js 程式執行實際的 release 清理邏輯。
|
||||
# 更新日期: 2026/06/26 10:28:36
|
||||
# =============================================================================
|
||||
|
||||
separator() {
|
||||
printf '\n%s\n' '=================================================='
|
||||
}
|
||||
# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯。確保失敗能即時暴露。
|
||||
set -eu
|
||||
|
||||
section() {
|
||||
separator
|
||||
printf '%s\n' "$1"
|
||||
printf '%s\n' '--------------------------------------------------'
|
||||
}
|
||||
|
||||
info() {
|
||||
printf '[INFO] %s\n' "$1"
|
||||
}
|
||||
|
||||
success() {
|
||||
printf '[OK] %s\n' "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[WARN] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[ERR] %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
is_empty_or_null() {
|
||||
[ -z "${1:-}" ] || [ "${1:-}" = "null" ]
|
||||
}
|
||||
|
||||
require_value() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
info "$name=$value"
|
||||
if is_empty_or_null "$value"; then
|
||||
fail "$name is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_integer() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
|
||||
fail "$name must be a non-negative integer"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_all_pages() {
|
||||
local base_url="$1"
|
||||
local all_json='[]'
|
||||
local page=1
|
||||
local page_url page_json
|
||||
|
||||
while :; do
|
||||
page_url="$base_url?page=$page"
|
||||
page_json="$(curl -fsS "${auth_header[@]}" "$page_url")"
|
||||
|
||||
if [ "$(jq 'length' <<<"$page_json")" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
all_json="$(jq -s 'add' <<<"$all_json"$'\n'"$page_json")"
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
printf '%s' "$all_json"
|
||||
}
|
||||
|
||||
section "參數檢查"
|
||||
require_value "GITEA_SERVER_URL" "$GITEA_SERVER_URL"
|
||||
require_value "GITEA_REPOSITORY" "$GITEA_REPOSITORY"
|
||||
require_value "KEEP_COUNT" "$KEEP_COUNT"
|
||||
require_integer "KEEP_COUNT" "$KEEP_COUNT"
|
||||
|
||||
if is_empty_or_null "${GITEA_TOKEN:-}"; then
|
||||
warn "GITEA_TOKEN is empty; release API calls will be anonymous"
|
||||
else
|
||||
info "GITEA_TOKEN=[redacted]"
|
||||
fi
|
||||
|
||||
release_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
|
||||
auth_header=()
|
||||
|
||||
if ! is_empty_or_null "${GITEA_TOKEN:-}"; then
|
||||
auth_header=(-H "Authorization: token $GITEA_TOKEN")
|
||||
fi
|
||||
|
||||
section "取得成品資訊"
|
||||
info "GET $release_api_url"
|
||||
|
||||
release_json="$(fetch_all_pages "$release_api_url")"
|
||||
release_json="$(jq -e 'sort_by(.created_at) | reverse' <<<"$release_json")"
|
||||
release_count="$(jq 'length' <<<"$release_json")"
|
||||
|
||||
info "RELEASE_COUNT=$release_count"
|
||||
info "KEEP_COUNT=$KEEP_COUNT"
|
||||
|
||||
if [ "$release_count" -le "$KEEP_COUNT" ]; then
|
||||
success "沒有需要清理的舊版本成品"
|
||||
else
|
||||
section "刪除舊版本成品"
|
||||
|
||||
release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$release_json")"
|
||||
|
||||
while IFS= read -r release_item; do
|
||||
[ -z "$release_item" ] && continue
|
||||
|
||||
release_id="$(jq -r '.id' <<<"$release_item")"
|
||||
release_tag="$(jq -r '.tag_name' <<<"$release_item")"
|
||||
release_name="$(jq -r '.name' <<<"$release_item")"
|
||||
|
||||
if is_empty_or_null "$release_id"; then
|
||||
warn "略過沒有 id 的成品: $release_tag ($release_name)"
|
||||
continue
|
||||
fi
|
||||
|
||||
delete_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases/$release_id"
|
||||
info "DELETE $release_tag ($release_name)"
|
||||
|
||||
delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")"
|
||||
|
||||
if [ "$delete_code" -eq 204 ]; then
|
||||
success "成功刪除: $release_tag ($release_name)"
|
||||
else
|
||||
fail "刪除失敗: $release_tag ($release_name), HTTP $delete_code"
|
||||
fi
|
||||
done < <(jq -c '.[]' <<<"$release_to_delete")
|
||||
fi
|
||||
|
||||
section "刪除未指定 release 的 tag"
|
||||
|
||||
# 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品
|
||||
current_release_json="$(fetch_all_pages "$release_api_url")"
|
||||
release_tags_json="$(jq -c '[.[].tag_name]' <<<"$current_release_json")"
|
||||
|
||||
tag_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/tags"
|
||||
info "GET $tag_api_url"
|
||||
|
||||
tag_json="$(fetch_all_pages "$tag_api_url")"
|
||||
tag_count="$(jq 'length' <<<"$tag_json")"
|
||||
info "TAG_COUNT=$tag_count"
|
||||
|
||||
while IFS= read -r tag_item; do
|
||||
[ -z "$tag_item" ] && continue
|
||||
|
||||
tag_name="$(jq -r '.name' <<<"$tag_item")"
|
||||
|
||||
if is_empty_or_null "$tag_name"; then
|
||||
warn "略過沒有名稱的 tag"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$(jq --arg name "$tag_name" 'any(.[]; . == $name)' <<<"$release_tags_json")" = "true" ]; then
|
||||
info "保留指定 release 的 tag: $tag_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
delete_url="$tag_api_url/$tag_name"
|
||||
info "DELETE tag $tag_name"
|
||||
|
||||
delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")"
|
||||
|
||||
if [ "$delete_code" -eq 204 ]; then
|
||||
success "成功刪除未指定 release 的 tag: $tag_name"
|
||||
else
|
||||
fail "刪除 tag 失敗: $tag_name, HTTP $delete_code"
|
||||
fi
|
||||
done < <(jq -c '.[]' <<<"$tag_json")
|
||||
|
||||
separator
|
||||
# 進入點:實際邏輯改由 Node.js 實作,集中於 /app 目錄。
|
||||
# 以 exec 取代當前 shell process,讓 node 成為容器的 PID 1,正確接收訊號 (SIGTERM 等)。
|
||||
exec node /app/index.js
|
||||
|
||||
嚴重等級:🟡 警告
審查員:Maya
問題:deleteResource 僅回傳狀態碼,對 401/403/404 等錯誤未做區分處理。
建議:在 deleteResource 內針對常見錯誤碼拋出更有意義的例外,讓呼叫方採取跳過/重試/中止策略。