From 1773c646d59afa4437e8f860255584a110ebc262 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 16 Jul 2026 09:30:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=B5=B1=E4=B8=80=E6=97=A5?= =?UTF-8?q?=E8=AA=8C=E6=A0=BC=E5=BC=8F=E7=82=BA=20[=E9=9A=8E=E6=AE=B5][?= =?UTF-8?q?=E7=AD=89=E7=B4=9A][=E6=99=82=E9=96=93]=20=E4=B8=A6=E8=A3=9C?= =?UTF-8?q?=E9=BD=8A=E6=8C=87=E4=BB=A4=E6=AA=94=E6=96=87=E4=BB=B6=E8=88=87?= =?UTF-8?q?=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logger.js format() 組字順序改為階段在前、等級居中、時間在後(Asia/Taipei) - entrypoint.sh 啟動訊息改用新格式並更新標頭時間 - action.yml 新增用途/更新時間標頭與逐行繁中註解,設定值不變 - dockerfile 檔名維持小寫並與 action.yml 的 image 引用一致 - 新增 src/logger.js、src/version.js 模組與完整 JSDoc;重建 README.md Co-Authored-By: Claude Fable 5 --- README.md | 191 +++++++++++++++++++++++++ action.yml | 54 ++++++- dockerfile | 60 +++++++- entrypoint.sh | 36 ++++- readme.md | 375 ------------------------------------------------- src/index.js | 170 ++++++++++++++++++++-- src/logger.js | 147 +++++++++++++++++++ src/version.js | 148 +++++++++++++++++++ 8 files changed, 779 insertions(+), 402 deletions(-) create mode 100644 README.md mode change 100644 => 100755 entrypoint.sh delete mode 100644 readme.md create mode 100644 src/logger.js create mode 100644 src/version.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..75815d2 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Calculate Next Version + +從 git tag 取得最新版號,依 `is_beta` 計算下一個正式版或 beta 版號(各號碼滿 9 進位)並輸出為 `value` 的 Gitea/GitHub Docker container action。 + +- 更新時間:2026/07/16 09:23:47 + +## 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' + - run: echo "下一版號:${{ steps.version.outputs.value }}" +``` + +| 類型 | 名稱 | 必填 | 預設值 | 說明 | +| --- | --- | --- | --- | --- | +| input | `is_beta` | 否 | `false` | 是否為 beta 版(true 時產生 X.Y.Z-beta.N 版號) | +| output | `value` | — | — | 計算出的下一版號 | + +版號規則: + +- 版號來源為 repo 的 git tag(格式 `X.Y.Z` 或 `X.Y.Z-beta.N`,不帶前綴),取最大版號;無任何版號 tag 時從 `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#L26) | [解析版號字串為版號物件,不符格式回傳 null](#versionparse) | +| [version.stringify](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L52) | [將版號物件轉換為版號字串](#versionstringify) | +| [version.compare](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L72) | [比較兩個版號物件的大小,正式版大於同號 beta](#versioncompare) | +| [version.next](https://gitea.jsc.idv.tw/docker-actions/calculate-next-version/src/branch/master/src/version.js#L136) | [依最新版號與 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`)兩種格式,`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('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..599c4bb 100644 --- a/action.yml +++ b/action.yml @@ -1,14 +1,54 @@ -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 09:17:01 +# ============================================================================ + +# 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' + +# 輸出參數區:供 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..494e503 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,169 @@ +'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 內容。 + * + * @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' }).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); +} + +/** + * 從 git tag 找出目前最新(最大)的版號。 + * + * 以 GITHUB_WORKSPACE(無則目前工作目錄)為目標 repo,先設定 git safe.directory + *(容器內以 root 執行而 workspace 屬於其他使用者時,git 會拒絕存取), + * 再列出全部 tag、逐一以 parse() 解析並以 compare() 取最大版號; + * 不符合版號格式的 tag 以 DBG 記錄後略過。 + * + * @returns {?{major: number, minor: number, patch: number, beta: ?number}} + * 最新版號物件;找不到任何符合格式的 tag 時輸出 WRN 並回傳 null(呼叫端從起始版號計算) + * @throws {Error} git 指令執行失敗時,由 git() 拋出 + * + * @example + * const latest = findLatestVersion(); + * // tag 有 1.2.3、1.2.4-beta.3 時 => { major: 1, minor: 2, patch: 4, beta: 3 } + */ +function findLatestVersion() { + const stage = '讀取版號'; + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + + git(['config', '--global', '--add', 'safe.directory', workspace], stage); + + const output = git(['-C', workspace, 'tag', '--list'], stage); + 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 輸入 → 從 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 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..8a6c428 --- /dev/null +++ b/src/version.js @@ -0,0 +1,148 @@ +'use strict'; + +// 版號格式:X.Y.Z 或 X.Y.Z-beta.N(不帶前綴) +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/; + +// major / minor / patch 各位數上限;beta 號碼無上限 +const MAX_DIGIT = 9; + +/** + * 解析版號字串為版號物件。 + * + * 支援正式版(X.Y.Z)與 beta 版(X.Y.Z-beta.N)兩種格式; + * 用於逐一解析 git tag,把 tag 字串轉成可比較、可計算的結構化物件。 + * 不符合格式(含 null、非字串轉出的值)一律回傳 null,不拋出例外。 + * + * @param {string} text - 待解析的版號字串,格式為 X.Y.Z 或 X.Y.Z-beta.N + * @returns {?{major: number, minor: number, patch: number, beta: ?number}} + * 版號物件;beta 為 null 表示正式版,為數字表示 beta 序號。 + * 輸入不符合版號格式時回傳 null。 + * + * @example + * parse('1.2.3'); // => { major: 1, minor: 2, patch: 3, beta: null } + * 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 }; -- 2.53.0