#4 GitHub Actions CD: deploy via workflow_dispatch #46

Merged
JMR-dev merged 2 commits from ticket-4-cd-deploy into main 2026-07-02 23:56:22 +00:00
JMR-dev commented 2026-07-02 22:41:28 +00:00 (Migrated from github.com)

Builds the deploy mechanism only for #4. No deploy/preview/provision was run; a maintainer supplies credentials and pulls the trigger later.

Deliverable 1 — .github/workflows/deploy.yml (CD)

Manual workflow_dispatch-only workflow (never runs on push/PR):

  • Input: stack choice, default prod (also dev).
  • Guards: runs in the production GitHub Actions environment (its secrets + any required-reviewer/protection rules apply) and a step fails the run if not launched from main.
  • Steps: checkout → Go 1.26 + TinyGo 0.41.1 + pnpm/Node → pnpm install → apply the .ci/tinygo-net-roundtrip.patch (same as CI) → pnpm run build (Wasm Worker) → pulumi up --stack <stack> via pulumi/actions, which installs the Pulumi CLI and injects the freshly built ../build/worker.mjs through config-map as workerScriptPath.
  • Concurrency: one deploy per stack, never cancels an in-flight pulumi up.
  • Every action is pinned by commit SHA (pulumi/actions@8e5e406… # v7.0.0, plus the same SHAs CI uses). actionlint clean.

Deliverable 2 — infra binding-wiring fix (#9)

infra/deploy.go previously registered a bindingless WorkersScript, so the deployed Worker couldn't reach its bucket/secrets/vars. Now the WorkersScript carries the full runtime contract (matches wrangler.jsonc):

Binding (JS var) Type Target
REPORTS_BUCKET r2_bucket libremail-bug-reports
BUGREPORT_ENC_KEYRING secrets_store_secret secretsStoreId / bugreport-enc-keyring
ADMIN_TOKEN secrets_store_secret secretsStoreId / bugreport-admin-token
GITHUB_TOKEN secrets_store_secret secretsStoreId / github-token
OTEL_EXPORTER_OTLP_HEADERS secrets_store_secret secretsStoreId / otel-exporter-otlp-headers
GITHUB_REPO plain_text JMR-dev/LibreMail
OTEL_EXPORTER_OTLP_ENDPOINT plain_text "" (disables telemetry)
OTEL_SERVICE_NAME plain_text libremail-bug-report-ingest

Also:

  • New cloudflare.WorkersCronTrigger registering the two Friday UTC crons 0 22 * * 5 and 0 23 * * 5 (#13), bound to the Worker.
  • Real artifact as content: when workerScriptPath is set the Worker uploads it via ContentFile + a program-computed ContentSha256; otherwise the documented placeholder module body is used (keeps the program testable without a build, as #2 did). The CD workflow sets the path.
  • Kept account-specific values as documented config/placeholders; secretsStoreId is a new required config. The #6/#7 rate-limit ruleset insertion point remains reserved.

Secrets & config a maintainer MUST set before the first deploy

1. production environment secrets (Settings → Environments → production):

Secret Purpose
PULUMI_ACCESS_TOKEN Pulumi Cloud token (state backend). Self-managed backend → use the action's cloud-url input + PULUMI_CONFIG_PASSPHRASE instead.
CLOUDFLARE_API_TOKEN Cloudflare token scoped to Workers Scripts + R2 (+ Cron Triggers).
CLOUDFLARE_ACCOUNT_ID Cloudflare account id.
GOOGLE_CREDENTIALS GCP service-account JSON with Cloud DNS admin on the zone.

2. Stack config in infra/Pulumi.<stack>.yaml — replace every REPLACE_ME_*: cloudflareAccountId, secretsStoreId (new), dnsManagedZone, dnsRecordName, dnsRecordTarget, gcp:project. (Optional overrides: githubRepo, otel*, r2*, dns*.)

3. Cloudflare Secrets Store — under secretsStoreId, store the four secret values: bugreport-enc-keyring, bugreport-admin-token, github-token, otel-exporter-otlp-headers. (This stack binds them by name; it does not create the values.)

4. Pulumi stack — pulumi stack init prod once (workflow runs upsert: false).

How to trigger

Actions → CD → Run workflow → branch main, stack prod.

Mock-test evidence (runs via go test in infra/, no CLI)

Extended the WithMocks suite; cd infra && go build ./... && go vet ./... && go test ./... is green:

--- PASS: TestWorkerScriptRegistered
--- PASS: TestWorkerScriptBindings        (R2 + 4 Secrets Store + 3 plain vars, exact store/secret_name)
--- PASS: TestWorkerContentFromArtifact   (ContentFile + computed ContentSha256; content unset)
--- PASS: TestR2BucketRegistered
--- PASS: TestDNSRecordRegistered
--- PASS: TestWorkerCronTriggers          (0 22 * * 5, 0 23 * * 5, bound to the Worker)
--- PASS: TestManagedResourceCounts       (1 each: WorkersScript, R2Bucket, WorkersCronTrigger, RecordSet)
--- PASS: TestMissingRequiredConfigIsAnError
ok  github.com/JMR-dev/LibreMail-Bug-Report-Ingest/infra

actionlint .github/workflows/deploy.yml → clean.

Not done (by design — this is the mechanism only)

No pulumi up/preview, no wrangler deploy, no gh workflow run, no cloud resources/secrets/environments created. The maintainer configures the above and triggers the deploy.

Closes #4

Builds the **deploy mechanism only** for #4. No deploy/preview/provision was run; a maintainer supplies credentials and pulls the trigger later. ## Deliverable 1 — `.github/workflows/deploy.yml` (CD) Manual **`workflow_dispatch`-only** workflow (never runs on push/PR): - **Input:** `stack` choice, default `prod` (also `dev`). - **Guards:** runs in the **`production`** GitHub Actions environment (its secrets + any required-reviewer/protection rules apply) and a step **fails the run if not launched from `main`**. - **Steps:** checkout → Go 1.26 + TinyGo 0.41.1 + pnpm/Node → `pnpm install` → **apply the `.ci/tinygo-net-roundtrip.patch`** (same as CI) → `pnpm run build` (Wasm Worker) → `pulumi up --stack <stack>` via `pulumi/actions`, which installs the Pulumi CLI and injects the freshly built `../build/worker.mjs` through `config-map` as `workerScriptPath`. - **Concurrency:** one deploy per stack, never cancels an in-flight `pulumi up`. - Every action is **pinned by commit SHA** (`pulumi/actions@8e5e406…` # v7.0.0, plus the same SHAs CI uses). **actionlint clean.** ## Deliverable 2 — infra binding-wiring fix (#9) `infra/deploy.go` previously registered a bindingless `WorkersScript`, so the deployed Worker couldn't reach its bucket/secrets/vars. Now the `WorkersScript` carries the full runtime contract (matches `wrangler.jsonc`): | Binding (JS var) | Type | Target | | --- | --- | --- | | `REPORTS_BUCKET` | `r2_bucket` | `libremail-bug-reports` | | `BUGREPORT_ENC_KEYRING` | `secrets_store_secret` | `secretsStoreId` / `bugreport-enc-keyring` | | `ADMIN_TOKEN` | `secrets_store_secret` | `secretsStoreId` / `bugreport-admin-token` | | `GITHUB_TOKEN` | `secrets_store_secret` | `secretsStoreId` / `github-token` | | `OTEL_EXPORTER_OTLP_HEADERS` | `secrets_store_secret` | `secretsStoreId` / `otel-exporter-otlp-headers` | | `GITHUB_REPO` | `plain_text` | `JMR-dev/LibreMail` | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `plain_text` | `""` (disables telemetry) | | `OTEL_SERVICE_NAME` | `plain_text` | `libremail-bug-report-ingest` | Also: - **New `cloudflare.WorkersCronTrigger`** registering the two Friday UTC crons `0 22 * * 5` and `0 23 * * 5` (#13), bound to the Worker. - **Real artifact as content:** when `workerScriptPath` is set the Worker uploads it via `ContentFile` + a program-computed `ContentSha256`; otherwise the documented placeholder module body is used (keeps the program testable without a build, as #2 did). The CD workflow sets the path. - Kept account-specific values as documented config/placeholders; **`secretsStoreId` is a new required config**. The #6/#7 rate-limit ruleset insertion point remains reserved. ## Secrets & config a maintainer MUST set before the first deploy **1. `production` environment secrets** (Settings → Environments → production): | Secret | Purpose | | --- | --- | | `PULUMI_ACCESS_TOKEN` | Pulumi Cloud token (state backend). Self-managed backend → use the action's `cloud-url` input + `PULUMI_CONFIG_PASSPHRASE` instead. | | `CLOUDFLARE_API_TOKEN` | Cloudflare token scoped to Workers Scripts + R2 (+ Cron Triggers). | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account id. | | `GOOGLE_CREDENTIALS` | GCP service-account JSON with Cloud DNS admin on the zone. | **2. Stack config** in `infra/Pulumi.<stack>.yaml` — replace every `REPLACE_ME_*`: `cloudflareAccountId`, **`secretsStoreId`** (new), `dnsManagedZone`, `dnsRecordName`, `dnsRecordTarget`, `gcp:project`. (Optional overrides: `githubRepo`, `otel*`, `r2*`, `dns*`.) **3. Cloudflare Secrets Store** — under `secretsStoreId`, store the four secret *values*: `bugreport-enc-keyring`, `bugreport-admin-token`, `github-token`, `otel-exporter-otlp-headers`. (This stack binds them by name; it does not create the values.) **4. Pulumi stack** — `pulumi stack init prod` once (workflow runs `upsert: false`). ## How to trigger Actions → **CD** → *Run workflow* → branch **`main`**, stack **`prod`**. ## Mock-test evidence (runs via `go test` in `infra/`, no CLI) Extended the `WithMocks` suite; `cd infra && go build ./... && go vet ./... && go test ./...` is green: ``` --- PASS: TestWorkerScriptRegistered --- PASS: TestWorkerScriptBindings (R2 + 4 Secrets Store + 3 plain vars, exact store/secret_name) --- PASS: TestWorkerContentFromArtifact (ContentFile + computed ContentSha256; content unset) --- PASS: TestR2BucketRegistered --- PASS: TestDNSRecordRegistered --- PASS: TestWorkerCronTriggers (0 22 * * 5, 0 23 * * 5, bound to the Worker) --- PASS: TestManagedResourceCounts (1 each: WorkersScript, R2Bucket, WorkersCronTrigger, RecordSet) --- PASS: TestMissingRequiredConfigIsAnError ok github.com/JMR-dev/LibreMail-Bug-Report-Ingest/infra ``` `actionlint .github/workflows/deploy.yml` → clean. ## Not done (by design — this is the mechanism only) No `pulumi up`/`preview`, no `wrangler deploy`, no `gh workflow run`, no cloud resources/secrets/environments created. The maintainer configures the above and triggers the deploy. Closes #4
Sign in to join this conversation.