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>
12 KiB
Infrastructure (Pulumi, Go)
Infrastructure-as-code for the LibreMail bug-report ingest pipeline. This is its
own Go module (github.com/JMR-dev/LibreMail-Bug-Report-Ingest/infra) so the
Worker module at the repo root stays lean.
It provisions, with the Pulumi Go SDK and the
pulumi-cloudflare and
pulumi-gcp providers:
| Resource | Type | Purpose |
|---|---|---|
ingest-worker |
cloudflare.WorkersScript |
The ingest Worker (libremail-bug-report-ingest, built in #1), wired with its full binding set (R2 + Secrets Store + vars, see Worker bindings). |
reports-bucket |
cloudflare.R2Bucket |
Encrypted bug-report storage (libremail-bug-reports). Per ADR 0001 only ciphertext is written. |
ingest-cron-triggers |
cloudflare.WorkersCronTrigger |
The two Friday UTC Cron Triggers (#13) that drive the weekly publish job; bound to the ingest Worker. |
ingest-dns-record |
gcp.dns.RecordSet |
Google Cloud DNS record pointing the ingest hostname at the Worker's route/custom domain. |
DNS authority is Google Cloud DNS. The managed zone is referenced by name (it already exists / is managed elsewhere), and this stack only adds a record to it.
Worker content: placeholder by default, real artifact at deploy. The deployed Worker is Go compiled by TinyGo to Wasm plus the
syumai/workersES-module shim, emitted bypnpm run buildinto../build/(git-ignored, produced by CI). Set theworkerScriptPathconfig to the built main module (../build/worker.mjs) and the program uploads it viaContentFile+ContentSha256(computed from the file). WhenworkerScriptPathis unset, a documented placeholder module body is uploaded instead, so the resource is fully described and unit-testable without the artifact. The CD workflow (.github/workflows/deploy.yml) builds the Worker and injectsworkerScriptPath=../build/worker.mjsat deploy time, so a localpulumi preview(no build) still works off the placeholder.Note: a TinyGo Worker is
worker.mjs(main module) that importsapp.wasm.ContentFileuploads the main module; if a functional deploy needs the wasm as a separate module part, add it alongsideworker.mjsin the build output the pipeline points at. The bindings/crons below are provider-verified by the mock tests regardless of which content source is used.
Worker bindings
The Worker resource carries the bindings the runtime code reads, matching
wrangler.jsonc and the Worker source (internal/storage.*Binding,
worker/*_wasm.go). This is the wiring #4 added (the gap #9 flagged); the mock
tests (deploy_test.go) assert every one of them:
| Binding (JS var) | Type | Points at |
|---|---|---|
REPORTS_BUCKET |
r2_bucket |
r2BucketName (libremail-bug-reports). Encrypted report objects (ADR 0001). |
BUGREPORT_ENC_KEYRING |
secrets_store_secret |
secretsStoreId / secret bugreport-enc-keyring — AES-256 keyring (ADR 0001). |
ADMIN_TOKEN |
secrets_store_secret |
secretsStoreId / secret bugreport-admin-token — admin API token (ADR 0003). |
GITHUB_TOKEN |
secrets_store_secret |
secretsStoreId / secret github-token — publish PAT (#14). |
OTEL_EXPORTER_OTLP_HEADERS |
secrets_store_secret |
secretsStoreId / secret otel-exporter-otlp-headers — OTLP auth headers (#17). |
GITHUB_REPO |
plain_text |
githubRepo (JMR-dev/LibreMail) — publish target (#14). |
OTEL_EXPORTER_OTLP_ENDPOINT |
plain_text |
otelExporterOtlpEndpoint (empty ⇒ telemetry disabled, #17). |
OTEL_SERVICE_NAME |
plain_text |
otelServiceName (libremail-bug-report-ingest, #17). |
The Cron Triggers resource registers both Friday UTC crons (0 22 * * 5 and
0 23 * * 5) from wrangler.jsonc (#13); the Worker's scheduled handler gates on
the real America/Chicago rule so exactly one publishes each Friday.
The Secrets Store secret values themselves (keyring, admin token, GitHub token, OTLP headers) are not created by this stack — the maintainer stores them in Cloudflare Secrets Store under
secretsStoreId. This stack only binds them to the Worker by name.
Prerequisites
- Go (matching the repo toolchain) —
go build ./.../go test ./...work without the Pulumi CLI. - The Pulumi CLI — required only to
pulumi preview/pulumi up. It is not installed in CI yet, so a realpulumi previewis a follow-up (see "Deploying").
Configuration
Config is per-stack (Pulumi.<stack>.yaml). Program keys are namespaced by the
project name, libremail-bug-report-ingest-infra. Provider keys use the
cloudflare: / gcp: namespaces.
Program config (namespace libremail-bug-report-ingest-infra)
| Key | Required | Default | Description |
|---|---|---|---|
cloudflareAccountId |
yes | — | Cloudflare account that owns the Worker + R2 bucket. |
secretsStoreId |
yes | — | Cloudflare Secrets Store id holding the Worker's secrets (keyring, admin token, GitHub token, OTLP headers). Account-specific; not itself secret. |
dnsManagedZone |
yes | — | Name of the existing Google Cloud DNS managed zone. |
dnsRecordName |
yes | — | Ingest hostname as an FQDN with a trailing dot (e.g. bugreport.libremail.example.). |
dnsRecordTarget |
yes | — | CNAME target = the Worker route/custom domain (FQDN, trailing dot). |
workerName |
no | libremail-bug-report-ingest |
Worker script name (matches wrangler.jsonc). |
workerCompatibilityDate |
no | 2025-06-01 |
Worker runtime compatibility date. |
workerScriptPath |
no | — | Path to the built main module (../build/worker.mjs). When set, upload it via ContentFile + computed ContentSha256. Set by the CD workflow at deploy. |
workerScriptContent |
no | placeholder | Inline Worker module body used when workerScriptPath is unset. |
githubRepo |
no | JMR-dev/LibreMail |
GITHUB_REPO var: owner/repo the weekly publish job files issues on (#14). |
otelExporterOtlpEndpoint |
no | "" |
OTEL_EXPORTER_OTLP_ENDPOINT var: OTLP base URL; empty disables telemetry (#17). |
otelServiceName |
no | libremail-bug-report-ingest |
OTEL_SERVICE_NAME var: reported service.name (#17). |
r2BucketName |
no | libremail-bug-reports |
R2 bucket name (also the REPORTS_BUCKET binding target). |
r2BucketLocation |
no | (provider default) | R2 location hint: apac, eeur, enam, weur, wnam, oc. |
dnsRecordType |
no | CNAME |
DNS record type. |
dnsTtlSeconds |
no | 300 |
DNS record TTL (seconds). |
gcpProject |
no | (from gcp:project) |
Project the record belongs to, if different from the provider project. |
cloudflareZoneId |
no | — | Reserved for #6/#7 (rate-limit ruleset) and Worker routes/custom domain. Unused today. |
Set a non-secret value with, e.g.:
pulumi config set libremail-bug-report-ingest-infra:cloudflareAccountId <account-id>
Secrets and credentials — DO NOT COMMIT
Set these as encrypted Pulumi secrets (--secret) or via the provider's
environment variables. Never place plaintext secrets in Pulumi.<stack>.yaml.
| What | How | Notes |
|---|---|---|
| Cloudflare API token | pulumi config set --secret cloudflare:apiToken <token> (or env CLOUDFLARE_API_TOKEN) |
Scope it to Workers Scripts + R2 admin for the account. |
| GCP project | pulumi config set gcp:project <project-id> |
Not secret; identifies the GCP project. |
| GCP credentials | pulumi config set --secret gcp:credentials "$(cat key.json)" (or env GOOGLE_CREDENTIALS / Application Default Credentials) |
Service account with Cloud DNS admin on the zone. |
The bug-report encryption keyring (BUGREPORT_ENC_KEYRING, see
ADR 0001) lives in Cloudflare Secrets
Store, bound to the Worker — it is managed there, not committed here.
Deploying
Via GitHub Actions (recommended)
.github/workflows/deploy.yml is a
manual, workflow_dispatch-only CD workflow. A maintainer runs it from the
Actions tab, on the main branch (a guard step fails otherwise), and picks a
stack (default prod). It reuses ci.yml's Go/TinyGo/pnpm setup + the TinyGo
net/http patch, runs pnpm run build, then pulumi up --stack <stack> over this
program (injecting the built ../build/worker.mjs as workerScriptPath). It is
gated to the production GitHub Actions environment, so that environment's
secrets and any required-reviewer / protection rules apply.
One-time setup before the first deploy — the maintainer configures:
-
productionenvironment secrets (repo Settings → Environments → production):Secret Purpose PULUMI_ACCESS_TOKENPulumi Cloud token (state backend). Self-managed backend? Use the action's cloud-urlinput + aPULUMI_CONFIG_PASSPHRASEsecret instead.CLOUDFLARE_API_TOKENCloudflare token scoped to Workers Scripts + R2 (+ Cron Triggers) on the account. CLOUDFLARE_ACCOUNT_IDCloudflare account id. GOOGLE_CREDENTIALSGCP service-account JSON with Cloud DNS admin on the managed zone. -
Stack config in
Pulumi.<stack>.yaml— replace everyREPLACE_ME_*(cloudflareAccountId,secretsStoreId,dnsManagedZone,dnsRecordName,dnsRecordTarget,gcp:project). -
Cloudflare Secrets Store — under
secretsStoreId, store the four secret values the Worker binds:bugreport-enc-keyring,bugreport-admin-token,github-token,otel-exporter-otlp-headers. -
Pulumi stack — create it and select it once (
pulumi stack init <stack>); the workflow runs withupsert: falseand expects it to exist.
Trigger: Actions → CD → Run workflow → branch main, stack prod.
Via the Pulumi CLI (manual)
pulumi stack select prod # or: pulumi stack init prod
# set the REPLACE_ME_* config values + provider secrets above, then build + deploy:
pnpm run build # produces ../build/worker.mjs (needed if workerScriptPath is set)
pulumi preview
pulumi up
No real deploy has been run from this repo yet: the mechanism (this program + the CD workflow) is compile-, test-, and actionlint-verified only. The maintainer supplies the credentials/config above and triggers the first deploy.
Testing (no Pulumi CLI required)
The program is exercised with the Pulumi Go SDK's mocking
(pulumi.RunErr + pulumi.WithMocks), which registers resources against an
in-memory monitor — no cloud calls, no CLI. The tests assert that the expected
resources are registered with the expected inputs: Worker name/account, its full
binding set (R2 REPORTS_BUCKET, the four Secrets Store secrets, the three
plain vars) and the Cron Triggers, the ContentFile/ContentSha256 artifact
path, R2 bucket name/location, and DNS type/name/target/ttl.
go vet ./...
go build ./...
go test ./...
Adding rate limiting later (#6 ADR / #7 impl)
The abuse/rate-limit ADR (#6) chose to
implement ingest rate limiting as Cloudflare Rate Limiting rules via Pulumi
(cloudflare.NewRuleset, phase http_ratelimit, scoped to a zone). That is out
of scope for this ticket. The cloudflareZoneId config key and the forward note
in deploy.go leave a clean insertion point; #7 adds the ruleset resource there.