ci: add OpenTofu plan (PR) and apply (main) workflows #12

Merged
cpressland merged 8 commits from ci/tofu-plan-apply into main 2026-08-26 13:59:08 +01:00
Collaborator

What

  • Adds .forgejo/workflows/plan.yaml: on pull requests targeting main, runs tofu init + tofu plan -detailed-exitcode, and posts the plan output as a single comment on the PR (updated in place on every push, not stacked).
  • Adds .forgejo/workflows/apply.yaml: on push to main, runs tofu init + tofu apply -auto-approve.
  • Adds .forgejo/scripts/pr-comment.sh: creates/updates the plan comment via the Forgejo issues/comments API using the automatic secrets.GITHUB_TOKEN.
  • Secrets: Forgejo uppercases secret names, so the workflows reference secrets.TF_VAR_HCLOUD_TOKEN, secrets.TF_VAR_HCLOUD_WEBDAV_USER, and secrets.TF_VAR_HCLOUD_WEBDAV_PASSWORD, and re-export them as the lowercase TF_VAR_hcloud_token / TF_VAR_hcloud_webdav_user / TF_VAR_hcloud_webdav_password env vars that OpenTofu actually expects.
  • Adds a harmless ci-test CNAME record in dns_cpressland_io.tf (pointing ci-test.cpressland.io at ingress) purely to validate the plan/apply pipeline end-to-end. Safe to remove once verified.

Verification plan

  1. This PR should trigger the plan workflow and comment the tofu plan output (showing the one new CNAME) on this PR.
  2. After merge, the apply workflow should run tofu apply -auto-approve and create the ci-test CNAME record.
  3. Once confirmed working, the ci-test record can be removed in a follow-up PR.
## What - Adds `.forgejo/workflows/plan.yaml`: on pull requests targeting `main`, runs `tofu init` + `tofu plan -detailed-exitcode`, and posts the plan output as a single comment on the PR (updated in place on every push, not stacked). - Adds `.forgejo/workflows/apply.yaml`: on push to `main`, runs `tofu init` + `tofu apply -auto-approve`. - Adds `.forgejo/scripts/pr-comment.sh`: creates/updates the plan comment via the Forgejo issues/comments API using the automatic `secrets.GITHUB_TOKEN`. - Secrets: Forgejo uppercases secret names, so the workflows reference `secrets.TF_VAR_HCLOUD_TOKEN`, `secrets.TF_VAR_HCLOUD_WEBDAV_USER`, and `secrets.TF_VAR_HCLOUD_WEBDAV_PASSWORD`, and re-export them as the lowercase `TF_VAR_hcloud_token` / `TF_VAR_hcloud_webdav_user` / `TF_VAR_hcloud_webdav_password` env vars that OpenTofu actually expects. - Adds a harmless `ci-test` CNAME record in `dns_cpressland_io.tf` (pointing `ci-test.cpressland.io` at `ingress`) purely to validate the plan/apply pipeline end-to-end. Safe to remove once verified. ## Verification plan 1. This PR should trigger the plan workflow and comment the `tofu plan` output (showing the one new CNAME) on this PR. 2. After merge, the apply workflow should run `tofu apply -auto-approve` and create the `ci-test` CNAME record. 3. Once confirmed working, the ci-test record can be removed in a follow-up PR.
ci: add OpenTofu plan (PR) and apply (main) workflows
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 1s
a54b4aaa1e
- Plan workflow runs on pull requests targeting main, comments the
  plan output (created/updated in place) on the PR via the Forgejo
  issues/comments API.
- Apply workflow runs tofu apply -auto-approve on push to main.
- Secrets are referenced in their Forgejo-uppercased form
  (TF_VAR_HCLOUD_TOKEN, TF_VAR_HCLOUD_WEBDAV_USER,
  TF_VAR_HCLOUD_WEBDAV_PASSWORD) but exported as the lowercase
  TF_VAR_* env vars OpenTofu expects.
- Added a harmless ci-test CNAME record to validate the pipeline end
  to end.
ci: install OpenTofu via standalone installer script
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 3s
22b40f07ab
opentofu/setup-opentofu is not mirrored on this Forgejo instance's
DEFAULT_ACTIONS_URL (code.forgejo.org), which caused the plan run to
fail instantly. Switch to the official get.opentofu.org standalone
install script instead.
ci: install unzip before running the OpenTofu installer
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 3s
d9a460afae
The standalone installer needs unzip, which the runner image doesn't
ship with by default.
ci: robustly install unzip/curl before OpenTofu install, verify with tofu -v
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 3s
4fd6a7d9d9
ci: pin OpenTofu version to avoid GitHub API lookup
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 11s
f849c280a3
The installer's 'latest version' lookup hits api.github.com, which
the runner can't reach reliably. Pin an explicit version instead.
ci: install jq, blank the injected GITHUB_TOKEN for tofu steps
Some checks failed
OpenTofu Plan / plan (pull_request) Failing after 3s
40c9d863e9
Two real bugs found from job logs:
- jq wasn't installed, so the PR comment script failed.
- Forgejo Actions injects an automatic GITHUB_TOKEN into every step;
  the github provider in main.tf picks it up and fails auth against
  api.github.com ('owner cannot be found by token'). Blank it out
  for the tofu init/plan/apply steps since we don't need GitHub API
  access there.
