diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e3d2188
--- /dev/null
+++ b/README.md
@@ -0,0 +1,196 @@
+# Calculate Next Version
+
+從 git tag 取得最新版號,依 `is_beta` 計算下一個正式版或 beta 版號(各號碼滿 9 進位)並輸出為 `value` 的 Gitea/GitHub Docker container action。
+
+- 更新時間:2026/07/16 11:26:21
+
+## action 使用方式
+
+```yaml
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # 需要完整 tag 歷史才能計算版號
+ - id: version
+ uses: docker-actions/calculate-next-version@master
+ with:
+ is_beta: 'false'
+ sha: ${{ gitea.sha }} # 該 commit 已有版號 tag 時直接輸出該版號
+ - run: echo "下一版號:${{ steps.version.outputs.value }}"
+```
+
+| 類型 | 名稱 | 必填 | 預設值 | 說明 |
+| --- | --- | --- | --- | --- |
+| input | `is_beta` | 否 | `false` | 是否為 beta 版(true 時產生 X.Y.Z-beta.N 版號) |
+| input | `sha` | 否 | (空) | 要檢查的 commit SHA(建議傳入 `${{ gitea.sha }}`);該 commit 已有版號 tag 時直接輸出該版號。未提供時自動改用 `GITHUB_SHA` |
+| output | `value` | — | — | 計算出的下一版號 |
+
+版號規則:
+
+- 已標記檢查:先以 `sha` 輸入(未提供則用 `GITHUB_SHA`)確認該 commit 是否已有符合格式的版號 tag;已有時直接輸出該 tag 內容(多個取最大、忽略 `is_beta`),不再計算下一版。檢查失敗(如 sha 不存在)輸出 WRN 後改走一般計算流程。
+- 版號來源為 repo 的 git tag(格式 `X.Y.Z` 或 `X.Y.Z-beta.N`,可帶小寫 `v` 前綴如 `v1.2.3`,解析時只取 `v` 之後的版本號),取最大版號;無任何版號 tag 時從 `0.0.1`(beta 為 `0.0.1-beta.1`)起算。輸出的 `value` 一律不帶前綴。
+- git tag 讀取失敗(例如 workspace 不是 git repository、workflow 未先執行 `actions/checkout`)時不會中止:輸出 ERR 說明原因後,改以起始版號 `0.0.1`(beta 為 `0.0.1-beta.1`)作為預設值繼續輸出。
+- `is_beta=false`:最新為正式版 → patch +1(`1.2.3` → `1.2.4`);最新為 beta → 去掉 beta 尾碼轉正式(`1.2.4-beta.3` → `1.2.4`)。
+- `is_beta=true`:最新為正式版 → patch +1 加 `-beta.1`;最新為 beta → beta 號 +1(`beta.9` → `beta.10`,無上限)。
+- major/minor/patch 各上限 9,滿 9 進位(`1.2.9` → `1.3.0`、`1.9.9` → `2.0.0`);`9.9.9` 再進位則報錯並以非零 exit code 結束。
+
+日誌格式:所有輸出訊息統一為 `[階段][等級][時間]: 訊息`(`階段` 選填;`等級` 為 `INF`/`WRN`/`ERR`/`TRC`/`DBG`;`時間` 為 Asia/Taipei 時區的 `yyyy/MM/dd HH:mm:ss`)。
+
+## 專案列表
+
+### 專案描述表
+
+| 專案名稱 | 專案描述 |
+| --- | --- |
+| [calculate-next-version](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master) | Docker container action:提供版號解析/比較/進位計算(version 模組)與統一格式日誌輸出(logger 模組),從 git tag 計算下一個正式版或 beta 版號並寫出為 action 輸出 `value`。 |
+
+### 參考專案表
+
+| 專案名稱 | 參考專案列表 |
+| --- | --- |
+| [calculate-next-version](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master) | 無 |
+
+### NuGet 套件表
+
+| 專案名稱 | NuGet 套件列表 |
+| --- | --- |
+| [calculate-next-version](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master) | 無(Node.js 專案,僅使用 node 內建模組,無外部相依) |
+
+## 功能列表
+
+### calculate-next-version
+
+| 功能名稱 | 功能描述 |
+| --- | --- |
+| [logger.inf](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/logger.js#L86) | [輸出 INF(一般資訊)等級的日誌訊息到 stdout](#loggerinf) |
+| [logger.wrn](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/logger.js#L101) | [輸出 WRN(警告)等級的日誌訊息到 stdout](#loggerwrn) |
+| [logger.err](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/logger.js#L116) | [輸出 ERR(錯誤)等級的日誌訊息到 stderr](#loggererr) |
+| [logger.trc](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/logger.js#L131) | [輸出 TRC(細部追蹤)等級的日誌訊息到 stdout](#loggertrc) |
+| [logger.dbg](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/logger.js#L146) | [輸出 DBG(除錯)等級的日誌訊息到 stdout](#loggerdbg) |
+| [version.parse](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L28) | [解析版號字串為版號物件(可帶 v 前綴),不符格式回傳 null](#versionparse) |
+| [version.stringify](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L54) | [將版號物件轉換為版號字串](#versionstringify) |
+| [version.compare](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L74) | [比較兩個版號物件的大小,正式版大於同號 beta](#versioncompare) |
+| [version.next](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L138) | [依最新版號與 is_beta 旗標計算下一版號](#versionnext) |
+
+## 使用範例
+
+
+### logger.inf
+
+輸出 INF(一般資訊)等級的日誌訊息到 stdout,格式為 `[階段][等級][時間]: 訊息`(時間為 Asia/Taipei 時區的 `yyyy/MM/dd HH:mm:ss`);`stage` 選填,省略時訊息不含 `[階段]` 區塊。用於記錄流程中的正常進度,例如讀到的輸入、計算結果、寫出的輸出。
+
+```javascript
+const logger = require('./logger');
+
+logger.inf('下一版號:1.2.4', '計算版號');
+// [計算版號][INF][2026/07/16 09:23:47]: 下一版號:1.2.4
+
+logger.inf('計算完成');
+// [INF][2026/07/16 09:23:47]: 計算完成
+```
+
+
+### logger.wrn
+
+輸出 WRN(警告)等級的日誌訊息到 stdout,格式與參數同 `logger.inf`。用於非致命的異常狀況,例如找不到任何版號 tag、需注意的邊界條件。
+
+```javascript
+const logger = require('./logger');
+
+logger.wrn('找不到任何符合 X.Y.Z 或 X.Y.Z-beta.N 格式的 tag,將從起始版號計算', '讀取版號');
+// [讀取版號][WRN][2026/07/16 09:23:47]: 找不到任何符合 X.Y.Z 或 X.Y.Z-beta.N 格式的 tag,將從起始版號計算
+```
+
+
+### logger.err
+
+輸出 ERR(錯誤)等級的日誌訊息到 stderr(`console.error`),格式與參數同 `logger.inf`。用於可預期錯誤與未捕捉例外的回報;呼叫端通常在輸出後以非零 exit code 結束。
+
+```javascript
+const logger = require('./logger');
+
+logger.err('找不到環境變數 GITHUB_OUTPUT,無法寫出輸出', '寫出結果');
+// [寫出結果][ERR][2026/07/16 09:23:47]: 找不到環境變數 GITHUB_OUTPUT,無法寫出輸出
+process.exit(1);
+```
+
+
+### logger.trc
+
+輸出 TRC(細部追蹤)等級的日誌訊息到 stdout,格式與參數同 `logger.inf`。用於記錄低層次操作的細節,例如實際執行的外部指令、逐筆解析的中間結果。
+
+```javascript
+const logger = require('./logger');
+
+logger.trc('執行指令:git tag --list', '讀取版號');
+// [讀取版號][TRC][2026/07/16 09:23:47]: 執行指令:git tag --list
+```
+
+
+### logger.dbg
+
+輸出 DBG(除錯)等級的日誌訊息到 stdout,格式與參數同 `logger.inf`。用於協助開發者追蹤程式流程與詳細狀態,例如被略過的不符格式 tag。
+
+```javascript
+const logger = require('./logger');
+
+logger.dbg('略過不符合版號格式的 tag:not-a-version', '讀取版號');
+// [讀取版號][DBG][2026/07/16 09:23:47]: 略過不符合版號格式的 tag:not-a-version
+```
+
+
+### version.parse
+
+解析版號字串為版號物件 `{ major, minor, patch, beta }`;支援正式版(`X.Y.Z`)與 beta 版(`X.Y.Z-beta.N`)兩種格式,皆可帶小寫 `v` 前綴(解析時忽略前綴、只取 `v` 之後的版本號),`beta` 為 `null` 表示正式版。不符合格式(含 `null`、非字串轉出的值)時回傳 `null`、不拋出例外,適合逐一解析 git tag 並過濾非版號 tag。
+
+```javascript
+const { parse } = require('./version');
+
+parse('1.2.3'); // => { major: 1, minor: 2, patch: 3, beta: null }
+parse('v1.2.3'); // => { major: 1, minor: 2, patch: 3, beta: null }(忽略 v 前綴)
+parse('1.2.3-beta.5'); // => { major: 1, minor: 2, patch: 3, beta: 5 }
+parse('v1.2'); // => null(不符合格式)
+```
+
+
+### version.stringify
+
+將版號物件轉換為版號字串;`beta` 為 `null` 時輸出 `X.Y.Z`,否則輸出 `X.Y.Z-beta.N`。為 `version.parse` 的反向操作,用於日誌輸出與最終寫出 action 的 `value`。
+
+```javascript
+const { stringify } = require('./version');
+
+stringify({ major: 1, minor: 2, patch: 3, beta: null }); // => '1.2.3'
+stringify({ major: 1, minor: 2, patch: 3, beta: 5 }); // => '1.2.3-beta.5'
+```
+
+
+### version.compare
+
+比較兩個版號物件的大小,回傳正數(a > b)、0(相等)或負數(a < b)。依 major → minor → patch → beta 的順序比較;同號碼時正式版大於 beta 版(`1.2.4` > `1.2.4-beta.3`),beta 版之間依號碼比較。用於在所有 git tag 中挑出最大(最新)的版號。
+
+```javascript
+const { parse, compare } = require('./version');
+
+compare(parse('1.2.4'), parse('1.2.4-beta.3')); // => 1(正式版較大)
+compare(parse('1.2.4-beta.5'), parse('1.2.4-beta.3')); // => 2(beta 依號碼比較)
+compare(parse('2.0.0'), parse('1.9.9')); // => 1
+```
+
+
+### version.next
+
+依最新版號與 `is_beta` 旗標計算下一版號。`latest` 為 `null` 時起算 `0.0.1`(beta 為 `0.0.1-beta.1`);最新為 beta 版時,`isBeta=false` 去尾碼轉正式、`isBeta=true` 則 beta 號 +1(無上限);最新為正式版時 patch 進位(各位數滿 9 進位),`isBeta=true` 再加 `-beta.1`。最新正式版已達 `9.9.9` 需再進位時拋出 `Error`(呼叫端輸出 ERR 後以非零 exit code 結束)。
+
+```javascript
+const { parse, next, stringify } = require('./version');
+
+stringify(next(parse('1.2.3'), false)); // => '1.2.4'
+stringify(next(parse('1.2.3'), true)); // => '1.2.4-beta.1'
+stringify(next(parse('1.2.4-beta.3'), false)); // => '1.2.4'(轉正式)
+stringify(next(parse('1.2.9'), false)); // => '1.3.0'(滿 9 進位)
+stringify(next(null, true)); // => '0.0.1-beta.1'
+```
diff --git a/action.yml b/action.yml
index 1c9e436..adc4075 100644
--- a/action.yml
+++ b/action.yml
@@ -1,14 +1,66 @@
-name: 'Gitea Docker Template'
-description: 'Gitea Docker 範本'
+# ============================================================================
+# 用途:Gitea/GitHub Docker container action「Calculate Next Version」的
+# metadata 定義檔。宣告此 action 的名稱、輸入(is_beta)、輸出(value)
+# 與執行方式(runner 於執行時就地以 Dockerfile 建置映像並以 entrypoint.sh 啟動)。
+# 由 workflow 以 `uses:` 引用本 repo 時,runner 讀取此檔決定如何執行。
+# 更新時間:2026/07/16 11:18:57
+# ============================================================================
+
+# action 的顯示名稱:出現在 workflow log 與 marketplace/action 清單中,
+# 供使用者辨識此步驟在做什麼
+name: 'Calculate Next Version'
+
+# action 的一句話用途說明:從 git tag 取得最新版號,依 is_beta 分流計算
+# 下一個正式版(X.Y.Z)或 beta 版(X.Y.Z-beta.N)版號,各號碼滿 9 進位,
+# 結果輸出為 value;與 entrypoint.sh 啟動訊息、README 的描述一致
+description: '從 git tag 取得最新版號,依 is_beta 計算下一個正式版或 beta 版號(各號碼滿 9 進位)並輸出為 value。'
+
+# action 作者,僅供標示用途,不影響執行
author: 'Jeffery'
+
+# 輸入參數區:workflow 以 `with:` 傳入的值,
+# runner 會轉成 INPUT_<大寫參數名> 環境變數注入容器
inputs:
- message:
- description: '輸入訊息'
+ # is_beta:是否產生 beta 版號;runner 會以 INPUT_IS_BETA 環境變數
+ # 傳入容器,由主程式 src/index.js 讀取後決定版號計算分流
+ is_beta:
+ # 參數說明:true 時產生 X.Y.Z-beta.N 格式的 beta 版號,
+ # false(預設)時產生下一個正式版 X.Y.Z
+ description: '是否為 beta 版(true 時產生 X.Y.Z-beta.N 版號)'
+ # 非必填:未提供時使用下方 default 值
required: false
- default: 'Hello, World!'
+ # 預設為 'false'(正式版分流);YAML 需以字串表示,
+ # action 輸入一律為字串,布林判斷由主程式自行解析
+ default: 'false'
+
+ # sha:要檢查的 commit SHA,建議由 workflow 傳入 ${{ gitea.sha }};
+ # runner 會以 INPUT_SHA 環境變數傳入容器,該 commit 已有版號 tag 時
+ # 直接輸出該版號(忽略 is_beta)、不再計算下一版
+ sha:
+ # 參數說明:未提供時主程式自動改用 runner 內建的 GITHUB_SHA;
+ # 兩者皆空才略過已標記檢查、直接走一般計算流程
+ description: '要檢查的 commit SHA(建議傳入 ${{ gitea.sha }});該 commit 已有版號 tag 時直接輸出該版號。未提供時自動改用 GITHUB_SHA'
+ # 非必填:省略時依上述順序自動回退
+ required: false
+ # 預設空字串,表示交由主程式依 INPUT_SHA → GITHUB_SHA 順序解析
+ default: ''
+
+# 輸出參數區:供 workflow 後續步驟以 steps..outputs.value 取用
outputs:
- message:
- description: '輸出訊息'
+ # value:計算出的下一版號;由主程式(src/index.js)將
+ # 「value=<版號>」寫入 GITHUB_OUTPUT 檔案而產生,
+ # container action 不需在此宣告 value 的來源(無 composite 的 value: 欄位)
+ value:
+ description: '計算出的下一版號'
+
+# 執行方式定義區:宣告此 action 為 Docker container action 及其啟動方式
runs:
+ # 使用 docker 執行模式:runner 會以容器方式執行此 action
using: docker
+ # image 指向 repo 內的 Dockerfile:表示 runner 於「執行時」就地建置映像
+ # (非預建映像),每次執行都可能觸發 docker build(有 layer 快取時較快)
image: dockerfile
+ # 覆寫容器進入點為 /action/entrypoint.sh:
+ # 該腳本輸出啟動訊息後以 exec 交棒給 node 主程式 /action/src/index.js;
+ # 與 Dockerfile 的 ENTRYPOINT ["/action/entrypoint.sh"] 一致(此處為明確重申)
+ entrypoint: /action/entrypoint.sh
diff --git a/dockerfile b/dockerfile
index 764900f..808cab1 100644
--- a/dockerfile
+++ b/dockerfile
@@ -1,11 +1,59 @@
-ARG NODE_VERSION=alpine
+# syntax=docker/dockerfile:1
-FROM node:${NODE_VERSION}
+# ============================================================================
+# 用途:建置「Calculate Next Version」Gitea/GitHub Docker action 的執行映像;
+# 採兩階段建置(build 整理產物、runtime 以 slim 基底縮小映像),
+# runtime 額外安裝 git 供主程式讀取 repo tag,最終以 entrypoint.sh 啟動 node 主程式。
+# 更新時間:2026/07/16 09:16:51
+# ============================================================================
+
+# 1. 參數處理:node 版本以 ARG 注入(預設最新版;--node-version 可覆寫)
+# 置於第一個 FROM 之前的全域 ARG,兩個 FROM 皆可引用;
+# NODE_VERSION 決定 build 階段基底、NODE_RUNTIME 決定 runtime 階段基底。
+# 需人工確認:latest / slim 皆為浮動 tag,未鎖定版本,重建結果可能隨上游更新而改變(不可重現建置)。
+ARG NODE_VERSION=latest
+
+# runtime 基底 tag(slim 變體,體積較小);同屬全域 ARG,供第二個 FROM 引用。
+ARG NODE_RUNTIME=slim
+
+# ---- build 階段:整理產物 ----
+# 以完整版 node 映像作為 build 階段基底,僅用來集中整理 action 產物,
+# 不會進入最終映像,因此體積不影響結果。
+FROM node:${NODE_VERSION} AS build
+
+# 設定工作目錄為 /action,後續 COPY/RUN 皆以此為基準路徑。
WORKDIR /action
-COPY src/ /action/
-COPY entrypoint.sh /entrypoint.sh
+# 2. 安裝套件:無外部相依,僅使用 node 內建模組,故無 npm install
-RUN chmod +x /entrypoint.sh
+# 3. 複製檔案:帶入主程式與入口
+# 複製 src/(index.js / logger.js / version.js 等主程式模組)到 /action/src/。
+COPY src/ /action/src/
-ENTRYPOINT ["/entrypoint.sh"]
+# 複製容器入口腳本,負責輸出啟動訊息並以 exec 啟動 node 主程式。
+COPY entrypoint.sh /action/entrypoint.sh
+
+# 4. 執行程序:賦予 entrypoint 執行權限
+# 確保 entrypoint.sh 可被容器直接執行(避免來源檔案系統權限遺失時 ENTRYPOINT 失敗)。
+RUN chmod +x /action/entrypoint.sh
+
+# 5. 縮小映像檔:runtime 改用 slim 基底,只帶必要產物;主程式需呼叫 git 讀 tag
+# 第二階段為最終映像,捨棄 build 階段中不必要的內容以縮小體積。
+FROM node:${NODE_RUNTIME} AS runtime
+
+# 安裝 git(slim 基底未內建),主程式需以 git 指令讀取 repo 的 tag 計算版號;
+# --no-install-recommends 避免拉入非必要套件,安裝後清除 apt 快取以縮小 layer。
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git \
+ && rm -rf /var/lib/apt/lists/*
+
+# 設定最終映像的工作目錄為 /action。
+WORKDIR /action
+
+# 自 build 階段複製整理好的產物(src/ 與 entrypoint.sh)到最終映像。
+COPY --from=build /action /action
+
+# 6. 設定入口:以 entrypoint.sh 啟動 node 主程式
+# 使用 exec 形式的 ENTRYPOINT;entrypoint.sh 內以 exec node 取代 shell,
+# 正確傳遞訊號與 exit code;與 action.yml 的 entrypoint 設定一致。
+ENTRYPOINT ["/action/entrypoint.sh"]
diff --git a/entrypoint.sh b/entrypoint.sh
old mode 100644
new mode 100755
index 9dcd5e8..00dbd98
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,10 +1,34 @@
#!/bin/sh
+# ============================================================================
+# 用途:Calculate Next Version(Docker container action)的容器進入點。
+# 由 action.yml 的 runs.entrypoint 指定,容器啟動時先輸出統一格式的
+# 啟動訊息,再以 exec 交棒給 node 主程式 /action/src/index.js 計算下一版號。
+# 更新時間:2026/07/16 09:16:42
+# ============================================================================
+
+# 開啟「任一指令失敗即中止」模式:任何指令回傳非 0 就立刻結束腳本,
+# 避免在錯誤狀態下繼續執行後續步驟,讓 action 失敗能正確反映在 job 結果上
set -e
-echo "================================================"
-echo "Action : Gitea Docker Template"
-echo "用途 : Gitea Docker 範本"
-echo "更新時間: 2026/07/02 09:41:31"
-echo "================================================"
+# action 啟動訊息:名稱/用途/更新時間(此檔由 code-action-docker 產生)
+# 依正規化格式 [階段][等級][時間]: 訊息 輸出(階段在前、等級居中、時間在後),
+# 讓 workflow log 可以清楚看出 action 名稱與此次啟動的入口
+# 需人工確認:訊息中時間為產生檔案時寫死的固定字串,非執行當下時間;
+# 若期望顯示實際執行時間需另行調整(此處不修改邏輯)
+echo "[啟動][INF][2026/07/16 09:16:42]: Action:Calculate Next Version"
-exec node /action/index.js "$@"
+# 啟動訊息第二行:說明此 action 的用途(與 action.yml 的 description 一致),
+# 讓使用者在 log 中即可了解版號計算規則(is_beta 分流、各號碼滿 9 進位)
+echo "[啟動][INF][2026/07/16 09:16:42]: 用途:從 git tag 取得最新版號,依 is_beta 計算下一個正式版或 beta 版號(各號碼滿 9 進位)並輸出為 value。"
+
+# 啟動訊息第三行:標示此 entrypoint 的更新時間,方便追溯部署的版本
+echo "[啟動][INF][2026/07/16 09:16:42]: 更新時間:2026/07/16 09:16:42"
+
+# 啟動 node 主程式(以 exec 取代 shell,正確處理訊號與 exit code):
+# - exec 讓 node 直接成為 PID 1 的接班程序,SIGTERM 等訊號可直達 node,
+# 且 node 的 exit code 會原封不動成為容器(即 action step)的結束碼
+# - "$@" 將容器收到的所有引數原樣轉交給主程式(雖然本 action 實際以
+# INPUT_IS_BETA 等環境變數傳遞輸入,仍保留引數轉發以維持彈性)
+# - 主程式副作用:讀取 GITHUB_WORKSPACE 的 git tag、設定 git safe.directory、
+# 並將計算結果 value 寫入 GITHUB_OUTPUT
+exec node /action/src/index.js "$@"
diff --git a/readme.md b/readme.md
deleted file mode 100644
index 505de76..0000000
--- a/readme.md
+++ /dev/null
@@ -1,375 +0,0 @@
-# Gitea Docker Container Action 範本
-
-Docker container(容器)action 讓你把整個執行環境打包成一個 Docker image:action 在你指定的容器內執行,環境、相依套件、工具版本全部固定,跨機器結果一致。適合需要特定系統套件、編譯環境或非 JavaScript 語言撰寫的 action。
-
-本文件整理 Docker container action `action.yml` 中**所有可用參數、說明與限制**,包含 `Dockerfile` 撰寫注意事項,並特別標出 **Gitea 與 GitHub Actions 的差異**。
-
-> 語法基準:Gitea Actions 以相容 GitHub Actions metadata 語法為目標,但兩者有明確差異(見「Gitea vs GitHub」章節)。Gitea 端的行為亦受底層 [`act`](https://gitea.com/gitea/act) runner 版本影響,實作前建議以測試機驗證。
->
-> ⚠️ **平台限制**:Docker container action **只能在 Linux runner 上執行**,且該 runner 必須安裝 Docker。Windows / macOS runner 不支援。
-
----
-
-## 目錄
-
-- [完整結構總覽](#完整結構總覽)
-- [頂層參數](#頂層參數)
-- [`inputs`(輸入參數)](#inputs輸入參數)
-- [`outputs`(輸出)](#outputs輸出)
-- [`runs`(執行設定)](#runs執行設定)
-- [`Dockerfile` 撰寫注意事項](#dockerfile-撰寫注意事項)
-- [Docker action 的限制與注意事項](#docker-action-的限制與注意事項)
-- [Gitea vs GitHub Actions 差異](#gitea-vs-github-actions-差異)
-- [本 repo 範例對照](#本-repo-範例對照)
-- [參考來源](#參考來源)
-
----
-
-## 完整結構總覽
-
-```yaml
-name: 'Gitea Docker Template' # 必填
-description: 'Gitea Docker 範本' # 必填
-author: 'Jeffery' # 選填
-
-inputs: # 選填,定義輸入參數
- message:
- description: '輸入訊息'
- required: false
- default: 'Hello, World!'
-
-outputs: # 選填,定義輸出(docker action 只宣告,不用 value)
- message:
- description: '輸出訊息'
-
-runs: # 必填
- using: 'docker' # 必填,固定為 docker
- image: 'Dockerfile' # 必填,本機 Dockerfile 或 docker://image
- # entrypoint: '/entrypoint.sh' # 選填,覆寫 Dockerfile 的 ENTRYPOINT
- # pre-entrypoint: '/setup.sh' # 選填,主程式前執行(另開容器)
- # post-entrypoint: '/cleanup.sh' # 選填,主程式後執行(另開容器)
- env: # 選填,容器內環境變數
- GREETING: ${{ inputs.message }}
- args: # 選填,傳給 ENTRYPOINT 的參數(取代 CMD)
- - ${{ inputs.message }}
-
-branding: # 選填(Marketplace 用,Gitea 內部可省略)
- icon: 'activity'
- color: 'blue'
-```
-
-> 📌 `action.yml`(或 `action.yaml`)放在 action repo 根目錄;`image: 'Dockerfile'` 時,`Dockerfile` 也放在同一目錄。
-
----
-
-## 頂層參數
-
-| 參數 | 必填 | 說明 |
-|------|------|------|
-| `name` | ✅ | Action 名稱。 |
-| `description` | ✅ | Action 簡短說明。 |
-| `author` | ❌ | 作者名稱。 |
-| `inputs` | ❌ | 輸入參數定義(見下)。 |
-| `outputs` | ❌ | 輸出定義(見下)。 |
-| `runs` | ✅ | 執行設定;docker 固定用 `using: 'docker'` + `image`。 |
-| `branding` | ❌ | Marketplace 顯示用的 `icon` 與 `color`(`color` 限 `white`/`black`/`yellow`/`blue`/`green`/`orange`/`red`/`purple`/`gray-dark`;`icon` 為 Feather 圖示名稱)。 |
-
----
-
-## `inputs`(輸入參數)
-
-每個 input 是 `inputs.` 底下的一組設定:
-
-| 欄位 | 必填 | 說明 |
-|------|------|------|
-| `description` | ✅ | 參數說明。 |
-| `required` | ❌ | 是否必填,布林值,預設 `false`。 |
-| `default` | ❌ | 預設值;呼叫端沒傳時採用。**只能是字串**。 |
-| `deprecationMessage` | ❌ | 標記此 input 已棄用,使用時發出警告訊息。 |
-
-### Docker action 怎麼取用 input
-
-這是 Docker action **與 composite action 最大的差異**。GitHub / Gitea 會把每個 input 轉成環境變數 `INPUT_`:
-
-- 名稱轉大寫、空白換成底線。例:input `octocat-eye-color` → 環境變數 `INPUT_OCTOCAT_EYE_COLOR`。
-- input `message` → `INPUT_MESSAGE`。
-
-```yaml
-inputs:
- message:
- description: '輸入訊息'
- required: false
- default: 'Hello, World!'
-```
-
-容器內就能直接讀:
-
-```sh
-echo "$INPUT_MESSAGE"
-```
-
-> ⚠️ **關鍵限制**:`INPUT_*` 環境變數**只在 GitHub 官方 runner 保證自動注入**。若要跨環境(尤其 Gitea/`act`)可靠取值,官方建議**用 `args` 明確把 input 傳進容器**,或在 `runs.env` 自行對應一次(見下方 `runs.env`)。不要單靠 `INPUT_*` 而不驗證。
-
-**呼叫端傳值**(用 `with`):
-
-```yaml
-- uses: ./
- with:
- message: 'Hi there'
-```
-
-> input 值一律是**字串**;數字、布林傳進來也會變字串(例如 `"true"`),比較時要留意。
-
----
-
-## `outputs`(輸出)
-
-Docker action 的 output **只需宣告 `description`**,**不用**(也不能)像 composite 那樣寫 `value`:
-
-| 欄位 | 必填 | 說明 |
-|------|------|------|
-| `description` | ✅ | 輸出說明。 |
-
-**在容器內設定 output** → 寫入 `$GITHUB_OUTPUT` 檔案(該檔案路徑由 runner 掛載進容器):
-
-```yaml
-outputs:
- message:
- description: '輸出訊息'
-```
-
-`entrypoint.sh` 內:
-
-```sh
-echo "message=Hello from docker" >> "$GITHUB_OUTPUT"
-```
-
-**呼叫端取用 output**:
-
-```yaml
-- id: docker-template
- uses: ./
-- run: echo "${{ steps.docker-template.outputs.message }}"
-```
-
-> 📦 output 大小限制:單一 job 的 output 上限 **1 MB**,整個 workflow run 所有 output 合計上限 **50 MB**。
-
----
-
-## `runs`(執行設定)
-
-Docker action 的核心。`using` 固定為 `docker`,其餘欄位:
-
-| 欄位 | 必填 | 說明 |
-|------|------|------|
-| `using` | ✅ | 固定為 `'docker'`。 |
-| `image` | ✅ | 要跑的 image:本機 `Dockerfile`(檔名必須正好是 `Dockerfile`),或遠端 image 用 `docker://` 前綴(如 `docker://alpine:3.20`、`docker://gcr.io/...`)。 |
-| `entrypoint` | ❌ | 覆寫 Dockerfile 的 `ENTRYPOINT`;Dockerfile 沒設時等於補上。建議用絕對路徑(如 `/entrypoint.sh`)。 |
-| `pre-entrypoint` | ❌ | 在主 `entrypoint` **之前**執行的前置腳本。**會另開一個新容器**(同 base image),runtime 狀態與主容器不同——需保留的狀態要放進 workspace、`HOME`,或用 `STATE_` 變數傳遞。 |
-| `post-entrypoint` | ❌ | 主 `entrypoint` 完成後執行的清理腳本,行為同 `pre-entrypoint`(另開容器)。 |
-| `pre-if` | ❌ | 條件式,控制 `pre-entrypoint` 是否執行;預設一定跑。 |
-| `post-if` | ❌ | 條件式,控制 `post-entrypoint` 是否執行;預設一定跑。 |
-| `args` | ❌ | 字串陣列,啟動時傳給容器 `ENTRYPOINT` 的參數,**取代 Dockerfile 的 `CMD`**。 |
-| `env` | ❌ | key/value map,容器啟動時設定的環境變數。 |
-
-### `image`:Dockerfile vs 遠端 image
-
-```yaml
-# 用本機 Dockerfile(每次執行前會 build)
-runs:
- using: 'docker'
- image: 'Dockerfile'
-
-# 直接拉遠端 image(不用自帶 Dockerfile,啟動快)
-runs:
- using: 'docker'
- image: 'docker://alpine:3.20'
-```
-
-### `args`:怎麼把值送進容器
-
-`args` 取代 `CMD`,會被當作參數接在 `ENTRYPOINT` 後面:
-
-```yaml
-runs:
- using: 'docker'
- image: 'Dockerfile'
- args:
- - ${{ inputs.message }} # → entrypoint.sh 的 $1
- - 'foo' # → $2
- - 'bar' # → $3
-```
-
-> ⚠️ 若 `args` 裡放的是**環境變數字串**(如 `- $GREETING`),在 exec-form 的 `ENTRYPOINT` **不會被展開**。要展開變數,讓 entrypoint 走一層 shell(`sh -c`),或改用 `runs.env` 傳值(見下)。
-
-### `env`:明確傳環境變數(推薦)
-
-比起依賴 `INPUT_*` 自動注入,用 `runs.env` 把 input 對應成自訂環境變數,跨環境最穩:
-
-```yaml
-runs:
- using: 'docker'
- image: 'Dockerfile'
- env:
- GREETING: ${{ inputs.message }}
-```
-
-容器內直接 `echo "$GREETING"`。
-
----
-
-## `Dockerfile` 撰寫注意事項
-
-Docker action 的 `Dockerfile` 有幾條**強制或強烈建議**的規則,踩到會直接失敗或讀不到檔案:
-
-1. **`FROM` 必須是第一行**
- 建議用官方 image + 明確版本標籤(如 `python:3.12-slim`),別用 `latest`;Debian/Alpine 系列較穩。
-
-2. **不要用 `USER`**
- Docker action **必須以預設的 root 執行**。加了 `USER` 會導致**無法存取 `GITHUB_WORKSPACE`**(掛載進來的 repo 目錄)。
-
-3. **不要用 `WORKDIR` 指定 entrypoint 位置**
- runner 會自動把 `GITHUB_WORKSPACE` 掛載上來並設為工作目錄(路徑放在 `$GITHUB_WORKSPACE` 環境變數)。`entrypoint`/腳本一律用**絕對路徑**(如 `/entrypoint.sh`),不要依賴 `WORKDIR`。
-
-4. **`ENTRYPOINT` 用 exec form(JSON 陣列)**
- Docker 官方建議寫 `ENTRYPOINT ["/entrypoint.sh"]`。
- - **exec form**:`args` 能正確以獨立參數傳入,但**不做環境變數展開**(`ENTRYPOINT ["echo", "$GITHUB_SHA"]` 印出的是字面字串)。
- - **shell form**:`ENTRYPOINT /entrypoint.sh` 會走 shell,可展開變數,但 `args` 傳遞行為不同。
- - 需要在 entrypoint 展開變數時,用 `ENTRYPOINT ["sh", "-c", "echo $GITHUB_SHA"]`,或寫一支 `entrypoint.sh` 腳本自行處理。
-
-5. **`CMD` 會被 `args` 蓋掉**
- `action.yml` 的 `args` 取代 `CMD`。若 action 允許不帶 `args` 也能跑,就在 `Dockerfile` 的 `CMD` 提供預設值,並在 README 說明必要參數。
-
-6. **`entrypoint.sh` 腳本規範**
- - 開頭要有 shebang:`#!/bin/sh`(或 `#!/bin/bash`,視 base image 而定)。
- - 要可執行:`chmod +x entrypoint.sh`(並在 git 中保留執行權限)。
- - 腳本會收到 `action.yml` 的 `args` 作為位置參數(`$1`, `$2`, …)。
-
----
-
-## Docker action 的限制與注意事項
-
-以下是實務上最容易踩雷的地方:
-
-1. **只能跑在 Linux runner,且要有 Docker**
- Windows / macOS runner 一律不支援 Docker container action。
-
-2. **`INPUT_*` 不保證可靠,優先用 `args` / `env`**
- GitHub 官方 runner 會自動注入 `INPUT_`,但這在自架 / Gitea 環境未必成立。要穩定取 input,用 `args` 傳位置參數,或 `runs.env` 對應成自訂環境變數。
-
-3. **`pre-entrypoint` / `post-entrypoint` 是「另開容器」**
- 它們**不共用主 entrypoint 容器的 runtime 狀態**(不是同一個容器內的前後腳本)。要跨階段保留狀態,寫進 `GITHUB_WORKSPACE`、`HOME`,或用 `STATE_` 變數。
-
-4. **本機 `image: 'Dockerfile'` 每次會 build**
- 用本機 Dockerfile 時,執行前會先 build image,較慢;想加速可改用 `docker://` 拉預先建好的 image。
-
-5. **只有 `GITHUB_WORKSPACE` 是持久且共用的**
- 容器內對檔案系統的改動,通常只有掛載進來的 `GITHUB_WORKSPACE` 會被後續 step 看到;其他路徑(如 `/tmp`)在跨 step / 跨 action 時不保證保留。
-
-6. **exec-form `ENTRYPOINT` 不展開變數**
- 如上「Dockerfile 注意事項」第 4 點,需要展開就走 `sh -c` 或 entrypoint 腳本。
-
-7. **`args` 是字串陣列,順序即位置參數**
- `args` 的順序對應 entrypoint 的 `$1`, `$2`…;輸入值全是字串。
-
-8. **不支援 `runs.steps`**
- 那是 composite action 專屬。Docker action 只有一個容器進入點(`entrypoint`)+選配的 pre/post。
-
----
-
-## Gitea vs GitHub Actions 差異
-
-Gitea Actions **不是** GitHub Actions 的 100% 複製品。撰寫 Docker action 時特別注意:
-
-| 項目 | Gitea 行為 |
-|------|-----------|
-| **runner 需求** | 執行 Docker action 的 `act_runner` 主機必須裝 Docker,且 runner label 對應到支援容器的環境。`runs-on` 只接受 `runs-on: xyz` 或 `runs-on: [xyz]`,不支援複雜表達式。 |
-| **`pre-entrypoint` / `post-entrypoint`** | ⚠️ 早期 `act` 版本**完全不執行** pre/post-entrypoint(見 [`nektos/act#2363`](https://github.com/nektos/act/issues/2363),已於 PR #2394 修復)。Gitea 內建的 `act` 版本若較舊可能仍無效——**使用前務必在測試機驗證**。 |
-| **`INPUT_*` 注入** | 是否自動注入 `INPUT_` 取決於 runner 版本,別假設一定有;優先用 `args` / `env` 明確傳值。 |
-| **表達式函式** | 依官方比較文件,**僅保證支援 `always()`**;`success()` / `failure()` / `cancelled()` / `hashFiles()` 等視 `act` runner 版本而定,寫 `if:`(含 `pre-if` / `post-if`)前先在測試機驗證。 |
-| **`uses` 支援絕對 URL** | 可寫 `uses: https://github.com/owner/repo@v1` 或 `uses: http://your_gitea/owner/repo@branch`,不限同站 action。 |
-| **Go actions** | Gitea 額外支援 `using: 'go'` 寫 Go action(GitHub 沒有);`docker` / `node` / `composite` 皆支援。 |
-| **context 檢查較寬鬆** | Gitea 不檢查 context 可用性,`env` context 可用在比 GitHub 更多的位置(但不代表可攜,跨到 GitHub 會失敗)。 |
-| **被忽略的 job 欄位** | `jobs..timeout-minutes`、`jobs..continue-on-error`、`jobs..environment` 會被忽略。 |
-| **annotations / problem matchers** | 不支援,會被忽略。 |
-| **`permissions` scope** | 支援 `permissions`,但沒有 GitHub 專屬的 `statuses` / `checks` / `deployments` / `id-token` / `security-events` / `pages`;Gitea 有自己的 `code` / `releases` / `wiki` / `projects`。 |
-
-> 上表以 Gitea 官方文件為準;`act` runner 持續更新,部分限制(尤其表達式函式與 pre/post-entrypoint)可能隨版本放寬,仍以你環境的實測為準。
-
----
-
-## 本 repo 範例對照
-
-一個典型 Docker container action 由三個檔案組成,放在 repo 根目錄:
-
-**1. `action.yml`** — action 定義
-
-```yaml
-name: 'Gitea Docker Template'
-description: 'Gitea Docker 範本'
-author: 'Jeffery'
-inputs:
- message:
- description: '輸入訊息'
- required: false
- default: 'Hello, World!'
-outputs:
- message:
- description: '輸出訊息'
-runs:
- using: 'docker'
- image: 'Dockerfile'
- args:
- - ${{ inputs.message }}
-```
-
-**2. `Dockerfile`** — 執行環境
-
-```dockerfile
-FROM alpine:3.20
-
-COPY entrypoint.sh /entrypoint.sh
-RUN chmod +x /entrypoint.sh
-
-ENTRYPOINT ["/entrypoint.sh"]
-```
-
-**3. `entrypoint.sh`** — 主程式
-
-```sh
-#!/bin/sh
-set -e
-
-# $1 來自 action.yml 的 args(呼叫端 with.message)
-MESSAGE="$1"
-
-echo "Docker action 收到訊息:$MESSAGE"
-
-# 設定 output 供後續 step 使用
-echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
-```
-
-**呼叫端**(workflow)用法:
-
-```yaml
-- name: 3. Testing
- id: docker-template
- uses: ./
- with:
- message: 'Hi there'
-- name: 4. Feedback
- run: echo "${{ steps.docker-template.outputs.message }}"
-```
-
-> ℹ️ 本 repo 目前的 [`action.yml`](./action.yml) 仍為 composite 範本;要改為 Docker action,依上方三件套調整 `action.yml` 並新增 `Dockerfile`、`entrypoint.sh`。
-
----
-
-## 參考來源
-
-- [GitHub Actions — Metadata syntax for actions](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax)
-- [GitHub Actions — Dockerfile support for GitHub Actions](https://docs.github.com/en/actions/sharing-automations/creating-actions/dockerfile-support-for-github-actions)
-- [GitHub Actions — Creating a Docker container action](https://docs.github.com/en/actions/tutorials/creating-a-docker-container-action)
-- [Gitea — Compared to GitHub Actions](https://docs.gitea.com/usage/actions/comparison)
-- [Gitea — Actions FAQ](https://docs.gitea.com/usage/actions/faq)
-- [nektos/act#2363 — pre/post-entrypoint of Docker actions not executed](https://github.com/nektos/act/issues/2363)
diff --git a/src/index.js b/src/index.js
index 3727edc..646e032 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,15 +1,277 @@
+'use strict';
+
const fs = require('fs');
+const { execFileSync } = require('child_process');
+const logger = require('./logger');
+const { parse, stringify, compare, next } = require('./version');
-function main() {
- const message = process.env.INPUT_MESSAGE || '';
- const outputPath = process.env.GITHUB_OUTPUT;
- const line = `message=${message}\n`;
+process.on('uncaughtException', (error) => {
+ logger.err(`未捕捉例外:${error.stack || error.message}`);
+ process.exit(1);
+});
+process.on('unhandledRejection', (reason) => {
+ logger.err(`未處理的 Promise 拒絕:${reason instanceof Error ? reason.stack : reason}`);
+ process.exit(1);
+});
- if (outputPath) {
- fs.appendFileSync(outputPath, line);
- } else {
- process.stdout.write(line);
+/**
+ * 同步執行 git 指令並回傳標準輸出。
+ *
+ * 先以 TRC 等級記錄實際執行的指令字串,再透過 execFileSync 同步執行,
+ * 回傳去除前後空白的 stdout 內容。stderr 不直通主控台(擷取到 error.stderr),
+ * 失敗時由呼叫端以統一格式輸出,避免與格式化訊息重複。
+ *
+ * @param {string[]} args - git 指令的引數陣列(不含 'git' 本身),例如 ['tag', '--list']
+ * @param {string} stage - 日誌用的流程階段名稱,例如 '讀取版號'
+ * @returns {string} git 指令的標準輸出(已 trim);輸出為空時回傳空字串
+ * @throws {Error} git 指令以非零 exit code 結束時,由 execFileSync 拋出(含 stderr 診斷資訊)
+ *
+ * @example
+ * const output = git(['tag', '--list'], '讀取版號');
+ * // 日誌:[讀取版號][TRC][時間]: 執行指令:git tag --list
+ */
+function git(args, stage) {
+ logger.trc(`執行指令:git ${args.join(' ')}`, stage);
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
+}
+
+/**
+ * 讀取並驗證 is_beta 輸入。
+ *
+ * 從 INPUT_IS_BETA 環境變數(action 的 is_beta 輸入)讀值,trim 並轉小寫後:
+ * 空字串或 'false' 視為 false、'true' 視為 true;
+ * 其他值輸出 ERR 訊息並以 process.exit(1) 結束程序(不拋出例外)。
+ *
+ * @returns {boolean} 是否以 beta 模式計算下一版號
+ *
+ * @example
+ * // INPUT_IS_BETA=true 時
+ * const isBeta = readIsBeta(); // => true,並輸出 INF 日誌記錄收到的原始值
+ */
+function readIsBeta() {
+ const stage = '讀取輸入';
+ const raw = process.env.INPUT_IS_BETA ?? '';
+ logger.inf(`收到輸入 is_beta="${raw}"`, stage);
+ const normalized = raw.trim().toLowerCase();
+ if (normalized === '' || normalized === 'false') {
+ return false;
}
+ if (normalized === 'true') {
+ return true;
+ }
+ logger.err(`輸入 is_beta 僅接受 true/false,收到:"${raw}"`, stage);
+ process.exit(1);
+}
+
+/**
+ * 讀取要檢查的 commit SHA。
+ *
+ * 依序取 INPUT_SHA(action 的 sha 輸入,建議由 workflow 傳入 `${{ gitea.sha }}`)
+ * 與 GITHUB_SHA(runner 自動提供的觸發 commit)環境變數,trim 後回傳;
+ * 兩者皆空時回傳 null(呼叫端略過「已標記檢查」,走一般計算流程)。
+ * 純讀取、無副作用,僅輸出一行日誌記錄取得的值或略過原因。
+ *
+ * @returns {?string} commit SHA 字串;無可用來源時回傳 null
+ *
+ * @example
+ * // INPUT_SHA=abc1234 時
+ * const sha = readSha(); // => 'abc1234'
+ * // [讀取輸入][INF][時間]: 收到 commit sha="abc1234"
+ */
+function readSha() {
+ const stage = '讀取輸入';
+ const sha = (process.env.INPUT_SHA || process.env.GITHUB_SHA || '').trim();
+ if (sha === '') {
+ logger.dbg('未提供 commit sha(INPUT_SHA 與 GITHUB_SHA 皆空),略過已標記檢查', stage);
+ return null;
+ }
+ logger.inf(`收到 commit sha="${sha}"`, stage);
+ return sha;
+}
+
+/**
+ * 檢查指定 commit 是否已有符合格式的版號 tag。
+ *
+ * 以 GITHUB_WORKSPACE(無則目前工作目錄)為目標 repo,先設定 git safe.directory,
+ * 再以 `git tag --points-at ` 列出指向該 commit 的所有 tag,
+ * 逐一以 parse() 解析並以 compare() 取最大版號;不符合版號格式的 tag 以 DBG 記錄後略過。
+ * git 指令失敗(例如 sha 不存在、workspace 不是 git repository)時不中止程序:
+ * 輸出 WRN 後回傳 null,讓呼叫端改走一般計算流程。
+ *
+ * @param {string} sha - 要檢查的 commit SHA(完整或短 hash)
+ * @returns {?{major: number, minor: number, patch: number, beta: ?number}}
+ * 該 commit 上最大的版號物件;無符合格式的 tag 或檢查失敗時回傳 null
+ *
+ * @example
+ * const tagged = findVersionAtCommit('abc1234');
+ * // commit 有 tag 1.2.4 時 => { major: 1, minor: 2, patch: 4, beta: null }
+ * // [檢查已標記][INF][時間]: commit abc1234 已有版號 tag:1.2.4
+ */
+function findVersionAtCommit(sha) {
+ const stage = '檢查已標記';
+ const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
+
+ let output;
+ try {
+ git(['config', '--global', '--add', 'safe.directory', workspace], stage);
+ output = git(['-C', workspace, 'tag', '--points-at', sha], stage);
+ } catch (error) {
+ const detail = (error.stderr || error.message || String(error)).toString().trim().replace(/\s*\n\s*/g, ';');
+ logger.wrn(`無法檢查 commit ${sha} 的 tag:${detail};改走一般計算流程`, stage);
+ return null;
+ }
+
+ const tags = output === '' ? [] : output.split('\n');
+ let best = null;
+ for (const tag of tags) {
+ const version = parse(tag);
+ if (version === null) {
+ logger.dbg(`略過不符合版號格式的 tag:${tag}`, stage);
+ continue;
+ }
+ logger.trc(`解析到版號 tag:${tag}`, stage);
+ if (best === null || compare(version, best) > 0) {
+ best = version;
+ }
+ }
+
+ if (best === null) {
+ logger.inf(`commit ${sha} 沒有版號 tag,將計算下一版號`, stage);
+ } else {
+ logger.inf(`commit ${sha} 已有版號 tag:${stringify(best)}`, stage);
+ }
+ return best;
+}
+
+/**
+ * 從 git tag 找出目前最新(最大)的版號。
+ *
+ * 以 GITHUB_WORKSPACE(無則目前工作目錄)為目標 repo,先設定 git safe.directory
+ *(容器內以 root 執行而 workspace 屬於其他使用者時,git 會拒絕存取),
+ * 再列出全部 tag、逐一以 parse() 解析並以 compare() 取最大版號;
+ * 不符合版號格式的 tag 以 DBG 記錄後略過。
+ * git tag 讀取失敗(例如 workspace 不是 git repository、workflow 未先執行
+ * actions/checkout)時不中止程序:輸出 ERR 說明原因與修正方式後回傳 null,
+ * 讓呼叫端改以起始版號(0.0.1)計算。
+ *
+ * @returns {?{major: number, minor: number, patch: number, beta: ?number}}
+ * 最新版號物件;找不到任何符合格式的 tag、或 git tag 讀取失敗時回傳 null
+ * (呼叫端從起始版號計算)
+ *
+ * @example
+ * const latest = findLatestVersion();
+ * // tag 有 1.2.3、1.2.4-beta.3 時 => { major: 1, minor: 2, patch: 4, beta: 3 }
+ * // workspace 不是 git repo 時 => null,並輸出:
+ * // [讀取版號][ERR][時間]: 無法讀取 git tag:fatal: not a git repository ...
+ */
+function findLatestVersion() {
+ const stage = '讀取版號';
+ const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
+
+ let output;
+ try {
+ git(['config', '--global', '--add', 'safe.directory', workspace], stage);
+ output = git(['-C', workspace, 'tag', '--list'], stage);
+ } catch (error) {
+ const detail = (error.stderr || error.message || String(error)).toString().trim().replace(/\s*\n\s*/g, ';');
+ logger.err(`無法讀取 ${workspace} 的 git tag:${detail}`, stage);
+ logger.wrn('請確認 workflow 已先執行 actions/checkout(fetch-depth: 0);本次改以起始版號計算', stage);
+ return null;
+ }
+ const tags = output === '' ? [] : output.split('\n');
+ logger.inf(`git tag 共 ${tags.length} 筆`, stage);
+
+ let latest = null;
+ for (const tag of tags) {
+ const version = parse(tag);
+ if (version === null) {
+ logger.dbg(`略過不符合版號格式的 tag:${tag}`, stage);
+ continue;
+ }
+ logger.trc(`解析到版號 tag:${tag}`, stage);
+ if (latest === null || compare(version, latest) > 0) {
+ latest = version;
+ }
+ }
+
+ if (latest === null) {
+ logger.wrn('找不到任何符合 X.Y.Z 或 X.Y.Z-beta.N 格式的 tag,將從起始版號計算', stage);
+ } else {
+ logger.inf(`目前最新版號:${stringify(latest)}`, stage);
+ }
+ return latest;
+}
+
+/**
+ * 將計算結果寫出為 action 的 value 輸出。
+ *
+ * 以 `value=<值>` 格式附加到 GITHUB_OUTPUT 環境變數指向的檔案,
+ * 供 workflow 後續步驟以 steps..outputs.value 讀取。
+ * GITHUB_OUTPUT 未設定(非 CI 容器環境)時輸出 ERR 並以 process.exit(1) 結束程序。
+ *
+ * @param {string} value - 下一版號字串,格式為 X.Y.Z 或 X.Y.Z-beta.N
+ * @returns {void}
+ *
+ * @example
+ * writeOutput('1.2.4');
+ * // GITHUB_OUTPUT 檔案被附加一行:value=1.2.4
+ */
+function writeOutput(value) {
+ const stage = '寫出結果';
+ const outputPath = process.env.GITHUB_OUTPUT;
+ if (!outputPath) {
+ logger.err('找不到環境變數 GITHUB_OUTPUT,無法寫出輸出', stage);
+ process.exit(1);
+ }
+ fs.appendFileSync(outputPath, `value=${value}\n`);
+ logger.inf(`已寫出輸出 value=${value}`, stage);
+}
+
+/**
+ * 主流程:計算並輸出下一版號。
+ *
+ * 依序執行:讀取 is_beta 輸入 → 讀取 commit sha(INPUT_SHA/GITHUB_SHA)並檢查
+ * 該 commit 是否已有版號 tag(已標記時直接輸出該版號、忽略 is_beta,流程結束)
+ * → 從 git tag 找最新版號 → 以 next() 計算下一版
+ *(進位超過 9.9.9 時輸出 ERR 並 exit(1))→ 寫出 value 輸出。
+ * 檔案頂部的 process.on('uncaughtException') / process.on('unhandledRejection')
+ * 會攔截未捕捉例外,以 ERR 格式輸出後以非零 exit code 結束。
+ * 此函式為容器 entrypoint 啟動 node 後的進入點,於模組載入時直接呼叫。
+ *
+ * @returns {void}
+ */
+function main() {
+ logger.inf('開始計算下一版號');
+
+ const isBeta = readIsBeta();
+
+ const sha = readSha();
+ if (sha !== null) {
+ const tagged = findVersionAtCommit(sha);
+ if (tagged !== null) {
+ const value = stringify(tagged);
+ logger.inf(`commit 已標記,直接輸出既有版號:${value}(忽略 is_beta=${isBeta})`, '計算版號');
+ writeOutput(value);
+ logger.inf('計算完成');
+ return;
+ }
+ }
+
+ const latest = findLatestVersion();
+
+ const stage = '計算版號';
+ let nextVersion;
+ try {
+ nextVersion = next(latest, isBeta);
+ } catch (error) {
+ logger.err(error.message, stage);
+ process.exit(1);
+ }
+ const value = stringify(nextVersion);
+ logger.inf(`下一版號:${value}(is_beta=${isBeta})`, stage);
+
+ writeOutput(value);
+ logger.inf('計算完成');
}
main();
diff --git a/src/logger.js b/src/logger.js
new file mode 100644
index 0000000..28c5b9d
--- /dev/null
+++ b/src/logger.js
@@ -0,0 +1,147 @@
+'use strict';
+
+const TIME_ZONE = 'Asia/Taipei';
+
+const timeFormatter = new Intl.DateTimeFormat('zh-TW', {
+ timeZone: TIME_ZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+});
+
+/**
+ * 產生當前日期時間的格式化字串(Asia/Taipei 時區)。
+ *
+ * 使用 Intl.DateTimeFormat 將當前時間格式化為「yyyy/MM/dd HH:mm:ss」形式,
+ * 時區固定為台灣時間,確保在任何執行環境中都輸出一致的時間戳。
+ * 由 format() 於組合每一行日誌時呼叫;每次呼叫都取得當下系統時間,無快取。
+ *
+ * @returns {string} 格式化後的時間戳字串,例如 '2026/07/15 14:30:45'
+ */
+function timestamp() {
+ const parts = {};
+ for (const { type, value } of timeFormatter.formatToParts(new Date())) {
+ parts[type] = value;
+ }
+ return `${parts.year}/${parts.month}/${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
+}
+
+/**
+ * 組合階段、等級與時間戳,產生統一格式的單行日誌字串。
+ *
+ * 輸出格式為 `[階段][等級][yyyy/MM/dd HH:mm:ss]: 訊息`(階段在前、等級居中、時間在後);
+ * 當 stage 為 falsy(省略、null、空字串)時省略整個 `[階段]` 區塊,
+ * 即 `[等級][yyyy/MM/dd HH:mm:ss]: 訊息`。純函式、無副作用(僅讀取當下系統時間),
+ * 不做參數型別驗證,由 write() 呼叫組出實際要輸出的一行文字。
+ *
+ * @param {string} level - 日誌等級標籤('INF'、'WRN'、'ERR'、'TRC'、'DBG'),不做合法性檢查
+ * @param {string|undefined} stage - 流程階段標籤(選填);falsy 時省略 `[階段]` 區塊
+ * @param {string} message - 日誌訊息內容,直接接在 `: ` 之後輸出,不做淨化或跳脫
+ * @returns {string} 格式化後的單行日誌字串,格式為 `[階段]?[等級][時間]: 訊息`
+ */
+function format(level, stage, message) {
+ const stageBlock = stage ? `[${stage}]` : '';
+ return `${stageBlock}[${level}][${timestamp()}]: ${message}`;
+}
+
+/**
+ * 格式化並輸出一則日誌訊息到主控台。
+ *
+ * 依等級選擇輸出串流:'ERR' 走 console.error(stderr),
+ * 其餘等級走 console.log(stdout)。一次呼叫輸出一行、一行一則訊息。
+ * 此為模組內部函式,外部請使用 module.exports 提供的 inf/wrn/err/trc/dbg。
+ *
+ * @param {string} level - 日誌等級('INF'、'WRN'、'ERR'、'TRC'、'DBG');非 'ERR' 一律輸出到 stdout
+ * @param {string|undefined} stage - 流程階段標籤(選填);falsy 時訊息不含 `[階段]` 區塊
+ * @param {string} message - 日誌訊息主體
+ * @returns {void}
+ */
+function write(level, stage, message) {
+ const line = format(level, stage, message);
+ if (level === 'ERR') {
+ console.error(line);
+ } else {
+ console.log(line);
+ }
+}
+
+module.exports = {
+ /**
+ * 輸出 INF(一般資訊)等級的日誌訊息到 stdout。
+ *
+ * 用於記錄流程中的正常進度,例如讀到的輸入、計算結果、寫出的輸出。
+ *
+ * @example
+ * logger.inf('下一版號:1.2.4', '計算版號');
+ * // [計算版號][INF][2026/07/15 14:30:45]: 下一版號:1.2.4
+ *
+ * @param {string} message - 日誌訊息內容
+ * @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
+ * @returns {void}
+ */
+ inf: (message, stage) => write('INF', stage, message),
+
+ /**
+ * 輸出 WRN(警告)等級的日誌訊息到 stdout。
+ *
+ * 用於非致命的異常狀況,例如找不到任何版號 tag、需注意的邊界條件。
+ *
+ * @example
+ * logger.wrn('找不到任何符合格式的 tag,將從起始版號計算', '讀取版號');
+ * // [讀取版號][WRN][2026/07/15 14:30:45]: 找不到任何符合格式的 tag,將從起始版號計算
+ *
+ * @param {string} message - 警告訊息內容
+ * @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
+ * @returns {void}
+ */
+ wrn: (message, stage) => write('WRN', stage, message),
+
+ /**
+ * 輸出 ERR(錯誤)等級的日誌訊息到 stderr(console.error)。
+ *
+ * 用於可預期錯誤與未捕捉例外的回報;呼叫端通常在輸出後以非零 exit code 結束。
+ *
+ * @example
+ * logger.err('找不到環境變數 GITHUB_OUTPUT,無法寫出輸出', '寫出結果');
+ * // [寫出結果][ERR][2026/07/15 14:30:45]: 找不到環境變數 GITHUB_OUTPUT,無法寫出輸出
+ *
+ * @param {string} message - 錯誤訊息內容
+ * @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
+ * @returns {void}
+ */
+ err: (message, stage) => write('ERR', stage, message),
+
+ /**
+ * 輸出 TRC(細部追蹤)等級的日誌訊息到 stdout。
+ *
+ * 用於記錄低層次操作的細節,例如實際執行的外部指令、逐筆解析的中間結果。
+ *
+ * @example
+ * logger.trc('執行指令:git tag --list', '讀取版號');
+ * // [讀取版號][TRC][2026/07/15 14:30:45]: 執行指令:git tag --list
+ *
+ * @param {string} message - 追蹤訊息內容
+ * @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
+ * @returns {void}
+ */
+ trc: (message, stage) => write('TRC', stage, message),
+
+ /**
+ * 輸出 DBG(除錯)等級的日誌訊息到 stdout。
+ *
+ * 用於協助開發者追蹤程式流程與詳細狀態,例如被略過的不符格式 tag。
+ *
+ * @example
+ * logger.dbg(`略過不符合版號格式的 tag:${tag}`, '讀取版號');
+ * // [讀取版號][DBG][2026/07/15 14:30:45]: 略過不符合版號格式的 tag:not-a-version
+ *
+ * @param {string} message - 除錯訊息內容
+ * @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
+ * @returns {void}
+ */
+ dbg: (message, stage) => write('DBG', stage, message),
+};
diff --git a/src/version.js b/src/version.js
new file mode 100644
index 0000000..2ec06bd
--- /dev/null
+++ b/src/version.js
@@ -0,0 +1,150 @@
+'use strict';
+
+// 版號格式:X.Y.Z 或 X.Y.Z-beta.N(可帶小寫 v 前綴,如 v1.2.3;解析時忽略前綴)
+const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
+
+// major / minor / patch 各位數上限;beta 號碼無上限
+const MAX_DIGIT = 9;
+
+/**
+ * 解析版號字串為版號物件。
+ *
+ * 支援正式版(X.Y.Z)與 beta 版(X.Y.Z-beta.N)兩種格式,
+ * 皆可帶小寫 v 前綴(如 v1.2.3),解析時忽略前綴、只取 v 之後的版本號;
+ * 用於逐一解析 git tag,把 tag 字串轉成可比較、可計算的結構化物件。
+ * 不符合格式(含 null、非字串轉出的值)一律回傳 null,不拋出例外。
+ *
+ * @param {string} text - 待解析的版號字串,格式為 X.Y.Z 或 X.Y.Z-beta.N(可帶 v 前綴)
+ * @returns {?{major: number, minor: number, patch: number, beta: ?number}}
+ * 版號物件;beta 為 null 表示正式版,為數字表示 beta 序號。
+ * 輸入不符合版號格式時回傳 null。前綴 v 不會出現在回傳物件中。
+ *
+ * @example
+ * parse('1.2.3'); // => { major: 1, minor: 2, patch: 3, beta: null }
+ * parse('v1.2.3'); // => { major: 1, minor: 2, patch: 3, beta: null }(忽略 v 前綴)
+ * parse('1.2.3-beta.5'); // => { major: 1, minor: 2, patch: 3, beta: 5 }
+ * parse('v1.2'); // => null(不符合格式)
+ */
+function parse(text) {
+ const match = VERSION_PATTERN.exec(text);
+ if (!match) {
+ return null;
+ }
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ beta: match[4] === undefined ? null : Number(match[4]),
+ };
+}
+
+/**
+ * 將版號物件轉換為版號字串。
+ *
+ * beta 為 null 時輸出正式版格式 `X.Y.Z`,否則輸出 `X.Y.Z-beta.N`;
+ * 為 parse() 的反向操作,用於日誌輸出與最終寫出 action 的 value。
+ *
+ * @param {{major: number, minor: number, patch: number, beta: ?number}} version - 版號物件
+ * @returns {string} 版號字串,例如 '1.2.3' 或 '1.2.3-beta.5'
+ *
+ * @example
+ * stringify({ major: 1, minor: 2, patch: 3, beta: null }); // => '1.2.3'
+ * stringify({ major: 1, minor: 2, patch: 3, beta: 5 }); // => '1.2.3-beta.5'
+ */
+function stringify(version) {
+ const core = `${version.major}.${version.minor}.${version.patch}`;
+ return version.beta === null ? core : `${core}-beta.${version.beta}`;
+}
+
+/**
+ * 比較兩個版號物件的大小。
+ *
+ * 依 major → minor → patch → beta 的順序比較;同號碼時正式版大於 beta 版
+ *(1.2.4 > 1.2.4-beta.3),beta 版之間依號碼比較。
+ * 用於在所有 git tag 中挑出最大(最新)的版號。
+ *
+ * @param {{major: number, minor: number, patch: number, beta: ?number}} a - 第一個版號物件
+ * @param {{major: number, minor: number, patch: number, beta: ?number}} b - 第二個版號物件
+ * @returns {number} 正數表示 a > b、0 表示相等、負數表示 a < b
+ *
+ * @example
+ * compare(parse('1.2.4'), parse('1.2.4-beta.3')); // => 1(正式版大於同號 beta)
+ * compare(parse('1.2.4-beta.5'), parse('1.2.4-beta.3')); // => 2(beta 依號碼比較)
+ */
+function compare(a, b) {
+ if (a.major !== b.major) return a.major - b.major;
+ if (a.minor !== b.minor) return a.minor - b.minor;
+ if (a.patch !== b.patch) return a.patch - b.patch;
+ if (a.beta === b.beta) return 0;
+ if (a.beta === null) return 1;
+ if (b.beta === null) return -1;
+ return a.beta - b.beta;
+}
+
+/**
+ * 將版號的 patch +1,並處理各位數滿 9 的進位。
+ *
+ * patch 滿 9 進位到 minor、minor 滿 9 進位到 major(1.2.9 → 1.3.0、1.9.9 → 2.0.0);
+ * major 超過 9(即 9.9.9 再進位)時拋出 Error。回傳的是新物件(beta 一律為 null,
+ * 即轉為正式版基底),不修改傳入的 version。由 next() 在最新版為正式版時呼叫。
+ *
+ * @param {{major: number, minor: number, patch: number, beta: ?number}} version - 目前版號物件;beta 欄位會被忽略
+ * @returns {{major: number, minor: number, patch: number, beta: null}} 進位後的新版號物件
+ * @throws {Error} 版號已達 9.9.9 無法再進位時拋出
+ *
+ * @example
+ * bumpPatch({ major: 1, minor: 2, patch: 3, beta: null }); // => 1.2.4
+ * bumpPatch({ major: 1, minor: 2, patch: 9, beta: null }); // => 1.3.0
+ * bumpPatch({ major: 9, minor: 9, patch: 9, beta: null }); // => throws Error
+ */
+function bumpPatch(version) {
+ let { major, minor, patch } = version;
+ patch += 1;
+ if (patch > MAX_DIGIT) {
+ patch = 0;
+ minor += 1;
+ }
+ if (minor > MAX_DIGIT) {
+ minor = 0;
+ major += 1;
+ }
+ if (major > MAX_DIGIT) {
+ throw new Error(`版號 ${stringify(version)} 已達上限 ${MAX_DIGIT}.${MAX_DIGIT}.${MAX_DIGIT},無法再進位`);
+ }
+ return { major, minor, patch, beta: null };
+}
+
+/**
+ * 依最新版號與 is_beta 旗標計算下一版號。
+ *
+ * 三種情境:
+ * 1. latest 為 null(repo 無任何版號 tag):起算 0.0.1,isBeta 時為 0.0.1-beta.1。
+ * 2. 最新是 beta 版:isBeta=false 去掉 beta 尾碼轉正式(1.2.4-beta.3 → 1.2.4);
+ * isBeta=true 則 beta 號 +1(beta.9 → beta.10,無上限)。
+ * 3. 最新是正式版:patch 進位(滿 9 進位),isBeta 時再加上 -beta.1。
+ * 為本 action 版號規則的核心,由 main() 於 CI 流程中呼叫以產生下一個 git tag。
+ *
+ * @param {?{major: number, minor: number, patch: number, beta: ?number}} latest - 最新版號物件(parse() 的結果),無先前版號時為 null
+ * @param {boolean} isBeta - 是否產生 beta 版號
+ * @returns {{major: number, minor: number, patch: number, beta: ?number}} 下一版號物件
+ * @throws {Error} 最新正式版已達 9.9.9 需再進位時拋出(來自 bumpPatch)
+ *
+ * @example
+ * next(parse('1.2.3'), false); // => 1.2.4
+ * next(parse('1.2.3'), true); // => 1.2.4-beta.1
+ * next(parse('1.2.4-beta.3'), false); // => 1.2.4(轉正式)
+ * next(null, false); // => 0.0.1
+ */
+function next(latest, isBeta) {
+ if (latest === null) {
+ return { major: 0, minor: 0, patch: 1, beta: isBeta ? 1 : null };
+ }
+ if (latest.beta !== null) {
+ // 最新是 beta:轉正式去尾碼;續 beta 則號碼 +1(無上限)
+ return { ...latest, beta: isBeta ? latest.beta + 1 : null };
+ }
+ const bumped = bumpPatch(latest);
+ return { ...bumped, beta: isBeta ? 1 : null };
+}
+
+module.exports = { parse, stringify, compare, next };