diff --git a/.github/workflows/_cf-worker-preview-gc.yml b/.github/workflows/_cf-worker-preview-gc.yml index b2b6007..c3bcb91 100644 --- a/.github/workflows/_cf-worker-preview-gc.yml +++ b/.github/workflows/_cf-worker-preview-gc.yml @@ -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 @@ -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 }} diff --git a/.github/workflows/_cf-worker-v2.yml b/.github/workflows/_cf-worker-v2.yml index 31d5995..0dc1b7d 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -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 @@ -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: @@ -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: @@ -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: diff --git a/actions/cf-preview/engine.sh b/actions/cf-preview/engine.sh index 074e408..52d3234 100755 --- a/actions/cf-preview/engine.sh +++ b/actions/cf-preview/engine.sh @@ -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 diff --git a/docs/cf-worker-preview-gc.md b/docs/cf-worker-preview-gc.md index 614c846..162c04b 100644 --- a/docs/cf-worker-preview-gc.md +++ b/docs/cf-worker-preview-gc.md @@ -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 `/preview/`. 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 ``. 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/` 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 --worker-name --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 ()` is appended to the newest deployment so it is never retried. GitHub Deployment history is never deleted. @@ -44,7 +44,7 @@ Only Preview names derived from `/preview/` 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. @@ -78,8 +78,8 @@ jobs: targets: '[{"app_name":"www","preview_worker_name":"www","account_id":""}]' protected_previews: '[{"account_id":"","host_worker":"www","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. diff --git a/docs/cf-worker-v2-refactor.md b/docs/cf-worker-v2-refactor.md index 8ab50af..387d395 100644 --- a/docs/cf-worker-v2-refactor.md +++ b/docs/cf-worker-v2-refactor.md @@ -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 | `/prod` | `/preview` | `/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..d1_databases` | @@ -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"`. @@ -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 `/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 `/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 `/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--`, 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. diff --git a/docs/cf-worker-v2.md b/docs/cf-worker-v2.md index 82f0e93..2620875 100644 --- a/docs/cf-worker-v2.md +++ b/docs/cf-worker-v2.md @@ -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 @@ -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. @@ -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 `/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 `/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. diff --git a/openspec/changes/redesign-cloudflare-worker-cicd-v2/design.md b/openspec/changes/redesign-cloudflare-worker-cicd-v2/design.md index ec14108..a7f588d 100644 --- a/openspec/changes/redesign-cloudflare-worker-cicd-v2/design.md +++ b/openspec/changes/redesign-cloudflare-worker-cicd-v2/design.md @@ -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. @@ -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 diff --git a/openspec/changes/redesign-cloudflare-worker-cicd-v2/proposal.md b/openspec/changes/redesign-cloudflare-worker-cicd-v2/proposal.md index ec34218..236195a 100644 --- a/openspec/changes/redesign-cloudflare-worker-cicd-v2/proposal.md +++ b/openspec/changes/redesign-cloudflare-worker-cicd-v2/proposal.md @@ -21,7 +21,7 @@ The published `_cf-worker-template.yml` v1/v1.5.0 uploads a version and then dep ## Approved decisions and platform facts (2026-10-20) -- Previews may reuse the repository's existing Cloudflare deploy credentials (no separate least-privilege token) — owner decision; a dedicated Preview token remains recommended for Workers with stateful bindings. +- Previews use the repository's existing Cloudflare deploy credentials (`CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`, same as Production and Dev); there is no separate Preview token or account ID and no fallback — owner decision. - No per-branch D1/R2 for Blog (see Data and security policy). - Facts proven in the `heaven-www` pilot and `wrangler 4.151.1`: see design.md "Verified platform facts". diff --git a/openspec/changes/refactor-cloudflare-worker-cicd-v2/design.md b/openspec/changes/refactor-cloudflare-worker-cicd-v2/design.md index 37403f7..5686456 100644 --- a/openspec/changes/refactor-cloudflare-worker-cicd-v2/design.md +++ b/openspec/changes/refactor-cloudflare-worker-cicd-v2/design.md @@ -94,7 +94,7 @@ One reusable `Deploy` job implements target-specific behavior; do not define par 7. If smoke fails, mark deployment failed and follow a documented, tested code recovery policy; recovery never claims to revert D1. 8. Reject stale Preview pushes and branch normalization collisions before overwriting an existing Preview. -Reuse tested v2 identity ledger, smoke, production version/recovery and safety functionality; simplify orchestration, not the correctness checks. Protected Persistent Dev and branch deletion/PR URL linking may use separate event-specific handlers outside the normal core DAG, without duplicating whole pipelines. Production and Preview credentials must not coexist in an untrusted execution context: validate trust and GitHub Environment protection, not merely a caller-supplied target string. +Reuse tested v2 identity ledger, smoke, production version/recovery and safety functionality; simplify orchestration, not the correctness checks. Protected Persistent Dev and branch deletion/PR URL linking may use separate event-specific handlers outside the normal core DAG, without duplicating whole pipelines. Preview and Production share one account credential; it must not reach an untrusted execution context: validate trust and GitHub Environment protection, not merely a caller-supplied target string. ### Preview and special worker policy diff --git a/openspec/changes/refactor-cloudflare-worker-cicd-v2/specs/cloudflare-worker-delivery/spec.md b/openspec/changes/refactor-cloudflare-worker-cicd-v2/specs/cloudflare-worker-delivery/spec.md index 814f394..a825c56 100644 --- a/openspec/changes/refactor-cloudflare-worker-cicd-v2/specs/cloudflare-worker-delivery/spec.md +++ b/openspec/changes/refactor-cloudflare-worker-cicd-v2/specs/cloudflare-worker-delivery/spec.md @@ -126,7 +126,7 @@ Production and Native Preview normal publishes SHALL share one reusable Deploy j #### Scenario: Trusted feature push - **WHEN** the source is an eligible non-main branch and required gates succeed -- **THEN** Deploy SHALL use Preview Environment/credentials, the stable branch identity and Native Preview publishing +- **THEN** Deploy SHALL use Preview Environment and the shared account credentials, the stable branch identity and Native Preview publishing - **AND** parse the actual Preview URL from Wrangler machine-readable output - **AND** smoke that exact URL, not a guessed or constant Dev endpoint. diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index 7ca5830..9b878e3 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -43,7 +43,7 @@ def ctx(ref, inputs=None, secrets=None, needs=None, event="push"): PROD_SECRETS = {"CLOUDFLARE_API_TOKEN": "PT", "CLOUDFLARE_ACCOUNT_ID": "PA"} -PREV_SECRETS = {"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"} +PREV_SECRETS = PROD_SECRETS # Preview uses the account credentials too (the dedicated Preview pair was removed) D1 = {"d1_migrations": True} @@ -53,7 +53,7 @@ def ctx(ref, inputs=None, secrets=None, needs=None, event="push"): def run_chain(ref=MAIN, inputs=None, secrets=None, env=None, toml=APP_TOML, env_by_job=None, event="push", needs_over=None, files=None): """Runs Migration -> Deploy -> Smoke -> Rollback like the scheduler would: each job's `if:` is evaluated against the real results/outputs of its `needs`, and each executed job gets a FRESH workspace (no shared runner state).""" - secrets = secrets if secrets is not None else (PROD_SECRETS if ref == MAIN else PREV_SECRETS) + secrets = secrets if secrets is not None else (PROD_SECRETS) i = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", app_url_prod="https://app.example.com", tools_ref="x", preview_host_check=False) i.update(inputs or {}) needs = {j: {"result": ("success" if i.get(c, "") else "skipped"), "outputs": {}} for j, c in @@ -196,8 +196,8 @@ def mig_file(r, target): check(r["result"] == "failure" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "failed build never publishes") r = deploy(secrets={}) check(r["result"] == "failure" and not published(r) and "pnpm" not in r["log"], "missing Production credentials fail closed at init (no fallback), before any work") -r = deploy(secrets=PREV_SECRETS) -check(r["result"] == "failure" and not published(r), "main with ONLY Preview credentials fails (never uses them for Production)") +r = deploy(secrets={"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"}) +check(r["result"] == "failure" and not published(r), "main with ONLY the deprecated Preview credentials fails (they are ignored)") # ======================================================================= Deploy: Preview r = deploy(FEAT) @@ -276,7 +276,7 @@ def good_dep(**over): r = deploy(FEAT, secrets=dict(PREV_SECRETS, PREVIEW_SECRETS="CLOUDFLARE_API_TOKEN=evil")) check(r["result"] == "failure" and not published(r), "Preview secrets cannot override Cloudflare credentials") r = deploy(FEAT, secrets=PROD_SECRETS) -check(r["result"] == "failure" and not published(r), "a feature branch with ONLY Production credentials fails (never uses them)") +check(r["result"] == "success" and published(r), "a feature branch publishes with the account credentials") r = deploy(MAIN, secrets=dict(PROD_SECRETS, PREVIEW_SECRETS="K=v"), inputs={"build_cmd": 'printf %s "${K:-unset}" > k.out'}) check(r["result"] == "success" and (r["ws"] / "w" / "k.out").read_text() == "unset", "Production build never receives Preview-only secrets") @@ -315,7 +315,7 @@ def good_dep(**over): m = deploy(FEAT, inputs=D1, secrets={}) check(m["result"] == "failure" and not migrated(m), "missing credentials: fails before any remote command") m = deploy(FEAT, inputs=D1, secrets=PROD_SECRETS) -check(m["result"] == "failure" and not migrated(m), "branch with ONLY Production credentials fails") +check(m["result"] == "success" and migrated(m), "branch migrates with the account credentials") for ref, label in ((FEAT, "preview"), (MAIN, "prod")): m = deploy(ref, inputs=D1, env={"STUB_APPLY_FAIL": "1"}) check(m["result"] == "failure" and migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false" and m["outputs"]["migration"] == "failed", f"failed {label} migration: job fails, NOTHING is published or verified") @@ -739,7 +739,7 @@ def manual(inputs=None, env=None, toml=DEV_TOML): def deferred(ref=MAIN, inputs=None, env=None, gate="success", call_over=None, deploy_env=None): base = dict(inputs or {}) - secrets = PROD_SECRETS if ref == MAIN else PREV_SECRETS + secrets = PROD_SECRETS d = run_chain(ref, dict(base, pipeline_phase="deploy"), env=deploy_env) blob = dict(ctx(ref, dict(base, pipeline_phase="deploy"), secrets)["inputs"]) state = json.dumps({"inputs": blob, "deploy": d["jobs"]["deploy"]["outputs"], "migration": d["jobs"]["migration"]["outputs"], "dev": {}, "group": "g"}) diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index 29613a5..692bf85 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -293,23 +293,19 @@ def combo(feature, dev, ref=FEAT, d1=False): # ---------------------------------------------------------------- 5. credentials, Environments, permissions SECRETS = {"CLOUDFLARE_API_TOKEN": "PROD-TOKEN", "CLOUDFLARE_ACCOUNT_ID": "PROD-ACCT", - "CLOUDFLARE_PREVIEW_API_TOKEN": "PREVIEW-TOKEN", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "PREVIEW-ACCT", "PREVIEW_SECRETS": "K=v"} env = jobs["deploy"]["env"] -c = scenario("push", MAIN, secrets=SECRETS) -check(render(env["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(env["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT" and not {"CLOUDFLARE_PREVIEW_API_TOKEN", "CLOUDFLARE_PREVIEW_ACCOUNT_ID"} & set(c.secret_reads), - "Deploy: main gets ONLY Production credentials (Preview secrets never read)") -c = scenario("push", FEAT, secrets=SECRETS) -check(render(env["CLOUDFLARE_API_TOKEN"], c) == "PREVIEW-TOKEN" and render(env["CLOUDFLARE_ACCOUNT_ID"], c) == "PREVIEW-ACCT" and not {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"} & set(c.secret_reads), - "Deploy: a feature branch gets ONLY Preview credentials (Production secrets never read)") -c = scenario("push", MAIN, secrets={k: v for k, v in SECRETS.items() if k != "CLOUDFLARE_API_TOKEN"}) -check(render(env["CLOUDFLARE_API_TOKEN"], c) == "", "Deploy: missing Production token stays EMPTY on main (no fallback to the Preview token)") -c = scenario("push", FEAT, secrets={k: v for k, v in SECRETS.items() if k != "CLOUDFLARE_PREVIEW_API_TOKEN"}) -check(render(env["CLOUDFLARE_API_TOKEN"], c) == "", "Deploy: missing Preview token stays EMPTY on a branch (no fallback to Production)") +for ref, label in ((MAIN, "main"), (FEAT, "a feature branch")): + c = scenario("push", ref, secrets=SECRETS) + check(render(env["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(env["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT", + f"Deploy: {label} uses the account credentials") + c = scenario("push", ref, secrets={k: v for k, v in SECRETS.items() if k != "CLOUDFLARE_API_TOKEN"}) + check(render(env["CLOUDFLARE_API_TOKEN"], c) == "", f"Deploy: missing account token stays EMPTY on {label} ") +check(not any("PREVIEW_API_TOKEN" in k or "PREVIEW_ACCOUNT_ID" in k for k in doc[True]["workflow_call"]["secrets"]), "workflow_call declares no Preview-specific Cloudflare credentials") check("CLOUDFLARE_DEV" not in yaml.dump(jobs["deploy"]), "Deploy never references the Dev credentials") for n in ("migration", "rollback"): check(jobs[n]["env"]["CLOUDFLARE_API_TOKEN"] == env["CLOUDFLARE_API_TOKEN"] and jobs[n]["env"]["CLOUDFLARE_ACCOUNT_ID"] == env["CLOUDFLARE_ACCOUNT_ID"], - f"{n}: the same target-bound credential selection as Deploy (Production credentials on main only, Preview credentials elsewhere, no fallback)") + f"{n}: the same credential selection as Deploy (the account credentials)") check("CLOUDFLARE" not in yaml.dump(jobs["smoke"]) and "secrets.CLOUDFLARE" not in yaml.dump(jobs["smoke"]), "Smoke Tests: NO Cloudflare credentials of any kind (it only probes the published URL)") check(set(jobs["smoke"]["env"]) == {"GH_TOKEN"}, "Smoke Tests: the job env holds nothing but the GitHub token") c = scenario("push", MAIN, secrets=SECRETS)