ci: fix package install to always apt-get update when installing anything
All checks were successful
OpenTofu Plan / plan (pull_request) Successful in 12s
e91e28c364
Previous logic only ran apt-get update inside the unzip-missing branch,
so jq alone (unzip already present) never got 'apt-get update' first,
causing 'Unable to locate package jq'.

📝 OpenTofu plan: changes detected

Plan: 1 to add, 0 to change, 0 to destroy.

Show plan output
data.github_ip_ranges.i: Reading...
hcloud_ssh_key.i: Refreshing state... [id=111080408]
hcloud_zone.binky_casa: Refreshing state... [name=binky.casa]
hcloud_storage_box.backups: Refreshing state... [name=backups]
hcloud_zone.bakacat_com: Refreshing state... [name=bakacat.com]
hcloud_zone.cpressland_com: Refreshing state... [name=cpressland.com]
hcloud_zone.cpressland_io: Refreshing state... [name=cpressland.io]
hcloud_zone_rrset.cpressland_com["jellyfin"]: Refreshing state... [id=jellyfin/CNAME]
hcloud_zone_rrset.cpressland_com["navidrome"]: Refreshing state... [id=navidrome/CNAME]
hcloud_zone_rrset.cpressland_com["git"]: Refreshing state... [id=git/CNAME]
hcloud_zone_rrset.bakacat_com["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME]
hcloud_zone_rrset.bakacat_com["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT]
hcloud_zone_rrset.bakacat_com["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME]
hcloud_zone_rrset.bakacat_com["txt_spf"]: Refreshing state... [id=@/TXT]
hcloud_zone_rrset.bakacat_com["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME]
hcloud_zone_rrset.bakacat_com["mx_fastmail"]: Refreshing state... [id=@/MX]
data.github_ip_ranges.i: Read complete after 0s [id=github-ip-ranges]
hcloud_zone_rrset.cpressland_io["cname_git"]: Refreshing state... [id=git/CNAME]
hcloud_zone_rrset.cpressland_io["cname_navidrome"]: Refreshing state... [id=navidrome/CNAME]
hcloud_zone_rrset.cpressland_io["cname_jellyfin"]: Refreshing state... [id=jellyfin/CNAME]
hcloud_zone_rrset.cpressland_io["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME]
hcloud_zone_rrset.cpressland_io["a_www"]: Refreshing state... [id=@/A]
hcloud_zone_rrset.cpressland_io["cname_files"]: Refreshing state... [id=files/CNAME]
hcloud_zone_rrset.cpressland_io["txt_bluesky"]: Refreshing state... [id=_atproto/TXT]
hcloud_zone_rrset.cpressland_io["cname_iperf"]: Refreshing state... [id=iperf/CNAME]
hcloud_zone_rrset.cpressland_io["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT]
hcloud_zone_rrset.cpressland_io["cname_audiobooks"]: Refreshing state... [id=audiobooks/CNAME]
hcloud_zone_rrset.cpressland_io["cname_immich"]: Refreshing state... [id=immich/CNAME]
hcloud_zone_rrset.cpressland_io["aaaa_vpn"]: Refreshing state... [id=vpn/AAAA]
hcloud_zone_rrset.cpressland_io["mx_fastmail"]: Refreshing state... [id=@/MX]
hcloud_zone_rrset.cpressland_io["txt_dmarc_report_bakacat_com"]: Refreshing state... [id=bakacat.com._report._dmarc/TXT]
hcloud_zone_rrset.cpressland_io["cname_www"]: Refreshing state... [id=www/CNAME]
hcloud_zone_rrset.cpressland_io["txt_dmarc_report_binky_casa"]: Refreshing state... [id=binky.casa._report._dmarc/TXT]
hcloud_zone_rrset.cpressland_io["cname_brewery"]: Refreshing state... [id=brewery/CNAME]
hcloud_zone_rrset.cpressland_io["txt_root"]: Refreshing state... [id=@/TXT]
hcloud_zone_rrset.cpressland_io["cname_homeassistant"]: Refreshing state... [id=homeassistant/CNAME]
hcloud_zone_rrset.cpressland_io["aaaa_www"]: Refreshing state... [id=@/AAAA]
hcloud_zone_rrset.cpressland_io["cname_chds"]: Refreshing state... [id=chds/CNAME]
hcloud_zone_rrset.cpressland_io["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME]
hcloud_zone_rrset.cpressland_io["a_vpn"]: Refreshing state... [id=vpn/A]
hcloud_zone_rrset.cpressland_io["aaaa_ingress"]: Refreshing state... [id=ingress/AAAA]
hcloud_zone_rrset.cpressland_io["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME]
hcloud_zone_rrset.cpressland_io["cname_meshcore"]: Refreshing state... [id=meshcore/CNAME]
hcloud_zone_rrset.cpressland_io["a_ingress"]: Refreshing state... [id=ingress/A]
hcloud_zone_rrset.binky_casa["aaaa_www"]: Refreshing state... [id=@/AAAA]
hcloud_zone_rrset.binky_casa["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME]
hcloud_zone_rrset.binky_casa["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME]
hcloud_zone_rrset.binky_casa["txt"]: Refreshing state... [id=@/TXT]
hcloud_zone_rrset.binky_casa["txt_bluesky"]: Refreshing state... [id=_atproto/TXT]
hcloud_zone_rrset.binky_casa["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME]
hcloud_zone_rrset.binky_casa["cname_db"]: Refreshing state... [id=db/CNAME]
hcloud_zone_rrset.binky_casa["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT]
hcloud_zone_rrset.binky_casa["mx_fastmail"]: Refreshing state... [id=@/MX]
hcloud_zone_rrset.binky_casa["a_www"]: Refreshing state... [id=@/A]
hcloud_zone_rrset.binky_casa["cname_www"]: Refreshing state... [id=www/CNAME]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # hcloud_zone_rrset.cpressland_io["cname_ci_test"] will be created
  + resource "hcloud_zone_rrset" "cpressland_io" {
      + change_protection = (known after apply)
      + id                = (known after apply)
      + labels            = {}
      + name              = "ci-test"
      + records           = [
          + {
              + value = "ingress"
            },
        ]
      + ttl               = 43200
      + type              = "CNAME"
      + zone              = "cpressland.io"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so OpenTofu can't
guarantee to take exactly these actions if you run "tofu apply" now.

Workflow run: 619

<!-- tofu-plan-comment --> ## 📝 OpenTofu plan: changes detected Plan: 1 to add, 0 to change, 0 to destroy. <details> <summary>Show plan output</summary> ``` data.github_ip_ranges.i: Reading... hcloud_ssh_key.i: Refreshing state... [id=111080408] hcloud_zone.binky_casa: Refreshing state... [name=binky.casa] hcloud_storage_box.backups: Refreshing state... [name=backups] hcloud_zone.bakacat_com: Refreshing state... [name=bakacat.com] hcloud_zone.cpressland_com: Refreshing state... [name=cpressland.com] hcloud_zone.cpressland_io: Refreshing state... [name=cpressland.io] hcloud_zone_rrset.cpressland_com["jellyfin"]: Refreshing state... [id=jellyfin/CNAME] hcloud_zone_rrset.cpressland_com["navidrome"]: Refreshing state... [id=navidrome/CNAME] hcloud_zone_rrset.cpressland_com["git"]: Refreshing state... [id=git/CNAME] hcloud_zone_rrset.bakacat_com["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME] hcloud_zone_rrset.bakacat_com["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT] hcloud_zone_rrset.bakacat_com["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME] hcloud_zone_rrset.bakacat_com["txt_spf"]: Refreshing state... [id=@/TXT] hcloud_zone_rrset.bakacat_com["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME] hcloud_zone_rrset.bakacat_com["mx_fastmail"]: Refreshing state... [id=@/MX] data.github_ip_ranges.i: Read complete after 0s [id=github-ip-ranges] hcloud_zone_rrset.cpressland_io["cname_git"]: Refreshing state... [id=git/CNAME] hcloud_zone_rrset.cpressland_io["cname_navidrome"]: Refreshing state... [id=navidrome/CNAME] hcloud_zone_rrset.cpressland_io["cname_jellyfin"]: Refreshing state... [id=jellyfin/CNAME] hcloud_zone_rrset.cpressland_io["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME] hcloud_zone_rrset.cpressland_io["a_www"]: Refreshing state... [id=@/A] hcloud_zone_rrset.cpressland_io["cname_files"]: Refreshing state... [id=files/CNAME] hcloud_zone_rrset.cpressland_io["txt_bluesky"]: Refreshing state... [id=_atproto/TXT] hcloud_zone_rrset.cpressland_io["cname_iperf"]: Refreshing state... [id=iperf/CNAME] hcloud_zone_rrset.cpressland_io["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT] hcloud_zone_rrset.cpressland_io["cname_audiobooks"]: Refreshing state... [id=audiobooks/CNAME] hcloud_zone_rrset.cpressland_io["cname_immich"]: Refreshing state... [id=immich/CNAME] hcloud_zone_rrset.cpressland_io["aaaa_vpn"]: Refreshing state... [id=vpn/AAAA] hcloud_zone_rrset.cpressland_io["mx_fastmail"]: Refreshing state... [id=@/MX] hcloud_zone_rrset.cpressland_io["txt_dmarc_report_bakacat_com"]: Refreshing state... [id=bakacat.com._report._dmarc/TXT] hcloud_zone_rrset.cpressland_io["cname_www"]: Refreshing state... [id=www/CNAME] hcloud_zone_rrset.cpressland_io["txt_dmarc_report_binky_casa"]: Refreshing state... [id=binky.casa._report._dmarc/TXT] hcloud_zone_rrset.cpressland_io["cname_brewery"]: Refreshing state... [id=brewery/CNAME] hcloud_zone_rrset.cpressland_io["txt_root"]: Refreshing state... [id=@/TXT] hcloud_zone_rrset.cpressland_io["cname_homeassistant"]: Refreshing state... [id=homeassistant/CNAME] hcloud_zone_rrset.cpressland_io["aaaa_www"]: Refreshing state... [id=@/AAAA] hcloud_zone_rrset.cpressland_io["cname_chds"]: Refreshing state... [id=chds/CNAME] hcloud_zone_rrset.cpressland_io["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME] hcloud_zone_rrset.cpressland_io["a_vpn"]: Refreshing state... [id=vpn/A] hcloud_zone_rrset.cpressland_io["aaaa_ingress"]: Refreshing state... [id=ingress/AAAA] hcloud_zone_rrset.cpressland_io["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME] hcloud_zone_rrset.cpressland_io["cname_meshcore"]: Refreshing state... [id=meshcore/CNAME] hcloud_zone_rrset.cpressland_io["a_ingress"]: Refreshing state... [id=ingress/A] hcloud_zone_rrset.binky_casa["aaaa_www"]: Refreshing state... [id=@/AAAA] hcloud_zone_rrset.binky_casa["cname_fastmail_1"]: Refreshing state... [id=fm1._domainkey/CNAME] hcloud_zone_rrset.binky_casa["cname_fastmail_2"]: Refreshing state... [id=fm2._domainkey/CNAME] hcloud_zone_rrset.binky_casa["txt"]: Refreshing state... [id=@/TXT] hcloud_zone_rrset.binky_casa["txt_bluesky"]: Refreshing state... [id=_atproto/TXT] hcloud_zone_rrset.binky_casa["cname_fastmail_3"]: Refreshing state... [id=fm3._domainkey/CNAME] hcloud_zone_rrset.binky_casa["cname_db"]: Refreshing state... [id=db/CNAME] hcloud_zone_rrset.binky_casa["txt_dmarc"]: Refreshing state... [id=_dmarc/TXT] hcloud_zone_rrset.binky_casa["mx_fastmail"]: Refreshing state... [id=@/MX] hcloud_zone_rrset.binky_casa["a_www"]: Refreshing state... [id=@/A] hcloud_zone_rrset.binky_casa["cname_www"]: Refreshing state... [id=www/CNAME] OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create OpenTofu will perform the following actions: # hcloud_zone_rrset.cpressland_io["cname_ci_test"] will be created + resource "hcloud_zone_rrset" "cpressland_io" { + change_protection = (known after apply) + id = (known after apply) + labels = {} + name = "ci-test" + records = [ + { + value = "ingress" }, ] + ttl = 43200 + type = "CNAME" + zone = "cpressland.io" } Plan: 1 to add, 0 to change, 0 to destroy. ───────────────────────────────────────────────────────────────────────────── Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now. ``` </details> *Workflow run: [619](https://git.cpressland.io/cpressland/tofu/actions/runs/619)*
ci: simplify workflows now the default runner image ships tofu/jq/curl/unzip
All checks were successful
OpenTofu Plan / plan (pull_request) Successful in 16s
OpenTofu Apply / apply (push) Successful in 23s
378b01f942
The runner now defaults to a custom image (cpressland/act) with
OpenTofu and the required tools preinstalled, so the manual
unzip/curl/jq bootstrap and the standalone installer script are no
longer needed.
cpressland deleted branch ci/tofu-plan-apply 2026-08-26 13:59:08 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
cpressland/tofu!12
No description provided.