diff --git a/Dockerfile b/Dockerfile index 0a80edf..2984be7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..eaf8de4 --- /dev/null +++ b/app/config.js @@ -0,0 +1,44 @@ +// 讀取並驗證環境變數,組出後續流程所需的設定物件。 +// 對應原本 entrypoint.sh 的「參數檢查」區段。 + +import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' +import { section, info, warn } from './logger.js' + +/** + * 從環境變數載入設定並完成驗證。 + * @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 process.env + * @returns 包含 API 位址、token、保留數量等資訊的設定物件 + */ +export function loadConfig(env = process.env) { + section('參數檢查') + + const serverUrl = env.GITEA_SERVER_URL + 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) + + info(`GITEA_REPOSITORY=${repository}`) + requireValue('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`, + } +} diff --git a/app/gitea-client.js b/app/gitea-client.js new file mode 100644 index 0000000..bb810f3 --- /dev/null +++ b/app/gitea-client.js @@ -0,0 +1,53 @@ +// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 +// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 + +export class GiteaClient { + /** + * @param {{ token?: string | null }} options 認證設定;有 token 時帶上 Authorization 標頭 + */ + constructor({ token = null } = {}) { + this.headers = {} + if (token) { + this.headers.Authorization = `token ${token}` + } + } + + /** + * 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。 + * @param {string} baseUrl 不含 query string 的 API 位址 + * @returns {Promise} 所有頁面合併後的項目 + */ + async fetchAllPages(baseUrl) { + const all = [] + let page = 1 + + while (true) { + const url = `${baseUrl}?page=${page}` + const res = await fetch(url, { headers: this.headers }) + + if (!res.ok) { + throw new Error(`GET ${url} failed: HTTP ${res.status}`) + } + + const items = await res.json() + if (!Array.isArray(items) || items.length === 0) { + break + } + + all.push(...items) + page += 1 + } + + return all + } + + /** + * 對指定資源發出 DELETE 請求。 + * @param {string} url 目標資源位址 + * @returns {Promise} HTTP 狀態碼 + */ + async deleteResource(url) { + const res = await fetch(url, { method: 'DELETE', headers: this.headers }) + return res.status + } +} diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..fceefc1 --- /dev/null +++ b/app/index.js @@ -0,0 +1,22 @@ +// 進入點:載入設定、建立 Gitea 客戶端,依序清理舊成品與孤立 tag。 + +import { loadConfig } from './config.js' +import { GiteaClient } from './gitea-client.js' +import { cleanupReleases } from './releases.js' +import { cleanupOrphanTags } from './tags.js' +import { separator, fail } from './logger.js' + +async function main() { + const config = loadConfig() + const client = new GiteaClient({ token: config.token }) + + await cleanupReleases(client, config) + await cleanupOrphanTags(client, config) + + separator() +} + +main().catch((error) => { + fail(error.message) + process.exit(1) +}) diff --git a/app/logger.js b/app/logger.js new file mode 100644 index 0000000..4db97f1 --- /dev/null +++ b/app/logger.js @@ -0,0 +1,53 @@ +// 統一的主控台輸出格式,對應原本 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`) +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..5e6849c --- /dev/null +++ b/app/package.json @@ -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" + } +} diff --git a/app/releases.js b/app/releases.js new file mode 100644 index 0000000..418ecbd --- /dev/null +++ b/app/releases.js @@ -0,0 +1,60 @@ +// 清理舊版本成品的功能模組。 +// 對應原本 entrypoint.sh 的「取得成品資訊」與「刪除舊版本成品」區段。 + +import { section, info, success, fail, warn } from './logger.js' +import { isEmptyOrNull } from './validate.js' + +/** + * 依建立時間由新到舊排序,保留最新的 keepCount 筆,回傳其餘待刪除的成品。 + * 純函式,方便單元測試。 + * @param {any[]} releases 成品清單 + * @param {number} keepCount 要保留的筆數 + * @returns {any[]} 需要刪除的成品(較舊者) + */ +export function selectReleasesToDelete(releases, keepCount) { + const sorted = [...releases].sort( + (a, b) => new Date(b.created_at) - new Date(a.created_at), + ) + return sorted.slice(keepCount) +} + +/** + * 讀取成品清單,刪除超出保留數量的舊版本成品。 + * @param {import('./gitea-client.js').GiteaClient} client + * @param {ReturnType} config + */ +export async function cleanupReleases(client, config) { + section('取得成品資訊') + info(`GET ${config.releaseApiUrl}`) + + 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('刪除舊版本成品') + const toDelete = selectReleasesToDelete(releases, config.keepCount) + + for (const release of toDelete) { + const { id, tag_name: tag, name } = release + + if (isEmptyOrNull(id)) { + warn(`略過沒有 id 的成品: ${tag} (${name})`) + continue + } + + const url = `${config.releaseApiUrl}/${id}` + info(`DELETE ${tag} (${name})`) + + const code = await client.deleteResource(url) + if (code === 204) { + success(`成功刪除: ${tag} (${name})`) + } else { + fail(`刪除失敗: ${tag} (${name}), HTTP ${code}`) + } + } +} diff --git a/app/tags.js b/app/tags.js new file mode 100644 index 0000000..230de91 --- /dev/null +++ b/app/tags.js @@ -0,0 +1,66 @@ +// 清理未指定 release 的 tag 的功能模組。 +// 對應原本 entrypoint.sh 的「刪除未指定 release 的 tag」區段。 + +import { section, info, success, fail, warn } from './logger.js' +import { isEmptyOrNull } from './validate.js' + +/** + * 將 tag 分類為保留、刪除或略過(無名稱)。 + * 仍被任一 release 指定的 tag 予以保留,其餘視為孤立 tag 待刪除。 + * 純函式,方便單元測試。 + * @param {any[]} tags tag 清單 + * @param {Iterable} releaseTagNames 仍被 release 指定的 tag 名稱 + * @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} + */ +export function categorizeTags(tags, releaseTagNames) { + const keep = new Set(releaseTagNames) + + return tags.map((tag) => { + if (isEmptyOrNull(tag.name)) { + return { tag, action: 'skip' } + } + if (keep.has(tag.name)) { + return { tag, action: 'keep' } + } + return { tag, action: 'delete' } + }) +} + +/** + * 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。 + * @param {import('./gitea-client.js').GiteaClient} client + * @param {ReturnType} config + */ +export async function cleanupOrphanTags(client, config) { + section('刪除未指定 release 的 tag') + + // 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 + const currentReleases = await client.fetchAllPages(config.releaseApiUrl) + const releaseTagNames = currentReleases.map((release) => release.tag_name) + + info(`GET ${config.tagApiUrl}`) + const tags = await client.fetchAllPages(config.tagApiUrl) + info(`TAG_COUNT=${tags.length}`) + + for (const { tag, action } of categorizeTags(tags, releaseTagNames)) { + if (action === 'skip') { + warn('略過沒有名稱的 tag') + continue + } + + if (action === 'keep') { + info(`保留指定 release 的 tag: ${tag.name}`) + continue + } + + const url = `${config.tagApiUrl}/${tag.name}` + info(`DELETE tag ${tag.name}`) + + const code = await client.deleteResource(url) + if (code === 204) { + success(`成功刪除未指定 release 的 tag: ${tag.name}`) + } else { + fail(`刪除 tag 失敗: ${tag.name}, HTTP ${code}`) + } + } +} diff --git a/app/validate.js b/app/validate.js new file mode 100644 index 0000000..3115cf4 --- /dev/null +++ b/app/validate.js @@ -0,0 +1,36 @@ +// 參數驗證,對應原本 entrypoint.sh 的 is_empty_or_null/require_value/require_integer。 +// 驗證失敗時丟出 Error,由進入點統一捕捉後以非零狀態結束。 + +/** + * 判斷值是否視為「空」。使用嚴格相等,因此 `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`) + } +} diff --git a/entrypoint.sh b/entrypoint.sh index dfefcad..a7ce172 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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