Files
release-cleanup/README.md
T
JefferyandClaude Opus 4.8 a95945262d docs(release-cleanup): 更新 README 功能列表與 Dockerfile 註解排版
README 新增 requireUrl/requireRepository、修正原始碼連結行號與更新時間;
Dockerfile 調整註解冒號/括號前的空白(無邏輯變更)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:55:45 +08:00

12 KiB

release-cleanup

release-cleanup 是一個 Gitea Docker Action,用於自動清理儲存庫中的成品(release)與標籤(tag):刪除超出保留數量的舊版本 release,並移除未被任何 release 指定的孤立 tag。原本以 bash 實作,現已改寫為模組化的 Node.js 程式,進入點仍為 entrypoint.sh

更新時間:2026/06/26 10:54:59

專案列表

專案描述

專案名稱 專案描述
release-cleanup Gitea Docker Action 的 Node.js 實作。提供主控台輸出、參數驗證、設定載入、Gitea API 客戶端,以及清理舊版本 release 與孤立 tag 的功能。

參考專案

專案名稱 參考專案列表
release-cleanup

相依套件(npm)

專案名稱 套件列表
release-cleanup 無(僅使用 Node.js 內建模組與 node:test)

功能列表

release-cleanup

功能名稱 功能描述
separator 輸出一條等號分隔線至 stdout。
section 輸出區段標題(分隔線 + 標題 + 虛線)。
info [INFO] 前綴輸出一般資訊。
success [OK] 前綴輸出成功訊息。
warn [WARN] 前綴輸出警告訊息。
fail [ERR] 前綴輸出錯誤訊息至 stderr。
isEmptyOrNull 判斷值是否為空(空字串、null、undefined 或字串 "null")。
requireValue 要求欄位有值,空值時丟出 Error。
requireInteger 要求欄位為非負整數,否則丟出 Error。
requireUrl 要求欄位為合法的 http/https URL。
requireRepository 要求欄位為合法的 owner/repo 形式且無路徑穿越。
loadConfig 從環境變數載入並驗證設定,回傳設定物件。
GiteaClient 建立帶認證標頭的 Gitea API 客戶端。
GiteaClient.fetchAllPages 逐頁讀取分頁式清單 API 並合併成單一陣列。
GiteaClient.deleteResource 對指定資源發出 DELETE 請求並回傳狀態碼。
selectReleasesToDelete 依時間排序後回傳超出保留數量的舊 release。
cleanupReleases 讀取並刪除超出保留數量的舊版本 release。
categorizeTags 將 tag 分類為保留、刪除或略過。
cleanupOrphanTags 刪除未被任何 release 指定的孤立 tag。

使用範例

separator

輸出一條前後換行的等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。

import { separator } from './logger.js'

separator()
// 輸出:
//
// ==================================================

section

先印分隔線,再印標題與一條虛線,用於標示流程進入新階段。

import { section } from './logger.js'

section('參數檢查')
// ==================================================
// 參數檢查
// --------------------------------------------------

info

[INFO] 前綴輸出一般資訊訊息至 stdout。

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

success

[OK] 前綴輸出成功訊息(前綴補空白以對齊其他標籤)。

import { success } from './logger.js'

success('成功刪除: v1.0.0 (Release 1.0.0)')
// [OK]   成功刪除: v1.0.0 (Release 1.0.0)

warn

[WARN] 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。

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

fail

[ERR] 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。

import { fail } from './logger.js'

fail('刪除失敗: v0.9.0 (舊版), HTTP 500')
// (stderr) [ERR]  刪除失敗: v0.9.0 (舊版), HTTP 500

isEmptyOrNull

判斷值是否視為「空」。使用嚴格相等,因此 0false、字串 "0" 都不算空;undefinednull、空字串與字串 "null" 才回傳 true

import { isEmptyOrNull } from './validate.js'

isEmptyOrNull('')      // true
isEmptyOrNull('null')  // true
isEmptyOrNull(0)       // false
isEmptyOrNull('v1.0')  // false

requireValue

要求指定欄位有值,空值時丟出 Error 以中止流程。空值判定委派給 isEmptyOrNull

import { requireValue } from './validate.js'

requireValue('GITEA_SERVER_URL', 'https://gitea.example.com') // 通過
requireValue('GITEA_SERVER_URL', '')                          // 丟出 Error: GITEA_SERVER_URL is required

requireInteger

要求欄位為非負整數(可為數字或純數字字串),拒絕負數、小數、空值與非數字內容。

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

requireUrl

要求欄位為合法的 http/https URL,避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。

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

requireRepository

要求欄位為合法的 owner/repo 形式(僅允許英數字與 . _ -、恰好一個 /),並拒絕含 .. 的路徑穿越輸入。

import { requireRepository } from './validate.js'

requireRepository('GITEA_REPOSITORY', 'owner/repo') // 通過
requireRepository('GITEA_REPOSITORY', '../evil')    // 丟出 Error: ... owner/repo without path traversal

loadConfig

從環境變數載入並驗證設定,回傳供清理流程使用的設定物件;必填項缺漏或 KEEP_COUNT 非整數時丟出 Error。

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'

GiteaClient

建立與 Gitea API 溝通的客戶端;傳入 token 時於後續請求帶上 Authorization 標頭,否則以匿名方式呼叫。

import { GiteaClient } from './gitea-client.js'

const client = new GiteaClient({ token: process.env.GITEA_TOKEN })
const anonymous = new GiteaClient() // 無 token,匿名呼叫

GiteaClient.fetchAllPages

逐頁讀取分頁式清單 API(每頁以 ?page=N 遞增),直到回傳空陣列為止,合併成單一陣列;任一頁 HTTP 非 2xx 時丟出 Error。

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`)

GiteaClient.deleteResource

對指定資源發出 DELETE 請求,回傳 HTTP 狀態碼供呼叫端判斷成敗(成功刪除通常為 204)。

const code = await client.deleteResource(
  'https://gitea.example.com/api/v1/repos/owner/repo/releases/123',
)
if (code === 204) {
  console.log('刪除成功')
}

selectReleasesToDelete

created_at 由新到舊排序,保留最新的 keepCount 筆,回傳其餘(較舊)待刪除的 release;為純函式,不改動原陣列。

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', ... }]  // 僅保留最新兩筆,回傳最舊的一筆

cleanupReleases

讀取全部 release,刪除超出保留數量的舊版本;總數不超過 keepCount 時不做任何刪除。

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)

categorizeTags

將每個 tag 分類為 keep(仍被 release 指定)、delete(孤立 tag)或 skip(無名稱);為純函式,方便測試。

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' },
// ]

cleanupOrphanTags

重新讀取 release 清單取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。

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)