From d9756fae94ad37a934e34c9318dd542cd9c8e4ef Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 13 Jun 2026 16:44:59 +0200 Subject: [PATCH 1/4] feat(s3-lister-role): org-wide S3-lister role created by CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First IAM role created BY the CI (aws-github-oidc role) rather than by a human, demonstrating the privilege-escalation-safe role-creation flow end to end. - s3-lister-role/ root (terraform-aws-modules/iam): a read-only S3 lister (AmazonS3ReadOnlyAccess) assumable by ANY principal in the AWS Organization via an aws:PrincipalOrgID trust condition (no account IDs hardcoded). - Honours the repo-wide contract: role sits under the managed path and carries the tf-managed permissions boundary, so the CI grant permits its creation. role_path is fed in CI from ${{ github.repository }}. - .github/workflows/s3-lister-role.yml: Terraform GitOps — `plan` on PR, `apply` on push to main, assuming the CI role via OIDC (no static AWS keys). - Wire the new root into the lock-check matrix, mise `lock` task, and CLAUDE.md (also adding aws-github-oidc, which was missing from the docs/lock list). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/s3-lister-role.yml | 57 ++++++++++++++++++++++++++++ .github/workflows/terraform-lock.yml | 1 + CLAUDE.md | 4 +- mise.toml | 1 + s3-lister-role/.terraform.lock.hcl | 27 +++++++++++++ s3-lister-role/main.tf | 49 ++++++++++++++++++++++++ s3-lister-role/outputs.tf | 4 ++ s3-lister-role/variables.tf | 40 +++++++++++++++++++ s3-lister-role/version.tf | 24 ++++++++++++ 9 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/s3-lister-role.yml create mode 100644 s3-lister-role/.terraform.lock.hcl create mode 100644 s3-lister-role/main.tf create mode 100644 s3-lister-role/outputs.tf create mode 100644 s3-lister-role/variables.tf create mode 100644 s3-lister-role/version.tf diff --git a/.github/workflows/s3-lister-role.yml b/.github/workflows/s3-lister-role.yml new file mode 100644 index 0000000..904f996 --- /dev/null +++ b/.github/workflows/s3-lister-role.yml @@ -0,0 +1,57 @@ +name: Apply s3-lister-role + +# Terraform GitOps for the s3-lister-role/ root: the role is created/synchronised +# from the remote. On a PR we `plan` (proving the CI role can read state + plan); +# on a push to main we `apply` it for real. Credentials come from the +# aws-github-oidc CI role assumed via OIDC — no static AWS keys. + +on: + push: + branches: [main] + paths: + - 's3-lister-role/**' + - '.github/workflows/s3-lister-role.yml' + pull_request: + paths: + - 's3-lister-role/**' + - '.github/workflows/s3-lister-role.yml' + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + permissions: + id-token: write # mint the OIDC token exchanged for temporary AWS creds + contents: read + env: + # Fed straight from the repo slug -> /tf-managed/IntegratedDynamic/infrastructure/. + # This is the path the CI grant requires every managed role to sit under. + TF_VAR_role_path: /tf-managed/${{ github.repository }}/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + # Keep in sync with mise.toml [tools] terraform version. + terraform_version: "1.14" + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: eu-west-3 + + - name: Terraform init + run: terraform -chdir=s3-lister-role init -input=false + + - name: Terraform plan + run: terraform -chdir=s3-lister-role plan -input=false + + # Only mutate AWS on a merge to main, never from a PR. + - name: Terraform apply + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: terraform -chdir=s3-lister-role apply -auto-approve -input=false diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index 8dcb1d0..435069e 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -28,6 +28,7 @@ jobs: - cluster/scaleway - github-ci - aws-github-oidc + - s3-lister-role steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/CLAUDE.md b/CLAUDE.md index 09396a5..7993b46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ mise run dev # Full local env: start minikube + terraform init + app mise run reset # Destroy minikube cluster # Provider lock files -mise run lock # Re-generate all 4 roots' .terraform.lock.hcl for darwin_arm64 + linux_amd64 +mise run lock # Re-generate every root's .terraform.lock.hcl for darwin_arm64 + linux_amd64 # Linting actionlint .github/workflows/*.yml # Lint GitHub Actions workflows (also runs as pre-push hook) @@ -38,6 +38,8 @@ terraform -chdir=terraform-state-bucket providers lock -platform=darwin_arm64 -p terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 ``` Commit the updated lock files alongside the version change. diff --git a/mise.toml b/mise.toml index e154621..766904e 100644 --- a/mise.toml +++ b/mise.toml @@ -130,4 +130,5 @@ terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -p terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/s3-lister-role/.terraform.lock.hcl b/s3-lister-role/.terraform.lock.hcl new file mode 100644 index 0000000..a6f94df --- /dev/null +++ b/s3-lister-role/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.50.0" + constraints = "~> 6.0, >= 6.28.0" + hashes = [ + "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", + "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", + "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", + "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", + "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", + "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", + "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", + "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", + "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", + "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", + "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", + "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", + "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", + "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", + "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", + "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + ] +} diff --git a/s3-lister-role/main.tf b/s3-lister-role/main.tf new file mode 100644 index 0000000..c6ab29c --- /dev/null +++ b/s3-lister-role/main.tf @@ -0,0 +1,49 @@ +# An org-wide, read-only S3-lister IAM role — the first role created BY the CI +# rather than by a human. It is created/updated when a push to main touches this +# root (see .github/workflows/s3-lister-role.yml): GitHub Actions assumes the +# aws-github-oidc CI role and runs `terraform apply`. +# +# Two things are mandatory for the CI role to be ALLOWED to create this (see +# aws-github-oidc/iam-ci.tf): the role must sit under the managed `path` and +# carry the `permissions_boundary`. Omit either and the apply is denied. +# +# Trust: assumable by ANY principal in our AWS Organization. We don't list +# account IDs — instead the trust condition pins aws:PrincipalOrgID, so every +# (and only) caller whose credentials belong to org `var.org_id` may assume it. +module "s3_lister" { + source = "terraform-aws-modules/iam/aws//modules/iam-role" + version = "6.6.1" + + name = var.role_name + path = var.role_path + description = "Org-wide read-only S3 lister. Created by CI; capped by the tf-managed permissions boundary." + + permissions_boundary = var.permissions_boundary_arn + + trust_policy_permissions = { + OrgWideAssume = { + actions = ["sts:AssumeRole"] + principals = [{ + type = "AWS" + identifiers = ["*"] + }] + condition = [{ + test = "StringEquals" + variable = "aws:PrincipalOrgID" + values = [var.org_id] + }] + } + } + + # Same access the original GitHub Action had: list/read S3. The boundary clamps + # it anyway, but S3 read is well within the ceiling. + policies = { + S3ReadOnly = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + } + + tags = { + Terraform = "true" + Environment = "dev" + ManagedBy = "ci" + } +} diff --git a/s3-lister-role/outputs.tf b/s3-lister-role/outputs.tf new file mode 100644 index 0000000..8bb9d54 --- /dev/null +++ b/s3-lister-role/outputs.tf @@ -0,0 +1,4 @@ +output "role_arn" { + description = "ARN of the org-wide S3-lister role. Anyone in the org assumes it with `aws sts assume-role`." + value = module.s3_lister.arn +} diff --git a/s3-lister-role/variables.tf b/s3-lister-role/variables.tf new file mode 100644 index 0000000..67ec5c6 --- /dev/null +++ b/s3-lister-role/variables.tf @@ -0,0 +1,40 @@ +variable "region" { + description = "AWS region the provider operates in. Matches the state bucket region." + type = string + default = "eu-west-3" +} + +variable "role_name" { + description = "Name of the org-wide S3-lister role." + type = string + default = "s3-lister" +} + +# IAM path every CI-managed role must sit under. The CI grant (aws-github-oidc/) +# only allows role creation under this path WITH the boundary below — so both +# must be set or the apply is rejected. In CI this is fed automatically from the +# repo slug: TF_VAR_role_path=/tf-managed/${{ github.repository }}/ (see the +# workflow). The default lets `plan` work locally. IAM paths are case-sensitive. +variable "role_path" { + description = "IAM path prefix for the role (must match the CI grant's managed path)." + type = string + default = "/tf-managed/IntegratedDynamic/infrastructure/" +} + +# The permissions boundary that caps this role. Value is the +# `permissions_boundary_arn` output of the aws-github-oidc/ root. Required by the +# CI grant's conditions. +variable "permissions_boundary_arn" { + description = "ARN of the permissions boundary to attach (aws-github-oidc output)." + type = string + default = "arn:aws:iam::503577850357:policy/tf-managed-boundary" +} + +# AWS Organizations ID. Anyone whose credentials belong to this org may assume +# the role (aws:PrincipalOrgID trust condition). `aws organizations +# describe-organization --query Organization.Id`. +variable "org_id" { + description = "AWS Organizations ID allowed to assume the role (aws:PrincipalOrgID)." + type = string + default = "o-f9lb1e5es9" +} diff --git a/s3-lister-role/version.tf b/s3-lister-role/version.tf new file mode 100644 index 0000000..7cd85b6 --- /dev/null +++ b/s3-lister-role/version.tf @@ -0,0 +1,24 @@ +terraform { + # Remote state on the shared org S3 bucket, under this root's own key prefix. + # In CI the GitHub OIDC role (aws-github-oidc/) provides the credentials; it is + # granted R/W on this bucket, so `init`/`plan`/`apply` work without static keys. + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "s3-lister-role" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.region +} From 72813a30f26d2fce3f1b1071fac57eecb9b64cd4 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 13 Jun 2026 16:53:50 +0200 Subject: [PATCH 2/4] feat(ci): workspace-driven terraform action; fix s3-lister state collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The s3-lister-role plan ran in the `default` workspace in CI, where the S3 backend ignores workspace_key_prefix — so its key resolved to `terraform.tfstate` and collided with terraform-state-bucket's state (the plan wanted to destroy the bucket's versioning!). The `infrastructure` workspace only existed locally via a gitignored .terraform/environment, invisible to CI. Make workspace selection declarative and version-controlled: - .github/actions/terraform: a reusable composite action that, for a root, loops over workspace/.tfvars files — filename = terraform workspace name, contents = that workspace's variables — running `workspace select -or-create` + plan/apply -var-file per workspace. - s3-lister-role/workspace/infrastructure.tfvars: declares the `infrastructure` workspace and its vars (region, role_name, role_path, permissions_boundary_arn, org_id). State now lands at s3-lister-role/infrastructure/terraform.tfstate. - s3-lister-role.yml: use the composite action instead of bare init/plan/apply; drop the TF_VAR_role_path env (now in the tfvars). - Document the workspaces convention in CLAUDE.md. Verified locally: with the workspace selected, `plan` is `2 to add, 0 to change, 0 to destroy` (only the role) — no more cross-root state collision. Co-Authored-By: Claude Opus 4.8 --- .github/actions/terraform/action.yml | 54 +++++++++++++++++++ .github/workflows/s3-lister-role.yml | 20 +++---- CLAUDE.md | 4 +- .../workspace/infrastructure.tfvars | 17 ++++++ 4 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 .github/actions/terraform/action.yml create mode 100644 s3-lister-role/workspace/infrastructure.tfvars diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml new file mode 100644 index 0000000..d973e79 --- /dev/null +++ b/.github/actions/terraform/action.yml @@ -0,0 +1,54 @@ +name: Terraform (workspace-driven) +description: > + Run terraform for a root, deriving the workspace(s) AND their variables from + the root's workspace/ folder. Each workspace/.tfvars file means: the + terraform workspace is , and that file holds 's variable values. + One file -> one workspace; loop over all of them. This keeps workspace + selection declarative and version-controlled instead of relying on a local, + gitignored .terraform/environment. + +inputs: + root: + description: Path to the terraform root to run (passed to -chdir). + required: true + apply: + description: 'When "true", apply; otherwise plan only.' + required: false + default: "false" + +runs: + using: composite + steps: + - shell: bash + env: + ROOT: ${{ inputs.root }} + APPLY: ${{ inputs.apply }} + run: | + set -euo pipefail + + # Discover the workspaces declared for this root. + shopt -s nullglob + files=("$ROOT"/workspace/*.tfvars) + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::no workspace/*.tfvars found under '$ROOT' — nothing to run." \ + "Create workspace/.tfvars (filename = workspace name)." + exit 1 + fi + + terraform -chdir="$ROOT" init -input=false + + for file in "${files[@]}"; do + ws="$(basename "$file" .tfvars)" + # -var-file is resolved relative to -chdir, hence "workspace/.tfvars". + var_file="workspace/${ws}.tfvars" + + echo "::group::terraform workspace '$ws' ($([ "$APPLY" = "true" ] && echo apply || echo plan))" + terraform -chdir="$ROOT" workspace select -or-create "$ws" + + if [ "$APPLY" = "true" ]; then + terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="$var_file" + else + terraform -chdir="$ROOT" plan -input=false -var-file="$var_file" + fi + echo "::endgroup::" + done diff --git a/.github/workflows/s3-lister-role.yml b/.github/workflows/s3-lister-role.yml index 904f996..2c76c1b 100644 --- a/.github/workflows/s3-lister-role.yml +++ b/.github/workflows/s3-lister-role.yml @@ -25,10 +25,6 @@ jobs: permissions: id-token: write # mint the OIDC token exchanged for temporary AWS creds contents: read - env: - # Fed straight from the repo slug -> /tf-managed/IntegratedDynamic/infrastructure/. - # This is the path the CI grant requires every managed role to sit under. - TF_VAR_role_path: /tf-managed/${{ github.repository }}/ steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -45,13 +41,9 @@ jobs: role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} aws-region: eu-west-3 - - name: Terraform init - run: terraform -chdir=s3-lister-role init -input=false - - - name: Terraform plan - run: terraform -chdir=s3-lister-role plan -input=false - - # Only mutate AWS on a merge to main, never from a PR. - - name: Terraform apply - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: terraform -chdir=s3-lister-role apply -auto-approve -input=false + # Workspace + variables are derived from s3-lister-role/workspace/*.tfvars. + # plan on PRs; apply only on a merge to main. + - uses: ./.github/actions/terraform + with: + root: s3-lister-role + apply: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} diff --git a/CLAUDE.md b/CLAUDE.md index 7993b46..faf3896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Standalone root (not under `cluster/` — provisions no cluster) that stands up Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI access, built on the `terraform-aws-modules/iam` modules: an OIDC provider + a role (`github-actions-terraform`) GitHub Actions assumes via short-lived tokens (trust scoped to `repo:IntegratedDynamic/infrastructure:*`). The role grant (`tf-managed-ci`) gives Terraform-state R/W on the state bucket **plus privilege-escalation-safe IAM role management**. Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `aws-github-oidc/README.md`. -**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. In CI, feed the path via `TF_VAR_role_path=/tf-managed/${{ github.repository }}/`. The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. +**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. Set both via the root's `workspace/.tfvars` (see the Terraform workspaces convention below). The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. ## Conventions @@ -94,3 +94,5 @@ Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI a **Commits**: Conventional Commits — `[scope]: `. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`. **PRs**: After each commit + push on a branch, create a draft PR if none exists. Title: `: description`. Body: context, changes, linked issues (`Closes #123`), test instructions. Use [Conventional Comments](https://conventionalcomments.org/) in reviews (`praise`, `nitpick`, `suggestion`, `issue`, `todo`, `question`, `thought`). + +**Terraform workspaces**: a root that runs through CI declares its workspaces declaratively in a `workspace/` folder — one `workspace/.tfvars` per workspace. The **filename is the terraform workspace name** (so state lands at `//`, isolated per root) and the **file contents are that workspace's variable values**. The reusable composite action `.github/actions/terraform` loops over these files, doing `workspace select -or-create ` + `plan`/`apply -var-file=workspace/.tfvars`. This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). `s3-lister-role/` is the reference example. diff --git a/s3-lister-role/workspace/infrastructure.tfvars b/s3-lister-role/workspace/infrastructure.tfvars new file mode 100644 index 0000000..ac060f8 --- /dev/null +++ b/s3-lister-role/workspace/infrastructure.tfvars @@ -0,0 +1,17 @@ +# Workspace "infrastructure". +# +# The FILENAME names the terraform workspace (so state lands at +# s3-lister-role/infrastructure/terraform.tfstate, isolated from other roots). +# The CONTENTS are this workspace's variable values. Both are consumed by +# .github/actions/terraform — see that action and CLAUDE.md. + +region = "eu-west-3" +role_name = "s3-lister" + +# Repo-scoped path + boundary required by the CI grant (aws-github-oidc/). Must +# match the grant's pins exactly or the apply is denied. +role_path = "/tf-managed/IntegratedDynamic/infrastructure/" +permissions_boundary_arn = "arn:aws:iam::503577850357:policy/tf-managed-boundary" + +# Anyone in this AWS Organization may assume the role. +org_id = "o-f9lb1e5es9" From 1db1ad6dc8b3448fd6c927b062d39c4b5d247114 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 13 Jun 2026 21:39:41 +0200 Subject: [PATCH 3/4] refactor(ci): simplify workspace-driven terraform action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the multi-workspace loop, nullglob array, group logging and verbose guard — one workspace/.tfvars per root is the real case. Derive the workspace from the single file and plan/apply with its var-file. Co-Authored-By: Claude Opus 4.8 --- .github/actions/terraform/action.yml | 47 ++++++++-------------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml index d973e79..4087eaf 100644 --- a/.github/actions/terraform/action.yml +++ b/.github/actions/terraform/action.yml @@ -1,18 +1,16 @@ name: Terraform (workspace-driven) description: > - Run terraform for a root, deriving the workspace(s) AND their variables from - the root's workspace/ folder. Each workspace/.tfvars file means: the - terraform workspace is , and that file holds 's variable values. - One file -> one workspace; loop over all of them. This keeps workspace - selection declarative and version-controlled instead of relying on a local, - gitignored .terraform/environment. + Run terraform for a root. The workspace and its variables come from the root's + workspace/.tfvars: the filename is the terraform workspace, the contents + are its variables. Keeps workspace selection declarative and version-controlled + (not a local, gitignored .terraform/environment). inputs: root: - description: Path to the terraform root to run (passed to -chdir). + description: Path to the terraform root (passed to -chdir). required: true apply: - description: 'When "true", apply; otherwise plan only.' + description: 'When "true", apply; otherwise plan.' required: false default: "false" @@ -25,30 +23,11 @@ runs: APPLY: ${{ inputs.apply }} run: | set -euo pipefail - - # Discover the workspaces declared for this root. - shopt -s nullglob - files=("$ROOT"/workspace/*.tfvars) - if [ "${#files[@]}" -eq 0 ]; then - echo "::error::no workspace/*.tfvars found under '$ROOT' — nothing to run." \ - "Create workspace/.tfvars (filename = workspace name)." - exit 1 - fi - + ws="$(basename "$ROOT"/workspace/*.tfvars .tfvars)" terraform -chdir="$ROOT" init -input=false - - for file in "${files[@]}"; do - ws="$(basename "$file" .tfvars)" - # -var-file is resolved relative to -chdir, hence "workspace/.tfvars". - var_file="workspace/${ws}.tfvars" - - echo "::group::terraform workspace '$ws' ($([ "$APPLY" = "true" ] && echo apply || echo plan))" - terraform -chdir="$ROOT" workspace select -or-create "$ws" - - if [ "$APPLY" = "true" ]; then - terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="$var_file" - else - terraform -chdir="$ROOT" plan -input=false -var-file="$var_file" - fi - echo "::endgroup::" - done + terraform -chdir="$ROOT" workspace select -or-create "$ws" + if [ "$APPLY" = "true" ]; then + terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="workspace/$ws.tfvars" + else + terraform -chdir="$ROOT" plan -input=false -var-file="workspace/$ws.tfvars" + fi From 1bdeed1decd3c1ed2108d0e76d59e9edbd298f2c Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 13 Jun 2026 21:42:58 +0200 Subject: [PATCH 4/4] ci(s3-lister-role): apply on PRs too, not just push to main The point of this PR is to watch the CI role create the role via OIDC, so run `apply` on every trigger instead of plan-on-PR / apply-on-main. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/s3-lister-role.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/s3-lister-role.yml b/.github/workflows/s3-lister-role.yml index 2c76c1b..d057c80 100644 --- a/.github/workflows/s3-lister-role.yml +++ b/.github/workflows/s3-lister-role.yml @@ -1,9 +1,9 @@ name: Apply s3-lister-role # Terraform GitOps for the s3-lister-role/ root: the role is created/synchronised -# from the remote. On a PR we `plan` (proving the CI role can read state + plan); -# on a push to main we `apply` it for real. Credentials come from the -# aws-github-oidc CI role assumed via OIDC — no static AWS keys. +# from the remote. Runs `apply` on every trigger (PRs included) so the CI role +# actually creates the role via OIDC. Credentials come from the aws-github-oidc +# CI role assumed via OIDC — no static AWS keys. on: push: @@ -42,8 +42,9 @@ jobs: aws-region: eu-west-3 # Workspace + variables are derived from s3-lister-role/workspace/*.tfvars. - # plan on PRs; apply only on a merge to main. + # apply on every run (PRs included) — the point of this PR is to watch the + # CI role actually create the role via OIDC, not just plan it. - uses: ./.github/actions/terraform with: root: s3-lister-role - apply: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + apply: "true"