diff --git a/Dockerfile b/Dockerfile
index 0a80edf..593c511 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,53 @@
-FROM alpine:latest
+# syntax=docker/dockerfile:1
-# 安裝必要的工具
-RUN apk add --no-cache --no-check-certificate bash curl jq
+# =============================================================================
+# 此 Dockerfile 的用途:
+# 建置 Release Cleanup docker container action 的映像。
+# 以 node alpine 為基底,複製 app/ 主程式與 entrypoint.sh,設定入口。
+#
+# 更新日期:2026/06/30 12:02:55(台灣時區)
+# =============================================================================
+# -----------------------------------------------------------------------------
+# 1. 參數處理:node base image tag 以 ARG 注入
+# - 預設 lts-alpine;建置時可用 --build-arg NODE_VERSION=... 覆寫
+# - 主程式以原生 fetch 呼叫 Gitea API(需 Node 18+),alpine 為最小可用基底
+# -----------------------------------------------------------------------------
+# 宣告可覆寫的建置參數 NODE_VERSION,預設值 lts-alpine
+ARG NODE_VERSION=lts-alpine
+# 以 node:${NODE_VERSION} 作為基底映像;引用上方 ARG 決定實際 node 版本/變體
+FROM node:${NODE_VERSION}
+
+# -----------------------------------------------------------------------------
+# 2. 安裝套件:主程式無第三方相依,亦未呼叫外部執行檔
+# (不需 curl/jq/git 等 OS 套件),故略過此步驟
+# -----------------------------------------------------------------------------
+
+# 設定後續指令的工作目錄為 /app;不存在時會自動建立,COPY 的相對目的地以此為基準
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# 3. 複製檔案:主程式(app/)與啟動腳本
+# -----------------------------------------------------------------------------
+# 複製 app/ 目錄全部內容到映像內 /app/(主程式,含 index.js 進入點)
+COPY app/ /app/
+# 複製啟動腳本到映像根目錄 /entrypoint.sh(供 ENTRYPOINT 使用)
COPY entrypoint.sh /entrypoint.sh
+# -----------------------------------------------------------------------------
+# 4. 執行程序:賦予啟動腳本執行權限
+# - 無 build/transpile 需求,故不執行建置
+# -----------------------------------------------------------------------------
+# 對 /entrypoint.sh 加上可執行權限,確保容器啟動時能直接執行該腳本
RUN chmod +x /entrypoint.sh
+# -----------------------------------------------------------------------------
+# 5. 縮小映像檔:無相依、無 build 產物可裁剪,alpine 單階段即為最小映像,
+# 多階段建置在此無縮小效益,故不採用
+# -----------------------------------------------------------------------------
+
+# -----------------------------------------------------------------------------
+# 6. 設定入口:以 entrypoint.sh 啟動(其內 node 指向 /app/index.js)
+# -----------------------------------------------------------------------------
+# 設定容器進入點為 /entrypoint.sh(exec 形式);容器啟動即執行該腳本
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c229e66
--- /dev/null
+++ b/README.md
@@ -0,0 +1,207 @@
+# Release Cleanup
+
+> 更新時間:2026/06/30 12:02:55(台灣時區 Asia/Taipei)
+
+清理舊成品的 **Gitea / GitHub docker container action**。依保留數量清理 repo 的舊 release,並刪除沒有對應 release 的 tag;正式版與 beta 版各自獨立保留指定數量。
+
+- 主程式語言:JavaScript(Node.js,使用原生 `fetch`,需 Node 18+)
+- 執行方式:docker container action(`action.yaml` → `Dockerfile` → `entrypoint.sh` → `node /app/index.js`)
+
+## 行為總覽
+
+1. **參數檢查**:驗證 `GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`KEEP_COUNT`(缺值或 `KEEP_COUNT` 非整數時以 exit code 1 結束)。
+2. **取得成品資訊**:取得所有 release,依建立時間由新到舊排序,並以 tag 名稱含 `-beta.` 與否分為正式版與 beta 版。
+3. **刪除舊版本成品**:正式版與 beta 版**各自**保留最新 `KEEP_COUNT` 筆,刪除其餘 release(單筆刪除失敗只記錄錯誤,不中斷)。
+4. **刪除未指定 release 的 tag**:重新取得 release 後,刪除所有未被任何 release 指定的 tag。
+
+`GITEA_TOKEN` 為空時以匿名身分呼叫 API。取得分頁失敗或參數不合法則直接以 exit code 1 結束程序。
+
+## Action 輸入與環境變數
+
+| 名稱 | 來源 | 必填 | 預設 | 說明 |
+| --- | --- | --- | --- | --- |
+| `keep_count` | `inputs` | 否 | `2` | 正式版與 beta 版各自要保留的成品數量 |
+| `GITEA_SERVER_URL` | env(`gitea.server_url`) | 是 | — | Gitea 站台 URL |
+| `GITEA_REPOSITORY` | env(`gitea.repository`) | 是 | — | `owner/repo` 形式的儲存庫 |
+| `GITEA_TOKEN` | env(`gitea.token`) | 否 | — | API token;為空時以匿名呼叫 |
+
+### Workflow 使用範例
+
+```yaml
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Release Cleanup
+ uses: docker-actions/release-cleanup@develop
+ with:
+ keep_count: 3
+```
+
+## 專案列表
+
+### 專案描述
+
+| 專案名稱 | 專案描述 |
+| --- | --- |
+| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 清理 Gitea repo 舊 release 與孤兒 tag 的 docker container action;正式版與 beta 版各自保留指定數量 |
+
+### 參考專案
+
+| 專案名稱 | 參考專案列表 |
+| --- | --- |
+| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無 |
+
+### NuGet 套件
+
+| 專案名稱 | NuGet 套件列表 |
+| --- | --- |
+| [release-cleanup](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app) | 無 |
+
+> 註:本專案為 Node.js 專案,無 .NET ProjectReference 與 NuGet 套件;`app/package.json` 亦無第三方相依(僅使用 Node 內建 API)。
+
+## 功能列表
+
+### release-cleanup
+
+| 功能名稱 | 功能描述 |
+| --- | --- |
+| [separator](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L10) | [在 stdout 輸出視覺分隔線](#separator) |
+| [section](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L21) | [輸出區段標題區塊](#section) |
+| [info](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L33) | [以 \[INFO\] 前綴輸出資訊訊息](#info) |
+| [success](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L43) | [以 \[OK\] 前綴輸出成功訊息](#success) |
+| [warn](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L53) | [以 \[WARN\] 前綴輸出警告訊息](#warn) |
+| [fail](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L64) | [以 \[ERR\] 前綴輸出錯誤訊息到 stderr](#fail) |
+| [isEmptyOrNull](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L76) | [判斷值是否視為空 / 未設定](#isemptyornull) |
+| [requireValue](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L89) | [檢查必填值,空值時結束程序](#requirevalue) |
+| [requireInteger](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L106) | [驗證非負整數,不符時結束程序](#requireinteger) |
+| [isBeta](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L120) | [判斷名稱是否為 beta 版本](#isbeta) |
+| [main](https://gitea.jsc.idv.tw/docker-actions/release-cleanup/src/branch/develop/app/index.js#L140) | [Action 主流程:清理舊 release 與孤兒 tag](#main) |
+
+## 使用範例
+
+
+### separator
+
+在 stdout 輸出一條前後帶換行、由 50 個等號組成的視覺分隔線,用於在 log 中切割區段。無參數、無回傳值。
+
+```js
+separator();
+// 輸出:
+//
+// ==================================================
+```
+
+
+### section
+
+輸出一個區段標題區塊:上緣等號分隔線、標題文字、下緣虛線,用於標示流程進入新階段。
+
+```js
+section('參數檢查');
+// 輸出:
+// ==================================================
+// 參數檢查
+// --------------------------------------------------
+```
+
+
+### info
+
+以 `[INFO]` 前綴將一般資訊訊息輸出到 stdout(`console.log`)。
+
+```js
+info('RELEASE_COUNT=10');
+// 輸出:[INFO] RELEASE_COUNT=10
+```
+
+
+### success
+
+以 `[OK]` 前綴將成功訊息輸出到 stdout。
+
+```js
+success('成功刪除: v1.0.0 (Release 1.0.0)');
+// 輸出:[OK] 成功刪除: v1.0.0 (Release 1.0.0)
+```
+
+
+### warn
+
+以 `[WARN]` 前綴將警告訊息輸出到 stdout(非致命情況,程式會繼續執行)。
+
+```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**(`console.error`)。僅負責輸出,不會結束程序;是否離開由呼叫端決定。
+
+```js
+fail('刪除失敗: v0.1.0 (Release 0.1.0), HTTP 500');
+process.exit(1); // 終止由呼叫端負責
+```
+
+
+### isEmptyOrNull
+
+判斷值是否視為「空 / 未設定」,涵蓋 `undefined`、`null`、空字串,以及字面字串 `'null'`(用於處理 CI/環境變數把未設定值帶成字串 `"null"` 的情況)。
+
+```js
+isEmptyOrNull(''); // true
+isEmptyOrNull('null'); // true(字面字串)
+isEmptyOrNull('abc'); // false
+```
+
+
+### requireValue
+
+檢查必填值。先以 `[INFO]` 印出 `name=value`,若值被判定為空則印出 `[ERR]` 並以 exit code 1 結束程序。
+
+```js
+requireValue('GITEA_SERVER_URL', process.env.GITEA_SERVER_URL);
+// 值為空 → 輸出 [ERR] GITEA_SERVER_URL is required,並 process.exit(1)
+```
+
+> 前置條件:應在程式啟動早期呼叫。注意此函式會將值原樣印到 stdout,故不用於機密值(程式對 `GITEA_TOKEN` 改以 redacted 方式輸出)。
+
+
+### requireInteger
+
+驗證值是否為非負整數字串(正則 `/^[0-9]+$/`,允許前導零、不允許負號或小數),不符合時印出 `[ERR]` 並以 exit code 1 結束程序。
+
+```js
+requireInteger('KEEP_COUNT', '2'); // 通過
+requireInteger('KEEP_COUNT', '-1'); // 輸出 [ERR] ... 並 process.exit(1)
+```
+
+
+### isBeta
+
+判斷 release / tag 名稱是否為 beta 版本,規則為名稱中含有子字串 `-beta.`。
+
+```js
+isBeta('v1.2.3-beta.1'); // true
+isBeta('v1.2.3'); // false
+isBeta('v1.2.3-beta'); // false(無結尾的點,不符合 -beta.)
+```
+
+
+### main
+
+Action 主流程(`async`)。讀取環境變數後依序執行參數檢查、取得 release、刪除舊版本(正式版與 beta 版各自保留 `KEEP_COUNT` 筆)、刪除未指定 release 的 tag。由檔尾 `main().catch(...)` 立即執行。
+
+```js
+// 容器啟動時由 entrypoint.sh 執行:exec node /app/index.js
+// 需提供環境變數:
+// GITEA_SERVER_URL、GITEA_REPOSITORY、KEEP_COUNT(必要)、GITEA_TOKEN(選填)
+main().catch((err) => {
+ fail(err && err.stack ? err.stack : String(err));
+ process.exit(1);
+});
+```
+
+> 前置條件:執行環境須提供必要環境變數,且可連線到 Gitea API。結果:清理符合條件的舊 release 與孤兒 tag,並輸出處理過程 log。
diff --git a/action.yaml b/action.yaml
index f5f1de3..baaf05e 100644
--- a/action.yaml
+++ b/action.yaml
@@ -3,7 +3,7 @@ description: '清理舊成品'
author: 'Jeffery'
inputs:
keep_count:
- description: ''
+ description: '要保留的成品數量'
required: false
default: 2
runs:
diff --git a/app/index.js b/app/index.js
new file mode 100644
index 0000000..ab96512
--- /dev/null
+++ b/app/index.js
@@ -0,0 +1,307 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * 在 stdout 輸出一條視覺分隔線(前後各一個換行 + 50 個等號),
+ * 用於在 log 中切割不同區段。
+ *
+ * @returns {void}
+ */
+function separator() {
+ process.stdout.write('\n==================================================\n');
+}
+
+/**
+ * 輸出一個區段標題區塊:上緣等號分隔線、標題文字、下緣虛線,
+ * 用於在 log 中標示流程進入新階段。
+ *
+ * @param {string} title 區段標題文字。
+ * @returns {void}
+ */
+function section(title) {
+ separator();
+ process.stdout.write(`${title}\n`);
+ process.stdout.write('--------------------------------------------------\n');
+}
+
+/**
+ * 以 [INFO] 前綴將一般資訊訊息輸出到 stdout。
+ *
+ * @param {string} msg 要輸出的訊息內容。
+ * @returns {void}
+ */
+function info(msg) {
+ console.log(`[INFO] ${msg}`);
+}
+
+/**
+ * 以 [OK] 前綴將成功訊息輸出到 stdout。
+ *
+ * @param {string} msg 要輸出的訊息內容。
+ * @returns {void}
+ */
+function success(msg) {
+ console.log(`[OK] ${msg}`);
+}
+
+/**
+ * 以 [WARN] 前綴將警告訊息輸出到 stdout。
+ *
+ * @param {string} msg 要輸出的訊息內容。
+ * @returns {void}
+ */
+function warn(msg) {
+ console.log(`[WARN] ${msg}`);
+}
+
+/**
+ * 以 [ERR] 前綴將錯誤訊息輸出到 stderr。
+ * 僅負責輸出,不會結束程序(是否離開由呼叫端決定)。
+ *
+ * @param {string} msg 要輸出的錯誤訊息內容。
+ * @returns {void}
+ */
+function fail(msg) {
+ console.error(`[ERR] ${msg}`);
+}
+
+/**
+ * 判斷值是否視為「空 / 未設定」。
+ * 涵蓋 undefined、null、空字串,以及字面字串 'null'
+ *(後者用於處理 CI/環境變數把未設定值帶成字串 "null" 的情況)。
+ *
+ * @param {*} value 要檢查的值。
+ * @returns {boolean} 視為空值時為 true,否則為 false。
+ */
+function isEmptyOrNull(value) {
+ return value === undefined || value === null || value === '' || value === 'null';
+}
+
+/**
+ * 檢查必填值。先以 [INFO] 印出 name=value,
+ * 若值被判定為空(見 isEmptyOrNull),印出 [ERR] 並以 exit code 1 結束程序。
+ *
+ * @param {string} name 參數名稱(用於 log 與錯誤訊息)。
+ * @param {*} value 要驗證的參數值。
+ * @returns {void}
+ * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。
+ */
+function requireValue(name, value) {
+ info(`${name}=${value}`);
+ if (isEmptyOrNull(value)) {
+ fail(`${name} is required`);
+ process.exit(1);
+ }
+}
+
+/**
+ * 驗證值是否為非負整數字串(正則 /^[0-9]+$/,允許前導零、不允許負號或小數)。
+ * 不符合時印出 [ERR] 並以 exit code 1 結束程序。
+ *
+ * @param {string} name 參數名稱(用於錯誤訊息)。
+ * @param {*} value 要驗證的值;會先轉為字串再比對。
+ * @returns {void}
+ * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。
+ */
+function requireInteger(name, value) {
+ if (!/^[0-9]+$/.test(String(value))) {
+ fail(`${name} must be a non-negative integer`);
+ process.exit(1);
+ }
+}
+
+/**
+ * 判斷 release/tag 名稱是否為 beta 版本,
+ * 規則為名稱中含有子字串 "-beta."(例如 v1.2.3-beta.1)。
+ *
+ * @param {string} [tagName] tag 或 release 的名稱;falsy 時視為非 beta。
+ * @returns {boolean} 為 beta 版本時 true,否則 false。
+ */
+function isBeta(tagName) {
+ return /-beta\./.test(tagName || '');
+}
+
+/**
+ * Action 主流程:依保留數量清理 Gitea repo 的舊 release,並刪除沒有對應 release 的 tag。
+ *
+ * 讀取環境變數 GITEA_SERVER_URL、GITEA_REPOSITORY、GITEA_TOKEN、KEEP_COUNT,
+ * 流程為:
+ * 1. 參數檢查(缺值或 KEEP_COUNT 非整數時以 exit code 1 結束)。
+ * 2. 取得所有 release,依建立時間由新到舊排序,並分為正式版與 beta 版。
+ * 3. 正式版與 beta 版各自保留最新 KEEP_COUNT 筆,刪除其餘 release。
+ * 4. 重新取得 release 後,刪除所有未被任何 release 指定的 tag。
+ *
+ * Token 為空時以匿名身分呼叫 API。release/tag 的單筆刪除失敗只記錄錯誤不中斷;
+ * 取得分頁失敗或參數不合法則直接以 exit code 1 結束程序。
+ *
+ * @async
+ * @returns {Promise}
+ */
+async function main() {
+ const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL;
+ const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY;
+ const GITEA_TOKEN = process.env.GITEA_TOKEN;
+ const KEEP_COUNT_RAW = process.env.KEEP_COUNT;
+
+ section('參數檢查');
+ requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL);
+ requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY);
+ requireValue('KEEP_COUNT', KEEP_COUNT_RAW);
+ requireInteger('KEEP_COUNT', KEEP_COUNT_RAW);
+
+ const KEEP_COUNT = parseInt(KEEP_COUNT_RAW, 10);
+
+ if (isEmptyOrNull(GITEA_TOKEN)) {
+ warn('GITEA_TOKEN is empty; release API calls will be anonymous');
+ } else {
+ info('GITEA_TOKEN=[redacted]');
+ }
+
+ const authHeaders = {};
+ if (!isEmptyOrNull(GITEA_TOKEN)) {
+ authHeaders['Authorization'] = `token ${GITEA_TOKEN}`;
+ }
+
+ /**
+ * 對指定 API base URL 逐頁(?page=N,從 1 起)抓取資料,
+ * 直到某頁回傳非陣列或空陣列為止,回傳所有頁面合併後的陣列。
+ * 任一頁 HTTP 非 2xx 時印出 [ERR] 並以 exit code 1 結束程序。
+ * 沿用外層 main 的 authHeaders(有 token 時帶 Authorization)。
+ *
+ * @async
+ * @param {string} baseUrl 不含分頁參數的 API 端點(例如 .../releases 或 .../tags)。
+ * @returns {Promise>} 所有頁面項目合併後的陣列。
+ * @throws 不擲出例外;HTTP 失敗時直接呼叫 process.exit(1) 結束程序。
+ */
+ async function fetchAllPages(baseUrl) {
+ const all = [];
+ let page = 1;
+
+ for (;;) {
+ const pageUrl = `${baseUrl}?page=${page}`;
+ const res = await fetch(pageUrl, { headers: authHeaders });
+ if (!res.ok) {
+ fail(`GET ${pageUrl} 失敗, HTTP ${res.status}`);
+ process.exit(1);
+ }
+ const pageJson = await res.json();
+ if (!Array.isArray(pageJson) || pageJson.length === 0) {
+ break;
+ }
+ all.push(...pageJson);
+ page += 1;
+ }
+
+ return all;
+ }
+
+ /**
+ * 對指定 URL 發送 DELETE 請求(沿用外層 main 的 authHeaders),
+ * 回傳 HTTP 狀態碼供呼叫端判斷成敗(成功慣例為 204)。
+ * 不輸出訊息、不判斷成敗、不結束程序。
+ *
+ * @async
+ * @param {string} url 要刪除之資源的完整 URL(release 或 tag 端點)。
+ * @returns {Promise} 回應的 HTTP 狀態碼。
+ */
+ async function deleteUrl(url) {
+ const res = await fetch(url, { method: 'DELETE', headers: authHeaders });
+ return res.status;
+ }
+
+ const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`;
+
+ section('取得成品資訊');
+ info(`GET ${releaseApiUrl}`);
+
+ let releases = await fetchAllPages(releaseApiUrl);
+ releases = releases.sort(
+ (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
+ ).reverse();
+
+ const normalReleases = releases.filter((r) => !isBeta(r.tag_name));
+ const betaReleases = releases.filter((r) => isBeta(r.tag_name));
+
+ info(`RELEASE_COUNT=${releases.length}`);
+ info(`NORMAL_RELEASE_COUNT=${normalReleases.length}`);
+ info(`BETA_RELEASE_COUNT=${betaReleases.length}`);
+ info(`KEEP_COUNT=${KEEP_COUNT}`);
+
+ const normalToDelete =
+ normalReleases.length > KEEP_COUNT ? normalReleases.slice(KEEP_COUNT) : [];
+ const betaToDelete =
+ betaReleases.length > KEEP_COUNT ? betaReleases.slice(KEEP_COUNT) : [];
+
+ const releasesToDelete = [...normalToDelete, ...betaToDelete];
+
+ if (releasesToDelete.length === 0) {
+ success('沒有需要清理的舊版本成品');
+ } else {
+ section('刪除舊版本成品');
+ info(`DELETE_RELEASE_COUNT=${releasesToDelete.length}`);
+
+ for (const release of releasesToDelete) {
+ const releaseId = release.id;
+ const releaseTag = release.tag_name;
+ const releaseName = release.name;
+
+ if (isEmptyOrNull(releaseId)) {
+ warn(`略過沒有 id 的成品: ${releaseTag} (${releaseName})`);
+ continue;
+ }
+
+ const url = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${releaseId}`;
+ info(`DELETE ${releaseTag} (${releaseName})`);
+
+ const code = await deleteUrl(url);
+ if (code === 204) {
+ success(`成功刪除: ${releaseTag} (${releaseName})`);
+ } else {
+ fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${code}`);
+ }
+ }
+ }
+
+ section('刪除未指定 release 的 tag');
+
+ // 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品
+ const currentReleases = await fetchAllPages(releaseApiUrl);
+ const releaseTags = new Set(currentReleases.map((r) => r.tag_name));
+
+ const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`;
+ info(`GET ${tagApiUrl}`);
+
+ const tags = await fetchAllPages(tagApiUrl);
+ info(`TAG_COUNT=${tags.length}`);
+
+ for (const tag of tags) {
+ const tagName = tag.name;
+
+ if (isEmptyOrNull(tagName)) {
+ warn('略過沒有名稱的 tag');
+ continue;
+ }
+
+ if (releaseTags.has(tagName)) {
+ info(`保留指定 release 的 tag: ${tagName}`);
+ continue;
+ }
+
+ const url = `${tagApiUrl}/${tagName}`;
+ info(`DELETE tag ${tagName}`);
+
+ const code = await deleteUrl(url);
+ if (code === 204) {
+ success(`成功刪除未指定 release 的 tag: ${tagName}`);
+ } else {
+ fail(`刪除 tag 失敗: ${tagName}, HTTP ${code}`);
+ }
+ }
+
+ separator();
+}
+
+main().catch((err) => {
+ fail(err && err.stack ? err.stack : String(err));
+ process.exit(1);
+});
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..7e4cf8e
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "release-cleanup",
+ "version": "1.0.0",
+ "description": "清理舊成品(Gitea release 與未綁定 release 的 tag)",
+ "author": "Jeffery",
+ "license": "MIT",
+ "type": "commonjs",
+ "main": "index.js",
+ "private": true,
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/entrypoint.sh b/entrypoint.sh
old mode 100644
new mode 100755
index 51d3db1..0dd682b
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,195 +1,40 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
+#!/usr/bin/env sh
-separator() {
- printf '\n%s\n' '=================================================='
-}
+# =============================================================================
+# 此指令檔的用途:
+# 本檔為 Release Cleanup action 的容器啟動腳本(container entrypoint)。
+# 啟動時先輸出一段橫幅,顯示 action 名稱/用途/更新時間,方便在 CI/CD log
+# 中辨識正在執行哪一個 action 及其版本;接著以 exec 啟動 node 主程式
+# /app/index.js,並把所有外部傳入的參數原樣交給它。
+#
+# 更新日期(台灣時區):2026/06/30 12:02:55
+# =============================================================================
-section() {
- separator
- printf '%s\n' "$1"
- printf '%s\n' '--------------------------------------------------'
-}
+# 啟用「遇到任何指令失敗(非 0 結束碼)即立刻中止腳本」的模式。
+# 用於避免前置步驟失敗後仍繼續往下執行,確保啟動失敗能正確反映成非 0 exit code。
+set -e
-info() {
- printf '[INFO] %s\n' "$1"
-}
+# action 顯示名稱,供下方橫幅輸出使用。
+ACTION_NAME='Release Cleanup'
+# action 用途說明,供下方橫幅輸出使用。
+ACTION_DESC='清理舊成品'
+# action 更新時間(固定字串,台灣時區),供下方橫幅輸出使用。
+ACTION_UPDATED='2026/06/30 12:02:55'
-success() {
- printf '[OK] %s\n' "$1"
-}
+# 輸出一個空行 + 分隔線,作為橫幅的上邊框,讓 log 中的資訊區塊更易閱讀。
+printf '\n%s\n' '=================================================='
+# 輸出 action 名稱;以 "$ACTION_NAME" 帶引號傳入,避免變數內容被分詞或萬用字元展開。
+printf '名稱 : %s\n' "$ACTION_NAME"
+# 輸出 action 用途;同樣以帶引號變數傳入確保字串完整。
+printf '用途 : %s\n' "$ACTION_DESC"
+# 輸出 action 更新時間;同樣以帶引號變數傳入確保字串完整。
+printf '更新 : %s\n' "$ACTION_UPDATED"
+# 輸出分隔線,作為橫幅的下邊框。
+printf '%s\n' '=================================================='
-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")"
-normal_release_json="$(jq -c '[.[] | select(((.tag_name // "") | test("-beta\\.")) | not)]' <<<"$release_json")"
-beta_release_json="$(jq -c '[.[] | select((.tag_name // "") | test("-beta\\."))]' <<<"$release_json")"
-normal_release_count="$(jq 'length' <<<"$normal_release_json")"
-beta_release_count="$(jq 'length' <<<"$beta_release_json")"
-
-info "RELEASE_COUNT=$release_count"
-info "NORMAL_RELEASE_COUNT=$normal_release_count"
-info "BETA_RELEASE_COUNT=$beta_release_count"
-info "KEEP_COUNT=$KEEP_COUNT"
-
-normal_release_to_delete='[]'
-if [ "$normal_release_count" -gt "$KEEP_COUNT" ]; then
- normal_release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$normal_release_json")"
-fi
-
-beta_release_to_delete='[]'
-if [ "$beta_release_count" -gt "$KEEP_COUNT" ]; then
- beta_release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$beta_release_json")"
-fi
-
-release_to_delete="$(jq -c -s 'add' <<<"$normal_release_to_delete"$'\n'"$beta_release_to_delete")"
-release_to_delete_count="$(jq 'length' <<<"$release_to_delete")"
-
-if [ "$release_to_delete_count" -eq 0 ]; then
- success "沒有需要清理的舊版本成品"
-else
- section "刪除舊版本成品"
- info "DELETE_RELEASE_COUNT=$release_to_delete_count"
-
- 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
+# 以 exec 啟動 node 主程式:exec 會用 node 行程「取代」目前的 shell 行程,
+# 而非另開子行程。如此可讓 node 成為 PID 1(或直接承接原 shell 的 PID),
+# 正確接收容器傳來的訊號(例如 SIGTERM/SIGINT)以利優雅關閉,
+# 並讓 node 的結束碼直接成為容器的結束碼。
+# "$@" 將腳本收到的所有參數原樣(逐一保留分隔,避免被重新分詞)轉交給 node 主程式。
+exec node /app/index.js "$@"