From f1a44ddaf5e141349130f40d5a96a6b6b4ace717 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 11:32:53 +0200 Subject: [PATCH 1/9] chore(infra): manage GitHub repo settings with Terraform --- .github/workflows/ci.yml | 6 -- .github/workflows/deploy-production.yml | 22 +++++++ .github/workflows/release-please.yml | 5 +- README.md | 12 ++-- infra/github/.gitignore | 4 ++ infra/github/.terraform.lock.hcl | 24 ++++++++ infra/github/README.md | 79 +++++++++++++++++++++++++ infra/github/main.tf | 65 ++++++++++++++++++++ infra/github/outputs.tf | 9 +++ infra/github/providers.tf | 4 ++ infra/github/secrets.tf | 17 ++++++ infra/github/terraform.tfvars.example | 8 +++ infra/github/variables.tf | 34 +++++++++++ infra/github/versions.tf | 10 ++++ 14 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/deploy-production.yml create mode 100644 infra/github/.gitignore create mode 100644 infra/github/.terraform.lock.hcl create mode 100644 infra/github/README.md create mode 100644 infra/github/main.tf create mode 100644 infra/github/outputs.tf create mode 100644 infra/github/providers.tf create mode 100644 infra/github/secrets.tf create mode 100644 infra/github/terraform.tfvars.example create mode 100644 infra/github/variables.tf create mode 100644 infra/github/versions.tf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a70422..ad70745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,3 @@ jobs: contents: read pull-requests: write secrets: inherit - - deploy-prod: - needs: qa - if: github.event_name == 'push' - uses: ./.github/workflows/deploy-prod.yml - secrets: inherit diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..ca1c963 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,22 @@ +name: Deploy production + +on: + release: + types: + - published + +permissions: + contents: read + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + qa: + uses: ./.github/workflows/qa.yml + + deploy-prod: + needs: qa + uses: ./.github/workflows/deploy-prod.yml + secrets: inherit diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ccba1aa..b76f526 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -16,8 +16,7 @@ jobs: steps: - uses: googleapis/release-please-action@v5 with: - # If RELEASE_PLEASE_TOKEN is set, Release PRs trigger CI like a human-made PR. - # Fallback keeps the workflow usable, but GitHub suppresses CI for PRs made with GITHUB_TOKEN. - token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + # PAT managed by infra/github. Required so Release Please PRs and Releases trigger workflows. + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json diff --git a/README.md b/README.md index a912a5e..6125446 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,14 @@ Two branches are kept as primary sources and are deliberately not merged: ## Deployment -Static build deployed by GitHub Actions to Netlify on push to `main`, served at -[timer.marcosgilf.com](https://timer.marcosgilf.com). +Pull requests deploy preview builds to Netlify. Production deploys only when a GitHub Release is +published, served at [timer.marcosgilf.com](https://timer.marcosgilf.com). ## Releases Release Please opens Release PRs from Conventional Commits. Merging a Release PR bumps -`package.json`, updates `CHANGELOG.md`, creates a `vX.Y.Z` Git tag and a GitHub Release. The app shows -that version in the bottom-right corner. +`package.json`, updates `CHANGELOG.md`, creates a `vX.Y.Z` Git tag and a GitHub Release. Publishing +that release triggers the production deploy. The app shows that version in the bottom-right corner. Use commit prefixes intentionally: @@ -85,8 +85,8 @@ Use commit prefixes intentionally: - `feat!:` or `BREAKING CHANGE:` → major version - `docs:` / `chore:` → no release unless paired with releasable changes -Set repository secret `RELEASE_PLEASE_TOKEN` if you want Release PRs to trigger CI like normal PRs; -otherwise GitHub suppresses workflows created by `GITHUB_TOKEN`. +`RELEASE_PLEASE_TOKEN` is managed by `infra/github` and is required; Release Please PRs and releases +must trigger CI/deploy like human-created ones. ## Contributing diff --git a/infra/github/.gitignore b/infra/github/.gitignore new file mode 100644 index 0000000..681ad1e --- /dev/null +++ b/infra/github/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +terraform.tfstate* +terraform.tfvars +*.tfplan diff --git a/infra/github/.terraform.lock.hcl b/infra/github/.terraform.lock.hcl new file mode 100644 index 0000000..15c56e3 --- /dev/null +++ b/infra/github/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/integrations/github" { + version = "6.13.0" + constraints = "~> 6.0" + hashes = [ + "h1:y0Sujto8gttV86innNp/LTMzq7CqsFpBs7XKH8AlMl4=", + "zh:0ab29fc21699f34345cf0bbbe44745fd1b143b7c73b410c1dc4abe05ffad0a84", + "zh:1aed10d06755d420bb3a893bf548ab2932297a9d094c04c5a8501e949ca186ed", + "zh:2a6a11c21eae408055f45b9533c07afd2e845f6d496fd1b645aec2e873012103", + "zh:5dd05dee677f6ebdbed00cbb1b9be444ab2d1062d345cbc9ec50a47cb41b8622", + "zh:6b757d034831243d67ddda869eac4368cef539848bd97511f4d68f1aa38a9c88", + "zh:947c9b5b238f0364c57a705beabd24d3eea3159a6f3a24c07e3fbb13657ffae0", + "zh:a676549a98164b61630658cbeb6c17820331ca04a049dc9b5095996a0c31ffbe", + "zh:a8a81b7fe41dd61eb6a6fa5e08a4dd9ee070e862868252a7fd4cfce30364efee", + "zh:c26a9bca4865665084e7f59b1402d7aff34ee63a418d7401a0658fa280cad4d4", + "zh:c638d8d0762e62ea188f86302954ef4c92803f2160f0a45fca0cd13974bd3725", + "zh:e739a0b7e81ca816944a18a38e679f4015edf8be7ac319815cdea865ba7727d7", + "zh:ec099487ea3de8999c84b3b791e242d728461e51fe344832b37bd8d521201c77", + "zh:f016ff9e2daab5b88185cec0795213049d105439ffd585d3309a714514ccae13", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + ] +} diff --git a/infra/github/README.md b/infra/github/README.md new file mode 100644 index 0000000..c4ad8ac --- /dev/null +++ b/infra/github/README.md @@ -0,0 +1,79 @@ +# GitHub repository IaC + +Terraform-managed settings for `marcosgilf/timer`. + +## What it manages + +- Repository settings: description, visibility, merge strategy, delete branch on merge +- Feature toggles: Issues enabled, Projects disabled, Wiki disabled +- Vulnerability alerts +- Repository ruleset for `main` +- GitHub Actions credentials: + - secret `NETLIFY_AUTH_TOKEN` + - variable `NETLIFY_SITE_ID` + - secret `RELEASE_PLEASE_TOKEN` + +## Why `RELEASE_PLEASE_TOKEN` exists + +Release Please failed with `GITHUB_TOKEN` because GitHub Actions was not allowed to create PRs. A +fine-grained PAT stored as `RELEASE_PLEASE_TOKEN` fixes two problems at once: + +1. Release Please can open Release PRs. +2. Release PRs and GitHub Releases created by Release Please trigger workflows like a human-created PR + or release. GitHub suppresses follow-up workflows when using `GITHUB_TOKEN`. + +Required permissions for that PAT, scoped to `marcosgilf/timer`: + +- Contents: Read and write +- Pull requests: Read and write +- Metadata: Read + +The Terraform `github_token` (used to apply this module) needs broader repo administration: + +- Administration: Read and write +- Secrets: Read and write +- Variables: Read and write +- Metadata: Read + +## First-time setup + +Create local `terraform.tfvars` (gitignored): + +```hcl +github_token = "github_pat_..." + +netlify_auth_token = "nfp_..." +netlify_site_id = "00000000-0000-0000-0000-000000000000" +release_please_token = "github_pat_..." +``` + +Initialize and import the existing repository and ruleset: + +```sh +terraform -chdir=infra/github init +terraform -chdir=infra/github import github_repository.this timer +terraform -chdir=infra/github import github_repository_ruleset.main timer:20583655 +``` + +Secrets and variables can be imported too, but it is usually simpler to let Terraform overwrite them +with the local `terraform.tfvars` values on first apply. + +Preview and apply: + +```sh +terraform -chdir=infra/github plan +terraform -chdir=infra/github apply +``` + +## Day to day + +```sh +terraform -chdir=infra/github fmt -recursive +terraform -chdir=infra/github validate +terraform -chdir=infra/github plan +terraform -chdir=infra/github apply +``` + +## State + +State is local and gitignored. Do not commit `terraform.tfstate`, `.terraform/` or `terraform.tfvars`. diff --git a/infra/github/main.tf b/infra/github/main.tf new file mode 100644 index 0000000..6a45b03 --- /dev/null +++ b/infra/github/main.tf @@ -0,0 +1,65 @@ +resource "github_repository" "this" { + name = var.repository_name + description = "Track tasks, workouts or run pomodoros in a Progressive Web Application" + visibility = "public" + + # PR merge strategy: squash + rebase only, no merge commits. + allow_squash_merge = true + allow_rebase_merge = true + allow_merge_commit = false + squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_message = "COMMIT_MESSAGES" + + # Cleanup + delete_branch_on_merge = true + + # Features + has_issues = true + has_projects = false + has_wiki = false +} + +resource "github_repository_vulnerability_alerts" "this" { + repository = github_repository.this.name +} + +resource "github_repository_ruleset" "main" { + name = "main" + repository = github_repository.this.name + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + deletion = true + non_fast_forward = true + required_linear_history = true + + pull_request { + allowed_merge_methods = ["squash", "rebase"] + dismiss_stale_reviews_on_push = false + require_code_owner_review = false + require_last_push_approval = false + required_approving_review_count = 0 + required_review_thread_resolution = false + } + + required_status_checks { + strict_required_status_checks_policy = true + + required_check { + context = "qa / qa" + } + + required_check { + context = "deploy-dev / deploy-dev" + } + } + } +} diff --git a/infra/github/outputs.tf b/infra/github/outputs.tf new file mode 100644 index 0000000..00b6308 --- /dev/null +++ b/infra/github/outputs.tf @@ -0,0 +1,9 @@ +output "repository_full_name" { + value = github_repository.this.full_name + description = "Full repository name" +} + +output "repository_html_url" { + value = github_repository.this.html_url + description = "Repository URL" +} diff --git a/infra/github/providers.tf b/infra/github/providers.tf new file mode 100644 index 0000000..1ed44d2 --- /dev/null +++ b/infra/github/providers.tf @@ -0,0 +1,4 @@ +provider "github" { + token = var.github_token + owner = var.github_owner +} diff --git a/infra/github/secrets.tf b/infra/github/secrets.tf new file mode 100644 index 0000000..995a071 --- /dev/null +++ b/infra/github/secrets.tf @@ -0,0 +1,17 @@ +resource "github_actions_secret" "netlify_auth_token" { + repository = github_repository.this.name + secret_name = "NETLIFY_AUTH_TOKEN" + plaintext_value = var.netlify_auth_token +} + +resource "github_actions_variable" "netlify_site_id" { + repository = github_repository.this.name + variable_name = "NETLIFY_SITE_ID" + value = var.netlify_site_id +} + +resource "github_actions_secret" "release_please_token" { + repository = github_repository.this.name + secret_name = "RELEASE_PLEASE_TOKEN" + plaintext_value = var.release_please_token +} diff --git a/infra/github/terraform.tfvars.example b/infra/github/terraform.tfvars.example new file mode 100644 index 0000000..fbc48f5 --- /dev/null +++ b/infra/github/terraform.tfvars.example @@ -0,0 +1,8 @@ +github_token = "github_pat_..." + +# Existing Netlify deploy credentials +netlify_auth_token = "nfp_..." # typo guard: rename to netlify_auth_token in your local tfvars +netlify_site_id = "00000000-0000-0000-0000-000000000000" + +# Fine-grained PAT for Release Please. Needs Contents R/W + Pull requests R/W. +release_please_token = "github_pat_..." diff --git a/infra/github/variables.tf b/infra/github/variables.tf new file mode 100644 index 0000000..7a496d8 --- /dev/null +++ b/infra/github/variables.tf @@ -0,0 +1,34 @@ +variable "github_token" { + type = string + description = "Fine-grained GitHub PAT used by Terraform to manage the repository" + sensitive = true +} + +variable "github_owner" { + type = string + description = "GitHub owner" + default = "marcosgilf" +} + +variable "repository_name" { + type = string + description = "GitHub repository name" + default = "timer" +} + +variable "netlify_auth_token" { + type = string + description = "Netlify PAT stored as NETLIFY_AUTH_TOKEN Actions secret" + sensitive = true +} + +variable "netlify_site_id" { + type = string + description = "Netlify site API ID stored as NETLIFY_SITE_ID Actions variable" +} + +variable "release_please_token" { + type = string + description = "GitHub PAT stored as RELEASE_PLEASE_TOKEN Actions secret, so Release Please PRs trigger CI" + sensitive = true +} diff --git a/infra/github/versions.tf b/infra/github/versions.tf new file mode 100644 index 0000000..11aba7f --- /dev/null +++ b/infra/github/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} From 12f60def56831fa8ee893f9f57f127ea67c9dd1f Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 12:03:39 +0200 Subject: [PATCH 2/9] chore(infra): add GitHub IaC bootstrap script --- infra/github/README.md | 28 ++++--- scripts/setup-github-iac.sh | 157 ++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 12 deletions(-) create mode 100755 scripts/setup-github-iac.sh diff --git a/infra/github/README.md b/infra/github/README.md index c4ad8ac..c070b23 100644 --- a/infra/github/README.md +++ b/infra/github/README.md @@ -37,34 +37,38 @@ The Terraform `github_token` (used to apply this module) needs broader repo admi ## First-time setup -Create local `terraform.tfvars` (gitignored): +Token creation is the only manual part; GitHub does not provide a safe API for scripts to mint user +PATs. Use the bootstrap script for everything else: + +```sh +./scripts/setup-github-iac.sh +``` + +It opens the PAT settings page, prompts for the four required values, writes local +`infra/github/terraform.tfvars` with `0600` permissions, runs `terraform init`, imports the existing +repo and `main` ruleset, then runs `terraform plan`. + +Equivalent manual setup: ```hcl github_token = "github_pat_..." -netlify_auth_token = "nfp_..." -netlify_site_id = "00000000-0000-0000-0000-000000000000" +netlify_auth_token = "nfp_..." +netlify_site_id = "00000000-0000-0000-0000-000000000000" release_please_token = "github_pat_..." ``` -Initialize and import the existing repository and ruleset: - ```sh terraform -chdir=infra/github init terraform -chdir=infra/github import github_repository.this timer terraform -chdir=infra/github import github_repository_ruleset.main timer:20583655 +terraform -chdir=infra/github plan +terraform -chdir=infra/github apply ``` Secrets and variables can be imported too, but it is usually simpler to let Terraform overwrite them with the local `terraform.tfvars` values on first apply. -Preview and apply: - -```sh -terraform -chdir=infra/github plan -terraform -chdir=infra/github apply -``` - ## Day to day ```sh diff --git a/scripts/setup-github-iac.sh b/scripts/setup-github-iac.sh new file mode 100755 index 0000000..40cbbeb --- /dev/null +++ b/scripts/setup-github-iac.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_owner="${GITHUB_OWNER:-marcosgilf}" +repo_name="${GITHUB_REPOSITORY_NAME:-timer}" +tf_dir="infra/github" +tfvars="$tf_dir/terraform.tfvars" + +open_url() { + local url="$1" + if command -v open >/dev/null 2>&1; then + open "$url" >/dev/null 2>&1 || true + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$url" >/dev/null 2>&1 || true + fi +} + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +read_secret() { + local name="$1" + local value="" + printf "%s: " "$name" >&2 + IFS= read -rs value + printf "\n" >&2 + printf "%s" "$value" +} + +read_plain() { + local name="$1" + local default="${2:-}" + local value="" + if [[ -n "$default" ]]; then + printf "%s [%s]: " "$name" "$default" >&2 + else + printf "%s: " "$name" >&2 + fi + IFS= read -r value + printf "%s" "${value:-$default}" +} + +hcl_string() { + python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$1" +} + +need terraform +need python3 +need gh + +cat <&2 + exit 1 + fi +done + +mkdir -p "$tf_dir" +umask 077 +cat > "$tfvars" </dev/null | grep -qx "$1" +} + +if ! state_has github_repository.this; then + terraform -chdir="$tf_dir" import github_repository.this "$repo_name" +else + echo "github_repository.this already imported" +fi + +ruleset_id="" +if command -v gh >/dev/null 2>&1; then + ruleset_id="$(gh api "repos/${repo_owner}/${repo_name}/rulesets" --jq '.[] | select(.name == "main" and .target == "branch") | .id' 2>/dev/null | head -1 || true)" +fi + +if [[ -z "$ruleset_id" ]]; then + ruleset_id="$(read_plain "main ruleset id (see https://github.com/${repo_owner}/${repo_name}/rules)")" +fi + +if [[ -n "$ruleset_id" ]]; then + if ! state_has github_repository_ruleset.main; then + terraform -chdir="$tf_dir" import github_repository_ruleset.main "${repo_name}:${ruleset_id}" + else + echo "github_repository_ruleset.main already imported" + fi +else + echo "No main ruleset id provided; Terraform will try to create it on apply." >&2 +fi + +terraform -chdir="$tf_dir" plan + +cat < Date: Tue, 11 Aug 2026 12:18:23 +0200 Subject: [PATCH 3/9] fix(infra): clean up GitHub Terraform plan --- infra/github/main.tf | 12 ++++++++---- infra/github/secrets.tf | 12 ++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/infra/github/main.tf b/infra/github/main.tf index 6a45b03..4a34ed4 100644 --- a/infra/github/main.tf +++ b/infra/github/main.tf @@ -7,7 +7,7 @@ resource "github_repository" "this" { allow_squash_merge = true allow_rebase_merge = true allow_merge_commit = false - squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_title = "COMMIT_OR_PR_TITLE" squash_merge_commit_message = "COMMIT_MESSAGES" # Cleanup @@ -47,18 +47,22 @@ resource "github_repository_ruleset" "main" { require_code_owner_review = false require_last_push_approval = false required_approving_review_count = 0 - required_review_thread_resolution = false + required_review_thread_resolution = true } required_status_checks { strict_required_status_checks_policy = true required_check { - context = "qa / qa" + # GitHub Actions app id. Pinning avoids Terraform replacing provider-read checks with + # integration_id = 0 on every plan. + context = "qa / qa" + integration_id = 15368 } required_check { - context = "deploy-dev / deploy-dev" + context = "deploy-dev / deploy-dev" + integration_id = 15368 } } } diff --git a/infra/github/secrets.tf b/infra/github/secrets.tf index 995a071..bc297f0 100644 --- a/infra/github/secrets.tf +++ b/infra/github/secrets.tf @@ -1,7 +1,7 @@ resource "github_actions_secret" "netlify_auth_token" { - repository = github_repository.this.name - secret_name = "NETLIFY_AUTH_TOKEN" - plaintext_value = var.netlify_auth_token + repository = github_repository.this.name + secret_name = "NETLIFY_AUTH_TOKEN" + value = var.netlify_auth_token } resource "github_actions_variable" "netlify_site_id" { @@ -11,7 +11,7 @@ resource "github_actions_variable" "netlify_site_id" { } resource "github_actions_secret" "release_please_token" { - repository = github_repository.this.name - secret_name = "RELEASE_PLEASE_TOKEN" - plaintext_value = var.release_please_token + repository = github_repository.this.name + secret_name = "RELEASE_PLEASE_TOKEN" + value = var.release_please_token } From 54f779c1872d8290bb2d8c86c8ca85420db0b53a Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 12:33:32 +0200 Subject: [PATCH 4/9] ci(infra): plan GitHub Terraform on PRs and apply on main --- .github/workflows/github-infra.yml | 77 ++++++++++++++++++++++++++++++ infra/github/README.md | 15 ++++++ scripts/import-github-infra.sh | 25 ++++++++++ 3 files changed, 117 insertions(+) create mode 100644 .github/workflows/github-infra.yml create mode 100755 scripts/import-github-infra.sh diff --git a/.github/workflows/github-infra.yml b/.github/workflows/github-infra.yml new file mode 100644 index 0000000..ff7b485 --- /dev/null +++ b/.github/workflows/github-infra.yml @@ -0,0 +1,77 @@ +name: GitHub infra + +on: + pull_request: + branches: + - main + paths: + - infra/github/** + - scripts/import-github-infra.sh + - .github/workflows/github-infra.yml + push: + branches: + - main + paths: + - infra/github/** + - scripts/import-github-infra.sh + - .github/workflows/github-infra.yml + +permissions: + contents: read + +concurrency: + group: github-infra-${{ github.ref }} + cancel-in-progress: true + +env: + GH_TOKEN: ${{ secrets.TERRAFORM_GITHUB_TOKEN }} + TF_VAR_github_token: ${{ secrets.TERRAFORM_GITHUB_TOKEN }} + TF_VAR_netlify_auth_token: ${{ secrets.NETLIFY_AUTH_TOKEN }} + TF_VAR_netlify_site_id: ${{ vars.NETLIFY_SITE_ID }} + TF_VAR_release_please_token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + +jobs: + plan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: 1.15.8 + + - name: Init + run: terraform -chdir=infra/github init -backend=false -input=false + + - name: Import existing GitHub resources + run: ./scripts/import-github-infra.sh + + - name: Validate + run: terraform -chdir=infra/github validate + + - name: Plan + run: terraform -chdir=infra/github plan -input=false -no-color + + apply: + needs: plan + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: 1.15.8 + + - name: Init + run: terraform -chdir=infra/github init -backend=false -input=false + + - name: Import existing GitHub resources + run: ./scripts/import-github-infra.sh + + - name: Apply + run: terraform -chdir=infra/github apply -input=false -auto-approve diff --git a/infra/github/README.md b/infra/github/README.md index c070b23..3ba6d9b 100644 --- a/infra/github/README.md +++ b/infra/github/README.md @@ -69,6 +69,21 @@ terraform -chdir=infra/github apply Secrets and variables can be imported too, but it is usually simpler to let Terraform overwrite them with the local `terraform.tfvars` values on first apply. +## GitHub Actions + +`.github/workflows/github-infra.yml` runs on changes to this folder: + +- Pull requests: import existing resources into ephemeral state, then `terraform plan` +- Pushes to `main`: same import, then `terraform apply` + +Required repository secret for the workflow itself: + +- `TERRAFORM_GITHUB_TOKEN` — fine-grained PAT with Administration R/W, Secrets R/W, Variables R/W, + Metadata Read, scoped to `marcosgilf/timer` + +The workflow imports current resources on every run instead of relying on remote state. That keeps this +small personal repo simple; if the repo grows, move state to Terraform Cloud or another remote backend. + ## Day to day ```sh diff --git a/scripts/import-github-infra.sh b/scripts/import-github-infra.sh new file mode 100755 index 0000000..978f094 --- /dev/null +++ b/scripts/import-github-infra.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +tf_dir="${TF_DIR:-infra/github}" +repo="${GITHUB_REPOSITORY:-marcosgilf/timer}" +repo_name="${repo##*/}" + +import_if_missing() { + local address="$1" + local id="$2" + if terraform -chdir="$tf_dir" state list 2>/dev/null | grep -qx "$address"; then + echo "$address already in state" + else + terraform -chdir="$tf_dir" import "$address" "$id" + fi +} + +ruleset_id="$(gh api "repos/${repo}/rulesets" --jq '.[] | select(.name == "main" and .target == "branch") | .id' | head -1)" + +import_if_missing github_repository.this "$repo_name" +import_if_missing github_repository_ruleset.main "${repo_name}:${ruleset_id}" +import_if_missing github_repository_vulnerability_alerts.this "$repo_name" +import_if_missing github_actions_secret.netlify_auth_token "${repo_name}:NETLIFY_AUTH_TOKEN" +import_if_missing github_actions_secret.release_please_token "${repo_name}:RELEASE_PLEASE_TOKEN" +import_if_missing github_actions_variable.netlify_site_id "${repo_name}:NETLIFY_SITE_ID" From 22ff57a7f06d18ea92284770df303cf9517d9a58 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 12:40:33 +0200 Subject: [PATCH 5/9] docs(infra): document and reuse GitHub IaC scripts --- infra/github/README.md | 8 ++-- scripts/README.md | 67 ++++++++++++++++++++++++++++++++++ scripts/import-github-infra.sh | 3 ++ scripts/setup-github-iac.sh | 29 +-------------- 4 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 scripts/README.md diff --git a/infra/github/README.md b/infra/github/README.md index 3ba6d9b..83e4258 100644 --- a/infra/github/README.md +++ b/infra/github/README.md @@ -45,8 +45,8 @@ PATs. Use the bootstrap script for everything else: ``` It opens the PAT settings page, prompts for the four required values, writes local -`infra/github/terraform.tfvars` with `0600` permissions, runs `terraform init`, imports the existing -repo and `main` ruleset, then runs `terraform plan`. +`infra/github/terraform.tfvars` with `0600` permissions, runs `terraform init`, calls +`./scripts/import-github-infra.sh`, then runs `terraform plan`. See [scripts/README.md](../../scripts/README.md). Equivalent manual setup: @@ -71,9 +71,9 @@ with the local `terraform.tfvars` values on first apply. ## GitHub Actions -`.github/workflows/github-infra.yml` runs on changes to this folder: +`.github/workflows/github-infra.yml` runs on changes to this folder or the import script: -- Pull requests: import existing resources into ephemeral state, then `terraform plan` +- Pull requests: `./scripts/import-github-infra.sh`, then `terraform plan` - Pushes to `main`: same import, then `terraform apply` Required repository secret for the workflow itself: diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..4227717 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,67 @@ +# Scripts + +Small repo automation. Keep scripts boring: one job per file, no hidden framework. + +## `setup-github-iac.sh` + +Interactive bootstrap for `infra/github`. + +Use it once per machine or when credentials rotate: + +```sh +./scripts/setup-github-iac.sh +``` + +What it does: + +1. Prints the required GitHub PAT permissions and opens the PAT page. +2. Prompts for: + - `github_token` — Terraform PAT + - `release_please_token` — Release Please PAT + - `netlify_auth_token` + - `netlify_site_id` +3. Writes `infra/github/terraform.tfvars` with `0600` permissions. +4. Runs `terraform init`. +5. Calls `import-github-infra.sh`. +6. Runs `terraform plan`. + +Why it does **not** create PATs: GitHub does not expose a safe API for scripts to mint user PATs. +Credential creation stays manual; the script automates everything after that. + +Environment overrides, useful for password managers or CI experiments: + +```sh +GITHUB_TOKEN_FOR_TERRAFORM=... \ +RELEASE_PLEASE_TOKEN=... \ +NETLIFY_AUTH_TOKEN=... \ +NETLIFY_SITE_ID=... \ +./scripts/setup-github-iac.sh +``` + +## `import-github-infra.sh` + +Non-interactive import of the existing GitHub repo resources into the current Terraform state. +Used by GitHub Actions before plan/apply, and by `setup-github-iac.sh` locally. + +```sh +./scripts/import-github-infra.sh +``` + +Imports if missing: + +- `github_repository.this` +- `github_repository_ruleset.main` +- `github_repository_vulnerability_alerts.this` +- `github_actions_secret.netlify_auth_token` +- `github_actions_secret.release_please_token` +- `github_actions_variable.netlify_site_id` + +Inputs: + +- `GITHUB_REPOSITORY` (default `marcosgilf/timer`) +- `TF_DIR` (default `infra/github`) +- `GH_TOKEN` must be available for `gh api` in CI. Locally, `gh auth login` is enough. + +Why this script exists: the repo uses local/ephemeral Terraform state for now. Importing each run keeps +the workflow simple without adding Terraform Cloud or a state bucket. If this grows, move to remote +state and delete the import step. diff --git a/scripts/import-github-infra.sh b/scripts/import-github-infra.sh index 978f094..84949ed 100755 --- a/scripts/import-github-infra.sh +++ b/scripts/import-github-infra.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Import existing GitHub resources into Terraform state if they are missing. +# Used by both local bootstrap and GitHub Actions because this repo intentionally uses local/ephemeral +# Terraform state for GitHub settings. set -euo pipefail tf_dir="${TF_DIR:-infra/github}" diff --git a/scripts/setup-github-iac.sh b/scripts/setup-github-iac.sh index 40cbbeb..f416087 100755 --- a/scripts/setup-github-iac.sh +++ b/scripts/setup-github-iac.sh @@ -117,34 +117,7 @@ echo "Wrote $tfvars (0600)" terraform -chdir="$tf_dir" init -state_has() { - terraform -chdir="$tf_dir" state list 2>/dev/null | grep -qx "$1" -} - -if ! state_has github_repository.this; then - terraform -chdir="$tf_dir" import github_repository.this "$repo_name" -else - echo "github_repository.this already imported" -fi - -ruleset_id="" -if command -v gh >/dev/null 2>&1; then - ruleset_id="$(gh api "repos/${repo_owner}/${repo_name}/rulesets" --jq '.[] | select(.name == "main" and .target == "branch") | .id' 2>/dev/null | head -1 || true)" -fi - -if [[ -z "$ruleset_id" ]]; then - ruleset_id="$(read_plain "main ruleset id (see https://github.com/${repo_owner}/${repo_name}/rules)")" -fi - -if [[ -n "$ruleset_id" ]]; then - if ! state_has github_repository_ruleset.main; then - terraform -chdir="$tf_dir" import github_repository_ruleset.main "${repo_name}:${ruleset_id}" - else - echo "github_repository_ruleset.main already imported" - fi -else - echo "No main ruleset id provided; Terraform will try to create it on apply." >&2 -fi +GITHUB_REPOSITORY="${repo_owner}/${repo_name}" TF_DIR="$tf_dir" ./scripts/import-github-infra.sh terraform -chdir="$tf_dir" plan From 3b1523c08105b982bcf028f627c08253005f8e05 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 12:44:47 +0200 Subject: [PATCH 6/9] fix(infra): reuse valid GitHub IaC bootstrap values --- infra/github/README.md | 4 +- scripts/README.md | 22 +++---- scripts/setup-github-iac.sh | 117 ++++++++++++++++++++++++++++++------ 3 files changed, 110 insertions(+), 33 deletions(-) diff --git a/infra/github/README.md b/infra/github/README.md index 83e4258..f25738e 100644 --- a/infra/github/README.md +++ b/infra/github/README.md @@ -44,8 +44,8 @@ PATs. Use the bootstrap script for everything else: ./scripts/setup-github-iac.sh ``` -It opens the PAT settings page, prompts for the four required values, writes local -`infra/github/terraform.tfvars` with `0600` permissions, runs `terraform init`, calls +It reuses valid values from env vars or `infra/github/terraform.tfvars`, prompts only for missing or +expired values, writes local `terraform.tfvars` with `0600` permissions, runs `terraform init`, calls `./scripts/import-github-infra.sh`, then runs `terraform plan`. See [scripts/README.md](../../scripts/README.md). Equivalent manual setup: diff --git a/scripts/README.md b/scripts/README.md index 4227717..5699795 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,21 +14,21 @@ Use it once per machine or when credentials rotate: What it does: -1. Prints the required GitHub PAT permissions and opens the PAT page. -2. Prompts for: - - `github_token` — Terraform PAT - - `release_please_token` — Release Please PAT - - `netlify_auth_token` - - `netlify_site_id` -3. Writes `infra/github/terraform.tfvars` with `0600` permissions. -4. Runs `terraform init`. -5. Calls `import-github-infra.sh`. -6. Runs `terraform plan`. +1. Reads existing values from env vars or `infra/github/terraform.tfvars`. +2. Validates them: + - GitHub tokens via GitHub API (repo access; Terraform token also checks ruleset access) + - Netlify token via Netlify API + - Netlify site id via Netlify API +3. Prompts only for values that are missing, expired or invalid. +4. Writes `infra/github/terraform.tfvars` with `0600` permissions. +5. Runs `terraform init`. +6. Calls `import-github-infra.sh`. +7. Runs `terraform plan`. Why it does **not** create PATs: GitHub does not expose a safe API for scripts to mint user PATs. Credential creation stays manual; the script automates everything after that. -Environment overrides, useful for password managers or CI experiments: +Environment overrides, useful for password managers or CI experiments. If valid, these skip prompts: ```sh GITHUB_TOKEN_FOR_TERRAFORM=... \ diff --git a/scripts/setup-github-iac.sh b/scripts/setup-github-iac.sh index f416087..ba86797 100755 --- a/scripts/setup-github-iac.sh +++ b/scripts/setup-github-iac.sh @@ -48,9 +48,63 @@ hcl_string() { python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$1" } +tfvar_value() { + local key="$1" + [[ -f "$tfvars" ]] || return 1 + python3 - "$tfvars" "$key" <<'PY' +import json +import re +import sys +from pathlib import Path + +path, key = sys.argv[1:] +text = Path(path).read_text() +match = re.search(rf'^\s*{re.escape(key)}\s*=\s*("(?:\\.|[^"])*")\s*$', text, re.M) +if not match: + sys.exit(1) +print(json.loads(match.group(1))) +PY +} + +valid_github_repo_token() { + local token="$1" + [[ -n "$token" ]] || return 1 + GH_TOKEN="$token" gh api "repos/${repo_owner}/${repo_name}" >/dev/null 2>&1 +} + +valid_github_admin_token() { + local token="$1" + valid_github_repo_token "$token" && \ + GH_TOKEN="$token" gh api "repos/${repo_owner}/${repo_name}/rulesets" >/dev/null 2>&1 +} + +valid_netlify_token() { + local token="$1" + [[ -n "$token" ]] || return 1 + curl -fsS -H "Authorization: Bearer $token" https://api.netlify.com/api/v1/user >/dev/null 2>&1 +} + +valid_netlify_site() { + local token="$1" + local site_id="$2" + [[ -n "$token" && -n "$site_id" ]] || return 1 + curl -fsS -H "Authorization: Bearer $token" "https://api.netlify.com/api/v1/sites/$site_id" >/dev/null 2>&1 +} + +value_from_env_or_tfvars() { + local env_name="$1" + local tf_name="$2" + local value="${!env_name:-}" + if [[ -z "$value" ]]; then + value="$(tfvar_value "$tf_name" 2>/dev/null || true)" + fi + printf "%s" "$value" +} + need terraform need python3 need gh +need curl cat <&2 + open_url "https://github.com/settings/personal-access-tokens/new" github_token="$(read_secret "github_token (Terraform PAT)")" + valid_github_admin_token "$github_token" || { + echo "github_token is invalid or lacks repository/ruleset access" >&2 + exit 1 + } fi -if [[ -z "$release_please_token" ]]; then + +if valid_github_repo_token "$release_please_token"; then + echo "Using existing release_please_token (valid)" +else + echo "release_please_token missing or invalid/expired. Opening GitHub token settings..." >&2 + open_url "https://github.com/settings/personal-access-tokens/new" release_please_token="$(read_secret "release_please_token")" + valid_github_repo_token "$release_please_token" || { + echo "release_please_token is invalid or expired" >&2 + exit 1 + } fi -if [[ -z "$netlify_auth_token" ]]; then + +if valid_netlify_token "$netlify_auth_token"; then + echo "Using existing netlify_auth_token (valid)" +else netlify_auth_token="$(read_secret "netlify_auth_token")" -fi -if [[ -z "$netlify_site_id" ]]; then - netlify_site_id="$(read_plain "netlify_site_id")" + valid_netlify_token "$netlify_auth_token" || { + echo "netlify_auth_token is invalid or expired" >&2 + exit 1 + } fi -for name in github_token release_please_token netlify_auth_token netlify_site_id; do - if [[ -z "${!name}" ]]; then - echo "$name is required" >&2 +if valid_netlify_site "$netlify_auth_token" "$netlify_site_id"; then + echo "Using existing netlify_site_id (valid for this Netlify token)" +else + netlify_site_id="$(read_plain "netlify_site_id")" + valid_netlify_site "$netlify_auth_token" "$netlify_site_id" || { + echo "netlify_site_id is invalid or not accessible with netlify_auth_token" >&2 exit 1 - fi -done + } +fi mkdir -p "$tf_dir" umask 077 @@ -117,7 +194,7 @@ echo "Wrote $tfvars (0600)" terraform -chdir="$tf_dir" init -GITHUB_REPOSITORY="${repo_owner}/${repo_name}" TF_DIR="$tf_dir" ./scripts/import-github-infra.sh +GH_TOKEN="$github_token" GITHUB_REPOSITORY="${repo_owner}/${repo_name}" TF_DIR="$tf_dir" ./scripts/import-github-infra.sh terraform -chdir="$tf_dir" plan From 105acfacfe3dbfd51577ae8bbf834479c2b7d7ef Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 12:56:08 +0200 Subject: [PATCH 7/9] fix(infra): skip missing GitHub resources during import --- infra/github/README.md | 8 ++++--- scripts/README.md | 6 ++++- scripts/import-github-infra.sh | 42 ++++++++++++++++++++++++++-------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/infra/github/README.md b/infra/github/README.md index f25738e..e469c93 100644 --- a/infra/github/README.md +++ b/infra/github/README.md @@ -46,7 +46,9 @@ PATs. Use the bootstrap script for everything else: It reuses valid values from env vars or `infra/github/terraform.tfvars`, prompts only for missing or expired values, writes local `terraform.tfvars` with `0600` permissions, runs `terraform init`, calls -`./scripts/import-github-infra.sh`, then runs `terraform plan`. See [scripts/README.md](../../scripts/README.md). +`./scripts/import-github-infra.sh`, then runs `terraform plan`. Existing GitHub Actions secrets are +write-only, so their values cannot be reused unless they are already in env vars or local tfvars. See +[scripts/README.md](../../scripts/README.md). Equivalent manual setup: @@ -66,8 +68,8 @@ terraform -chdir=infra/github plan terraform -chdir=infra/github apply ``` -Secrets and variables can be imported too, but it is usually simpler to let Terraform overwrite them -with the local `terraform.tfvars` values on first apply. +Secrets and variables are imported when they already exist. Missing ones are skipped during import and +created on apply from `terraform.tfvars` / `TF_VAR_*` values. ## GitHub Actions diff --git a/scripts/README.md b/scripts/README.md index 5699795..80d6fdf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,6 +20,10 @@ What it does: - Netlify token via Netlify API - Netlify site id via Netlify API 3. Prompts only for values that are missing, expired or invalid. + +Important: GitHub Actions secrets are write-only. The script can check whether a secret named +`NETLIFY_AUTH_TOKEN` exists, but it cannot read the secret value back from GitHub. If the value is not +in env vars or local `terraform.tfvars`, the script must ask for it again. 4. Writes `infra/github/terraform.tfvars` with `0600` permissions. 5. Runs `terraform init`. 6. Calls `import-github-infra.sh`. @@ -47,7 +51,7 @@ Used by GitHub Actions before plan/apply, and by `setup-github-iac.sh` locally. ./scripts/import-github-infra.sh ``` -Imports if missing: +Imports if present, skips if missing so Terraform can create on apply: - `github_repository.this` - `github_repository_ruleset.main` diff --git a/scripts/import-github-infra.sh b/scripts/import-github-infra.sh index 84949ed..27f52a3 100755 --- a/scripts/import-github-infra.sh +++ b/scripts/import-github-infra.sh @@ -1,28 +1,50 @@ #!/usr/bin/env bash -# Import existing GitHub resources into Terraform state if they are missing. -# Used by both local bootstrap and GitHub Actions because this repo intentionally uses local/ephemeral -# Terraform state for GitHub settings. +# Import existing GitHub resources into Terraform state if they are present. +# Missing secrets/variables are skipped: Terraform will create them from TF_VAR_* on apply. set -euo pipefail tf_dir="${TF_DIR:-infra/github}" repo="${GITHUB_REPOSITORY:-marcosgilf/timer}" repo_name="${repo##*/}" +state_has() { + terraform -chdir="$tf_dir" state list 2>/dev/null | grep -qx "$1" +} + import_if_missing() { local address="$1" local id="$2" - if terraform -chdir="$tf_dir" state list 2>/dev/null | grep -qx "$address"; then + if state_has "$address"; then echo "$address already in state" else terraform -chdir="$tf_dir" import "$address" "$id" fi } -ruleset_id="$(gh api "repos/${repo}/rulesets" --jq '.[] | select(.name == "main" and .target == "branch") | .id' | head -1)" +import_if_exists() { + local address="$1" + local id="$2" + local check_path="$3" + + if state_has "$address"; then + echo "$address already in state" + elif gh api "$check_path" >/dev/null 2>&1; then + terraform -chdir="$tf_dir" import "$address" "$id" + else + echo "$address not found in GitHub; Terraform will create it on apply" + fi +} import_if_missing github_repository.this "$repo_name" -import_if_missing github_repository_ruleset.main "${repo_name}:${ruleset_id}" -import_if_missing github_repository_vulnerability_alerts.this "$repo_name" -import_if_missing github_actions_secret.netlify_auth_token "${repo_name}:NETLIFY_AUTH_TOKEN" -import_if_missing github_actions_secret.release_please_token "${repo_name}:RELEASE_PLEASE_TOKEN" -import_if_missing github_actions_variable.netlify_site_id "${repo_name}:NETLIFY_SITE_ID" + +ruleset_id="$(gh api "repos/${repo}/rulesets" --jq '.[] | select(.name == "main" and .target == "branch") | .id' | head -1)" +if [[ -z "$ruleset_id" ]]; then + echo "main branch ruleset not found in ${repo}; Terraform will create it on apply" >&2 +else + import_if_missing github_repository_ruleset.main "${repo_name}:${ruleset_id}" +fi + +import_if_exists github_repository_vulnerability_alerts.this "$repo_name" "repos/${repo}/vulnerability-alerts" +import_if_exists github_actions_secret.netlify_auth_token "${repo_name}:NETLIFY_AUTH_TOKEN" "repos/${repo}/actions/secrets/NETLIFY_AUTH_TOKEN" +import_if_exists github_actions_secret.release_please_token "${repo_name}:RELEASE_PLEASE_TOKEN" "repos/${repo}/actions/secrets/RELEASE_PLEASE_TOKEN" +import_if_exists github_actions_variable.netlify_site_id "${repo_name}:NETLIFY_SITE_ID" "repos/${repo}/actions/variables/NETLIFY_SITE_ID" From 3320daa8243e781014b53f1f51dfe8739990be3a Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 13:02:14 +0200 Subject: [PATCH 8/9] ci(infra): fail fast when Terraform GitHub token is missing --- .github/workflows/github-infra.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/github-infra.yml b/.github/workflows/github-infra.yml index ff7b485..a910016 100644 --- a/.github/workflows/github-infra.yml +++ b/.github/workflows/github-infra.yml @@ -37,6 +37,17 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Check Terraform GitHub token + shell: bash + run: | + if [ -z "${TERRAFORM_GITHUB_TOKEN:-}" ]; then + echo "TERRAFORM_GITHUB_TOKEN repository secret is required." >&2 + echo "It cannot be created by Terraform because Terraform needs it to manage GitHub." >&2 + exit 1 + fi + env: + TERRAFORM_GITHUB_TOKEN: ${{ secrets.TERRAFORM_GITHUB_TOKEN }} + - name: Setup Terraform uses: hashicorp/setup-terraform@v4 with: @@ -62,6 +73,17 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Check Terraform GitHub token + shell: bash + run: | + if [ -z "${TERRAFORM_GITHUB_TOKEN:-}" ]; then + echo "TERRAFORM_GITHUB_TOKEN repository secret is required." >&2 + echo "It cannot be created by Terraform because Terraform needs it to manage GitHub." >&2 + exit 1 + fi + env: + TERRAFORM_GITHUB_TOKEN: ${{ secrets.TERRAFORM_GITHUB_TOKEN }} + - name: Setup Terraform uses: hashicorp/setup-terraform@v4 with: From e713bc9d6888d64e80447f727a3aaca6a13de8f2 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Tue, 11 Aug 2026 13:07:37 +0200 Subject: [PATCH 9/9] ci(infra): check Terraform formatting --- .github/workflows/github-infra.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/github-infra.yml b/.github/workflows/github-infra.yml index a910016..45e123d 100644 --- a/.github/workflows/github-infra.yml +++ b/.github/workflows/github-infra.yml @@ -59,6 +59,9 @@ jobs: - name: Import existing GitHub resources run: ./scripts/import-github-infra.sh + - name: Format check + run: terraform -chdir=infra/github fmt -check -recursive + - name: Validate run: terraform -chdir=infra/github validate