From 7ac64709b905b1cb3353b9af3e08c2f9ee68f26a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:41:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(release-cleanup):=20=E6=94=B9=E7=82=BA=20N?= =?UTF-8?q?ode=20=E4=B8=BB=E7=A8=8B=E5=BC=8F=E4=B8=A6=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將 action 由 bash(curl/jq) 改寫為 Node 主程式 app/index.js(沿用原清理邏輯: 正式版與 beta 版各自保留 KEEP_COUNT 筆、刪除多餘 release 與未綁定 release 的 tag), entrypoint.sh 改為輸出橫幅後 exec node 主程式,Dockerfile 改用 node alpine 六步結構, 並補上 app/package.json 與 action.yaml 輸入說明。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 49 +++++++- action.yaml | 2 +- app/index.js | 307 +++++++++++++++++++++++++++++++++++++++++++++++ app/package.json | 13 ++ entrypoint.sh | 225 ++++++---------------------------- 5 files changed, 402 insertions(+), 194 deletions(-) create mode 100644 app/index.js create mode 100644 app/package.json mode change 100644 => 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 0a80edf..593c511 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,53 @@ -FROM alpine:latest +# syntax=docker/dockerfile:1 -# 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash curl jq +# ============================================================================= +# 此 Dockerfile 的用途: +# 建置 Release Cleanup docker container action 的映像。 +# 以 node alpine 為基底,複製 app/ 主程式與 entrypoint.sh,設定入口。 +# +# 更新日期:2026/06/30 12:02:55(台灣時區) +# ============================================================================= +# ----------------------------------------------------------------------------- +# 1. 參數處理:node base image tag 以 ARG 注入 +# - 預設 lts-alpine;建置時可用 --build-arg NODE_VERSION=... 覆寫 +# - 主程式以原生 fetch 呼叫 Gitea API(需 Node 18+),alpine 為最小可用基底 +# ----------------------------------------------------------------------------- +# 宣告可覆寫的建置參數 NODE_VERSION,預設值 lts-alpine +ARG NODE_VERSION=lts-alpine +# 以 node:${NODE_VERSION} 作為基底映像;引用上方 ARG 決定實際 node 版本/變體 +FROM node:${NODE_VERSION} + +# ----------------------------------------------------------------------------- +# 2. 安裝套件:主程式無第三方相依,亦未呼叫外部執行檔 +# (不需 curl/jq/git 等 OS 套件),故略過此步驟 +# ----------------------------------------------------------------------------- + +# 設定後續指令的工作目錄為 /app;不存在時會自動建立,COPY 的相對目的地以此為基準 +WORKDIR /app + +# ----------------------------------------------------------------------------- +# 3. 複製檔案:主程式(app/)與啟動腳本 +# ----------------------------------------------------------------------------- +# 複製 app/ 目錄全部內容到映像內 /app/(主程式,含 index.js 進入點) +COPY app/ /app/ +# 複製啟動腳本到映像根目錄 /entrypoint.sh(供 ENTRYPOINT 使用) COPY entrypoint.sh /entrypoint.sh +# ----------------------------------------------------------------------------- +# 4. 執行程序:賦予啟動腳本執行權限 +# - 無 build/transpile 需求,故不執行建置 +# ----------------------------------------------------------------------------- +# 對 /entrypoint.sh 加上可執行權限,確保容器啟動時能直接執行該腳本 RUN chmod +x /entrypoint.sh +# ----------------------------------------------------------------------------- +# 5. 縮小映像檔:無相依、無 build 產物可裁剪,alpine 單階段即為最小映像, +# 多階段建置在此無縮小效益,故不採用 +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# 6. 設定入口:以 entrypoint.sh 啟動(其內 node 指向 /app/index.js) +# ----------------------------------------------------------------------------- +# 設定容器進入點為 /entrypoint.sh(exec 形式);容器啟動即執行該腳本 ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yaml b/action.yaml index f5f1de3..baaf05e 100644 --- a/action.yaml +++ b/action.yaml @@ -3,7 +3,7 @@ description: '清理舊成品' author: 'Jeffery' inputs: keep_count: - description: '' + description: '要保留的成品數量' required: false default: 2 runs: diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..ab96512 --- /dev/null +++ b/app/index.js @@ -0,0 +1,307 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 在 stdout 輸出一條視覺分隔線(前後各一個換行 + 50 個等號), + * 用於在 log 中切割不同區段。 + * + * @returns {void} + */ +function separator() { + process.stdout.write('\n==================================================\n'); +} + +/** + * 輸出一個區段標題區塊:上緣等號分隔線、標題文字、下緣虛線, + * 用於在 log 中標示流程進入新階段。 + * + * @param {string} title 區段標題文字。 + * @returns {void} + */ +function section(title) { + separator(); + process.stdout.write(`${title}\n`); + process.stdout.write('--------------------------------------------------\n'); +} + +/** + * 以 [INFO] 前綴將一般資訊訊息輸出到 stdout。 + * + * @param {string} msg 要輸出的訊息內容。 + * @returns {void} + */ +function info(msg) { + console.log(`[INFO] ${msg}`); +} + +/** + * 以 [OK] 前綴將成功訊息輸出到 stdout。 + * + * @param {string} msg 要輸出的訊息內容。 + * @returns {void} + */ +function success(msg) { + console.log(`[OK] ${msg}`); +} + +/** + * 以 [WARN] 前綴將警告訊息輸出到 stdout。 + * + * @param {string} msg 要輸出的訊息內容。 + * @returns {void} + */ +function warn(msg) { + console.log(`[WARN] ${msg}`); +} + +/** + * 以 [ERR] 前綴將錯誤訊息輸出到 stderr。 + * 僅負責輸出,不會結束程序(是否離開由呼叫端決定)。 + * + * @param {string} msg 要輸出的錯誤訊息內容。 + * @returns {void} + */ +function fail(msg) { + console.error(`[ERR] ${msg}`); +} + +/** + * 判斷值是否視為「空 / 未設定」。 + * 涵蓋 undefined、null、空字串,以及字面字串 'null' + *(後者用於處理 CI/環境變數把未設定值帶成字串 "null" 的情況)。 + * + * @param {*} value 要檢查的值。 + * @returns {boolean} 視為空值時為 true,否則為 false。 + */ +function isEmptyOrNull(value) { + return value === undefined || value === null || value === '' || value === 'null'; +} + +/** + * 檢查必填值。先以 [INFO] 印出 name=value, + * 若值被判定為空(見 isEmptyOrNull),印出 [ERR] 並以 exit code 1 結束程序。 + * + * @param {string} name 參數名稱(用於 log 與錯誤訊息)。 + * @param {*} value 要驗證的參數值。 + * @returns {void} + * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。 + */ +function requireValue(name, value) { + info(`${name}=${value}`); + if (isEmptyOrNull(value)) { + fail(`${name} is required`); + process.exit(1); + } +} + +/** + * 驗證值是否為非負整數字串(正則 /^[0-9]+$/,允許前導零、不允許負號或小數)。 + * 不符合時印出 [ERR] 並以 exit code 1 結束程序。 + * + * @param {string} name 參數名稱(用於錯誤訊息)。 + * @param {*} value 要驗證的值;會先轉為字串再比對。 + * @returns {void} + * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。 + */ +function requireInteger(name, value) { + if (!/^[0-9]+$/.test(String(value))) { + fail(`${name} must be a non-negative integer`); + process.exit(1); + } +} + +/** + * 判斷 release/tag 名稱是否為 beta 版本, + * 規則為名稱中含有子字串 "-beta."(例如 v1.2.3-beta.1)。 + * + * @param {string} [tagName] tag 或 release 的名稱;falsy 時視為非 beta。 + * @returns {boolean} 為 beta 版本時 true,否則 false。 + */ +function isBeta(tagName) { + return /-beta\./.test(tagName || ''); +} + +/** + * Action 主流程:依保留數量清理 Gitea repo 的舊 release,並刪除沒有對應 release 的 tag。 + * + * 讀取環境變數 GITEA_SERVER_URL、GITEA_REPOSITORY、GITEA_TOKEN、KEEP_COUNT, + * 流程為: + * 1. 參數檢查(缺值或 KEEP_COUNT 非整數時以 exit code 1 結束)。 + * 2. 取得所有 release,依建立時間由新到舊排序,並分為正式版與 beta 版。 + * 3. 正式版與 beta 版各自保留最新 KEEP_COUNT 筆,刪除其餘 release。 + * 4. 重新取得 release 後,刪除所有未被任何 release 指定的 tag。 + * + * Token 為空時以匿名身分呼叫 API。release/tag 的單筆刪除失敗只記錄錯誤不中斷; + * 取得分頁失敗或參數不合法則直接以 exit code 1 結束程序。 + * + * @async + * @returns {Promise} + */ +async function main() { + const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL; + const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY; + const GITEA_TOKEN = process.env.GITEA_TOKEN; + const KEEP_COUNT_RAW = process.env.KEEP_COUNT; + + section('參數檢查'); + requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL); + requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); + requireValue('KEEP_COUNT', KEEP_COUNT_RAW); + requireInteger('KEEP_COUNT', KEEP_COUNT_RAW); + + const KEEP_COUNT = parseInt(KEEP_COUNT_RAW, 10); + + if (isEmptyOrNull(GITEA_TOKEN)) { + warn('GITEA_TOKEN is empty; release API calls will be anonymous'); + } else { + info('GITEA_TOKEN=[redacted]'); + } + + const authHeaders = {}; + if (!isEmptyOrNull(GITEA_TOKEN)) { + authHeaders['Authorization'] = `token ${GITEA_TOKEN}`; + } + + /** + * 對指定 API base URL 逐頁(?page=N,從 1 起)抓取資料, + * 直到某頁回傳非陣列或空陣列為止,回傳所有頁面合併後的陣列。 + * 任一頁 HTTP 非 2xx 時印出 [ERR] 並以 exit code 1 結束程序。 + * 沿用外層 main 的 authHeaders(有 token 時帶 Authorization)。 + * + * @async + * @param {string} baseUrl 不含分頁參數的 API 端點(例如 .../releases 或 .../tags)。 + * @returns {Promise>} 所有頁面項目合併後的陣列。 + * @throws 不擲出例外;HTTP 失敗時直接呼叫 process.exit(1) 結束程序。 + */ + async function fetchAllPages(baseUrl) { + const all = []; + let page = 1; + + for (;;) { + const pageUrl = `${baseUrl}?page=${page}`; + const res = await fetch(pageUrl, { headers: authHeaders }); + if (!res.ok) { + fail(`GET ${pageUrl} 失敗, HTTP ${res.status}`); + process.exit(1); + } + const pageJson = await res.json(); + if (!Array.isArray(pageJson) || pageJson.length === 0) { + break; + } + all.push(...pageJson); + page += 1; + } + + return all; + } + + /** + * 對指定 URL 發送 DELETE 請求(沿用外層 main 的 authHeaders), + * 回傳 HTTP 狀態碼供呼叫端判斷成敗(成功慣例為 204)。 + * 不輸出訊息、不判斷成敗、不結束程序。 + * + * @async + * @param {string} url 要刪除之資源的完整 URL(release 或 tag 端點)。 + * @returns {Promise} 回應的 HTTP 狀態碼。 + */ + async function deleteUrl(url) { + const res = await fetch(url, { method: 'DELETE', headers: authHeaders }); + return res.status; + } + + const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`; + + section('取得成品資訊'); + info(`GET ${releaseApiUrl}`); + + let releases = await fetchAllPages(releaseApiUrl); + releases = releases.sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ).reverse(); + + const normalReleases = releases.filter((r) => !isBeta(r.tag_name)); + const betaReleases = releases.filter((r) => isBeta(r.tag_name)); + + info(`RELEASE_COUNT=${releases.length}`); + info(`NORMAL_RELEASE_COUNT=${normalReleases.length}`); + info(`BETA_RELEASE_COUNT=${betaReleases.length}`); + info(`KEEP_COUNT=${KEEP_COUNT}`); + + const normalToDelete = + normalReleases.length > KEEP_COUNT ? normalReleases.slice(KEEP_COUNT) : []; + const betaToDelete = + betaReleases.length > KEEP_COUNT ? betaReleases.slice(KEEP_COUNT) : []; + + const releasesToDelete = [...normalToDelete, ...betaToDelete]; + + if (releasesToDelete.length === 0) { + success('沒有需要清理的舊版本成品'); + } else { + section('刪除舊版本成品'); + info(`DELETE_RELEASE_COUNT=${releasesToDelete.length}`); + + for (const release of releasesToDelete) { + const releaseId = release.id; + const releaseTag = release.tag_name; + const releaseName = release.name; + + if (isEmptyOrNull(releaseId)) { + warn(`略過沒有 id 的成品: ${releaseTag} (${releaseName})`); + continue; + } + + const url = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${releaseId}`; + info(`DELETE ${releaseTag} (${releaseName})`); + + const code = await deleteUrl(url); + if (code === 204) { + success(`成功刪除: ${releaseTag} (${releaseName})`); + } else { + fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${code}`); + } + } + } + + section('刪除未指定 release 的 tag'); + + // 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 + const currentReleases = await fetchAllPages(releaseApiUrl); + const releaseTags = new Set(currentReleases.map((r) => r.tag_name)); + + const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`; + info(`GET ${tagApiUrl}`); + + const tags = await fetchAllPages(tagApiUrl); + info(`TAG_COUNT=${tags.length}`); + + for (const tag of tags) { + const tagName = tag.name; + + if (isEmptyOrNull(tagName)) { + warn('略過沒有名稱的 tag'); + continue; + } + + if (releaseTags.has(tagName)) { + info(`保留指定 release 的 tag: ${tagName}`); + continue; + } + + const url = `${tagApiUrl}/${tagName}`; + info(`DELETE tag ${tagName}`); + + const code = await deleteUrl(url); + if (code === 204) { + success(`成功刪除未指定 release 的 tag: ${tagName}`); + } else { + fail(`刪除 tag 失敗: ${tagName}, HTTP ${code}`); + } + } + + separator(); +} + +main().catch((err) => { + fail(err && err.stack ? err.stack : String(err)); + process.exit(1); +}); diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..7e4cf8e --- /dev/null +++ b/app/package.json @@ -0,0 +1,13 @@ +{ + "name": "release-cleanup", + "version": "1.0.0", + "description": "清理舊成品(Gitea release 與未綁定 release 的 tag)", + "author": "Jeffery", + "license": "MIT", + "type": "commonjs", + "main": "index.js", + "private": true, + "engines": { + "node": ">=18" + } +} diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 51d3db1..0dd682b --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,195 +1,40 @@ -#!/usr/bin/env bash -set -Eeuo pipefail +#!/usr/bin/env sh -separator() { - printf '\n%s\n' '==================================================' -} +# ============================================================================= +# 此指令檔的用途: +# 本檔為 Release Cleanup action 的容器啟動腳本(container entrypoint)。 +# 啟動時先輸出一段橫幅,顯示 action 名稱/用途/更新時間,方便在 CI/CD log +# 中辨識正在執行哪一個 action 及其版本;接著以 exec 啟動 node 主程式 +# /app/index.js,並把所有外部傳入的參數原樣交給它。 +# +# 更新日期(台灣時區):2026/06/30 12:02:55 +# ============================================================================= -section() { - separator - printf '%s\n' "$1" - printf '%s\n' '--------------------------------------------------' -} +# 啟用「遇到任何指令失敗(非 0 結束碼)即立刻中止腳本」的模式。 +# 用於避免前置步驟失敗後仍繼續往下執行,確保啟動失敗能正確反映成非 0 exit code。 +set -e -info() { - printf '[INFO] %s\n' "$1" -} +# action 顯示名稱,供下方橫幅輸出使用。 +ACTION_NAME='Release Cleanup' +# action 用途說明,供下方橫幅輸出使用。 +ACTION_DESC='清理舊成品' +# action 更新時間(固定字串,台灣時區),供下方橫幅輸出使用。 +ACTION_UPDATED='2026/06/30 12:02:55' -success() { - printf '[OK] %s\n' "$1" -} +# 輸出一個空行 + 分隔線,作為橫幅的上邊框,讓 log 中的資訊區塊更易閱讀。 +printf '\n%s\n' '==================================================' +# 輸出 action 名稱;以 "$ACTION_NAME" 帶引號傳入,避免變數內容被分詞或萬用字元展開。 +printf '名稱 : %s\n' "$ACTION_NAME" +# 輸出 action 用途;同樣以帶引號變數傳入確保字串完整。 +printf '用途 : %s\n' "$ACTION_DESC" +# 輸出 action 更新時間;同樣以帶引號變數傳入確保字串完整。 +printf '更新 : %s\n' "$ACTION_UPDATED" +# 輸出分隔線,作為橫幅的下邊框。 +printf '%s\n' '==================================================' -warn() { - printf '[WARN] %s\n' "$1" -} - -fail() { - printf '[ERR] %s\n' "$1" >&2 -} - -is_empty_or_null() { - [ -z "${1:-}" ] || [ "${1:-}" = "null" ] -} - -require_value() { - local name="$1" - local value="$2" - - info "$name=$value" - if is_empty_or_null "$value"; then - fail "$name is required" - exit 1 - fi -} - -require_integer() { - local name="$1" - local value="$2" - - if ! [[ "$value" =~ ^[0-9]+$ ]]; then - fail "$name must be a non-negative integer" - exit 1 - fi -} - -fetch_all_pages() { - local base_url="$1" - local all_json='[]' - local page=1 - local page_url page_json - - while :; do - page_url="$base_url?page=$page" - page_json="$(curl -fsS "${auth_header[@]}" "$page_url")" - - if [ "$(jq 'length' <<<"$page_json")" -eq 0 ]; then - break - fi - - all_json="$(jq -s 'add' <<<"$all_json"$'\n'"$page_json")" - page=$((page + 1)) - done - - printf '%s' "$all_json" -} - -section "參數檢查" -require_value "GITEA_SERVER_URL" "$GITEA_SERVER_URL" -require_value "GITEA_REPOSITORY" "$GITEA_REPOSITORY" -require_value "KEEP_COUNT" "$KEEP_COUNT" -require_integer "KEEP_COUNT" "$KEEP_COUNT" - -if is_empty_or_null "${GITEA_TOKEN:-}"; then - warn "GITEA_TOKEN is empty; release API calls will be anonymous" -else - info "GITEA_TOKEN=[redacted]" -fi - -release_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" -auth_header=() - -if ! is_empty_or_null "${GITEA_TOKEN:-}"; then - auth_header=(-H "Authorization: token $GITEA_TOKEN") -fi - -section "取得成品資訊" -info "GET $release_api_url" - -release_json="$(fetch_all_pages "$release_api_url")" -release_json="$(jq -e 'sort_by(.created_at) | reverse' <<<"$release_json")" -release_count="$(jq 'length' <<<"$release_json")" -normal_release_json="$(jq -c '[.[] | select(((.tag_name // "") | test("-beta\\.")) | not)]' <<<"$release_json")" -beta_release_json="$(jq -c '[.[] | select((.tag_name // "") | test("-beta\\."))]' <<<"$release_json")" -normal_release_count="$(jq 'length' <<<"$normal_release_json")" -beta_release_count="$(jq 'length' <<<"$beta_release_json")" - -info "RELEASE_COUNT=$release_count" -info "NORMAL_RELEASE_COUNT=$normal_release_count" -info "BETA_RELEASE_COUNT=$beta_release_count" -info "KEEP_COUNT=$KEEP_COUNT" - -normal_release_to_delete='[]' -if [ "$normal_release_count" -gt "$KEEP_COUNT" ]; then - normal_release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$normal_release_json")" -fi - -beta_release_to_delete='[]' -if [ "$beta_release_count" -gt "$KEEP_COUNT" ]; then - beta_release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$beta_release_json")" -fi - -release_to_delete="$(jq -c -s 'add' <<<"$normal_release_to_delete"$'\n'"$beta_release_to_delete")" -release_to_delete_count="$(jq 'length' <<<"$release_to_delete")" - -if [ "$release_to_delete_count" -eq 0 ]; then - success "沒有需要清理的舊版本成品" -else - section "刪除舊版本成品" - info "DELETE_RELEASE_COUNT=$release_to_delete_count" - - while IFS= read -r release_item; do - [ -z "$release_item" ] && continue - - release_id="$(jq -r '.id' <<<"$release_item")" - release_tag="$(jq -r '.tag_name' <<<"$release_item")" - release_name="$(jq -r '.name' <<<"$release_item")" - - if is_empty_or_null "$release_id"; then - warn "略過沒有 id 的成品: $release_tag ($release_name)" - continue - fi - - delete_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases/$release_id" - info "DELETE $release_tag ($release_name)" - - delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")" - - if [ "$delete_code" -eq 204 ]; then - success "成功刪除: $release_tag ($release_name)" - else - fail "刪除失敗: $release_tag ($release_name), HTTP $delete_code" - fi - done < <(jq -c '.[]' <<<"$release_to_delete") -fi - -section "刪除未指定 release 的 tag" - -# 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 -current_release_json="$(fetch_all_pages "$release_api_url")" -release_tags_json="$(jq -c '[.[].tag_name]' <<<"$current_release_json")" - -tag_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/tags" -info "GET $tag_api_url" - -tag_json="$(fetch_all_pages "$tag_api_url")" -tag_count="$(jq 'length' <<<"$tag_json")" -info "TAG_COUNT=$tag_count" - -while IFS= read -r tag_item; do - [ -z "$tag_item" ] && continue - - tag_name="$(jq -r '.name' <<<"$tag_item")" - - if is_empty_or_null "$tag_name"; then - warn "略過沒有名稱的 tag" - continue - fi - - if [ "$(jq --arg name "$tag_name" 'any(.[]; . == $name)' <<<"$release_tags_json")" = "true" ]; then - info "保留指定 release 的 tag: $tag_name" - continue - fi - - delete_url="$tag_api_url/$tag_name" - info "DELETE tag $tag_name" - - delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")" - - if [ "$delete_code" -eq 204 ]; then - success "成功刪除未指定 release 的 tag: $tag_name" - else - fail "刪除 tag 失敗: $tag_name, HTTP $delete_code" - fi -done < <(jq -c '.[]' <<<"$tag_json") - -separator +# 以 exec 啟動 node 主程式:exec 會用 node 行程「取代」目前的 shell 行程, +# 而非另開子行程。如此可讓 node 成為 PID 1(或直接承接原 shell 的 PID), +# 正確接收容器傳來的訊號(例如 SIGTERM/SIGINT)以利優雅關閉, +# 並讓 node 的結束碼直接成為容器的結束碼。 +# "$@" 將腳本收到的所有參數原樣(逐一保留分隔,避免被重新分詞)轉交給 node 主程式。 +exec node /app/index.js "$@"