Day 14 的 Pull Request(PR)CI 會對 Todo.Api 與 Todo.Bff 執行 restore、test 和 build。通過檢查後,程式碼還不能直接交給 GitOps:Kubernetes 的 Deployment 需要的是可拉取的 Container Image。
image 建出來後,版本識別會立刻變成問題。若有人說 Production 執行的是 todo-api:v1.2.0,我們仍不知道目前的 container 是否正是當初檢查過的那份內容。Container registry 的 tag 可以被重新指向;v1.2.0、Git SHA tag,甚至 latest 都不是不可變的內容識別。
這篇以服務名稱作為 release tag 的前綴。建立 todo-api-v1.2.0 時,GitHub Actions 只建置並推送 Todo.Api;建立 todo-bff-v1.2.0 時,才處理 Todo.Bff。workflow 同時保留 release tag、source commit 的 SHA tag 與 image digest,讓後續的 GitOps 變更能選定一份確切內容。
同一份 image 可以有多個 tag,但 tag 和 digest 的用途不同:
| 識別方式 | 主要用途 | 內容是否可變 |
|---|---|---|
Release tag,例如 todo-api-v1.2.0 |
release note、版本溝通,並指定要發布的服務 | 可變 |
Git SHA tag,例如 a1b2c3d |
從 image 回查 source revision | 可變 |
Digest,例如 sha256:... |
部署時指定確切的 image manifest | 不可變 |
Git SHA tag 與 release tag 是追蹤索引,方便人閱讀;兩者仍可能被重新推送。部署設定應使用 image@sha256:...,因為 registry 會依內容計算 digest。就算同一個 tag 之後被覆寫,已經 merge 的設定仍會指向原本 review 過的 image。
下面以 Todo API 與 BFF 說明這條對應關係。兩個服務可以從同一個 source commit 發布,但各自使用 release tag、Git SHA tag 與 digest;建立 API 的 release tag 不會順便發布 BFF。

