統一日誌格式與文件化,新增容錯回退、sha 已標記檢查與 v 前綴支援 #4

Merged
admin merged 7 commits from develop into master 2026-07-16 05:35:59 +00:00
8 changed files with 906 additions and 402 deletions
+196
View File
@@ -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) |
## 使用範例
<a id="loggerinf"></a>
### 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]: 計算完成
```
<a id="loggerwrn"></a>
### 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,將從起始版號計算
```
<a id="loggererr"></a>
### 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);
```
<a id="loggertrc"></a>
### 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
```
<a id="loggerdbg"></a>
### logger.dbg
輸出 DBG(除錯)等級的日誌訊息到 stdout,格式與參數同 `logger.inf`。用於協助開發者追蹤程式流程與詳細狀態,例如被略過的不符格式 tag。
```javascript
const logger = require('./logger');
logger.dbg('略過不符合版號格式的 tagnot-a-version', '讀取版號');
// [讀取版號][DBG][2026/07/16 09:23:47]: 略過不符合版號格式的 tagnot-a-version
```
<a id="versionparse"></a>
### 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(不符合格式)
```
<a id="versionstringify"></a>
### 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'
```
<a id="versioncompare"></a>
### 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')); // => 2beta 依號碼比較)
compare(parse('2.0.0'), parse('1.9.9')); // => 1
```
<a id="versionnext"></a>
### 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'
```
+59 -7
View File
@@ -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' author: 'Jeffery'
# 輸入參數區:workflow 以 `with:` 傳入的值,
# runner 會轉成 INPUT_<大寫參數名> 環境變數注入容器
inputs: inputs:
message: # is_beta:是否產生 beta 版號;runner 會以 INPUT_IS_BETA 環境變數
description: '輸入訊息' # 傳入容器,由主程式 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 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.<id>.outputs.value 取用
outputs: outputs:
message: # value:計算出的下一版號;由主程式(src/index.js)將
description: '輸出訊息' # 「value=<版號>」寫入 GITHUB_OUTPUT 檔案而產生,
# container action 不需在此宣告 value 的來源(無 composite 的 value: 欄位)
value:
description: '計算出的下一版號'
# 執行方式定義區:宣告此 action 為 Docker container action 及其啟動方式
runs: runs:
# 使用 docker 執行模式:runner 會以容器方式執行此 action
using: docker using: docker
# image 指向 repo 內的 Dockerfile:表示 runner 於「執行時」就地建置映像
# (非預建映像),每次執行都可能觸發 docker build(有 layer 快取時較快)
image: dockerfile image: dockerfile
# 覆寫容器進入點為 /action/entrypoint.sh
# 該腳本輸出啟動訊息後以 exec 交棒給 node 主程式 /action/src/index.js
# 與 Dockerfile 的 ENTRYPOINT ["/action/entrypoint.sh"] 一致(此處為明確重申)
entrypoint: /action/entrypoint.sh
+54 -6
View File
@@ -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 WORKDIR /action
COPY src/ /action/ # 2. 安裝套件:無外部相依,僅使用 node 內建模組,故無 npm install
COPY entrypoint.sh /entrypoint.sh
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 形式的 ENTRYPOINTentrypoint.sh 內以 exec node 取代 shell
# 正確傳遞訊號與 exit code;與 action.yml 的 entrypoint 設定一致。
ENTRYPOINT ["/action/entrypoint.sh"]
Regular → Executable
+30 -6
View File
@@ -1,10 +1,34 @@
#!/bin/sh #!/bin/sh
# ============================================================================
# 用途:Calculate Next VersionDocker container action)的容器進入點。
# 由 action.yml 的 runs.entrypoint 指定,容器啟動時先輸出統一格式的
# 啟動訊息,再以 exec 交棒給 node 主程式 /action/src/index.js 計算下一版號。
# 更新時間:2026/07/16 09:16:42
# ============================================================================
# 開啟「任一指令失敗即中止」模式:任何指令回傳非 0 就立刻結束腳本,
# 避免在錯誤狀態下繼續執行後續步驟,讓 action 失敗能正確反映在 job 結果上
set -e set -e
echo "================================================" # action 啟動訊息:名稱/用途/更新時間(此檔由 code-action-docker 產生)
echo "Action : Gitea Docker Template" # 依正規化格式 [階段][等級][時間]: 訊息 輸出(階段在前、等級居中、時間在後),
echo "用途 : Gitea Docker 範本" # 讓 workflow log 可以清楚看出 action 名稱與此次啟動的入口
echo "更新時間: 2026/07/02 09:41:31" # 需人工確認:訊息中時間為產生檔案時寫死的固定字串,非執行當下時間;
echo "================================================" # 若期望顯示實際執行時間需另行調整(此處不修改邏輯)
echo "[啟動][INF][2026/07/16 09:16:42]: ActionCalculate 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 "$@"
-375
View File
@@ -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.<input_id>` 底下的一組設定:
| 欄位 | 必填 | 說明 |
|------|------|------|
| `description` | ✅ | 參數說明。 |
| `required` | ❌ | 是否必填,布林值,預設 `false`。 |
| `default` | ❌ | 預設值;呼叫端沒傳時採用。**只能是字串**。 |
| `deprecationMessage` | ❌ | 標記此 input 已棄用,使用時發出警告訊息。 |
### Docker action 怎麼取用 input
這是 Docker action **與 composite action 最大的差異**。GitHub / Gitea 會把每個 input 轉成環境變數 `INPUT_<NAME>`
- 名稱轉大寫、空白換成底線。例: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 formJSON 陣列)**
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_<NAME>`,但這在自架 / 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_<NAME>` 取決於 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 actionGitHub 沒有);`docker` / `node` / `composite` 皆支援。 |
| **context 檢查較寬鬆** | Gitea 不檢查 context 可用性,`env` context 可用在比 GitHub 更多的位置(但不代表可攜,跨到 GitHub 會失敗)。 |
| **被忽略的 job 欄位** | `jobs.<job_id>.timeout-minutes``jobs.<job_id>.continue-on-error``jobs.<job_id>.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)
+270 -8
View File
@@ -1,15 +1,277 @@
'use strict';
const fs = require('fs'); const fs = require('fs');
const { execFileSync } = require('child_process');
const logger = require('./logger');
const { parse, stringify, compare, next } = require('./version');
function main() { process.on('uncaughtException', (error) => {
const message = process.env.INPUT_MESSAGE || ''; logger.err(`未捕捉例外:${error.stack || error.message}`);
const outputPath = process.env.GITHUB_OUTPUT; process.exit(1);
const line = `message=${message}\n`; });
process.on('unhandledRejection', (reason) => {
logger.err(`未處理的 Promise 拒絕:${reason instanceof Error ? reason.stack : reason}`);
process.exit(1);
});
if (outputPath) { /**
fs.appendFileSync(outputPath, line); * 同步執行 git 指令並回傳標準輸出。
} else { *
process.stdout.write(line); * 先以 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_SHAaction 的 sha 輸入,建議由 workflow 傳入 `${{ gitea.sha }}`
* 與 GITHUB_SHArunner 自動提供的觸發 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 shaINPUT_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 <sha>` 列出指向該 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 已有版號 tag1.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 tagfatal: 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/checkoutfetch-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.<id>.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 shaINPUT_SHAGITHUB_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(); main();
+147
View File
@@ -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.errorstderr),
* 其餘等級走 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(錯誤)等級的日誌訊息到 stderrconsole.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]: 略過不符合版號格式的 tagnot-a-version
*
* @param {string} message - 除錯訊息內容
* @param {string} [stage] - 選填的流程階段標籤;省略時訊息不含 `[階段]` 區塊
* @returns {void}
*/
dbg: (message, stage) => write('DBG', stage, message),
};
+150
View File
@@ -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')); // => 2beta 依號碼比較)
*/
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 進位到 major1.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 為 nullrepo 無任何版號 tag):起算 0.0.1isBeta 時為 0.0.1-beta.1。
* 2. 最新是 beta 版:isBeta=false 去掉 beta 尾碼轉正式(1.2.4-beta.3 → 1.2.4);
* isBeta=true 則 beta 號 +1beta.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 };