Files
LibreMail-Bug-Report-Ingest/.github/workflows/deploy.yml
T
JMR-devandClaude Opus 4.8 985fb684c5 #4 GitHub Actions CD: deploy via workflow_dispatch
Add a manual, workflow_dispatch-only CD workflow and close the infra
binding-wiring gap (#9) so the deployed Worker is actually functional.

Deliverable 1 - .github/workflows/deploy.yml:
- workflow_dispatch only, with a `stack` choice input (default prod).
- Gated to the `production` GitHub Actions environment and to the main
  branch (guard step fails otherwise); no push/PR trigger.
- Reuses ci.yml's Go 1.26 + TinyGo + pnpm setup and the TinyGo net/http
  patch, builds the Wasm Worker (pnpm run build), then runs pulumi up over
  infra/ via pulumi/actions, injecting the built ../build/worker.mjs as the
  workerScriptPath config. Provider/backend creds come from environment
  secrets (nothing committed). All actions pinned by commit SHA; actionlint
  clean.

Deliverable 2 - infra/deploy.go binding wiring (#9):
- WorkersScript now carries the R2 bucket binding (REPORTS_BUCKET), the four
  Secrets Store bindings (BUGREPORT_ENC_KEYRING, ADMIN_TOKEN, GITHUB_TOKEN,
  OTEL_EXPORTER_OTLP_HEADERS), and the plain vars (GITHUB_REPO, OTEL_*),
  matching wrangler.jsonc and the Worker runtime contract.
- New WorkersCronTrigger resource registers the two Friday UTC crons (#13),
  bound to the Worker.
- Real built artifact wired via ContentFile + computed ContentSha256 when
  the workerScriptPath config is set; documented placeholder otherwise
  (keeps the program testable without the artifact, as #2 did).
- secretsStoreId is a new required config; git rate-limit ruleset insertion
  point (#6/#7) kept reserved.
- Extended the WithMocks tests to assert the R2 + Secrets Store + var
  bindings, the crons, and the artifact ContentFile/ContentSha256 path.
- Updated Pulumi.<stack>.yaml and infra/README.md with the full
  secret/config list and how a maintainer triggers the deploy.

Verified in infra/: go build, go vet, go test all green; actionlint clean
on deploy.yml. No deploy/preview/provision was run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:40:08 -05:00

145 lines
6.6 KiB
YAML

# Continuous deployment for the LibreMail bug-report ingest Worker + infra.
#
# MANUAL ONLY: this workflow never runs on push/PR. A maintainer triggers it from
# the Actions tab (workflow_dispatch), choosing a stack. It builds the TinyGo/Wasm
# Worker end to end (same setup as ci.yml) and then runs `pulumi up` over the
# infra/ program to deploy the Worker (with its R2 + Secrets Store + var bindings
# and Cron Triggers), the R2 bucket, and the Google Cloud DNS record.
#
# It is gated to the `production` GitHub Actions environment, so that environment's
# secrets and any required-reviewer / branch protection rules apply, and to the
# `main` branch (a guard step fails the run otherwise). Deploying is real and
# billable, hence manual + environment-gated + maintainer-run-from-main.
#
# Supply-chain note: every action (first- and third-party) is pinned to a full
# commit SHA with a trailing "# vX.Y.Z" comment, matching ci.yml / autoupdate.yml.
#
# Secrets/config the maintainer must set BEFORE the first deploy (see infra/README.md):
# production environment SECRETS (Settings > Environments > production):
# - PULUMI_ACCESS_TOKEN Pulumi Cloud access token (state backend). For a
# self-managed backend instead, set the `cloud-url`
# input + a PULUMI_CONFIG_PASSPHRASE secret.
# - CLOUDFLARE_API_TOKEN Cloudflare token scoped to Workers Scripts + R2 (+ Cron).
# - CLOUDFLARE_ACCOUNT_ID Cloudflare account id (also set as stack config).
# - GOOGLE_CREDENTIALS GCP service-account JSON with Cloud DNS admin on the zone.
# stack CONFIG (infra/Pulumi.<stack>.yaml — replace every REPLACE_ME_* first):
# cloudflareAccountId, secretsStoreId, dnsManagedZone, dnsRecordName,
# dnsRecordTarget, gcp:project (+ optional r2/otel/dns overrides).
# Cloudflare Secrets Store must already hold the four secret values
# (bugreport-enc-keyring, bugreport-admin-token, github-token,
# otel-exporter-otlp-headers) under the configured secretsStoreId.
name: CD
on:
workflow_dispatch:
inputs:
stack:
description: 'Pulumi stack to deploy'
required: true
default: prod
type: choice
options:
- prod
- dev
# Least privilege: the job only needs to read the repo out; Pulumi auth is via env.
permissions:
contents: read
# Never run two deploys of the same stack concurrently; do not cancel an in-flight
# deploy (interrupting `pulumi up` can leave a stack mid-update).
concurrency:
group: cd-${{ github.event.inputs.stack }}
cancel-in-progress: false
jobs:
deploy:
name: deploy
runs-on: ubuntu-latest
# Gate on the production environment so its secrets + protection rules apply.
environment: production
steps:
# Deploys must be cut from main. workflow_dispatch lets a user pick any ref,
# so fail loudly if this was launched from a non-main branch.
- name: Guard - deploy only from main
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Deploy must be run from the 'main' branch (got '${{ github.ref }}')."
exit 1
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Mirror ci.yml: cache both the root and infra/ module go.sum (infra pulls the
# heavy Pulumi SDKs) so warm runs restore deps instead of re-downloading.
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.26'
cache-dependency-path: |
go.sum
infra/go.sum
# TinyGo builds the Wasm Worker (pnpm run build). Same version as ci.yml.
- name: Set up TinyGo
uses: acifani/setup-tinygo@dd8a7075d951a7595b2ef2123ed0ab1af0c13e56 # v3.0.0
with:
tinygo-version: '0.41.1'
- name: Set up pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: '10'
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: pnpm
- name: Install Node dependencies
run: pnpm install --frozen-lockfile
# TEMPORARY (tracking #26; tinygo-org/tinygo#5467): identical to ci.yml. TinyGo
# 0.41.1 vendors a net/http js/wasm overlay that fails to compile on Go 1.26;
# apply the exact upstream fix to the installed TinyGo source before building.
# git apply exits non-zero (failing loudly) if the source has drifted.
- name: Patch TinyGo net/http (temporary)
run: |
patch_file="$PWD/.ci/tinygo-net-roundtrip.patch"
tinygoroot="$(tinygo env TINYGOROOT)"
echo "Applying $patch_file to $tinygoroot/src/net/http/roundtrip_js.go"
git -C "$tinygoroot" apply --verbose "$patch_file" || {
echo "::error::TinyGo net/http patch did not apply cleanly; TinyGo source may have changed. Update or remove .ci/tinygo-net-roundtrip.patch (see #26)."
exit 1
}
# Produce build/worker.mjs (ES-module shim) + build/app.wasm. The infra program
# uploads the shim as the Worker's main module via the workerScriptPath config
# injected below.
- name: Build Wasm Worker
run: pnpm run build
# Install the Pulumi CLI and run `pulumi up` over infra/. config-map injects the
# freshly built artifact path so the WorkersScript uploads the real module
# (ContentFile) instead of the placeholder. Provider + backend credentials come
# from the production environment secrets below; nothing secret is committed.
- name: Pulumi up
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0
with:
command: up
stack-name: ${{ github.event.inputs.stack }}
work-dir: infra
upsert: false
config-map: '{ "libremail-bug-report-ingest-infra:workerScriptPath": { value: "../build/worker.mjs", secret: false } }'
env:
# Pulumi state backend (Pulumi Cloud). For a self-managed backend, drop this,
# set the action's `cloud-url` input, and add PULUMI_CONFIG_PASSPHRASE.
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
# Cloudflare provider (Workers + R2 + Cron Triggers).
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# GCP provider (Cloud DNS record).
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}