
把 GitHub Actions 接到 GCP 推 Docker image, 最常見的做法是建一個 Service Account, 下載 key JSON, 存進 GitHub Secrets。這個方法能跑, 但 key 是長期憑證, 洩漏就完了。

這篇整理我用 Terraform 建出 bootstrap 層的過程: WIF 設定、Artifact Registry、Service Account 最小權限, 以及 GitHub Actions workflow 怎麼把這些串起來, 完全不需要任何 SA key。我實際使用 OpenTofu (Terraform 的開源分支), 語法與 Terraform 完全相容。

## Bootstrap 層 vs Environment 層

Terraform 結構常見的分法是把資源按「建立頻率和共用程度」分層。

```text
terraform/
├── bootstrap/          # one-time setup, shared across environments
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── backend.tf
├── environments/
│   └── dev/            # environment-specific resources
└── modules/
    └── vpc/
```

**bootstrap 放什麼**: 只建一次、很少改、整個 project 共用的資源。WIF pool 是 per-project 的, Artifact Registry 各環境共用同一個, Service Account 由 CI 統一使用。這些都適合放在 bootstrap。

**environments 放什麼**: VPC、Subnet、GKE cluster, 這些會隨 dev/prod 不同而有差異。

bootstrap 的一個特殊性: 它自己的 state 存在哪裡? 如果 GCS bucket 已經存在, 直接用同一個 bucket 不同 prefix 即可。

```hcl
# bootstrap/backend.tf
terraform {
  backend "gcs" {
    bucket = "<project-id>-tfstate"
    prefix = "bootstrap"
  }
}
```

## WIF 的信任鏈

WIF 解決的問題是: GitHub Actions 怎麼向 GCP 證明「這個請求真的來自我的 repo」?

```text
GitHub Actions executes
  -> GitHub issues a short-lived OIDC token (JWT)
     contains: iss, sub, repository, ref, actor, exp (minutes away)
          |
          v
GCP Workload Identity Pool
  -> validates: is iss from GitHub?
  -> checks attribute_condition: is repository == allowed repo?
          |
          v
impersonate Service Account
  -> SA has only artifactregistry.writer
  -> token expires in minutes, no key ever exists
```

沒有任何 key 存在任何地方。就算 token 被攔截, 幾分鐘後就過期。

## Terraform 設定

### 需要啟用的 GCP API

這是最容易忘記的部分。GCP 每個服務預設是關閉的, 要明確啟用:

```hcl
resource "google_project_service" "artifact_registry" {
  service = "artifactregistry.googleapis.com"
}

resource "google_project_service" "iam_credentials" {
  service = "iamcredentials.googleapis.com"
}
```

`iamcredentials.googleapis.com` 是 WIF impersonate SA 需要的, 不開就會在 CI 跑到一半時出現 403。`depends_on` 確保資源建立順序正確:

```hcl
resource "google_artifact_registry_repository" "go_api" {
  depends_on    = [google_project_service.artifact_registry]
  repository_id = "go-api"
  format        = "DOCKER"
  location      = var.region
}
```

`depends_on` 是 Terraform 的顯式依賴宣告。通常 Terraform 能從 resource 之間的引用自動推斷順序, 但這裡 `google_artifact_registry_repository` 並沒有直接引用 `google_project_service`, 所以需要手動告訴它「先等 API 啟用再建 repository」。

### Service Account 與最小權限

```hcl
resource "google_service_account" "github_actions" {
  account_id   = "github-actions-ci"
  display_name = "GitHub Actions CI"
}

resource "google_project_iam_member" "go_devops" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}
```

SA 只給 `roles/artifactregistry.writer`, 不給 `Editor` 或 `Owner`。就算 CI pipeline 被入侵, 最多只能推 image, 不能碰其他 GCP 資源。

### WIF Pool 與 Provider

```hcl
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-actions-pool"
  display_name              = "GitHub Actions Pool"
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-actions-oidc"
  display_name                       = "GitHub Actions OIDC"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }

  attribute_condition = "attribute.repository == '${var.github_repository}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}
```

**`attribute_mapping` 的兩側意思不同**:

- 左邊是你在 GCP 這側定義的名字 (`attribute.repository`)
- 右邊是 GitHub JWT 裡的原始 claim (`assertion.repository`)

`assertion` 就是 GitHub 發出的 JWT token 內容。`attribute_mapping` 把 JWT claims 映射成 GCP 可以在 condition 裡使用的屬性。

**`attribute_condition`** 限制只有指定的 repo 才能通過驗證。沒有這個 condition, 任何 GitHub repo 都可以用這個 WIF pool。

### SA Impersonation Binding

```hcl
resource "google_service_account_iam_member" "wif_devops" {
  service_account_id = google_service_account.github_actions.id
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repository}"
}
```

這個 binding 允許「來自指定 repo 的 WIF principal」去 impersonate 這個 SA。`principalSet` 代表一組符合條件的 principal, 不是單一個。

### Outputs

CI workflow 需要這兩個值:

```hcl
output "workload_identity_provider" {
  value = google_iam_workload_identity_pool_provider.github.name
}

output "service_account_email" {
  value = google_service_account.github_actions.email
}
```

`name` 和 `email` 是 Attributes Reference 裡的欄位, 是資源建立後 GCP 回傳的值, 不是你傳入的 argument。

## GitHub Actions Workflow

bootstrap apply 完, 把兩個 output 值存進 GitHub repo 的 Variables (不是 Secrets, 這兩個不是機密):

- `GCP_WORKLOAD_IDENTITY_PROVIDER`
- `GCP_SERVICE_ACCOUNT`

```yaml
deploy:
  needs: build
  runs-on: ubuntu-latest
  permissions:
    contents: read
    id-token: write
  env:
    GCP_REGION: us-east1
    GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
  steps:
    - uses: actions/checkout@v4
    - uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
        service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
    - uses: google-github-actions/setup-gcloud@v2
    - name: Build and push to Artifact Registry
      run: |
        gcloud auth configure-docker ${GCP_REGION}-docker.pkg.dev --quiet
        docker build -t ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }} .
        docker push ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }}
```

`id-token: write` 只開在需要 GCP 認證的 job, 不要在 workflow 頂層給。test 和 build job 不需要 GCP 認證, 就不需要這個權限。

`env` 區塊定義的變數在 `run:` 步驟裡用 `$VAR_NAME` 取用, 不是 `${{ }}` syntax。`${{ }}` 是 GitHub Actions 的 expression syntax, 用來讀 `vars.`、`secrets.`、`github.` 這些 context。

## Arguments vs Attributes

這個概念在寫 Terraform 時很重要:

- **Arguments** (Argument Reference): 你傳入 resource 的值, 例如 `account_id`、`display_name`
- **Attributes** (Attributes Reference): 資源建立後可以引用的值, 包含你傳入的和 GCP 自動產生的

`email` 不在 `google_service_account` 的 arguments 裡, 你不需要寫它。但 apply 完之後它就存在, 可以用 `google_service_account.github_actions.email` 引用。查法: Terraform Registry 每個 resource 頁面底部的 Attributes Reference 段落。

## References

- [OpenTofu Registry - google provider](https://search.opentofu.org/provider/hashicorp/google/latest)
- [Terraform Registry - google provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs)
- [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation)
- [Workload Identity Federation with deployment pipelines](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines)
- [About security hardening with OpenID Connect](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
- [google-github-actions/auth](https://github.com/google-github-actions/auth)
- [GCP IAM Roles Reference](https://cloud.google.com/iam/docs/understanding-roles)
- [OpenTofu Documentation](https://opentofu.org/docs/)
