From b0402ef475c4cf85010a179ee998d293f63f0f9c Mon Sep 17 00:00:00 2001 From: dhamsey3 <73405591+dhamsey3@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:28:57 +0200 Subject: [PATCH] ci: deploy from GitHub Actions through OIDC Pull requests that touch the code or the infrastructure run `make plan` with a read-only role and put the plan in the run summary. Merges to main plan and apply with the apply role, which only main can assume. Both roles live in aws-oidc-bootstrap; there are no stored AWS keys. The workflow refuses to run without the ALERT_EMAIL secret, since an empty alert_email would plan the alert subscription away. alert_email is now sensitive, so the address stays out of the public Actions logs. --- .github/workflows/deploy.yml | 102 +++++++++++++++++++++++++++++++++++ README.md | 19 ++++++- infrastructure/variables.tf | 2 + 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..eafd6b1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,102 @@ +name: deploy + +# Plan on every pull request, apply on main. AWS access is GitHub OIDC, no +# stored keys: pull requests get a read-only plan role, and only main can +# assume the apply role (aws-oidc-bootstrap, role-voltcast.tf). + +on: + pull_request: + paths: + - src/** + - infrastructure/** + - requirements-lambda.txt + - Makefile + - .github/workflows/deploy.yml + push: + branches: [main] + paths: + - src/** + - infrastructure/** + - requirements-lambda.txt + - Makefile + - .github/workflows/deploy.yml + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + terraform: + name: ${{ github.event_name == 'pull_request' && 'plan' || 'apply' }} + # Fork pull requests get no OIDC token; deploys only ever run from main. + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + concurrency: + group: deploy-${{ github.event_name == 'pull_request' && github.ref || 'main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + IS_PR: ${{ github.event_name == 'pull_request' }} + AWS_REGION: ${{ vars.AWS_REGION }} + TF_INPUT: "0" + TF_IN_AUTOMATION: "1" + TF_VAR_alert_email: ${{ secrets.ALERT_EMAIL }} + # The plan role can't write the lock table; a plan never needs it. + TF_CLI_ARGS_plan: ${{ github.event_name == 'pull_request' && '-lock=false' || '' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.8 + terraform_wrapper: false + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ github.event_name == 'pull_request' && vars.AWS_PLAN_ROLE_ARN || vars.AWS_APPLY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Check alert email + # An empty alert_email would plan the alert subscription away. + run: | + if [ -z "$TF_VAR_alert_email" ]; then + echo "::error::The ALERT_EMAIL secret is not set; a deploy without it would delete the alert email subscription." + exit 1 + fi + + - name: Write backend config + env: + STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }} + LOCK_TABLE: ${{ vars.TF_STATE_LOCK_TABLE }} + run: | + cat > infrastructure/backend.hcl < plan.txt + { + echo "### $(grep -E '^(Plan:|No changes)' plan.txt || echo 'Plan')" + echo + echo '
Full plan' + echo + echo '```' + cat plan.txt + echo '```' + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Apply + if: env.IS_PR != 'true' + run: make deploy diff --git a/README.md b/README.md index 1a6425a..33b0d3f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,21 @@ State goes to `./data` (gitignored), laid out exactly as in the S3 bucket. ## Deploy +Merging to `main` deploys. The [deploy workflow](.github/workflows/deploy.yml) +runs `make plan` on every pull request that touches the code or the +infrastructure, and puts the plan in the run summary. On `main` it plans +again and applies, stamping the Lambda with the merge commit's SHA. AWS +access is GitHub OIDC with no stored keys. Pull requests get a read-only +plan role, and only `main` can assume the apply role. Both roles live in +`aws-oidc-bootstrap`, scoped to voltcast's own resources. + +The workflow reads the role ARNs, region, state bucket and lock table from +repository variables, and the alert address from the `ALERT_EMAIL` secret. +It refuses to run without that secret, because an empty address would +delete the alert subscription. + +### By hand + Prerequisites: the shared Terraform state bucket and lock table from `aws-oidc-bootstrap`, plus AWS credentials for the target account. @@ -145,8 +160,8 @@ Berlin. ## Roadmap 1. **Live baseline service.** Live since 2026-10-14 in eu-central-1; the first live forecast is for 16 Oct 2026. -2. **Deploy from CI, public status page.** GitHub Actions deploys through OIDC (role - in `aws-oidc-bootstrap`). A public page shows the latest forecast against the +2. **Deploy from CI, public status page.** GitHub Actions deploys through OIDC + ([Deploy](#deploy)). Still to come: a public page showing the latest forecast against the cleared price, rolling error and model version, plus a monthly report with the real AWS bill. 3. **First learned model.** Features from ENTSO-E (load, wind and solar forecasts) and DWD weather. Trained on SageMaker and registered in the Model Registry with diff --git a/infrastructure/variables.tf b/infrastructure/variables.tf index 025c825..8038014 100644 --- a/infrastructure/variables.tf +++ b/infrastructure/variables.tf @@ -20,6 +20,8 @@ variable "alert_email" { description = "Where alarms go. Empty disables the email subscription." type = string default = "" + # The repo and its Actions logs are public; keep the address out of plans. + sensitive = true } # Promotion is a reviewed change to these two values - see docs/adr/0003.