什麼是 CI/CD
CI/CD 是兩個縮寫拼在一起:
- CI(Continuous Integration,持續整合):每次 push 程式碼,就自動跑 lint、測試、build。目的是「壞掉的程式碼在合併前就被抓到」,而不是靠人記得跑測試。
- CD(Continuous Delivery / Deployment,持續交付 / 部署):測試通過後,自動把產出物(artifact)送到正式環境。Delivery 通常還留一個人工按鈕;Deployment 則是全自動上線。
把兩者串起來就是一條 pipeline:從 commit 到上線之間的每一步都寫成程式碼、由機器執行、每次結果一致。
好處很直接:
- 可重複:流程寫在 YAML 裡,不會因為誰忘了哪一步而出事。
- 快速回饋:push 幾分鐘後就知道 build 有沒有壞。
- 可回滾:每次上線都對應一個不可變的 image,出事直接切回上一版。
- 伺服器輕鬆:編譯在 CI 上做,VPS 只負責跑。
以前的流程:手動五步
這個 blog 跑在一台 VPS 上,用 Docker Compose 管三個容器:Next.js、nginx、certbot。上線流程長這樣:
- 本機跑
npm run build確認會過。 git push。- SSH 進 VPS。
git pull。docker compose up -d --build。
痛點也很明確:
- VPS 要自己編譯:
next build會吃掉一台小 VPS 的大部分 CPU 與記憶體,build 的當下網站會變慢。 - 沒有測試閘門:忘記跑測試也照樣能上線。
- 沒有回滾:build 壞了就是壞了,要修好再重新 build 一次。
- 靠人記步驟:五步裡漏一步,網站就停在舊版或半新不舊。
大公司怎麼做
規模再大的團隊,骨架其實都一樣,只是每一格換成更重的工具:
| 階段 | 常見工具 | 概念 |
|---|---|---|
| CI | GitHub Actions、GitLab CI、Jenkins、Buildkite | PR 觸發 lint / test / build |
| Artifact | 容器 registry(GHCR、ECR、Harbor) | image 用 commit SHA 當 tag,不可變 |
| CD | Kubernetes + ArgoCD / Flux(GitOps) | Git 裡改 manifest 的 tag,叢集自動同步 |
| 環境 | dev → staging → production | production 常需人工 approve |
| 發布策略 | blue-green、canary | 失敗自動回滾 |
| 觀測 | Prometheus / Grafana、Sentry | 健康檢查決定發布成敗 |
單機 VPS 上 Kubernetes 太重,但上面的原則可以原封不動搬過來:image 不可變、用 SHA 當 tag、健康檢查把關、失敗就回滾。我們做的就是這套架構的縮小版,把 CD 那格從 ArgoCD 換成「SSH 進 VPS 跑 docker compose」。
我們的流程
整條 pipeline 放在一個 workflow 檔裡,四個 job 依序執行:
push main ──► test ──► build ──► deploy ──► promote
lint docker SSH→VPS :latest
vitest push pull/up 只在成功後
:sha-xxx healthcheck
rollback
- PR 或 push 到 dev:只跑
test與build(build 只驗證 Dockerfile 能過,不 push)。 - push 到 main:四段全跑,真正上線。
- 手動觸發並指定 tag:跳過
test/build,直接部署既有的 image。這就是回滾按鈕。
1. test:lint 與單元測試
Loading...
沒什麼特別的,重點是它擋在 build 前面。測試沒過,後面全部不會跑。
2. build:在 CI 上建 image,推到 GHCR
Loading...
幾個設計點:
- tag 用 commit SHA 前七碼(
sha-abc1234)。同一個 tag 永遠對應同一份程式碼,不會被覆蓋。 - GHCR(GitHub Container Registry):跟 repo 同一個帳號,
GITHUB_TOKEN直接能登入,不用另外申請金鑰。 cache-from / cache-to: type=gha:把 Docker layer cache 存在 GitHub Actions cache 裡。只改了程式碼沒動package.json時,npm ci那層直接命中,build 時間大幅縮短。build-args:把NEXT_PUBLIC_*塞進去。為什麼需要這步,下一節會講。
3. deploy:SSH 進 VPS,pull 新 image 並重啟
Loading...
遠端 script 做的事,一步一步:
git fetch+git merge --ff-only:docker-compose.yml與 nginx 的設定檔是從 VPS 上的 git checkout 掛載進容器的,所以還是要拉最新。fetch 走 HTTPS 並帶 job 的GITHUB_TOKEN,VPS 上的部署帳號不需要 GitHub 的 SSH key。用--ff-only是為了 VPS 上若有人手動改過檔案,這裡會直接失敗而不是產生 merge commit。- 登入 GHCR:用的是 workflow 的
GITHUB_TOKEN。它只在這個 job 執行期間有效,VPS 上不需要長期存一把 PAT。 docker compose pull:只拉 nextjs 的 image。docker compose up -d --wait:重建容器,並等 healthcheck 轉成 healthy。超時就視為部署失敗。- nginx reload:nginx 的
proxy_pass http://nextjs:3000在啟動時就把nextjs解析成容器 IP。容器重建後 IP 可能變,reload 一次讓它重新解析,是 graceful 的,不會斷線。 - smoke test:從外部打一次網站首頁,確認整條 Cloudflare → nginx → Next.js 路徑都通。
- 任何一步失敗就
rollback:拉:latest回來重啟。
rollback 是遠端 script 裡的一個 shell function:
Loading...
而 healthcheck 定義在 docker-compose.yml:
Loading...
Alpine 版的 Node image 沒有 curl,直接用 Node 20 內建的 fetch 打首頁即可。
4. promote:部署成功後才把 :latest 指過去
Loading...
這是整條 pipeline 裡最值得講的一個決定。
一般直覺是 build 完就同時打 :sha 和 :latest 兩個 tag。但這樣一來,如果新版部署失敗,:latest 已經指向壞掉的 image,回滾時沒有東西可以拉。
所以我們把 :latest 的語意改成「最後一版部署成功的 image」:只有 deploy job 綠燈之後,promote job 才用 imagetools create 把 :latest 重新指向這次的 SHA tag。它不重新 build,只是改 registry 上的 manifest tag,幾秒鐘完成。
於是 docker-compose.yml 裡的 image: ghcr.io/your-name/your-blog:${IMAGE_TAG:-latest} 就有了一個安全的預設值:deploy 時帶 IMAGE_TAG 用新版;rollback 或有人手動在 VPS 上 docker compose up -d,拿到的永遠是上一版好的。
回滾
兩條路:
- 自動:deploy script 內任何一步失敗,立刻拉
:latest重啟。 - 手動:在 GitHub Actions 頁面按 Run workflow,填入想回去的 tag(例如
sha-abc1234)。test與build會被跳過,直接部署那個 image,成功後它會被 promote 成新的:latest。
設計原理:五個原則
逐項解釋設定之前,先講整條 pipeline 背後的五個原則。後面每一個設定都能對應回其中一條。
- Artifact 不可變:一個 commit 對應一個 image,tag 是 SHA,建好之後不再改。部署與回滾都只是「換一個 tag」。
- Build-time 與 run-time 分離:烤進 bundle 的東西(
NEXT_PUBLIC_*)在 CI 給;伺服器才需要的機密在 VPS 給。兩者不混。 - 健康檢查是閘門:容器沒 healthy、首頁打不通,就不算部署成功。成功與否由機器判定,不靠人看。
- 最小權限、短命憑證:CI 用 job 結束就失效的
GITHUB_TOKEN做所有對 GitHub 的操作;VPS 上只有一把只能登入部署帳號的 SSH key。 - 預設安全(fail-closed):任何一步失敗,結果都是「網站維持舊版」。
:latest只在成功後移動,手動docker compose up -d也拿到好的版本。
Workflow 逐項解釋
頂層設定
| 欄位 | 值 | 意義 |
|---|---|---|
name | Pipeline | Actions 頁面顯示的名稱 |
on.push.branches | [main, dev] | 推這兩個分支才觸發。其他分支不跑,省額度 |
on.pull_request.branches | [main, dev] | 目標是這兩個分支的 PR 觸發。PR 只跑 test + build |
on.workflow_dispatch.inputs.image_tag | string,預設空 | 手動觸發時的輸入欄。填了就是回滾模式 |
permissions | contents: read、packages: write | 收窄 GITHUB_TOKEN:能讀 repo、能推 GHCR,其他都不行 |
env.IMAGE_NAME | ghcr.io/<owner>/<repo> | 全 workflow 共用的常數。GHCR 要求全小寫 |
env.NODE_VERSION | 20 | 與 Dockerfile 的 node:20-alpine 對齊 |
concurrency.group | pipeline-${{ github.ref }} | 同一分支同時只跑一個 run |
concurrency.cancel-in-progress | github.ref != 'refs/heads/main' | 非 main 的舊 run 被新 push 取消;main 不取消,排隊 |
Job 層級
| 欄位 | 意義 |
|---|---|
needs | 依賴。build needs test,deploy needs [test, build]。上游失敗,下游預設被 skip |
if | 這個 job 要不要跑。上游 skip 時,needs.x.result 是 skipped,不是 success,寫條件時要分清楚 |
runs-on: ubuntu-latest | GitHub 提供的臨時 VM,跑完銷毀 |
environment.name: production | 標記這是正式環境部署。Actions 頁會記錄歷史,之後可加 required reviewers 變人工 approve |
environment.url | 部署完成後顯示的連結 |
outputs | 給下游 job 讀的字串,例如 deploy 算出的 tag 給 promote |
job 層 env | 該 job 所有 step 都看得到的變數。test job 用它把 NEXT_PUBLIC_* 給 next lint |
job 層 concurrency | deploy 另外掛 production-deploy、cancel-in-progress: false,確保部署不會被砍 |
deploy 的 if 條件
Loading...
!cancelled():預設的success()會在任何上游被 skip 時讓 job 不跑,但「dispatch 帶 tag」本來就要跳過 test/build,所以改用這個。- 第一條路:build 成功,而且事件是 push main 或手動觸發。PR 的 build 也會成功,但事件不對,不部署。
- 第二條路:手動、有填 tag、test 和 build 都是被自己的
if跳過。三個條件缺一不可,否則「test 失敗導致 build 被 skip」也會被誤判為回滾模式。這是實際踩到的坑,後面會講。
Step 與 action 參數
| Step / action | 參數 | 意義 |
|---|---|---|
actions/checkout@v4 | — | 把 repo 拉進 runner。build 需要完整 context |
actions/setup-node@v4 | node-version、cache: npm | 裝 Node;cache: npm 依 package-lock.json hash 快取 ~/.npm,npm ci 變快 |
run: npm i -g [email protected] | — | runner 內建 npm 10,與本機 / Dockerfile 的 npm 11 對 lock 檔的判定不同 |
docker/setup-buildx-action@v3 | — | 啟用 BuildKit builder,才有 cache-from/to 與多平台 |
docker/login-action@v3 | registry、username: github.actor、password: GITHUB_TOKEN | 登入 GHCR。只在要 push 的 run 執行 |
docker/build-push-action@v6 | context: . | build context 是 repo 根目錄 |
platforms: linux/amd64 | VPS 是 x86。runner 也是 x86 所以不用 QEMU | |
push | 只有 push main 為 true;PR 只 build 驗證 | |
tags | IMAGE_NAME:sha-xxxxxxx | |
build-args | 對應 Dockerfile 的 ARG,值來自 vars.* | |
cache-from: type=gha / cache-to: type=gha,mode=max | layer cache 存在 Actions cache。mode=max 連中間 stage 也存 | |
appleboy/ssh-action@v1 | host、port、username、key | SSH 連線資訊,全部來自 Secrets |
envs | 白名單:哪些 runner 環境變數要 export 進遠端 shell | |
script | 在 VPS 上執行的 shell 內容 | |
docker buildx imagetools create | --tag :latest :sha-xxx | 只改 registry 上的 manifest tag,不下載也不重建 |
用到的表達式與 context
| 寫法 | 意義 |
|---|---|
${{ github.sha }} / ${GITHUB_SHA::7} | 觸發的 commit。shell 裡取前七碼當 tag |
${{ github.ref }} | refs/heads/main 這種完整 ref |
${{ github.event_name }} | push、pull_request、workflow_dispatch |
${{ github.actor }} | 觸發的人,拿來當 GHCR 登入帳號 |
${{ github.repository }} | owner/repo,遠端 fetch 用 |
${{ inputs.image_tag }} | dispatch 輸入;非 dispatch 事件時是空字串 |
${{ secrets.X }} / ${{ vars.X }} | 機密 / 明文設定 |
${{ needs.build.result }} | success / failure / skipped / cancelled |
${{ steps.tag.outputs.tag }} | 同 job 內某 step 用 echo "tag=..." >> "$GITHUB_OUTPUT" 寫出的值 |
secrets.VPS_PORT || 22 | 表達式的 ||:空字串視為 false,退回 22 |
Docker 與 Compose 設定的意義
Dockerfile
Loading...
- 三個 stage:deps 層只在
package*.json變動時重建;builder 每次跑;runner 只 COPY 產物,不含 devDependencies 與原始碼。 ARG而不是ENV:ARG只存在於 build 期,而且沒傳時是「未設定」。若寫成ENV X=${X},沒傳會變成空字串,把.env.production的值蓋掉。USER nextjs:非 root 執行。
docker-compose.yml 的 nextjs
| 欄位 | 意義 |
|---|---|
image: ghcr.io/…:${IMAGE_TAG:-latest} | 從 registry 拉。${VAR:-default} 是 compose 的插值語法,沒給就 latest |
build: . | 保留本機 docker compose up --build 的路徑。同時有 image 與 build 時,pull 拉 image,--build 才會本機建 |
env_file: ${ENV_FILE:-.env.production} | run-time 變數來源,不進 image |
healthcheck.test | 容器內執行的指令,exit 0 = 健康 |
healthcheck.interval: 30s | 每 30 秒測一次 |
healthcheck.timeout: 10s | 單次超過 10 秒算失敗 |
healthcheck.retries: 3 | 連續 3 次失敗才標 unhealthy |
healthcheck.start_period: 20s | 啟動後 20 秒內的失敗不計,給 Next.js 暖機 |
restart: always | daemon 重啟或容器掛掉自動拉起 |
docker compose up -d --wait --wait-timeout 180:-d 背景、--wait 等到所有服務 running 或 healthy 才返回、--wait-timeout 最多等 180 秒,超時回非零 exit。這就是把 healthcheck 變成部署閘門的關鍵。
.dockerignore
COPY . . 會把整個 context 送進 builder。排除 certbot/(憑證私鑰)、.git、.next、tests、docs、.github 之後,context 小、build 快,私鑰也不會留在 layer 裡。
遠端 script 逐行
| 指令 | 意義 |
|---|---|
set -eu | 任一指令失敗立即退出;用到未定義變數也退出。不用 pipefail,因為遠端可能是 dash |
trap '… docker logout …' EXIT | 不論怎麼結束都登出 GHCR,token 不留在 VPS |
git fetch "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPO}.git" "+main:refs/remotes/origin/main" | 用 job token 走 HTTPS 抓 main,寫進 origin/main 這個追蹤 ref。VPS 不需要 GitHub SSH key |
git checkout -q main | 確保在 main 上 |
git merge --ff-only origin/main | 只允許快轉。VPS 上有人手動改過就會失敗,寧可停也不要產生 merge commit |
printf '%s' "$GH_TOKEN" | docker login … --password-stdin | 從 stdin 讀密碼,不出現在 process list |
rollback() { … } | 定義在會失敗的步驟之前。內容:印 log、拉 :latest、重啟、reload nginx、exit 1 |
IMAGE_TAG=… docker compose pull nextjs | 只拉 nextjs 的新 image。拉失敗直接退出,此時什麼都還沒改 |
IMAGE_TAG=… docker compose up -d --wait … || rollback | 重建容器並等 healthy;超時就回滾 |
docker compose exec -T nginx nginx -s reload | nginx 啟動時就把 nextjs 解析成 IP,容器重建後可能變,reload 重新解析。-T 是不配 tty |
curl --fail --retry 6 --retry-delay 5 --retry-all-errors "$SITE_URL/" || rollback | 從外部打首頁,最多重試 6 次、每次隔 5 秒。任何錯誤都重試 |
docker image prune -f | 清掉沒被任何容器用的舊 image,磁碟不會被 sha tag 塞滿 |
要注意的點
1. NEXT_PUBLIC_* 是 build 期烤進去的
這是 Next.js 專案改 CI/CD 最容易踩的坑。NEXT_PUBLIC_ 開頭的環境變數會在 next build 時被寫死進 client bundle。以前在 VPS 上 build,Next.js 讀的是 VPS 上的 .env.production;搬到 CI 之後,那個檔案不在 git 裡,CI 根本看不到它。
解法是 Dockerfile 的 builder stage 宣告 ARG,由 CI 透過 build-args 傳入:
Loading...
ARG 沒有給預設值時,在 RUN 裡是「未設定」而不是空字串,所以本機直接 docker compose up --build 的行為和以前一樣,Next.js 照舊讀 context 裡的 .env.production。
延伸的注意事項:CI 那邊的值必須跟 VPS 上 .env.production 的值一致。Server Component 在 runtime 讀的是 VPS 上的 env,client bundle 用的是 CI 烤進去的值,兩邊不一樣會出現很難追的 hydration 差異。
2. 分清楚 build-time 與 run-time 的變數
| 種類 | 例子 | 要放在 |
|---|---|---|
| build-time | NEXT_PUBLIC_* | CI 的 build-args |
| run-time(server 端) | R2 access key、admin 開關 | VPS 上的 .env.production,由 compose 的 env_file 注入 |
Secret 永遠不該進 build-args。它會留在 image 的 build history 裡,任何能 pull 這個 image 的人都看得到。
3. Secrets 與 Variables 分開放
GitHub 提供兩種倉庫層級的設定:
- Secrets:加密儲存,log 裡會自動打碼。放 SSH 私鑰、主機位址、伺服器上的路徑。
- Variables:明文儲存,適合本來就不是機密的設定。
NEXT_PUBLIC_*反正會被烤進公開的 client bundle,放這裡就好。
分開放的好處是 Variables 能在 workflow log 裡看見,debug 方便;而 Secrets 就算不小心 echo 出來也會是 ***。
4. SSH 用專用帳號,不用 root
在 VPS 上建一個只做部署的使用者,加進 docker 群組,authorized_keys 只放 CI 用的那把 key。repo 目錄的擁有者也設成這個使用者,不然 git merge 會因為權限失敗。防火牆若允許,SSH 只開給 GitHub Actions 的 IP 範圍,或改走 Tailscale / Cloudflare Tunnel。
5. 部署要排隊,不能被取消
Loading...
連續 push 兩次 main 時,第二次要等第一次部署完。若允許取消,很可能第一次 docker compose pull 拉到一半被砍掉,留下一個半套的狀態。反過來,PR 的 CI 就適合 cancel-in-progress: true,新 push 直接把舊的 run 作廢省資源。
6. .dockerignore 不只是縮小 context
Dockerfile 裡的 COPY . . 會把 build context 全部複製進 builder stage。以前在 VPS 上 build,certbot/ 目錄裡的憑證私鑰就這樣被 COPY 進了 image layer。雖然最終的 runner stage 沒有它,但 builder layer 還留在 VPS 的 Docker cache 裡。
把 certbot、.git、__tests__、cypress、docs、.github 全部列進 .dockerignore,既縮小 context、加快上傳,也堵住這個洞。
7. 手動觸發的輸入要當成不可信
workflow_dispatch 的 inputs.image_tag 會被塞進遠端 shell script。雖然只有 repo 協作者能觸發,還是先用白名單 regex 檢查一次:
Loading...
8. Docker Compose 版本
docker compose up --wait --wait-timeout 需要 Compose v2.17 以上。VPS 上先跑 docker compose version 確認。
9. 不是零停機
docker compose up -d 重建 nextjs 容器時,舊容器停止到新容器 healthy 之間會有幾秒鐘 502。個人 blog 可以接受;真要零停機得做 blue-green,也就是同時起兩個容器再切 nginx upstream。這是之後的題目。
能傳入的參數種類
pipeline 裡的「參數」其實來自很多層,每一層的生命週期、可見範圍、安全等級都不同。整理成一張表:
| 種類 | 定義位置 | 讀取方式 | 適合放什麼 | 備註 |
|---|---|---|---|---|
| Secrets | repo Settings → Secrets | ${{ secrets.NAME }} | SSH 私鑰、主機、路徑 | log 自動打碼;fork 的 PR 拿不到 |
| Variables | repo Settings → Variables | ${{ vars.NAME }} | 非機密設定,如 NEXT_PUBLIC_* | 明文,log 看得到 |
| Environment 層級的 Secrets / Vars | repo Settings → Environments | 同上,但只在宣告 environment: 的 job 內生效 | 區分 staging / production 的值 | 可設定人工 approve、限制分支 |
GITHUB_TOKEN | 自動產生 | ${{ secrets.GITHUB_TOKEN }} | 登入 GHCR、呼叫 GitHub API | 每個 job 一把,job 結束即失效;權限由 permissions: 控制 |
workflow_dispatch inputs | workflow 檔的 on.workflow_dispatch.inputs | ${{ inputs.NAME }} | 手動觸發時的參數,如要回滾的 tag | 使用者輸入,要驗證 |
env: | workflow / job / step 層級 | ${{ env.NAME }} 或 shell 的 $NAME | 常數,如 image 名稱、Node 版本 | 純設定,不是機密 |
| Contexts | GitHub 自動提供 | ${{ github.sha }}、${{ github.ref }}、${{ github.actor }} | 算 tag、判斷分支、登入帳號 | 唯讀 |
| Job outputs | job 的 outputs: + step 的 $GITHUB_OUTPUT | ${{ needs.job.outputs.NAME }} | job 之間傳值,如 deploy 算出的 tag 給 promote 用 | 字串 |
Docker build-args | docker/build-push-action 的 build-args: | Dockerfile 的 ARG | build 期需要的公開設定 | 會留在 image history,不放 secret |
| Docker build secrets | docker/build-push-action 的 secrets: | Dockerfile 的 RUN --mount=type=secret | build 期需要的機密,如私有套件 token | 不會留在 layer |
| Runtime env | VPS 上的 .env.production | compose 的 env_file: / environment: | server 端執行時需要的機密與設定 | 不進 git、不進 image |
| Compose 變數插值 | 執行 compose 的 shell 環境或 .env | ${IMAGE_TAG:-latest} | 切換 image tag、port、domain | 有預設值語法 |
ssh-action 的 envs: | step 的 env: + envs: 白名單 | 遠端 shell 的 $NAME | 把 CI 的變數送進 VPS script | 沒列在 envs: 的不會傳過去 |
用一句話記:機密放 Secrets 或 VPS runtime env;公開設定放 Variables;build 期要的用 build-args;人工參數走 inputs;job 之間用 outputs。
實戰踩坑紀錄
上面的設定不是一次寫對的。第一次 push 到 main 之後紅了三次才綠,每一次都值得記下來。
1. npm ci 說 lock 檔不同步
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @swc/[email protected] from lock file
本機 npm ci 明明沒事。差別在 npm 版本:runner 的 Node 20 內建 npm 10,本機和 Dockerfile 用 npm 11。lock 裡 next-intl 底下有一份巢狀的 @swc/core,宣告了 optional peer @swc/helpers >=0.5.17;npm 11 認為 optional 不必滿足,npm 10 認為缺少。lock 沒錯、程式沒錯,是工具版本不一致。
修法:test job 在 npm ci 前先 npm i -g [email protected],與 Dockerfile 同版。教訓是 CI 的工具鏈要和 build image 對齊,否則同一份 lock 會有兩種解讀。
2. Host key verification failed
第二次 run,SSH 進 VPS 成功了,第一個指令就掛:
==> sync repo
Host key verification failed.
fatal: Could not read from remote repository.
VPS 上的 origin 是 [email protected]: 的 SSH remote,以前用 root 操作,root 有 GitHub key 和 known_hosts。新的 deploy 帳號兩樣都沒有。
可以幫 deploy 產一把 key 加成 repo 的 deploy key,但那又是一把長期存在 VPS 上的憑證。改成用 job 的 GITHUB_TOKEN 走 HTTPS:
Loading...
+main:refs/remotes/origin/main 這個 refspec 把抓到的 main 寫進 origin/main,後面的 merge --ff-only origin/main 一行都不用改。VPS 上的 origin remote 保留給人手動用。
3. test 失敗,deploy 卻跑了
第三次 run 更詭異:lint 紅燈,Deploy to VPS 居然執行,然後在 docker compose pull 報 manifest unknown。因為 image 根本沒 build。
原因在 deploy 的 if。原本寫 needs.build.result == 'success' || needs.build.result == 'skipped',想涵蓋「手動帶 tag 時 build 被跳過」。但 test 失敗時 build 也是 skipped,條件一樣成立。
修法是把「回滾模式」寫成完整的三個條件:事件是 dispatch、有填 tag、test 與 build 都 skipped。教訓:skipped 不等於 success,任何用到 needs.*.result 的條件都要把「上游失敗導致的 skip」考慮進去。幸好 script 在改動任何東西之前就因 pull 失敗退出,VPS 沒事。
4. next lint 在 next.config.js 炸掉
Invalid next.config.js options detected:
"images.remotePatterns[0].hostname" is missing, expected string
next.config.js 的 remotePatterns 直接用 process.env.NEXT_PUBLIC_R2_PUBLIC_URL。本機有 .env 檔所以永遠有值;CI 沒有,next lint 載入 config 時驗證失敗。順帶一提,這也代表任何人 fresh clone 之後不建 .env 就跑不了 lint。
修法:加 fallback || 'media.example.com',與程式碼裡其他地方的寫法一致;同時 test job 也帶入 NEXT_PUBLIC_* Variables。教訓:CI 沒有 .env 檔,config 與 build 期讀 env 的地方都要有 fallback 或明確傳值。
5. VPS 少了一個變數
對照 GitHub Variables 時發現 VPS 的 .env.production 沒有 NEXT_PUBLIC_SITE_URL。網站沒壞,因為程式碼有 fallback。但這正是「client bundle 用 CI 的值、server 用 VPS 的值」會分岔的地方。補上,兩邊一致。
6. 目錄搬家與 compose project name
repo 原本在 /root 底下,deploy 帳號進不去。搬到 /home/deploy/<repo> 時要注意:Compose 用目錄名當 project name,目錄名不變就還是同一組容器;搬完跑一次 docker compose up -d 讓 bind mount 指到新路徑。
7. 小事
- 文章沒 cover 時列表頁會破圖:cover 是必填。
docs/在.gitignore裡但部分檔案已被追蹤,新檔要git add -f。gh auth login是互動式,塞進非互動 session 會卡住。
三次紅燈之後的第四次 run 全綠。之後每次 push 都是幾分鐘內自動上線。
結語
改完之後的日常只剩一件事:git push。幾分鐘後 Actions 頁面綠燈,網站就是新版;紅燈的話網站還是舊版,什麼都不用做。
還沒做、之後想補的:
- staging 環境:用 GitHub Environments 分出一套 staging,main 先進 staging,approve 後才進 production。
- E2E 進 pipeline:Cypress 現在只在本機跑,可以在 build 後起一個容器跑一輪。
- 零停機:blue-green 或至少讓 nginx 用
resolver動態解析 upstream。 - 通知:部署成功 / 失敗發訊息到 Discord 或 Slack。
流程一旦寫成程式碼,這些都只是往 YAML 裡再加一個 job 的事。