Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/_cf-worker-preview-gc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ on:
type: string
default: ubuntu-latest
secrets:
CLOUDFLARE_PREVIEW_API_TOKEN:
CLOUDFLARE_API_TOKEN:
required: true
CLOUDFLARE_PREVIEW_ACCOUNT_ID:
CLOUDFLARE_ACCOUNT_ID:
required: true


jobs:
gc:
name: Preview GC
Expand All @@ -64,8 +65,8 @@ jobs:
deployments: write # only used to record "Preview garbage-collected" on the ledger when deleting
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GC_ENABLED: ${{ inputs.enabled }}
GC_DRY_RUN: ${{ inputs.dry_run }}
GC_RETENTION_DAYS: ${{ inputs.retention_days }}
Expand Down
19 changes: 7 additions & 12 deletions .github/workflows/_cf-worker-v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,8 @@ on:
type: string
default: linux
secrets:
CLOUDFLARE_PREVIEW_API_TOKEN:
description: Least-privilege token (Workers Scripts edit + Preview resources + Preview D1). NEVER the Production token.
required: false
CLOUDFLARE_PREVIEW_ACCOUNT_ID:
required: false
CLOUDFLARE_API_TOKEN:
description: Account token for Production and Dev; Preview has separate credentials
description: Account token for Production, Preview and Dev
required: false
CLOUDFLARE_ACCOUNT_ID:
required: false
Expand Down Expand Up @@ -641,8 +636,8 @@ jobs:
d1_scan: ${{ steps.engine.outputs.d1_scan }}
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_API_TOKEN) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_API_TOKEN) || '' }}
CLOUDFLARE_ACCOUNT_ID: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_ACCOUNT_ID) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID) || '' }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -706,8 +701,8 @@ jobs:
migration: ${{ needs.migration.outputs.migration }}
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_API_TOKEN) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_API_TOKEN) || '' }}
CLOUDFLARE_ACCOUNT_ID: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_ACCOUNT_ID) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID) || '' }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -826,8 +821,8 @@ jobs:
rollback_result: ${{ steps.engine.outputs.rollback_result }}
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_API_TOKEN) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_API_TOKEN) || '' }}
CLOUDFLARE_ACCOUNT_ID: ${{ (github.ref == 'refs/heads/main' && secrets.CLOUDFLARE_ACCOUNT_ID) || (github.ref != 'refs/heads/main' && secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID) || '' }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- uses: actions/checkout@v6
with:
Expand Down
4 changes: 2 additions & 2 deletions actions/cf-preview/engine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,10 @@ case "$PHASE" in
R="$(node "$TOOLS_DIR/cli.mjs" resolve --event "$EVENT" --ref "$BRANCH_REF" --strategy "$STRATEGY" --preview-on-push "$(inp preview_on_branch_push)" --dispatch-target "$(inp dispatch_target)" | sed -n 's/^target=//p')"
[ "$R" = "$T" ] || die "target mismatch (resolved '$R', engine '$T'); refusing to deploy"
fi
# Each credential belongs to exactly one target; the job passes only that one and there is no fallback.
# Prod, Preview and Dev all use the one account credential pair (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID).
# The Smoke job (stage verify) never receives Cloudflare credentials: it only probes the published URL.
if [ "$STAGE" != verify ]; then
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "$T credentials not provided; refusing to fall back to other credentials"
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "$T credentials not provided (set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)"
elif [ -n "${CLOUDFLARE_API_TOKEN:-}${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
die "the Smoke stage must not receive Cloudflare credentials"
fi
Expand Down
8 changes: 4 additions & 4 deletions docs/cf-worker-preview-gc.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Retention starts at the **branch-deletion tombstone**, not at creation or last d
1. **Ledger** (GitHub Deployments of the consumer repo, read with its own `github.token`, fully paginated): environments `<app>/preview/<name>`. All records of a Preview must agree on `repo`, `worker`, `host_worker`, `preview_name` and a single `branch_ref`, and `previewIdentity()` of that ref must reproduce `<name>`. Anything else (legacy records without payload/host, two refs sharing a slug) → SKIP.
2. **Tombstone**: the latest status of the newest deployment must be `inactive` with description exactly `Branch deleted; Preview kept` (written by the v2 `lifecycle` job via `gh.sh retire`) and created by `github-actions[bot]`. Its `created_at` is `deleted_at`. A superseded/other inactive status is not a tombstone; a newer deployment (including one in progress) after it means the Preview is live.
3. **Branch**: live `git/ref/heads/<branch>` must be 404, and no existing branch may normalise to the same Preview name.
4. **Cloudflare**: the target `account_id` must equal `CLOUDFLARE_PREVIEW_ACCOUNT_ID`; `GET /accounts/{id}/workers/workers/{host}/previews/{name}` decides existence (404 = already gone, treated as idempotent ledger cleanup). 401/403/429 abort the run.
4. **Cloudflare**: the target `account_id` must equal `CLOUDFLARE_ACCOUNT_ID`; `GET /accounts/{id}/workers/workers/{host}/previews/{name}` decides existence (404 = already gone, treated as idempotent ledger cleanup). 401/403/429 abort the run.
5. **Just before each delete**: branch, newest deployment id, ownership payload, tombstone status and the Cloudflare Preview are re-read; any change skips it (protects against concurrent pushes / branch recreation).
6. **Delete**: `wrangler@4.135.0 preview delete --name <name> --worker-name <host> --skip-confirmation` with the account passed explicitly. Never `wrangler delete`. At most `max_deletions_per_run`, oldest first. Afterwards an `inactive` status `Preview garbage-collected (<ts>)` is appended to the newest deployment so it is never retried. GitHub Deployment history is never deleted.

Expand All @@ -44,7 +44,7 @@ Only Preview names derived from `<app>/preview/<name>` ledger environments are e
| `only_previews` | – | JSON `["name"]`, restrict a controlled cleanup to exactly these |
| `tools_ref` | required | must equal the ref the caller pins |

Secrets: `CLOUDFLARE_PREVIEW_API_TOKEN`, `CLOUDFLARE_PREVIEW_ACCOUNT_ID` (same contract as v2). A target whose `account_id` differs from the supplied credentials is reported as ERROR and untouched; for several Cloudflare accounts add one caller job per account with its own secrets.
Secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` (same pair as v2; there are no Preview-specific credentials). A target whose `account_id` differs from the supplied credentials is reported as ERROR and untouched; for several Cloudflare accounts add one caller job per account with its own secrets.

Permissions: `contents: read`, `deployments: write` (only used when deleting). The Cloudflare token needs Workers Scripts Edit on the Preview account.

Expand Down Expand Up @@ -78,8 +78,8 @@ jobs:
targets: '[{"app_name":"www","preview_worker_name":"www","account_id":"<cloudflare account id>"}]'
protected_previews: '[{"account_id":"<id>","host_worker":"www","preview_name":"<fixed-dev-preview-name>"}]'
secrets:
CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
CLOUDFLARE_PREVIEW_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

The workflow file must be on the default branch for `schedule` to run.
Expand Down
6 changes: 3 additions & 3 deletions docs/cf-worker-v2-refactor.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ detect ─┬─ A (deploy call): CI ‖ E2E ─► Migration ─► Deploy
| Concern | prod | preview | dev |
|---|---|---|---|
| Trigger | main push / dispatch | non-main push (`preview_on_branch_push`) | non-main push (`shared_dev_on_branch_push`); manual: protected dispatch |
| Credentials in the job env | `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` | `CLOUDFLARE_PREVIEW_*` (+ `PREVIEW_SECRETS`) | `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` (same account token as Production) |
| Credentials in the job env | `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` | `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` (same account token; + `PREVIEW_SECRETS`) | `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` (same account token as Production) |
| Environment | `<app>/prod` | `<app>/preview` | `<app>/dev` |
| Build env / vars | `build_env_prod` | `build_env_preview` | `build_env_dev`, `dev_build_cmd`, `CLOUDFLARE_ENV=dev_wrangler_env` |
| D1 (same project SQL) | top-level `d1_databases` | `previews.d1_databases` | `env.<dev_wrangler_env>.d1_databases` |
Expand Down Expand Up @@ -174,7 +174,7 @@ A reusable workflow called from another repository resolves `uses: ./...` agains
| Hybrid | `persistent_dev_ref`, `dev_deploy_cmd`, `dev_smoke_cmd`, `dispatch_target` |
| Shared Dev | `shared_dev_on_branch_push`, `app_url_dev`, `dev_deploy_cmd`, `dev_smoke_cmd`, `build_env_dev`, `dev_build_cmd`, `dev_wrangler_env` (default `dev`), `dev_worker_name`, `dev_binding_allow`, `dev_deny_resources`; reuses `d1_migrations`, `d1_binding`, `sha_path`, `smoke_env`, `access_protected_targets`, `preview_deny_resources` |

Outputs: `deploy_url`, `deploy_target`, `version_id` (prod only), `dev_url` (Shared Dev). The internal `deployed_and_verified` Deploy output is what gates Release. Secrets: `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` (prod), `CLOUDFLARE_PREVIEW_API_TOKEN/ACCOUNT_ID`, `PREVIEW_SECRETS`, `SMOKE_USERNAME/PASSWORD`, `RELEASE_TOKEN`.
Outputs: `deploy_url`, `deploy_target`, `version_id` (prod only), `dev_url` (Shared Dev). The internal `deployed_and_verified` Deploy output is what gates Release. Secrets: `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` (prod), `PREVIEW_SECRETS`, `SMOKE_USERNAME/PASSWORD`, `RELEASE_TOKEN`.

`*_env` inputs are `KEY=VALUE` lines imported by `actions/cf-preview/parse-env.mjs` **from an environment variable** (never interpolated into a script). It rejects malformed lines and refuses `CLOUDFLARE_*`, `GITHUB_*`, `RUNNER_*`, `PATH`, `NODE_OPTIONS`, `LD_*`, `BASH_ENV`, `GIT_*`, `SMOKE_*` credentials, etc.; all-or-nothing. Application variable names remain consumer-owned. Command inputs are passed through `env:` and run with `bash -c "$CMD"`.

Expand Down Expand Up @@ -218,7 +218,7 @@ Two independent switches decide what a non-main push deploys. Neither requires t
* **Same D1.** `cli.mjs d1-consistency` requires the D1 binding of the config that will be published (effective config, or the source for an un-flattened config) to have the same physical `database_id` as the validated migration target. A mismatch fails before the migration and the publish: DB-A is never migrated while a Worker bound to DB-B is deployed.
* With `d1_migrations`, `dev_build_cmd` is **required**, so the Dev build always succeeds before the Dev D1 is migrated.
* **Shared-D1 guard:** if the Preview and Dev D1 are the same physical database and the Preview Deploy job reported its migration `failed` (or was cancelled), Shared Dev stops before touching anything (D1 state unknown). With distinct D1s, Preview off, or a Preview failure after a successful migration (e.g. a failed smoke), Dev still deploys; a Preview failure never blocks Dev otherwise.
* Credentials: Dev reuses `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` in the `<app>/dev` Environment. Preview credentials and `PREVIEW_SECRETS` are not read in Dev. Missing account credentials fail closed; Dev Worker, environment and resource audits remain separate.
* Credentials: Dev reuses `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` in the `<app>/dev` Environment. `PREVIEW_SECRETS` is not read in Dev. Missing account credentials fail closed; Dev Worker, environment and resource audits remain separate.
* Smoke fails the job. Recovery: the job records a ledger entry `<app>/dev/shared`; after a failed smoke it redeploys the last verified SHA (from any branch) with the same build, Worker-identity/resource audit and full smoke (probe, `sha_path` for that SHA, `dev_smoke_cmd`), or states **manual recovery required** when none exists. Recovery is skipped when a newer push already owns the branch. The run stays red either way, and a successful recovery never marks the failed SHA verified.
* Concurrency: workflow-level group (below) queues all branches; the job also uses `cfv2-dev-<repo>-<d1_lock_scope or app_name>`, the same group as `persistent-dev`, so automatic and manual Dev deployments never overlap. It differs from the workflow-level groups, so there is no caller/callee deadlock.
* Retention: branch deletion and disabling a switch deploy or delete nothing; no job deletes a Worker.
Expand Down
8 changes: 3 additions & 5 deletions docs/cf-worker-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Spec: `openspec/changes/redesign-cloudflare-worker-cicd-v2`; tracking issue #43.

* Every push to a non-`main` branch publishes (or updates) **that branch's own Native Preview** with a stable URL — no PR needed.
* `main` pushes go through the Production path (Migration → Deploy → Smoke Tests → Revert on Verify Failure, see docs/cf-worker-v2-refactor.md).
* A Preview is **never** deployed to a shared Dev Worker, and Production credentials never reach Preview jobs.
* A Preview is **never** deployed to a shared Dev Worker. Preview, Dev and Production share one account credential pair; isolation is by target Environment, Worker identity and binding guardrails.
* Strategies: `production-preview` (default), `hybrid` (adds a protected persistent Dev for Cron/Queue), `production-only` (CI only, no Preview — e.g. `anas-mcp`).

## Versions and pinning
Expand Down Expand Up @@ -87,15 +87,13 @@ jobs:
deployment_strategy: production-preview
app_url_prod: https://example.com
secrets:
CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
CLOUDFLARE_PREVIEW_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

Notes

* A repo may map the Preview secrets to its existing deploy credentials (owner decision; acceptable for stateless Workers). A dedicated least-privilege Preview token is recommended for Workers with stateful bindings.
* Preview uses the same `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` as Production and Dev (owner decision). The old `CLOUDFLARE_PREVIEW_*` secrets no longer exist: callers must stop passing them (GitHub rejects undeclared secrets) and there is no fallback.
* Monorepos: one caller job per Worker (`app_name`, `app_path`), each guarded by a changed-path filter; the `delete` event must run **all** caller jobs (retiring the ledger is idempotent).
* Run `lint`/typecheck before the shared pipeline in the caller, as usual, and make the `pipeline` job depend on them.

Expand Down Expand Up @@ -176,7 +174,7 @@ There is no automatic reclaim; Previews accumulate until removed manually. A rep

## Security

* Production and Dev jobs use the same `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` pair by owner decision. Preview keeps its `CLOUDFLARE_PREVIEW_*` credentials. Dev still uses its own `<app>/dev` Environment, Worker identity and binding guardrails.
* Production and Dev jobs use the same `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` pair by owner decision. Preview uses the same pair too. Dev still uses its own `<app>/dev` Environment, Worker identity and binding guardrails.
* Fork PRs get no deployment job and no secrets.
* `production-only` never creates a Preview (use for `anas-mcp`: its authentication depends on Cloudflare Access at the custom domain).
* Native Preview hostnames may sit behind Cloudflare Access (Blog's do): anonymous requests see only the Access login.
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/redesign-cloudflare-worker-cicd-v2/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Preserve app-owned build, unit/integration/E2E/migrate/verify commands, app_path
### Preview configuration and state

- Top-level Wrangler config is Production. New Native Previews MUST have an explicit `[previews]` (or equivalent JSON `previews`) block and resource bindings. Do not assume `[env.dev]` or `preview_urls = true` creates Native Preview isolation. Keep top-level assets and compatibility flags at their documented location; build each branch's assets before Preview deployment.
- Preview secrets use separate Preview Base/Preview-specific secrets, never Prod credentials. Base-secret changes do not automatically update existing Preview instances; include a rotation/reseed process.
- Preview secrets use separate Preview Base/Preview-specific secrets, Cloudflare API credentials are the shared account pair (no Preview-specific token). Base-secret changes do not automatically update existing Preview instances; include a rotation/reseed process.
- Preflight audits D1, R2, KV, Queues, Durable Objects, Containers, Workflows, Service Bindings, Hyperdrive, Secrets Store, routes, auth, outgoing webhook/API side effects, rate limits and analytics. Reject missing/unknown/Production-risk bindings and unsafe public ingress. Service bindings from Preview target the bound Worker's **Production** deployment by default; isolate, mock or explicitly deny unsafe cross-service calls.
- `blog` binds ONE shared non-production Preview D1 (`firstsun-blog-db-preview`, migrations applied to it before publish; never Production) and ONE preview R2 bucket (`firstsun-blog-shinyi-muyu-voice-cache-preview`, objects namespaced by `SHINYI_MUYU_VOICE_CACHE_ENVIRONMENT=preview`) — **no per-branch resources (owner decision)**; it cannot write to Production `firstsun-blog-assets`. Also safe Preview secrets, test-only notification/TTS/analytics, `SITE_NOINDEX=true`, and a real Preview URL in smoke. Build flags (such as voice QA) are keyed on `VITE_APP_ENV=preview`, not the old shared `SITE_URL`. Accepted limit: branches share the preview DB, so differing schema revisions can interfere; concurrent migrations may race, so the migrate step retries once.
- For other Workers, shared non-production resources need a written owner decision stating the isolation level and its limits (namespace isolation proof, or accepted shared state); branch-scoped resources are the recommended option when migrations or concurrent writes can interfere, never an unconditional requirement. Never bind a Preview to a Production resource as a fallback when provisioning/quota checks fail.
Expand Down Expand Up @@ -92,7 +92,7 @@ Branch deletion is handled by the repository default-branch workflow's GitHub `d

Hybrid `heaven-id` Cron and `notification-hub` Queue/Cron are **not executed by Native Previews**. Run them in tests or protected, independently deployed persistent Dev; no generic branch push changes that environment. Ensure Dev Queue producers/consumers and Cron do not deliver real notifications or production side effects.

Production retains required quality gates, version history, migration approval and smoke. Recovery policy is per Worker: automatic rollback only with proven schema/state compatibility, otherwise manual/disabled; Worker rollback cannot undo D1 schema or third-party mutations. Separate concurrency groups and credentials by app/branch/environment.
Production retains required quality gates, version history, migration approval and smoke. Recovery policy is per Worker: automatic rollback only with proven schema/state compatibility, otherwise manual/disabled; Worker rollback cannot undo D1 schema or third-party mutations. Separate concurrency groups by app/branch/environment; Cloudflare credentials are the shared account pair.

## Release and validation

Expand Down
Loading
Loading