From c1cdd2f01b0e4900a4e02c45037f088d68f2a1a3 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:14:00 +0200 Subject: [PATCH 1/6] feat(github-ci): scoped Scaleway IAM key for GitHub Actions New `github-ci/` Terraform root that stands up the Scaleway identity GitHub Actions uses to authenticate to Scaleway: - `scaleway_iam_application` + `scaleway_iam_policy` (`ObjectStorageReadOnly`, project-scoped) + `scaleway_iam_api_key` - key written into Infisical (`staging` env, `/ci` folder); secret half is sensitive, state-only - `workflow_dispatch` smoke test that authenticates with SCW_ACCESS_KEY/SCW_SECRET_KEY and lists fr-par buckets, failing clearly when the secrets are absent Keyless GitHub-OIDC -> Scaleway is a non-goal: blocked upstream (Scaleway IAM is not an OIDC relying party). Static least-privilege key is Scaleway's supported pattern; mitigated with a dedicated, independently-revocable identity. Wires the new root into `mise run lock`, the terraform-lock CI matrix, convenience mise tasks, and CLAUDE.md. Closes #21 --- .github/workflows/scaleway-auth-check.yml | 35 +++++++ .github/workflows/terraform-lock.yml | 1 + CLAUDE.md | 5 + github-ci/.terraform.lock.hcl | 48 ++++++++++ github-ci/README.md | 112 ++++++++++++++++++++++ github-ci/main.tf | 51 ++++++++++ github-ci/outputs.tf | 11 +++ github-ci/variables.tf | 36 +++++++ github-ci/version.tf | 44 +++++++++ mise.toml | 18 ++++ 10 files changed, 361 insertions(+) create mode 100644 .github/workflows/scaleway-auth-check.yml create mode 100644 github-ci/.terraform.lock.hcl create mode 100644 github-ci/README.md create mode 100644 github-ci/main.tf create mode 100644 github-ci/outputs.tf create mode 100644 github-ci/variables.tf create mode 100644 github-ci/version.tf diff --git a/.github/workflows/scaleway-auth-check.yml b/.github/workflows/scaleway-auth-check.yml new file mode 100644 index 0000000..42ceb07 --- /dev/null +++ b/.github/workflows/scaleway-auth-check.yml @@ -0,0 +1,35 @@ +name: Scaleway Auth Check + +# Validates the github-ci/ Scaleway IAM key end to end: authenticate with the +# scoped API key from GitHub secrets and list the fr-par Object Storage buckets. +# Manual trigger only — this is a smoke test, not part of any pipeline yet. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + list-buckets: + runs-on: ubuntu-latest + steps: + # Fail fast and clearly if the secrets aren't set, instead of letting the + # CLI emit a confusing auth error (or worse, appear to pass). + - name: Assert credentials are present + env: + SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} + SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} + run: | + if [ -z "$SCW_ACCESS_KEY" ] || [ -z "$SCW_SECRET_KEY" ]; then + echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set. Run 'gh secret set' (see github-ci/README.md)." + exit 1 + fi + + - name: List fr-par Object Storage buckets + uses: scaleway/action-scw@2e34a1eb35cf3cac627f24643a101fea269cbd83 # v0.0.3 + with: + version: v2.41.0 + access-key: ${{ secrets.SCW_ACCESS_KEY }} + secret-key: ${{ secrets.SCW_SECRET_KEY }} + args: object bucket list region=fr-par diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index 451462f..2d7d55e 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -26,6 +26,7 @@ jobs: - state-backend - cluster/local - cluster/scaleway + - github-ci steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/CLAUDE.md b/CLAUDE.md index 38bda78..1345c76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ The `.terraform.lock.hcl` in each root must cover **both** `darwin_arm64` (local terraform -chdir=state-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 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 ``` Commit the updated lock files alongside the version change. @@ -64,6 +65,10 @@ Terraform here is only a **one-time bootstrapper** — everything after ArgoCD i Same bootstrap pattern as `local/`, plus the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) in one consolidated module. Writes the kubeconfig to `~/.kube/scaleway-homelab.yaml`. Scaleway credentials are read from the `scw` CLI config (`~/.config/scw/config.yaml`), not from tfvars. Still early — intentionally undocumented in the commands above for now. +### `github-ci/` + +Standalone root (not under `cluster/` — provisions no cluster) that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `github-ci/README.md`. + ## Conventions **Branches**: `/` — lowercase, hyphens only. Types: `feature/`, `bugfix/`, `hotfix/`, `ci/`, `chore/`. diff --git a/github-ci/.terraform.lock.hcl b/github-ci/.terraform.lock.hcl new file mode 100644 index 0000000..964fdb1 --- /dev/null +++ b/github-ci/.terraform.lock.hcl @@ -0,0 +1,48 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/infisical/infisical" { + version = "0.16.28" + constraints = "~> 0.16" + hashes = [ + "h1:3u5WxYFLl+DUSqoma4DEY/DYbN7fMt6yTDcrkpFQz5Q=", + "h1:BvcG6jgReLptymYOXetIEpaZBLA2rsbexEl7THzENM0=", + "zh:09d25451a3ebbb1e9ba5a73f29f6c9dfd2f890c3966ec66af401969164b42a67", + "zh:2e1eac9f42920336694baaa83e1e0ae252d4fded21d3c4ce874831c8ca9575b4", + "zh:306b370bdfb18ffb0d819c613fb2bc3377037a9be47a70ecdd6cc2e83bdeff14", + "zh:63d6291c6a81fe9d1469ff86d4dc2b6e8fbf93e255d0f3d58f98cfdc6817fc98", + "zh:66f01a8234b079cb3e9e80eefa2ee2d107191632d03db84fa5da42ad8e925261", + "zh:75794f043a2320a67706fe545f488d4cbaaccae844622b2a80967198f39bf226", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:a66be07dd80c836b7bd44cc1818b35156665b50011209711ce264e4320c6ef9d", + "zh:ae895218e7616f439cc03ce98d1d9179741df89abb72520d70773fd427adf320", + "zh:bbb50efbfb6d1dd29013329fd38ab72e5b7b4694b840aae84e550f1991cee1a1", + "zh:c25cfe7ecf55201bd2433dc5d4dfe3bf608c75f3c22a3ec3f146636fef75a9dd", + "zh:d54fc418ff11ffd11fce7b02c77735686ee4efcb2222c9f62dd85d5a275cda4b", + "zh:e38e93b6f72204f37475b692c3c9878b62ebdd5ee2eabac90b9368f6651c3f88", + "zh:ed9a4625835728b0d6d8fa82be0cf6d71224654666223e0e74f49471ba12e90e", + "zh:f85e74fae43fb35182c99b2a1cc57cf40aa831218d359fe48f66e66b89ec5f9b", + ] +} + +provider "registry.terraform.io/scaleway/scaleway" { + version = "2.76.0" + constraints = "~> 2.0" + hashes = [ + "h1:3RImo3Jf88dXcIqmBRuO/A85jAxrAQsvYa4zhVqW1tI=", + "h1:ktRogBsJlCf3JTOTYPm9hKaPUzaps0aLwxsNfP155ns=", + "zh:13b6790dca2c91c7478d6e9cd03d84713e0b0ec001c9923d3c93bd12a1f4152f", + "zh:219582ef77ec6f27d2928684b95603b5a921001631f9eec68c14819fdbc1efea", + "zh:26bc09c7aadc49fe83a9d6af9c1d0951f93de129f90d04e5e65e1d82c8ac1914", + "zh:4015a970be3669ee009344afb269a09eaf9fe1363a12a10d3311326ba769463a", + "zh:4c1c5997ef4182e49e46ff6563581a90b5cfa30f0ec8d7d919b3dc0603ed3043", + "zh:58d91e08a8fe38ddda2c03013d1ba77ff4e21a91fef0a425575b5af7bf7f4ebc", + "zh:644a61f963483c8eba7f8427650c4956e1d8c59710a11bb2691ad8996ee14a9c", + "zh:6745d5d80006c375b104a005cfc007f90e2cb547cf9e96c886d453be3307a399", + "zh:8dcac392ca182af40b823c6721833de1325c279b1e5bdcddf1a1cf2789f3558a", + "zh:9eb284d3e9a14d4d64e2a7a865be45ec0eee32ec16c51bcc1722c0449bfb9fab", + "zh:a4eb4973cc1d9ac4911733d62f5b0e53fbc7b90112efa312918c0320966e276e", + "zh:ce58ae8014b2981bbc875bc7ad4cdeb93957370bfa4308eeb193155ee988fbec", + "zh:d3f0f3bec0a6f4759948749cdb0eb64e4415507a82c79659f1ede894f96f4976", + ] +} diff --git a/github-ci/README.md b/github-ci/README.md new file mode 100644 index 0000000..d3a368b --- /dev/null +++ b/github-ci/README.md @@ -0,0 +1,112 @@ +# github-ci + +A standalone Terraform root that provisions the **Scaleway identity GitHub +Actions uses to authenticate to Scaleway**. First real consumer: a smoke-test +workflow that lists Object Storage buckets; the Terraform CI/CD pipeline itself +is a separate, later concern. + +This is **not** under `cluster/` — it provisions no cluster. It's a CI-platform +concern, kept as its own root so its state and blast radius stay small. + +## Why a static key and not OIDC + +The ideal flow would be **keyless GitHub-OIDC → Scaleway** (GitHub mints a +short-lived OIDC token, Scaleway trades it for temporary credentials, no +long-lived secret). **This is not possible today**: Scaleway IAM is not an OIDC +relying party — the IAM API exposes only API keys, SSH keys, SAML SSO, SCIM and +an internal user-session JWT. The feature request for it is still open: + +- https://feature-request.scaleway.com/posts/761/oidc-provider-for-external-ci-cd + +So we use Scaleway's supported pattern — a dedicated, least-privilege **API +key** — and mitigate the long-lived-secret risk with: + +- **Least privilege** — `ObjectStorageReadOnly`, scoped to a single project. +- **A dedicated, independently-revocable identity** — its own IAM application, so + it can be rotated/revoked without touching anything else. + +Revisit OIDC if/when Scaleway ships it (see the link above). + +## What it creates + +- `scaleway_iam_application.github_ci` — the CI identity. +- `scaleway_iam_policy.github_ci` — `permission_set_names = ["ObjectStorageReadOnly"]`, + scoped to `var.project_id` (and **no** broader set). +- `scaleway_iam_api_key.github_ci` — the API key for that application, with + `default_project_id` baked in so `scw object bucket list` resolves the right + scope without the workflow passing a project ID. +- `infisical_secret.scw_access_key` / `infisical_secret.scw_secret_key` — the key + written into Infisical (env `staging`, folder `/ci` by default). The secret half + is Terraform-`sensitive`; it's never printed or committed (state-only, per the + repo's bootstrap model). + +## Credentials + +Same as the other roots: + +- **Scaleway** provider reads creds + default region/project from the **scw CLI + config** (`~/.config/scw/config.yaml`). +- **Infisical** provider authenticates via a **universal-auth machine identity**; + its `client_id` / `client_secret` come from `*.auto.tfvars` (per-developer, + gitignored — see `nico.auto.tfvars`). +- The **S3 state backend** authenticates with AWS-style env vars derived from the + scw config; `mise.toml`'s `[env]` block injects them automatically under mise. + +## Apply + +```bash +mise run github-ci-plan # terraform init && plan — review first +mise run github-ci-apply # terraform apply (billable: creates an IAM key) +``` + +> Never `terraform apply`/`destroy` here without explicit approval. + +After apply, the access key is an output and both halves are in Infisical. + +## Wiring the GitHub secrets (manual) + +Automating the Infisical → GitHub push is deferred (it'd mean adding a GitHub +token to this bootstrap). For now, set the two repo secrets by hand. Read the +values straight out of the Terraform state/output and Infisical — **don't paste +them into your shell history or echo them**: + +```bash +# SCW_ACCESS_KEY is a public identifier, exposed as a Terraform output: +gh secret set SCW_ACCESS_KEY \ + --repo IntegratedDynamic/infrastructure \ + --body "$(terraform -chdir=github-ci output -raw access_key)" + +# SCW_SECRET_KEY is sensitive — pipe it from the API key resource without printing: +gh secret set SCW_SECRET_KEY \ + --repo IntegratedDynamic/infrastructure \ + --body "$(terraform -chdir=github-ci state show -no-color scaleway_iam_api_key.github_ci \ + | awk '/secret_key/ {print $3; exit}' | tr -d '\"')" +``` + +(Or copy the secret from Infisical → `staging` → `/ci` → `SCW_SECRET_KEY` and +`gh secret set SCW_SECRET_KEY --repo IntegratedDynamic/infrastructure` reading +from stdin.) + +## Verify end to end + +Trigger the smoke-test workflow and confirm it lists the `fr-par` buckets: + +```bash +gh workflow run "Scaleway Auth Check" --repo IntegratedDynamic/infrastructure +gh run watch --repo IntegratedDynamic/infrastructure +``` + +The workflow (`.github/workflows/scaleway-auth-check.yml`) fails clearly if the +secrets are missing, so a green run means real authentication succeeded. + +## Rotation / revocation + +The API key lives entirely in this root's state. To rotate: + +```bash +terraform -chdir=github-ci apply -replace=scaleway_iam_api_key.github_ci +``` + +then re-run the `gh secret set` steps above. To kill access entirely, destroy +the application (revokes the key) — but mind that any workflow depending on it +will start failing. diff --git a/github-ci/main.tf b/github-ci/main.tf new file mode 100644 index 0000000..5d98654 --- /dev/null +++ b/github-ci/main.tf @@ -0,0 +1,51 @@ +# Dedicated, least-privilege identity for GitHub Actions to authenticate to +# Scaleway. The keyless GitHub-OIDC -> Scaleway flow isn't possible yet (Scaleway +# IAM is not an OIDC relying party — see README), so we use Scaleway's supported +# pattern: a scoped, independently-revocable API key consumed from GH secrets. + +resource "scaleway_iam_application" "github_ci" { + name = "github-ci" + description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." +} + +# Least privilege: read-only Object Storage, scoped to a single project. The +# future Terraform-CI identity gets its own broader policy (out of scope here). +resource "scaleway_iam_policy" "github_ci" { + name = "github-ci-object-storage-ro" + description = "Read-only Object Storage for the GitHub Actions CI application, project-scoped." + application_id = scaleway_iam_application.github_ci.id + + rule { + project_ids = [var.project_id] + permission_set_names = ["ObjectStorageReadOnly"] + } +} + +resource "scaleway_iam_api_key" "github_ci" { + application_id = scaleway_iam_application.github_ci.id + description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." + + # Bakes the project into the key so `scw object bucket list` resolves the right + # scope without the workflow passing a project ID. + default_project_id = var.project_id +} + +# ── Write the key into Infisical ──────────────────────────────────────────── +# GitHub secrets themselves are still set manually via `gh secret set` (see +# README) — automating that push is deferred to avoid a GitHub token here. + +resource "infisical_secret" "scw_access_key" { + name = "SCW_ACCESS_KEY" + value = scaleway_iam_api_key.github_ci.access_key + env_slug = var.infisical_env_slug + workspace_id = var.infisical_workspace_id + folder_path = var.infisical_folder_path +} + +resource "infisical_secret" "scw_secret_key" { + name = "SCW_SECRET_KEY" + value = scaleway_iam_api_key.github_ci.secret_key + env_slug = var.infisical_env_slug + workspace_id = var.infisical_workspace_id + folder_path = var.infisical_folder_path +} diff --git a/github-ci/outputs.tf b/github-ci/outputs.tf new file mode 100644 index 0000000..45e42cf --- /dev/null +++ b/github-ci/outputs.tf @@ -0,0 +1,11 @@ +output "application_id" { + description = "IAM application ID backing the GitHub Actions CI identity." + value = scaleway_iam_application.github_ci.id +} + +# The access key is a public identifier (like an AWS access key ID), so it's safe +# to surface. The secret half is never output — read it from Infisical or state. +output "access_key" { + description = "SCW_ACCESS_KEY for the CI identity (public identifier)." + value = scaleway_iam_api_key.github_ci.access_key +} diff --git a/github-ci/variables.tf b/github-ci/variables.tf new file mode 100644 index 0000000..5be98f1 --- /dev/null +++ b/github-ci/variables.tf @@ -0,0 +1,36 @@ +# Infisical universal-auth machine identity (per-developer, from nico.auto.tfvars). +# Used to write the generated API key back into Infisical. +variable "infisical_client_id" { + type = string +} + +variable "infisical_client_secret" { + type = string + sensitive = true +} + +variable "infisical_workspace_id" { + description = "Infisical project (workspace) ID the CI secrets are written to." + type = string + default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" +} + +variable "infisical_env_slug" { + description = "Infisical environment slug the CI secrets live in." + type = string + default = "staging" +} + +variable "infisical_folder_path" { + description = "Infisical folder the CI secrets are written to (kept separate from the cluster bootstrap secrets)." + type = string + default = "/ci" +} + +# The default project shares the organization's UUID on Scaleway. The buckets the +# CI identity must list live here, so we scope the policy and the API key to it. +variable "project_id" { + description = "Scaleway project the CI identity is scoped to (Object Storage buckets it may list)." + type = string + default = "6283c05b-a4c7-4f83-a75f-83adad236d54" +} diff --git a/github-ci/version.tf b/github-ci/version.tf new file mode 100644 index 0000000..cac725d --- /dev/null +++ b/github-ci/version.tf @@ -0,0 +1,44 @@ +terraform { + # Remote state in the org-wide bucket (state-backend/). Creds: see mise.toml. + backend "s3" { + bucket = "id-terraform-state" + key = "github-ci/terraform.tfstate" + region = "fr-par" + + # Root-specific prefix so this root's workspaces don't mix with others'. + workspace_key_prefix = "github-ci" + + endpoints = { s3 = "https://s3.fr-par.scw.cloud" } + + # Disable the backend's AWS-only preflight checks (IMDS, STS account-id, + # region allowlist): Scaleway speaks the S3 API but isn't AWS itself. + skip_credentials_validation = true + skip_region_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_s3_checksum = true + } + + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + infisical = { + source = "infisical/infisical" + version = "~> 0.16" + } + } +} + +# Creds, region and project_id come from the scw CLI config (like the other roots). +provider "scaleway" {} + +provider "infisical" { + auth = { + universal = { + client_id = var.infisical_client_id + client_secret = var.infisical_client_secret + } + } +} diff --git a/mise.toml b/mise.toml index 899eafd..590e56e 100644 --- a/mise.toml +++ b/mise.toml @@ -95,6 +95,23 @@ description = "Tear down the Scaleway cluster (terraform destroy)" dir = "cluster/scaleway" run = "terraform destroy" +# ── GitHub CI identity (github-ci/) ───────────────────────────────────────── + +[tasks.github-ci-init] +description = "terraform init on the github-ci root" +dir = "github-ci" +run = "terraform init" + +[tasks.github-ci-plan] +description = "terraform plan on the github-ci root" +dir = "github-ci" +run = "terraform init && terraform plan" + +[tasks.github-ci-apply] +description = "terraform apply on the github-ci root (provisions the Scaleway CI IAM key)" +dir = "github-ci" +run = "terraform apply" + # ── Provider locks ─────────────────────────────────────────────────────────── [tasks.lock] @@ -103,4 +120,5 @@ run = """ terraform -chdir=state-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 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 """ From 9b8fdef6d86afddd10e80add225a9402c4f88e75 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:19:42 +0200 Subject: [PATCH 2/6] ci(github-ci): trigger auth check on PRs + scope to scaleway environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a `pull_request` trigger (paths: github-ci/**, the workflow file) so the Scaleway auth check can be validated before merge — workflow_dispatch can't be triggered from a branch, only once on the default branch. - Run the job in a dedicated `scaleway` GitHub Environment, scoping secret usage (resolves zizmor's secrets-outside-env finding). - Document both triggers + the validation order in the README. --- .github/workflows/scaleway-auth-check.yml | 15 ++++++++++++- github-ci/README.md | 26 +++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/scaleway-auth-check.yml b/.github/workflows/scaleway-auth-check.yml index 42ceb07..3459fd4 100644 --- a/.github/workflows/scaleway-auth-check.yml +++ b/.github/workflows/scaleway-auth-check.yml @@ -2,10 +2,19 @@ name: Scaleway Auth Check # Validates the github-ci/ Scaleway IAM key end to end: authenticate with the # scoped API key from GitHub secrets and list the fr-par Object Storage buckets. -# Manual trigger only — this is a smoke test, not part of any pipeline yet. +# This is a smoke test, not part of any pipeline yet. +# +# Runs on PRs that touch the CI identity (workflow_dispatch can't be triggered +# from a branch — it only runs once the workflow is on the default branch — so a +# pull_request trigger is what actually lets us validate before merge), plus +# manual dispatch once it's on main. on: workflow_dispatch: + pull_request: + paths: + - '.github/workflows/scaleway-auth-check.yml' + - 'github-ci/**' permissions: contents: read @@ -13,6 +22,10 @@ permissions: jobs: list-buckets: runs-on: ubuntu-latest + # Reference a dedicated environment so secret usage is scoped to it + # (satisfies zizmor's secrets-outside-env audit, and gives a place to attach + # protection rules later). Auto-created on first run with no protection. + environment: scaleway steps: # Fail fast and clearly if the secrets aren't set, instead of letting the # CLI emit a confusing auth error (or worse, appear to pass). diff --git a/github-ci/README.md b/github-ci/README.md index d3a368b..173b58b 100644 --- a/github-ci/README.md +++ b/github-ci/README.md @@ -89,15 +89,23 @@ from stdin.) ## Verify end to end -Trigger the smoke-test workflow and confirm it lists the `fr-par` buckets: - -```bash -gh workflow run "Scaleway Auth Check" --repo IntegratedDynamic/infrastructure -gh run watch --repo IntegratedDynamic/infrastructure -``` - -The workflow (`.github/workflows/scaleway-auth-check.yml`) fails clearly if the -secrets are missing, so a green run means real authentication succeeded. +The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs the +`scw object bucket list region=fr-par` against the key. It triggers two ways: + +- **On any PR** that touches `github-ci/**` or the workflow itself — so you can + validate before merge (`workflow_dispatch` can't be triggered from a branch). +- **Manual dispatch**, once the workflow is on `main`: + + ```bash + gh workflow run "Scaleway Auth Check" --repo IntegratedDynamic/infrastructure + gh run watch --repo IntegratedDynamic/infrastructure + ``` + +So the validation order is: `apply` → `gh secret set` (above) → push the branch / +re-run the PR check. The job runs in the `scaleway` GitHub Environment (so its +secret usage is scoped — secrets can be set at repo or environment level; the +repo-level commands above work either way). It fails clearly if the secrets are +missing, so a green run means real authentication succeeded. ## Rotation / revocation From 4103af0cdc9dbcdb447a166cd9cee683f0167bb5 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:25:43 +0200 Subject: [PATCH 3/6] fix(github-ci): set required API key expiry via time_rotating The org enforces an expiration date on every Scaleway API key, so a key with no expires_at fails at create ("expiration date required"). Drive expires_at from a time_rotating resource (default 365d, var.api_key_rotation_days): the timestamp holds steady until the window lapses, then the next apply rotates the key (expires_at is ForceNew). Adds the hashicorp/time provider (lock re-generated for both platforms) and documents the rotation behaviour in the README. --- github-ci/.terraform.lock.hcl | 22 ++++++++++++++++++++++ github-ci/README.md | 27 +++++++++++++++++++-------- github-ci/main.tf | 11 +++++++++++ github-ci/variables.tf | 8 ++++++++ github-ci/version.tf | 4 ++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/github-ci/.terraform.lock.hcl b/github-ci/.terraform.lock.hcl index 964fdb1..2effc6a 100644 --- a/github-ci/.terraform.lock.hcl +++ b/github-ci/.terraform.lock.hcl @@ -1,6 +1,28 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.0" + constraints = "~> 0.12" + hashes = [ + "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", + "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", + "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", + "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", + "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", + "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", + "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", + "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", + "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", + "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", + "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", + "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", + "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", + ] +} + provider "registry.terraform.io/infisical/infisical" { version = "0.16.28" constraints = "~> 0.16" diff --git a/github-ci/README.md b/github-ci/README.md index 173b58b..39d406a 100644 --- a/github-ci/README.md +++ b/github-ci/README.md @@ -34,7 +34,10 @@ Revisit OIDC if/when Scaleway ships it (see the link above). scoped to `var.project_id` (and **no** broader set). - `scaleway_iam_api_key.github_ci` — the API key for that application, with `default_project_id` baked in so `scw object bucket list` resolves the right - scope without the workflow passing a project ID. + scope without the workflow passing a project ID. The org enforces an expiry on + every key, so `time_rotating.api_key` drives `expires_at` (default 365 days, + `var.api_key_rotation_days`) and rotates the key on the next apply after it + lapses — see [Rotation / revocation](#rotation--revocation). - `infisical_secret.scw_access_key` / `infisical_secret.scw_secret_key` — the key written into Infisical (env `staging`, folder `/ci` by default). The secret half is Terraform-`sensitive`; it's never printed or committed (state-only, per the @@ -109,12 +112,20 @@ missing, so a green run means real authentication succeeded. ## Rotation / revocation -The API key lives entirely in this root's state. To rotate: +The API key lives entirely in this root's state. -```bash -terraform -chdir=github-ci apply -replace=scaleway_iam_api_key.github_ci -``` +- **Automatic** — `time_rotating.api_key` expires the key after + `var.api_key_rotation_days` (default 365). Once that window lapses, the next + `terraform apply` rolls the expiry forward, which (since `expires_at` is + ForceNew) creates fresh key material. +- **On demand** — force it early with: + + ```bash + terraform -chdir=github-ci apply -replace=scaleway_iam_api_key.github_ci + ``` + +Either way the key material changes, so **re-run the `gh secret set` steps above** +afterwards (the Infisical copies update automatically; the GitHub secrets don't). -then re-run the `gh secret set` steps above. To kill access entirely, destroy -the application (revokes the key) — but mind that any workflow depending on it -will start failing. +To kill access entirely, destroy the application (revokes the key) — but mind +that any workflow depending on it will start failing. diff --git a/github-ci/main.tf b/github-ci/main.tf index 5d98654..3947ec6 100644 --- a/github-ci/main.tf +++ b/github-ci/main.tf @@ -21,6 +21,15 @@ resource "scaleway_iam_policy" "github_ci" { } } +# The org enforces an expiry on every API key, and `expires_at` is ForceNew, so +# the key inherently rotates when the expiry moves. time_rotating makes that +# concrete and self-renewing: the timestamp holds steady until the window +# elapses, then the next apply pushes it forward and rotates the key (re-run +# `gh secret set` afterwards — see README). +resource "time_rotating" "api_key" { + rotation_days = var.api_key_rotation_days +} + resource "scaleway_iam_api_key" "github_ci" { application_id = scaleway_iam_application.github_ci.id description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." @@ -28,6 +37,8 @@ resource "scaleway_iam_api_key" "github_ci" { # Bakes the project into the key so `scw object bucket list` resolves the right # scope without the workflow passing a project ID. default_project_id = var.project_id + + expires_at = time_rotating.api_key.rotation_rfc3339 } # ── Write the key into Infisical ──────────────────────────────────────────── diff --git a/github-ci/variables.tf b/github-ci/variables.tf index 5be98f1..5a3c3b5 100644 --- a/github-ci/variables.tf +++ b/github-ci/variables.tf @@ -34,3 +34,11 @@ variable "project_id" { type = string default = "6283c05b-a4c7-4f83-a75f-83adad236d54" } + +# Scaleway's org policy requires every API key to carry an expiry. This drives +# the key's expires_at; once the window elapses, the next apply rotates the key. +variable "api_key_rotation_days" { + description = "Lifetime (days) of the CI API key before terraform rotates it on the next apply." + type = number + default = 365 +} diff --git a/github-ci/version.tf b/github-ci/version.tf index cac725d..994ed72 100644 --- a/github-ci/version.tf +++ b/github-ci/version.tf @@ -28,6 +28,10 @@ terraform { source = "infisical/infisical" version = "~> 0.16" } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } } } From 3237d83f9a3c41a3262ba4ccd9fa02b9b22670a3 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:27:21 +0200 Subject: [PATCH 4/6] fix(github-ci): create the Infisical CI folder before writing secrets infisical_secret doesn't create missing folders, so writing to /ci 404'd ("Folder with path '/ci' ... not found"). Add an infisical_secret_folder resource for the CI folder and point the secrets at its computed path so the folder is created first. --- github-ci/main.tf | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/github-ci/main.tf b/github-ci/main.tf index 3947ec6..58584d8 100644 --- a/github-ci/main.tf +++ b/github-ci/main.tf @@ -45,12 +45,22 @@ resource "scaleway_iam_api_key" "github_ci" { # GitHub secrets themselves are still set manually via `gh secret set` (see # README) — automating that push is deferred to avoid a GitHub token here. +# infisical_secret does not create missing folders, so the CI folder must exist +# first. var.infisical_folder_path is "/"; create that name under root. +resource "infisical_secret_folder" "ci" { + project_id = var.infisical_workspace_id + environment_slug = var.infisical_env_slug + folder_path = "/" + name = trimprefix(var.infisical_folder_path, "/") + description = "CI secrets for GitHub Actions (managed by terraform: github-ci/)." +} + resource "infisical_secret" "scw_access_key" { name = "SCW_ACCESS_KEY" value = scaleway_iam_api_key.github_ci.access_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id - folder_path = var.infisical_folder_path + folder_path = infisical_secret_folder.ci.path } resource "infisical_secret" "scw_secret_key" { @@ -58,5 +68,5 @@ resource "infisical_secret" "scw_secret_key" { value = scaleway_iam_api_key.github_ci.secret_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id - folder_path = var.infisical_folder_path + folder_path = infisical_secret_folder.ci.path } From d047c793ee982d933a8abbaf31903c89793c1d1f Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:33:26 +0200 Subject: [PATCH 5/6] fix(github-ci): pass org/project ID to the scw auth check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scw object bucket list needs a default organization ID even for a project-scoped key ("organization ID is required"). Pass it (and the project ID) as action inputs — public identifiers, not secrets (same UUID already committed in github-ci/variables.tf). --- .github/workflows/scaleway-auth-check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/scaleway-auth-check.yml b/.github/workflows/scaleway-auth-check.yml index 3459fd4..40260e6 100644 --- a/.github/workflows/scaleway-auth-check.yml +++ b/.github/workflows/scaleway-auth-check.yml @@ -45,4 +45,8 @@ jobs: version: v2.41.0 access-key: ${{ secrets.SCW_ACCESS_KEY }} secret-key: ${{ secrets.SCW_SECRET_KEY }} + # Public identifiers, not credentials (the scw CLI wants an org ID even + # for project-scoped calls). Same UUID as github-ci/variables.tf. + default-organization-id: 6283c05b-a4c7-4f83-a75f-83adad236d54 + default-project-id: 6283c05b-a4c7-4f83-a75f-83adad236d54 args: object bucket list region=fr-par From 4a6ba34c82ced7925540a6c8ede3fda97f59e9f4 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 11 Jun 2026 14:38:24 +0200 Subject: [PATCH 6/6] ci(github-ci): drop workflow_dispatch + move org/project IDs to repo vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trigger the auth check on pull_request only (workflow_dispatch was un-triggerable from a branch anyway). - Read default-organization-id / default-project-id from repo variables (vars.SCW_DEFAULT_ORGANIZATION_ID / SCW_DEFAULT_PROJECT_ID) instead of hardcoding the UUID — public identifiers, not secrets. - Document the two repo variables in the README. --- .github/workflows/scaleway-auth-check.yml | 14 +++++--------- github-ci/README.md | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.github/workflows/scaleway-auth-check.yml b/.github/workflows/scaleway-auth-check.yml index 40260e6..b6b0d1e 100644 --- a/.github/workflows/scaleway-auth-check.yml +++ b/.github/workflows/scaleway-auth-check.yml @@ -4,13 +4,9 @@ name: Scaleway Auth Check # scoped API key from GitHub secrets and list the fr-par Object Storage buckets. # This is a smoke test, not part of any pipeline yet. # -# Runs on PRs that touch the CI identity (workflow_dispatch can't be triggered -# from a branch — it only runs once the workflow is on the default branch — so a -# pull_request trigger is what actually lets us validate before merge), plus -# manual dispatch once it's on main. +# Runs on PRs that touch the CI identity so it can be validated before merge. on: - workflow_dispatch: pull_request: paths: - '.github/workflows/scaleway-auth-check.yml' @@ -45,8 +41,8 @@ jobs: version: v2.41.0 access-key: ${{ secrets.SCW_ACCESS_KEY }} secret-key: ${{ secrets.SCW_SECRET_KEY }} - # Public identifiers, not credentials (the scw CLI wants an org ID even - # for project-scoped calls). Same UUID as github-ci/variables.tf. - default-organization-id: 6283c05b-a4c7-4f83-a75f-83adad236d54 - default-project-id: 6283c05b-a4c7-4f83-a75f-83adad236d54 + # Public identifiers (not credentials) the scw CLI wants even for a + # project-scoped key. Repo variables, not secrets. + default-organization-id: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} + default-project-id: ${{ vars.SCW_DEFAULT_PROJECT_ID }} args: object bucket list region=fr-par diff --git a/github-ci/README.md b/github-ci/README.md index 39d406a..770288a 100644 --- a/github-ci/README.md +++ b/github-ci/README.md @@ -92,17 +92,18 @@ from stdin.) ## Verify end to end -The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs the -`scw object bucket list region=fr-par` against the key. It triggers two ways: +The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs +`scw object bucket list region=fr-par` against the key. It triggers on any PR +that touches `github-ci/**` or the workflow itself, so you can validate before +merge. -- **On any PR** that touches `github-ci/**` or the workflow itself — so you can - validate before merge (`workflow_dispatch` can't be triggered from a branch). -- **Manual dispatch**, once the workflow is on `main`: +It needs two **repo variables** (public identifiers, not secrets — the scw CLI +wants them even for a project-scoped key). Set once: - ```bash - gh workflow run "Scaleway Auth Check" --repo IntegratedDynamic/infrastructure - gh run watch --repo IntegratedDynamic/infrastructure - ``` +```bash +gh variable set SCW_DEFAULT_ORGANIZATION_ID --repo IntegratedDynamic/infrastructure --body "" +gh variable set SCW_DEFAULT_PROJECT_ID --repo IntegratedDynamic/infrastructure --body "" +``` So the validation order is: `apply` → `gh secret set` (above) → push the branch / re-run the PR check. The job runs in the `scaleway` GitHub Environment (so its