Todo API 與 BFF 各自有一份 multi-stage Dockerfile。build stage 使用 .NET SDK restore 和 publish;最後的 stage 改用 ASP.NET runtime image,只複製 /app/publish 的輸出。
以下是 Todo.Api Dockerfile 的關鍵部分:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY Todo.Api.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish --configuration Release --output /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Todo.Api.dll"]
Todo.Bff 使用相同的分層方式,只是 project 與 entry point 分別是 Todo.Bff.csproj 和 Todo.Bff.dll。SDK、原始碼與 NuGet 快取不會被複製到最後的 runtime stage;不過 multi-stage build 本身不會處理 secret。token、password 和其他敏感值不能透過 ARG 或 ENV 寫進 build layer。
在本機可先確認兩個 image 能建置:
docker build \
-t ghcr.io/yrw9281/it30-todo-api:local \
src/0-todo-microservices/Todo.Api
docker build \
-t ghcr.io/yrw9281/it30-todo-bff:local \
src/0-todo-microservices/Todo.Bff
這兩個 command 的 build context 是各自的專案目錄,與 Dockerfile 裡的 COPY Todo.Api.csproj .、COPY Todo.Bff.csproj . 相符。
PR workflow 維持 contents: read,不應擁有發佈權限。發佈工作由服務專屬的 release tag 觸發:todo-api-v* 只檢查並推送 API,todo-bff-v* 則只處理 BFF。這讓服務可以獨立發布和回滾,不必因為 API 的修正重建 BFF。
name: todo-image
on:
push:
tags:
- "todo-api-v*"
- "todo-bff-v*"
permissions:
contents: read
jobs:
validate-todo-api:
if: startsWith(github.ref_name, 'todo-api-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet restore src/0-todo-microservices/Todo.Api/Todo.Api.csproj
- run: dotnet test src/0-todo-microservices/Todo.Api/Todo.Api.csproj --configuration Release --no-restore
- run: dotnet build src/0-todo-microservices/Todo.Api/Todo.Api.csproj --configuration Release --no-restore
push-todo-api:
if: startsWith(github.ref_name, 'todo-api-v') && needs.validate-todo-api.result == 'success'
needs: validate-todo-api
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: image
uses: docker/build-push-action@v6
with:
context: ./src/0-todo-microservices/Todo.Api
push: true
tags: |
ghcr.io/yrw9281/it30-todo-api:${{ github.sha }}
ghcr.io/yrw9281/it30-todo-api:${{ github.ref_name }}
provenance: true
sbom: true
- run: 'echo "todo-api digest: ${{ steps.image.outputs.digest }}"'
validate-todo-bff:
if: startsWith(github.ref_name, 'todo-bff-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet restore src/0-todo-microservices/Todo.Bff/Todo.Bff.csproj
- run: dotnet test src/0-todo-microservices/Todo.Bff/Todo.Bff.csproj --configuration Release --no-restore
- run: dotnet build src/0-todo-microservices/Todo.Bff/Todo.Bff.csproj --configuration Release --no-restore
push-todo-bff:
if: startsWith(github.ref_name, 'todo-bff-v') && needs.validate-todo-bff.result == 'success'
needs: validate-todo-bff
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: image
uses: docker/build-push-action@v6
with:
context: ./src/0-todo-microservices/Todo.Bff
push: true
tags: |
ghcr.io/yrw9281/it30-todo-bff:${{ github.sha }}
ghcr.io/yrw9281/it30-todo-bff:${{ github.ref_name }}
provenance: true
sbom: true
- run: 'echo "todo-bff digest: ${{ steps.image.outputs.digest }}"'
workflow 以 github.ref_name 判斷要執行哪一對驗證與發布 job。雖然兩組步驟相似,服務各自的 job 名稱、project 路徑與 image repository 都清楚列出,review 時能直接確認一個 release tag 會影響哪個服務。GITHUB_TOKEN 是 repository workflow 的短期 token;它只在需要推送的 job 取得 packages: write,不需要改用長期 Personal Access Token(PAT)。
GITHUB_TOKEN 不需要手動建立或存進 repository secret。GitHub Actions 在每次 workflow 執行時會自動提供這個短期 token,packages: write 則決定發布 job 能否拿它推送 image。
不過,workflow 有 packages: write 不代表它一定能寫入既有的 GHCR package。若 it30-todo-api 或 it30-todo-bff 先前是由 PAT、另一個 repository 或不同 workflow 建立,package 可能沒有授權目前的 repository。此時 build 可以完成,但 push 會出現 denied: permission_denied: write_package。
遇到這個錯誤時,到 GitHub 的 Packages 開啟對應 package,依序進入 Package settings 與 Manage Actions access,加入 yrw9281/IT30.Platform.Engineering 並授予 Write 權限。若頁面提供 Repository source 或連結 repository 的選項,也應連結到同一個 repository。todo-api 和 todo-bff 是不同的 package,要分別確認權限。
repository 的 Settings → Actions → General → Workflow permissions 也不能被組織或 repository 政策限制。完成 package 授權後,直接在失敗的 Actions run 選擇重新執行失敗 job 即可;因為只有 GitHub 上的 package 權限改變,不需要建立新的 release tag。
provenance: true 與 sbom: true 會要求 Buildx 建立 provenance 和 Software Bill of Materials(SBOM)attestation。它們提供可供後續驗證的建置資訊,但不代表 image 已通過弱點掃描,也不會自行決定哪些依賴風險可以接受。
假設 workflow 為 API 產生下列對應:
source commit: a1b2c3d
release tag: todo-api-v1.2.0
image tag: ghcr.io/yrw9281/it30-todo-api:a1b2c3d
image digest: ghcr.io/yrw9281/it30-todo-api@sha256:******
Promotion 到 Staging 或 Production 時,應帶走最後一行的 digest,而不是讓環境重新解析 todo-api-v1.2.0。rollback 也只要將部署設定改回一個已知正常的 digest,不必重新建置舊版 source。
目前的 GHCR manifest 仍使用 :0.1.0 tag。它能讓本機或測試叢集拉取既有 image,還不具備以 digest promotion 的 GitOps 設定;下一篇會把 CI 產生的 digest 寫成一筆可 review 的設定變更,而不是讓 CI 直接呼叫 Kubernetes API。
若 GHCR package 設為 private,叢集必須用自己的唯讀身分拉取 image。這份憑證不是 CI 的 GITHUB_TOKEN,應限縮為目標 package 的 read:packages,並以目標 Namespace 的 image pull Secret 提供給 workload。GHCR manifest 的 README 已列出建立與引用 ghcr-pull-secret 的方式。
release workflow 推送完成後,以 tag 查看 registry 回報的 digest:
docker buildx imagetools inspect \
ghcr.io/yrw9281/it30-todo-api:todo-api-v1.2.0
驗收時要能從部署中的 digest 回查 image manifest、對應的 GitHub Actions run 和 source commit;反向也要能回答某個 source commit 是否已經產生指定 image。只靠 tag 猜測版本時,這條交付鏈仍然缺少不可變的依據。
以 todo-api-v0.1.0-test.2 為例,GitHub Actions 只執行 validate-todo-api 與 push-todo-api;BFF 的 job 會略過,確認 release tag 沒有觸發不相關的服務。

發布成功後,GitHub Packages 會列出相同的 release tag 與 source commit SHA tag。這兩個 tag 供人回查版本,部署設定仍應使用該 image 的 digest。

CI 現在能產出可追溯的 image,但它不應直接部署。下一篇會將 digest 寫入 GitOps configuration 的 Pull Request,讓 review 和 Argo CD 決定它何時進入哪個環境。