From 54e966d90683af09f21b853859b4c863e0631c49 Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 6 Nov 2026 03:17:10 +0000 Subject: [PATCH 1/4] refactor(cf-v2): manual persistent-dev dispatch runs the standard pipeline (remove the manual delivery job and mode) A workflow_dispatch with dispatch_target=persistent-dev now only selects the persistent-dev kind in the Plan; CI, Plan, Migrate, Deploy, Smoke and conditional Rollback are the same as for an automatic Shared Dev push. The delivery workflow has only deploy / smoke / rollback; the manual job, input, legacy two-command contract and the engine mode input are removed. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/_cf-worker-delivery.yml | 80 ++-------------- .github/workflows/_cf-worker-v2.yml | 26 +++--- actions/cf-deploy/action.yml | 6 +- actions/cf-preview/cli.mjs | 4 +- actions/cf-preview/delivery.mjs | 45 ++++----- actions/cf-preview/engine.sh | 12 +-- docs/cf-worker-v2-refactor.md | 15 ++- docs/cf-worker-v2-unified-delivery.md | 21 +++++ scripts/cf-preview/delivery.test.mjs | 46 +++++++--- scripts/cf-preview/test-jobs.py | 97 ++++++++++++-------- scripts/cf-preview/test-workflow-contract.py | 80 ++++++---------- 11 files changed, 198 insertions(+), 234 deletions(-) diff --git a/.github/workflows/_cf-worker-delivery.yml b/.github/workflows/_cf-worker-delivery.yml index a1892c5..494f721 100644 --- a/.github/workflows/_cf-worker-delivery.yml +++ b/.github/workflows/_cf-worker-delivery.yml @@ -11,11 +11,8 @@ name: CF Worker delivery (one target) # deploy -> smoke -> conditional rollback of THIS kind, independent of every other kind # (pipeline_phase deploy = deploy only; verify = smoke + rollback from the handed-over deploy result) # -# `manual: true` (kind persistent-dev only; the public workflow sets it for the protected workflow_dispatch of the approved ref) runs the -# single `manual` job INSTEAD of deploy / smoke / rollback: the same engine in manual mode (stage all: build -> audit -> migration -> publish -# -> smoke -> ledger; NO automatic recovery) or, when dev_build_cmd is empty, the legacy two-command contract (never recorded in the -# last-good ledger). Plan and Migration do not run for a dispatch, so there is no descriptor and no migration hand-over; the job reads its -# settings from inputs_json. Its Environment `/dev`, narrower permissions and concurrency group are properties of the job itself. +# The trigger (push or protected workflow_dispatch) never selects a different implementation: a manual persistent-dev dispatch is planned, +# migrated, deployed, smoked and (conditionally) rolled back by exactly these three jobs, like every other kind. # # Pre-mutation validation and the D1 migration are NOT here: the public workflow runs them once, in its single Migration job, and # passes the result in (`migration_result` / `migration_json`). @@ -68,13 +65,9 @@ on: type: string default: '' descriptor: - description: toJSON of THIS kind's target descriptor (versioned, validated by the Plan job and re-validated by the engine). Carries the engine target, GitHub Environment, concurrency groups, expected physical D1, migration owner and smoke mode; never a secret. `{}` with manual (no Plan runs for the dispatch; unused). + description: toJSON of THIS kind's target descriptor (versioned, validated by the Plan job and re-validated by the engine). Carries the engine target, GitHub Environment, concurrency groups, expected physical D1, migration owner and smoke mode; never a secret. type: string required: true - manual: - description: persistent-dev only - the protected manual dispatch of the approved ref; runs the `manual` job instead of deploy / smoke / rollback - type: boolean - default: false outputs: handoff: description: the validated deploy result of THIS kind (own publish in the deploy phase, the handed-over one in the verify phase) @@ -118,7 +111,7 @@ jobs: # ------------------------------------------------------------------------- deploy: name: Deploy - if: inputs.phase != 'verify' && !inputs.manual + if: inputs.phase != 'verify' runs-on: ${{ inputs.runner }} timeout-minutes: 45 concurrency: @@ -165,7 +158,7 @@ jobs: smoke: name: Smoke Tests needs: [deploy] - if: always() && inputs.phase != 'deploy' && !inputs.manual + if: always() && inputs.phase != 'deploy' runs-on: ${{ inputs.runner }} timeout-minutes: 30 permissions: @@ -251,7 +244,7 @@ jobs: rollback: name: Revert on Verify Failure needs: [deploy, smoke] - if: always() && inputs.phase != 'deploy' && !inputs.manual && needs.smoke.outputs.smoke_status == 'failure' && (fromJSON(inputs.descriptor).kind != 'production' || fromJSON(inputs.inputs_json).production_rollback_policy == 'automatic') && fromJSON(needs.smoke.outputs.smoke_json || '{}').gate_blocked != 'true' + if: always() && inputs.phase != 'deploy' && needs.smoke.outputs.smoke_status == 'failure' && (fromJSON(inputs.descriptor).kind != 'production' || fromJSON(inputs.inputs_json).production_rollback_policy == 'automatic') && fromJSON(needs.smoke.outputs.smoke_json || '{}').gate_blocked != 'true' runs-on: ${{ inputs.runner }} timeout-minutes: 45 concurrency: @@ -305,64 +298,3 @@ jobs: deploy_outputs: ${{ env.HANDOFF }} migration_outputs: ${{ env.MIGJSON }} smoke_outputs: ${{ env.SMOKEJSON }} - - # ------------------------------------------------------------------------- - # Manual persistent Dev (Hybrid) — explicit dispatch of the approved ref only (the caller's `if` checks dispatch_target, strategy hybrid and - # persistent_dev_ref; the engine re-checks the ref). Never a side effect of a feature-branch push. Runs INSTEAD of deploy / smoke / rollback. - # dev_build_cmd set : the SAME delivery engine in manual mode, ONE job (build -> audit -> migration -> publish -> smoke -> ledger, - # recorded verified ONLY after the smoke passes; no automatic recovery) - # dev_build_cmd empty : the legacy two-command contract, unchanged (dev_deploy_cmd builds AND publishes, dev_smoke_cmd checks). It is never - # recorded in the Shared Dev last-good ledger, so automatic recovery can never treat it as known-good. - # The Environment `/dev` is a property of this job, so its protection rules and approvals apply exactly as for the automatic kind. - # ------------------------------------------------------------------------- - manual: - name: manual - if: inputs.manual - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: ${{ fromJSON(inputs.inputs_json).app_name }}/dev - concurrency: - group: cfv2-dev-${{ github.repository }}-${{ fromJSON(inputs.inputs_json).d1_lock_scope != '' && fromJSON(inputs.inputs_json).d1_lock_scope || fromJSON(inputs.inputs_json).app_name }} - cancel-in-progress: false - permissions: - contents: read - deployments: write # engine mode only: Dev ledger entry, recorded verified ONLY after the smoke passes - env: - GH_TOKEN: ${{ github.token }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - # ---- Engine contract (dev_build_cmd set) - - id: engine - if: fromJSON(inputs.inputs_json).dev_build_cmd != '' - uses: $/actions/cf-deploy - with: - target: dev - mode: manual - inputs_json: ${{ inputs.inputs_json }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - # ---- Legacy contract (dev_build_cmd empty) - - uses: $/actions/setup - if: fromJSON(inputs.inputs_json).dev_build_cmd == '' - with: - setup-node: 'false' - setup-pnpm: 'false' - - name: Deploy persistent Dev - if: fromJSON(inputs.inputs_json).dev_build_cmd == '' - working-directory: ${{ fromJSON(inputs.inputs_json).app_path }} - env: - CMD: ${{ fromJSON(inputs.inputs_json).dev_deploy_cmd }} - run: | - [ -n "$CMD" ] || { echo "::error::dev_deploy_cmd required"; exit 1; } - bash -c "$CMD" - - name: Dev smoke (Cron/Queue-adjacent checks) - if: fromJSON(inputs.inputs_json).dev_build_cmd == '' && fromJSON(inputs.inputs_json).dev_smoke_cmd != '' - working-directory: ${{ fromJSON(inputs.inputs_json).app_path }} - env: - CMD: ${{ fromJSON(inputs.inputs_json).dev_smoke_cmd }} - run: bash -c "$CMD" diff --git a/.github/workflows/_cf-worker-v2.yml b/.github/workflows/_cf-worker-v2.yml index 89601a3..dc72f88 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -16,7 +16,7 @@ name: CF Worker Pipeline v2 # Release optional, main only, only after a VERIFIED Production delivery with no rollback # # Event handlers outside that DAG: `lifecycle` (_cf-worker-lifecycle.yml: PR comment / branch delete) and `config-guard`. -# The Hybrid protected manual dispatch of persistent Dev is NOT a separate job: it is the `manual` entry of `delivery_persistent_dev`. +# The Hybrid protected manual dispatch of persistent Dev is NOT a separate path: it only selects the `persistent-dev` kind in the Plan, then runs this same DAG. # One pipeline migrates at most one physical D1: distinct databases among the enabled targets are rejected by the plan, before any mutation. # The run-level concurrency group below spans the whole run. Interface, design record and measurements: docs/cf-worker-v2-refactor.md. # @@ -335,8 +335,7 @@ on: OPTIONAL publish-only command for the Dev Worker (default: `wrangler deploy --config `). The engine builds with dev_build_cmd (or build_cmd) and audits the effective config FIRST; this command runs afterwards with CF_AUDITED_CONFIG exported, must publish THAT config (`wrangler deploy --config - "$CF_AUDITED_CONFIG"`) and must not rebuild: a changed config or deployable fails the run. Legacy manual - dispatch (dev_build_cmd empty): dev_deploy_cmd builds and publishes exactly as before. + "$CF_AUDITED_CONFIG"`) and must not rebuild: a changed config or deployable fails the run. type: string default: '' dev_smoke_cmd: @@ -348,7 +347,7 @@ on: type: string default: '' dev_build_cmd: - description: Dev build from app_path (with build_env_dev), build only; falls back to build_cmd when empty. REQUIRED with d1_migrations (the Dev build must succeed before the Dev D1 is migrated). Setting it also opts the manual persistent-dev dispatch into the engine. Leave empty only for a legacy persistent-dev caller whose dev_deploy_cmd builds itself. + description: Dev build from app_path (with build_env_dev), build only; falls back to build_cmd when empty. REQUIRED with d1_migrations (the Dev build must succeed before the Dev D1 is migrated). Required by the persistent-dev kind (automatic and manual dispatch alike). type: string default: '' dev_wrangler_env: @@ -373,7 +372,7 @@ on: type: string default: '' dispatch_target: - description: Pass github.event.inputs.target from the caller; "persistent-dev" triggers the protected Dev path + description: Pass github.event.inputs.target from the caller; empty (default) or "persistent-dev". "persistent-dev" only SELECTS the persistent-dev delivery kind for a workflow_dispatch of the approved persistent_dev_ref (hybrid); the run is the standard CI -> Plan -> Migrate -> Deploy -> Smoke -> Rollback pipeline. Any other value, or an unapproved ref, fails the Plan before any mutation. type: string default: '' pipeline_phase: @@ -422,7 +421,9 @@ on: # -> cfv2-nonprod-d1- (Preview AND Shared Dev D1) # branch, Shared Dev on, no D1 lock -> cfv2-shared-dev- (the ONE Dev Worker) # branch, both channels off -> cfv2--preview- (CI-only: no shared D1 serialization) -# PR / delete / persistent-dev / any other event -> a private group (never queues behind a deploy) +# workflow_dispatch target=persistent-dev (any approved ref, main included) -> the SAME group as the automatic Shared Dev push +# (cfv2-nonprod-d1- with d1_migrations, else cfv2-shared-dev-): never the Production group +# PR / delete / any other event -> a private group (never queues behind a deploy) # The `cfv2-` prefix is static on purpose: never reuse it (or ${{ github.workflow }}) in a CALLER group, or caller and callee deadlock. # A repo-local group does NOT protect a D1 shared with another repository. `queue` is unknown to actionlint 1.7.12: .github/actionlint.yaml. # @@ -432,7 +433,7 @@ on: # phase_state; the Plan job then proves, before any mutation, that it equals runLockGroup() of the deploy call's own inputs - a wrong or # forged value fails the run there, it can never mutate under another lock. concurrency: - group: ${{ inputs.pipeline_phase == 'verify' && (fromJSON(inputs.phase_state).group || format('cfv2-run-{0}', github.run_id)) || ((github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dispatch_target != 'persistent-dev')) && github.ref_type == 'branch') && (github.ref == 'refs/heads/main' && format('cfv2-prod-{0}', inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name) || (inputs.deployment_strategy != 'production-only' && (inputs.preview_on_branch_push || inputs.shared_dev_on_branch_push || inputs.shared_dev_native_preview_on_branch_push) && inputs.d1_migrations && format('cfv2-nonprod-d1-{0}', inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name) || (inputs.deployment_strategy != 'production-only' && (inputs.shared_dev_on_branch_push || inputs.shared_dev_native_preview_on_branch_push) && format('cfv2-shared-dev-{0}', inputs.app_name) || format('cfv2-{0}-preview-{1}', inputs.app_name, github.ref_name)))) || format('cfv2-run-{0}', github.run_id) }} + group: ${{ inputs.pipeline_phase == 'verify' && (fromJSON(inputs.phase_state).group || format('cfv2-run-{0}', github.run_id)) || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref_type == 'branch') && ((github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev') && (inputs.d1_migrations && format('cfv2-nonprod-d1-{0}', inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name) || format('cfv2-shared-dev-{0}', inputs.app_name)) || github.ref == 'refs/heads/main' && format('cfv2-prod-{0}', inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name) || (inputs.deployment_strategy != 'production-only' && (inputs.preview_on_branch_push || inputs.shared_dev_on_branch_push || inputs.shared_dev_native_preview_on_branch_push) && inputs.d1_migrations && format('cfv2-nonprod-d1-{0}', inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name) || (inputs.deployment_strategy != 'production-only' && (inputs.shared_dev_on_branch_push || inputs.shared_dev_native_preview_on_branch_push) && format('cfv2-shared-dev-{0}', inputs.app_name) || format('cfv2-{0}-preview-{1}', inputs.app_name, github.ref_name)))) || format('cfv2-run-{0}', github.run_id) }} queue: max permissions: @@ -459,7 +460,7 @@ jobs: # ------------------------------------------------------------------------- ci: name: CI - if: github.event_name != 'delete' && inputs.dispatch_target != 'persistent-dev' && inputs.pipeline_phase != 'verify' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) + if: github.event_name != 'delete' && inputs.pipeline_phase != 'verify' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) uses: $/.github/workflows/_cf-worker-ci.yml with: app_name: ${{ inputs.app_name }} @@ -670,14 +671,11 @@ jobs: delivery_persistent_dev: name: persistent-dev needs: [plan, migration] - # Two entries, one job: the automatic DAG delivery (plan-driven), or the protected manual dispatch of the approved ref (Hybrid only; - # Plan and Migration are skipped for a dispatch, so this branch needs no `needs` result and never runs on a push). - if: (always() && needs.plan.result == 'success' && needs.plan.outputs.on_persistent_dev == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped')) || (!cancelled() && inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && inputs.deployment_strategy == 'hybrid' && inputs.persistent_dev_ref != '' && github.ref == inputs.persistent_dev_ref) + if: always() && needs.plan.result == 'success' && needs.plan.outputs.on_persistent_dev == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped') uses: $/.github/workflows/_cf-worker-delivery.yml permissions: *delivery_permissions with: - descriptor: ${{ needs.plan.outputs.descriptors && toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) || '{}' }} - manual: ${{ inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' }} + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) }} runner: ${{ inputs.runner }} inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).inputs) || toJSON(inputs) }} call_inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(inputs) || '' }} @@ -733,7 +731,7 @@ jobs: # ------------------------------------------------------------------------- # Event handlers outside the delivery DAG, one small reusable workflow each: `lifecycle` (same-repo PR -> comment the verified Preview URL; - # branch delete -> retire the ledger, the Preview is KEPT). The Hybrid manual dispatch of persistent Dev is the `manual` entry of `delivery_persistent_dev`. + # branch delete -> retire the ledger, the Preview is KEPT). # ------------------------------------------------------------------------- lifecycle: name: lifecycle diff --git a/actions/cf-deploy/action.yml b/actions/cf-deploy/action.yml index 33eca60..d919c70 100644 --- a/actions/cf-deploy/action.yml +++ b/actions/cf-deploy/action.yml @@ -28,9 +28,6 @@ inputs: stage: description: all | deploy | preflight | migrate | publish | verify | rollback (see description). dev supports all, deploy, preflight, migrate, publish, verify, rollback; prod and preview support preflight, migrate, publish, verify, rollback. default: all - mode: - description: auto (branch / main push, with automatic recovery) | manual (persistent Dev dispatch, no recovery) - default: auto inputs_json: description: toJSON(inputs) of the calling workflow (data only; never interpolated into a script) required: true @@ -159,7 +156,6 @@ runs: ENGINE_TARGET: ${{ inputs.target }} ENGINE_KIND: ${{ inputs.kind }} ENGINE_STAGE: ${{ inputs.stage }} - ENGINE_MODE: ${{ inputs.mode }} ENGINE_D1_EXPECT: ${{ inputs.d1_expected_id }} ENGINE_DESCRIPTOR: ${{ inputs.descriptor }} INPUTS_JSON: ${{ inputs.inputs_json }} @@ -300,7 +296,7 @@ runs: # Production version). stage all (Shared Dev): runs inside the failed job; stage rollback (Preview): its own job. - name: Recovery - find the last known-good deployment of this target id: good - if: (failure() && inputs.stage == 'all' && inputs.mode == 'auto' && inputs.target != 'prod' && steps.publish.outcome == 'success' && steps.smoke.outcome == 'failure') || (success() && inputs.stage == 'rollback' && inputs.target != 'prod') + if: (failure() && inputs.stage == 'all' && inputs.target != 'prod' && steps.publish.outcome == 'success' && steps.smoke.outcome == 'failure') || (success() && inputs.stage == 'rollback' && inputs.target != 'prod') shell: bash run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" recover-find | tee -a "$GITHUB_OUTPUT"' - name: Recovery - checkout last good diff --git a/actions/cf-preview/cli.mjs b/actions/cf-preview/cli.mjs index e075525..ebc6ddb 100644 --- a/actions/cf-preview/cli.mjs +++ b/actions/cf-preview/cli.mjs @@ -303,14 +303,14 @@ try { case 'init-checks': { // --state DIR --inputs FILE; env ENGINE_* GITHUB_EVENT_NAME GITHUB_REF CALL_INPUTS_JSON PREVIEW_SECRETS. ONE process for every init check. const e = process.env; const r = validateInit({ - inputs: JSON.parse(readFileSync(opt('inputs'), 'utf8')), target: e.ENGINE_TARGET, kind: e.ENGINE_KIND ?? '', stage: e.ENGINE_STAGE || 'all', mode: e.ENGINE_MODE || 'auto', + inputs: JSON.parse(readFileSync(opt('inputs'), 'utf8')), target: e.ENGINE_TARGET, kind: e.ENGINE_KIND ?? '', stage: e.ENGINE_STAGE || 'all', d1Expect: e.ENGINE_D1_EXPECT ?? '', descriptor: e.ENGINE_DESCRIPTOR ?? '', callInputs: e.CALL_INPUTS_JSON ?? '', event: e.GITHUB_EVENT_NAME, ref: e.GITHUB_REF, previewSecrets: e.PREVIEW_SECRETS ?? '', }); const dir = opt('state'); const put = (name, text) => writeFileSync(resolve(dir, name), text, { mode: 0o600 }); const env = (name, pairs) => { if (pairs?.length) appendFileSync(resolve(dir, name), pairs.map(([k, v]) => `${k}=${v}`).join('\n') + '\n', { mode: 0o600 }); }; - put('kind', r.kind); put('smoke_mode', r.smokeMode); put('stage', r.stage); put('d1_expect', r.d1Expect); put('target', r.target); put('mode', r.mode); + put('kind', r.kind); put('smoke_mode', r.smokeMode); put('stage', r.stage); put('d1_expect', r.d1Expect); put('target', r.target); env('build.env', r.buildEnv); env('smoke.env', r.smokeEnv); env('preview.env', r.previewEnv); for (const [, v] of r.previewEnv ?? []) if (v) console.error(`::add-mask::${v}`); out({ kind: r.kind, smoke_mode: r.smokeMode }); diff --git a/actions/cf-preview/delivery.mjs b/actions/cf-preview/delivery.mjs index 96e402f..51bab21 100644 --- a/actions/cf-preview/delivery.mjs +++ b/actions/cf-preview/delivery.mjs @@ -42,8 +42,10 @@ export function resolveSmokeMode(kind, smokeMode = 'full', overridesText = '') { /** * Enabled delivery kinds for one event, in a stable order. Exactly the enabled targets - a disabled channel creates no - * phantom path. `persistentDevDispatch` = the protected manual dispatch (a separate, non-automatic path). - * Rejects (throws) the combinations the spec forbids instead of silently dropping them. + * phantom path. A protected manual dispatch (`dispatchTarget: persistent-dev`) is NOT a special pipeline: it only SELECTS the + * `persistent-dev` kind, and every downstream stage (CI, Plan, Migration, Deploy, Smoke, Rollback) is the standard one. + * Rejects (throws, so the Plan fails before any mutation) the combinations the spec forbids instead of silently dropping them: + * an unknown dispatch_target, or persistent-dev outside hybrid / without an exact match of the dispatch ref and persistent_dev_ref. */ export function resolveDeliveryTargets({ event, ref, strategy, previewOnPush = true, sharedDevOnPush = false, sharedDevNativeOnPush = false, dispatchTarget = '', devRef = '', @@ -55,12 +57,16 @@ export function resolveDeliveryTargets({ if (sharedDevOnPush && sharedDevNativeOnPush) { throw new Error('shared_dev_on_branch_push (separate persistent Dev Worker) and shared_dev_native_preview_on_branch_push (named Native Preview "dev") are conflicting automatic Dev channels; enable only one'); } + if (event === 'workflow_dispatch' && dispatchTarget !== '') { + if (dispatchTarget !== 'persistent-dev') throw new Error(`dispatch_target '${dispatchTarget}' is not valid (expected persistent-dev or empty); refusing to fall back to another target`); + const r = String(ref).startsWith('refs/heads/') ? classifyEvent({ event, ref, strategy, devRef, dispatchTarget }) : { action: 'skip', reason: 'not a branch ref' }; + if (r.action !== 'persistent-dev') throw new Error(`manual persistent-dev dispatch rejected: ${r.reason} (requires deployment_strategy hybrid and the dispatch ref equal to persistent_dev_ref; nothing was changed)`); + return ['persistent-dev']; + } if (!String(ref).startsWith('refs/heads/')) return []; const r = classifyEvent({ event, ref, strategy, devRef, dispatchTarget }); if (r.action === 'production') return ['production']; - if (r.action === 'persistent-dev') return ['persistent-dev']; - const automatic = event === 'push' || (event === 'workflow_dispatch' && dispatchTarget !== 'persistent-dev'); - if (r.action !== 'preview' || !automatic) return []; + if (r.action !== 'preview' || !(event === 'push' || event === 'workflow_dispatch')) return []; const kinds = []; if (previewOnPush) kinds.push('native-branch'); if (sharedDevNativeOnPush) kinds.push('native-dev'); @@ -230,7 +236,7 @@ const DESCRIPTOR_KEYS = ['v', 'kind', 'target', 'environment', 'deploy_group', ' /** * Concurrency identity of a kind. Production / Native Branch / Native Dev get uncontended per-run groups (no cross-target * waiting). persistent-dev keeps the LEGACY Shared Dev group - keyed by repository and the shared-D1 scope (or the app) - for - * Deploy AND Rollback, so it serialises with the manual dispatch and with every other app using the same physical D1. + * Deploy AND Rollback, so it serialises with the manual persistent-dev dispatch (same kind, same descriptor) and with every other app using the same physical D1. */ export function concurrencyGroups(kind, { runId, repo = '', app, lockScope = '' }) { if (assertKind(kind) === 'persistent-dev') { @@ -386,7 +392,10 @@ export function runLockGroup({ event, ref, inputs, runId }) { const on = (k) => inputs?.[k] === true || inputs?.[k] === 'true'; const branch = String(ref ?? '').startsWith('refs/heads/'); const scope = s('d1_lock_scope') || s('app_name'); - if ((event === 'push' || (event === 'workflow_dispatch' && s('dispatch_target') !== 'persistent-dev')) && branch) { + if ((event === 'push' || event === 'workflow_dispatch') && branch) { + // A manual persistent-dev dispatch mutates the same Dev D1 / Dev Worker as the automatic Shared Dev channel, so it takes the SAME + // run-level group (never the Production one, even from main) and serialises with every feature-branch Shared Dev push. + if (event === 'workflow_dispatch' && s('dispatch_target') === 'persistent-dev') return on('d1_migrations') ? `cfv2-nonprod-d1-${scope}` : `cfv2-shared-dev-${s('app_name')}`; if (ref === 'refs/heads/main') return `cfv2-prod-${scope}`; const nonProdOnly = s('deployment_strategy') !== 'production-only'; const channels = on('preview_on_branch_push') || on('shared_dev_on_branch_push') || on('shared_dev_native_preview_on_branch_push'); @@ -474,7 +483,7 @@ const KIND_OF_TARGET = { prod: 'production', preview: 'native-branch', dev: 'per const STAGES = ['all', 'deploy', 'preflight', 'migrate', 'publish', 'verify', 'rollback']; const BUILD_ENV_INPUT = { production: 'build_env_prod', 'native-branch': 'build_env_preview', 'native-dev': 'build_env_shared_dev_native_preview', 'persistent-dev': 'build_env_dev' }; -export function validateInit({ inputs, target, kind = '', stage = 'all', mode = 'auto', d1Expect = '', descriptor = '', callInputs = '', event, ref, previewSecrets = '' }) { +export function validateInit({ inputs, target, kind = '', stage = 'all', d1Expect = '', descriptor = '', callInputs = '', event, ref, previewSecrets = '' }) { if (inputs === null || typeof inputs !== 'object' || Array.isArray(inputs)) throw new Error('inputs_json is not an object'); const s = (k) => (inputs[k] == null ? '' : String(inputs[k])); const on = (k) => s(k) === 'true'; @@ -493,9 +502,6 @@ export function validateInit({ inputs, target, kind = '', stage = 'all', mode = throw new Error(`${K} must run as split stages (preflight/migrate/publish/verify/rollback), got '${stage}'`); } if (!/^[A-Za-z0-9_-]*$/.test(d1Expect)) throw new Error('malformed expected D1 id'); - if (!['auto', 'manual'].includes(mode)) throw new Error(`bad engine mode: ${mode}`); - if (mode !== 'auto' && target !== 'dev') throw new Error('manual mode is only for the persistent Dev target'); - if (mode !== 'auto' && stage !== 'all') throw new Error('manual Dev runs as one job (stage all)'); if (callInputs) { // deferred verification: the verify call's identity inputs must equal the deploy call's let c; try { c = JSON.parse(callInputs); } catch { c = null; } @@ -511,17 +517,12 @@ export function validateInit({ inputs, target, kind = '', stage = 'all', mode = throw new Error('deployment_strategy: production-only cannot be combined with shared_dev_on_branch_push / shared_dev_native_preview_on_branch_push: true; refusing to publish or migrate anything'); } if (on('shared_dev_on_branch_push') && on('shared_dev_native_preview_on_branch_push')) throw new Error('shared_dev_on_branch_push and shared_dev_native_preview_on_branch_push are conflicting automatic Dev channels; enable only one'); - if (mode === 'manual') { - const action = classifyEvent({ event, ref, strategy, devRef: s('persistent_dev_ref'), dispatchTarget: s('dispatch_target') }).action; - if (action !== 'persistent-dev') throw new Error(`manual Dev is only allowed from the approved persistent_dev_ref via dispatch (classified '${action}')`); - } else { - const kinds = resolveDeliveryTargets({ - event, ref, strategy, previewOnPush: on('preview_on_branch_push'), sharedDevOnPush: on('shared_dev_on_branch_push'), - sharedDevNativeOnPush: on('shared_dev_native_preview_on_branch_push'), dispatchTarget: s('dispatch_target'), devRef: s('persistent_dev_ref'), - }); - if (!kinds.includes(K)) throw new Error(`target mismatch (enabled kinds '${kinds.join(',')}', engine '${K}'); refusing to deploy`); - } - return { kind: K, stage, mode, target, smokeMode, d1Expect, ...validateTargetConfig({ inputs, kind: K, target, previewSecrets }) }; + const kinds = resolveDeliveryTargets({ + event, ref, strategy, previewOnPush: on('preview_on_branch_push'), sharedDevOnPush: on('shared_dev_on_branch_push'), + sharedDevNativeOnPush: on('shared_dev_native_preview_on_branch_push'), dispatchTarget: s('dispatch_target'), devRef: s('persistent_dev_ref'), + }); + if (!kinds.includes(K)) throw new Error(`target mismatch (enabled kinds '${kinds.join(',')}', engine '${K}'); refusing to deploy`); + return { kind: K, stage, target, smokeMode, d1Expect, ...validateTargetConfig({ inputs, kind: K, target, previewSecrets }) }; } /** Per-target requirements and the validated KEY=VALUE env data each kind builds/smokes with (a build env never falls back to another kind's). */ diff --git a/actions/cf-preview/engine.sh b/actions/cf-preview/engine.sh index a6fdd73..000e60a 100755 --- a/actions/cf-preview/engine.sh +++ b/actions/cf-preview/engine.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # CF Worker v2 delivery engine. ONE implementation of # Build -> Config audit -> Migration (optional) -> Publish -> Smoke -> Recovery -# for every target (prod | preview | dev) and both modes (auto = branch/main push, manual = persistent Dev dispatch). +# for every target (prod | preview | dev) and every trigger (push or dispatch: the trigger never selects a different pipeline). # actions/cf-deploy/action.yml calls one phase per step; every phase reads the workflow inputs from the validated # inputs.json written by `init`, so no workflow text is interpolated into a script. # @@ -17,7 +17,7 @@ # (`deploy` = migrate + publish in ONE job, then `verify` / `rollback`): a caller that must run a cross-service smoke # AFTER another Worker's deployment (workflow input pipeline_phase) verifies the published Dev deployment later. # -# State ($STATE_DIR, default $RUNNER_TEMP/cfv2, 0700): inputs.json, target, mode, build.env / smoke.env / +# State ($STATE_DIR, default $RUNNER_TEMP/cfv2, 0700): inputs.json, target, build.env / smoke.env / # preview.env (validated KEY=VALUE data), config.* and artifact.sha (from build.sh), ledger_id, preview_name, ... # Application env (build_env_*, smoke_env, preview secrets) is only ever loaded inside the process that needs it and # can never set a reserved control variable (parse-env.mjs RESERVED). The target is bound ONCE in `init` (from the @@ -48,7 +48,7 @@ rd() { [ -f "$S/$1" ] && cat "$S/$1" || true; } # Engine-owned context, derived from state and the runner (never from caller env text). ctx() { [ -s "$S/target" ] || die "delivery engine not initialised" - TARGET="$(rd target)"; MODE="$(rd mode)"; KIND="$(rd kind)" + TARGET="$(rd target)"; KIND="$(rd kind)" if [ -z "$KIND" ]; then case "$TARGET" in prod) KIND=production;; preview) KIND=native-branch;; dev) KIND=persistent-dev;; esac; fi APP_NAME="$(inp app_name)"; APP_PATH="$(inp app_path)"; APP_PATH="${APP_PATH:-.}" APP_DIR="${APP_DIR_OVERRIDE:-${GITHUB_WORKSPACE:?}/$APP_PATH}" @@ -63,7 +63,7 @@ ctx() { esac PREVIEW_NAME="$(rd preview_name)"; ENV_NAME="$(rd ledger_env)"; LEDGER_ID="$(rd ledger_id)" [ "$KIND" != native-dev ] || PREVIEW_NAME=dev - export KIND TARGET MODE APP_NAME APP_DIR BRANCH_REF BRANCH RUN_SHA DEV_ENV WRANGLER_CONFIG D1_BINDING HOST_WORKER PREVIEW_NAME ENV_NAME STATE_DIR="$S" + export KIND TARGET APP_NAME APP_DIR BRANCH_REF BRANCH RUN_SHA DEV_ENV WRANGLER_CONFIG D1_BINDING HOST_WORKER PREVIEW_NAME ENV_NAME STATE_DIR="$S" } # Which commands/env belong to the target. Never falls back across targets (except dev_build_cmd -> build_cmd). @@ -171,8 +171,8 @@ case "$PHASE" in DESC="Native Preview" elif [ "$TARGET" = dev ]; then printf '%s' "$APP_NAME/dev/shared" > "$S/ledger_env" - PAYLOAD="$(jq -nc --arg ref "$BRANCH_REF" --arg w "$APP_NAME" --arg d "$HOST_WORKER" --arg r "$GITHUB_REPOSITORY" --arg m "$MODE" '{kind:"persistent-dev",branch_ref:$ref,worker:$w,dev_worker:$d,repo:$r,mode:$m}')" - DESC="Shared Dev ($MODE)" + PAYLOAD="$(jq -nc --arg ref "$BRANCH_REF" --arg w "$APP_NAME" --arg d "$HOST_WORKER" --arg r "$GITHUB_REPOSITORY" '{kind:"persistent-dev",branch_ref:$ref,worker:$w,dev_worker:$d,repo:$r}')" + DESC="Shared Dev" fi # The Migration job runs the identity / preflight checks but opens no ledger entry (the Deploy job owns it). if [ "$TARGET" != prod ] && [ "${PREPARE_LEDGER:-true}" = true ]; then diff --git a/docs/cf-worker-v2-refactor.md b/docs/cf-worker-v2-refactor.md index 8b9072a..721e4ff 100644 --- a/docs/cf-worker-v2-refactor.md +++ b/docs/cf-worker-v2-refactor.md @@ -97,9 +97,8 @@ one unspecified leg's output per key, so keeping them apart would need a results ### Event handlers -`lifecycle` (PR comment of the verified Preview URL; branch-delete ledger retirement, only if this branch still owns the name and the branch does not exist again; the Cloudflare Native Preview is **kept**) and `persistent-dev` -(Hybrid, protected dispatch of the approved ref, `/dev` Environment; engine in manual mode when `dev_build_cmd` is set, the unchanged legacy two-command contract otherwise) are small reusable workflows. They contain no deployment -implementation: the persistent-dev engine path is the same `$/actions/cf-deploy`. The legacy path stays because `heaven-monorepo` and `innovation-apps` pass `persistent_dev_ref`; it cannot move into the engine without changing its "one command builds and publishes" contract. +`lifecycle` (PR comment of the verified Preview URL; branch-delete ledger retirement, only if this branch still owns the name and the branch does not exist again; the Cloudflare Native Preview is **kept**) is a small reusable workflow with no deployment +implementation. The Hybrid protected dispatch of persistent Dev is not a separate path: it only selects the `persistent-dev` kind in the Plan and then runs the standard pipeline (see `docs/cf-worker-v2-unified-delivery.md`, "Manual dispatch"). ### Concurrency and `phase_state` @@ -209,7 +208,7 @@ No call enables two distinct physical D1s in any modeled event, so the single-D1 | ` / Migration` | ` / Migrate` | | ` / Deploy`, `Smoke Tests`, `Revert on Verify Failure`, `Shared Dev`, `Shared Dev Smoke Tests`, `Shared Dev Revert on Verify Failure` | ` / / Deploy\|Smoke Tests\|Revert on Verify Failure` (`` = `production\|native-branch\|native-dev\|persistent-dev`) | | ` / Release` | unchanged | -| ` / Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | ` / Guard`, ` / lifecycle / ledger`, ` / persistent-dev / manual`; new ` / Plan` | +| ` / Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | ` / Guard`, ` / lifecycle / ledger`, ` / persistent-dev / Deploy` (a manual dispatch of persistent Dev has the same checks as a Shared Dev push); new ` / Plan` | Skipped jobs (unconfigured checks) report as skipped; a required context that is never emitted blocks merging. Nothing was modified in any repository's protection settings. @@ -263,7 +262,7 @@ Detect Changes (monorepo only — the CALLER runs _detect-changes.yml and gates outside the core DAG: lifecycle (same-repo PR comment / branch-delete ledger retire) config-guard (production-only + Shared Dev is rejected) - persistent-dev (Hybrid, protected workflow_dispatch on the approved ref; the same engine, mode=manual) + (a Hybrid workflow_dispatch of the approved ref is NOT a separate path: it selects the persistent-dev kind and runs the same DAG) ``` There is **no Prepare, Plan or Test-Gate job**. Migration is its own job again (v1 parity), but it keeps the v2 order *inside* it: the deployable is built and the effective Wrangler config audited and the exact D1 target validated BEFORE the schema is touched, and the migration publishes nothing. Deploy then repeats build + audit from the same SHA and publishes. Smoke and Rollback are separate jobs so the DAG shows whether a deployment was verified or reverted. @@ -436,7 +435,7 @@ Two independent switches decide what a non-main push deploys. Neither requires t | false | false | validation only | * **Feature Native Preview** — the `Deploy` job with target `preview`: one stable URL per branch. -* **Shared Dev** — job `dev-deploy` ("Shared Dev"). Every branch targets the **same** Dev Worker and `app_url_dev`; the last verified deployment wins and there is no per-branch Dev. Use `shared_dev_on_branch_push` for this; the manual `persistent-dev` dispatch (Hybrid, approved ref, Cron/Queue checks) is a separate trigger of the **same engine** (`mode: manual`, no automatic recovery) when `dev_build_cmd` is set; without it the legacy two-command contract runs `dev_deploy_cmd` alone. +* **Shared Dev** — job `dev-deploy` ("Shared Dev"). Every branch targets the **same** Dev Worker and `app_url_dev`; the last verified deployment wins and there is no per-branch Dev. Use `shared_dev_on_branch_push` for this; the manual `persistent-dev` dispatch (Hybrid, approved ref, Cron/Queue checks) is another trigger of the **same pipeline** (same CI, Plan, Migrate, Deploy, Smoke and conditional Revert). * `config-guard` fails the run when `deployment_strategy: production-only` is combined with `shared_dev_on_branch_push: true`. `production-only` still silently ignores `preview_on_branch_push` (its default is `true`; failing on it would break existing callers) — open question for review. ### Shared Dev job order @@ -493,9 +492,9 @@ Rules: an unknown kind, a malformed entry, a duplicate entry, an entry that matc ### Manual vs automatic Dev contract -Engine contract (new consumers; opt in by setting `dev_build_cmd`): `dev_build_cmd` builds only, `dev_deploy_cmd` (optional) publishes only, `dev_smoke_cmd` verifies only. Automatic Shared Dev and the manual `persistent-dev` dispatch then both run build → config audit → D1 check → Dev D1 migration (`d1_migrations`) → publish → smoke through the same engine; the manual run also requires `dev_worker_name` and `app_url_dev`, keeps the approved-ref gate and protected `/dev` Environment, and cannot skip the build. A manual run is recorded in the last-good ledger **only after its smoke passes** (failures are recorded as failed); it has no automatic recovery. +Dev contract (automatic Shared Dev push and manual dispatch alike): `dev_build_cmd` builds only (falls back to `build_cmd`; required with `d1_migrations`), `dev_deploy_cmd` (optional) publishes only, `dev_smoke_cmd` verifies only. Both triggers run CI → Plan → Migrate → Deploy → Smoke → conditional Revert; both require `dev_worker_name` and `app_url_dev`, and a manual dispatch additionally keeps the approved-ref gate and the protected `/dev` Environment. -Legacy contract (`dev_build_cmd` empty, existing hybrid callers): the manual dispatch runs `dev_deploy_cmd` (which must build itself) and the optional `dev_smoke_cmd` exactly as before and is **never** written to the ledger, so automatic recovery can never mistake it for a known-good release. Migration plan: add `dev_build_cmd`, `dev_worker_name`, `app_url_dev` (and an audit-clean Dev config); no caller is forced to. +The former manual-only "legacy contract" (`dev_build_cmd` empty, `dev_deploy_cmd` builds and publishes in one command) no longer exists: a dispatch cannot select a different implementation than a push. Migration: set `dev_build_cmd`, make `dev_deploy_cmd` publish the audited config only (`wrangler deploy --config "$CF_AUDITED_CONFIG"`), and set `dev_worker_name` / `app_url_dev`. ### Recovery limits diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md index 477a2cb..97be8bc 100644 --- a/docs/cf-worker-v2-unified-delivery.md +++ b/docs/cf-worker-v2-unified-delivery.md @@ -356,3 +356,24 @@ YAML anchors (`&delivery_permissions`, `&delivery_secrets`, `&fetch_ci_tools`) w with a message naming both databases. Nothing is changed before it fails. The consumer plan (blog, innovation-apps, heaven-monorepo, anas-mcp, heaven-www) does not need this, but each caller must be confirmed before the `@v2` move; the rollout stays blocked on that confirmation. * The Migration job runs in the owner's Environment, so on main the `/prod` approval is requested **before** the read-only validation steps as well as before the migration. + +## Manual dispatch (persistent-dev) — one pipeline, trigger-independent + +Deployment **targets** decide what is deployed; **triggers** decide when. A `workflow_dispatch` with `dispatch_target: persistent-dev` therefore is not a delivery kind or a job of its own. It only makes `resolveDeliveryTargets()` return `["persistent-dev"]`, and the run is the standard + +`CI → Validation Gate → Plan → Migrate → persistent-dev / Deploy → Smoke Tests → Revert on Verify Failure` + +identical to a feature-branch Shared Dev push. The delivery workflow has exactly `deploy`, `smoke`, `rollback`; the `manual` job, its `manual` input and the engine's `mode` input are gone. + +| Event | Ref | Requested target | Resolved delivery | +| --- | --- | --- | --- | +| push | main | – | production | +| push | feature | enabled channels | native-branch / native-dev / persistent-dev (as before) | +| workflow_dispatch | = `persistent_dev_ref` (hybrid) | `persistent-dev` | persistent-dev only (also from `main`; never Production, never Release) | +| workflow_dispatch | any other ref, non-hybrid, empty `persistent_dev_ref`, unknown target | `persistent-dev` / other | Plan **fails** before any mutation | +| workflow_dispatch | main / feature | empty | unchanged (production / enabled previews) | +| pull_request, delete | – | – | CI only / lifecycle only | + +Concurrency: the run-level group of a persistent-dev dispatch is the same as for the automatic Shared Dev push (`cfv2-nonprod-d1-` with `d1_migrations`, else `cfv2-shared-dev-`; never the Production group, even from `main`), so the Migrate job cannot race a feature-branch Dev push. Deploy and Revert keep the descriptor's job-level group `cfv2-dev--` (a different name, so no nested-group deadlock). + +Behaviour changes versus the old manual path: it now runs CI, Plan and the dedicated Migrate job; a failed Smoke triggers the same conditional recovery to the last known-good Dev deployment (it previously had none); D1 is still never rolled back. Callers that relied on `dev_build_cmd` being empty (one `dev_deploy_cmd` that builds and publishes) must add `dev_build_cmd` and make `dev_deploy_cmd` publish only (see `docs/cf-worker-v2-refactor.md`). diff --git a/scripts/cf-preview/delivery.test.mjs b/scripts/cf-preview/delivery.test.mjs index ce0e958..ed21d2b 100644 --- a/scripts/cf-preview/delivery.test.mjs +++ b/scripts/cf-preview/delivery.test.mjs @@ -26,11 +26,21 @@ test('non-deploy events and refs never produce a target', () => { assert.deepEqual(T({ ref: 'refs/tags/v1', sharedDevNativeOnPush: true }), []); assert.deepEqual(T({ ref: 'refs/pull/1/merge' }), []); }); -test('manual persistent-dev dispatch is its own path, only from the approved ref on hybrid', () => { +test('manual persistent-dev dispatch only SELECTS the persistent-dev kind (approved ref, hybrid); everything else rejects', () => { const d = { event: 'workflow_dispatch', strategy: 'hybrid', dispatchTarget: 'persistent-dev', devRef: 'refs/heads/dev-ref', ref: 'refs/heads/dev-ref' }; assert.deepEqual(T(d), ['persistent-dev']); - assert.deepEqual(T({ ...d, ref: 'refs/heads/other' }), []); - assert.deepEqual(T({ ...d, strategy: 'production-preview' }), []); + assert.throws(() => T({ ...d, ref: 'refs/heads/other' }), /rejected.*approved persistent_dev_ref/); + assert.throws(() => T({ ...d, strategy: 'production-preview' }), /rejected.*requires hybrid/); + assert.throws(() => T({ ...d, devRef: '' }), /rejected/); + assert.throws(() => T({ ...d, ref: 'refs/tags/v1', devRef: 'refs/tags/v1' }), /rejected/); + assert.throws(() => T({ ...d, dispatchTarget: 'production' }), /dispatch_target 'production' is not valid/); + // approved ref = main: still ONLY persistent-dev, never production, and no automatic Preview target alongside it + assert.deepEqual(T({ ...d, ref: 'refs/heads/main', devRef: 'refs/heads/main', sharedDevOnPush: true }), ['persistent-dev']); + // dispatch without a target keeps the existing behaviour (main -> production, branch -> the enabled automatic previews) + assert.deepEqual(T({ event: 'workflow_dispatch', strategy: 'hybrid', ref: 'refs/heads/main', devRef: 'refs/heads/main' }), ['production']); + assert.deepEqual(T({ event: 'workflow_dispatch', strategy: 'hybrid', ref: 'refs/heads/x', sharedDevOnPush: true }), ['native-branch', 'persistent-dev']); + // dispatch_target is meaningless on other events (never selects anything there) + assert.deepEqual(T({ event: 'push', strategy: 'hybrid', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), ['production']); }); test('production-only rejects every Dev channel; conflicting automatic Dev channels are rejected', () => { assert.throws(() => T({ strategy: 'production-only', sharedDevNativeOnPush: true }), /production-only/); @@ -482,21 +492,18 @@ const IN0 = { const INIT = (o = {}, i = {}) => validateInit({ inputs: { ...IN0, ...i }, target: 'preview', stage: 'migrate', event: 'push', ref: 'refs/heads/feat/x', ...o }); const initFails = (re, o, i) => assert.throws(() => INIT(o, i), re); -test('init: a valid Native Branch push binds kind, stage, mode and the smoke policy', () => { +test('init: a valid Native Branch push binds kind, stage and the smoke policy', () => { const r = INIT(); - assert.deepEqual([r.kind, r.target, r.stage, r.mode, r.smokeMode], ['native-branch', 'preview', 'migrate', 'auto', 'full']); + assert.deepEqual([r.kind, r.target, r.stage, r.smokeMode], ['native-branch', 'preview', 'migrate', 'full']); assert.equal(INIT({ target: 'prod', stage: 'publish', ref: 'refs/heads/main' }).kind, 'production'); }); -test('init: target, kind, stage and mode binding refuse everything unknown or cross-target', () => { +test('init: target, kind, and stage binding refuse everything unknown or cross-target', () => { initFails(/bad engine target/, { target: 'staging' }); initFails(/does not belong to engine target/, { kind: 'production' }); initFails(/must run as split stages/, { stage: 'all' }); initFails(/bad engine stage/, { stage: 'nope' }); initFails(/malformed expected D1 id/, { d1Expect: 'a b' }); - initFails(/bad engine mode/, { mode: 'weird' }); - initFails(/manual mode is only for the persistent Dev target/, { mode: 'manual' }); - assert.throws(() => INIT({ target: 'dev', kind: 'persistent-dev', mode: 'manual', stage: 'migrate' }, { dev_build_cmd: 'x' }), /manual Dev runs as one job/); assert.throws(() => validateInit({ inputs: [], target: 'preview', event: 'push', ref: 'refs/heads/x' }), /inputs_json is not an object/); }); @@ -514,11 +521,17 @@ test('init: the engine kind must be among the kinds this event enables (no phant assert.equal(INIT({ kind: 'native-dev' }, { shared_dev_native_preview_on_branch_push: true }).kind, 'native-dev'); }); -test('init: manual persistent Dev only from the approved ref via dispatch', () => { - const M = (o = {}, i = {}) => validateInit({ inputs: { ...IN0, deployment_strategy: 'hybrid', dev_build_cmd: 'x', persistent_dev_ref: 'refs/heads/dev', dispatch_target: 'persistent-dev', ...i }, target: 'dev', kind: 'persistent-dev', stage: 'all', mode: 'manual', event: 'workflow_dispatch', ref: 'refs/heads/dev', ...o }); +test('init: a manual persistent Dev dispatch is validated by the same rule as every other event (approved ref via dispatch only)', () => { + const M = (o = {}, i = {}) => validateInit({ inputs: { ...IN0, deployment_strategy: 'hybrid', dev_build_cmd: 'x', persistent_dev_ref: 'refs/heads/dev', dispatch_target: 'persistent-dev', ...i }, target: 'dev', kind: 'persistent-dev', stage: 'publish', event: 'workflow_dispatch', ref: 'refs/heads/dev', ...o }); assert.equal(M().kind, 'persistent-dev'); - assert.throws(() => M({ ref: 'refs/heads/other' }), /manual Dev is only allowed from the approved persistent_dev_ref/); - assert.throws(() => M({ event: 'push' }), /manual Dev is only allowed/); + assert.throws(() => M({ ref: 'refs/heads/other' }), /manual persistent-dev dispatch rejected/); + assert.throws(() => M({}, { deployment_strategy: 'production-preview' }), /rejected/); + assert.throws(() => M({}, { persistent_dev_ref: '' }), /rejected/); + assert.throws(() => M({}, { dispatch_target: 'production' }), /dispatch_target 'production' is not valid/); + assert.throws(() => M({ kind: 'production', target: 'prod' }), /target mismatch|does not belong/, 'a dispatch from the approved main ref never validates as Production'); + const onMain = (o = {}) => M({ ref: 'refs/heads/main', ...o }, { persistent_dev_ref: 'refs/heads/main' }); + assert.equal(onMain().kind, 'persistent-dev'); + assert.throws(() => onMain({ kind: 'production', target: 'prod' }), /target mismatch/); }); test('init: per-target requirements (Native Dev flag, Dev identity/URL/build, build command)', () => { @@ -568,7 +581,12 @@ test('lock identity: run-level group follows the same rule as the workflow (scop assert.equal(runLockGroup({ event: 'push', ref: 'refs/heads/x', inputs: { ...I, d1_migrations: false }, runId: 1 }), 'cfv2-blog-preview-x'); assert.equal(runLockGroup({ event: 'pull_request', ref: 'refs/pull/1/merge', inputs: I, runId: 7 }), 'cfv2-run-7'); assert.equal(runLockGroup({ event: 'push', ref: 'refs/tags/v1', inputs: I, runId: 7 }), 'cfv2-run-7'); - assert.equal(runLockGroup({ event: 'workflow_dispatch', ref: 'refs/heads/dev', inputs: { ...I, dispatch_target: 'persistent-dev' }, runId: 7 }), 'cfv2-run-7'); + // a manual persistent-dev dispatch takes the SAME group as the automatic Shared Dev push (never the Production group, even from main) + const M = { ...I, dispatch_target: 'persistent-dev', d1_lock_scope: 'shared-db' }; + assert.equal(runLockGroup({ event: 'workflow_dispatch', ref: 'refs/heads/dev', inputs: M, runId: 7 }), 'cfv2-nonprod-d1-shared-db'); + assert.equal(runLockGroup({ event: 'workflow_dispatch', ref: 'refs/heads/main', inputs: M, runId: 7 }), 'cfv2-nonprod-d1-shared-db'); + assert.equal(runLockGroup({ event: 'workflow_dispatch', ref: 'refs/heads/main', inputs: { ...M, d1_migrations: false }, runId: 7 }), 'cfv2-shared-dev-blog'); + assert.equal(runLockGroup({ event: 'workflow_dispatch', ref: 'refs/heads/main', inputs: I, runId: 7 }), 'cfv2-prod-blog', 'a dispatch without a target keeps the Production group on main'); }); test('lock identity: same Deploy/Verify d1_lock_scope -> same effective lock; a verify default never selects another', () => { diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index 9594fbf..f46a8c5 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -76,7 +76,7 @@ def desc(kind, d1="", owner="", app="app", scope="", phase="all", **over): return json.dumps(dict({"v": 1, "kind": kind, "target": TARGET_OF[kind], "environment": f"{app}/{_ENV_OF[kind]}", "deploy_group": g, "rollback_group": g if kind == "persistent-dev" else g + "-rollback", "d1_expected_id": d1, "d1_owner_kind": owner, "owns_migration": bool(d1 and owner == kind), "smoke_mode": "full", "phase": phase}, **over)) -INNER = ("deploy", "smoke", "rollback", "manual") +INNER = ("deploy", "smoke", "rollback") SKIPPED = {"result": "skipped", "outputs": {}} @@ -106,8 +106,6 @@ def call_workflow(job, c, ctl): w = {k: render(v, c) for k, v in job["with"].items()} dinputs = dict(DINPUT_DEFAULTS) dinputs.update(w) - if "manual" in dinputs: # type: boolean - GitHub coerces the rendered 'true' / 'false' string - dinputs["manual"] = str(dinputs["manual"]).lower() == "true" sec = {k: render(v, c) for k, v in job.get("secrets", {}).items()} kind = json.loads(w["descriptor"])["kind"] dc = Ctx({"github": c["github"], "inputs": dinputs, "needs": {}, "jobs": {}}, sec) @@ -400,27 +398,14 @@ def advertised(r): GD = {"STUB_DEPLOYMENTS": json.dumps([{"id": 7, "sha": GOOD, "payload": {"repo": "o/r", "dev_worker": "app-dev", "worker": "app"}}]), "STUB_STATUS": "success https://dev.example.com Serving good (content)"} tamper = 'case "$PWD" in */recover*) sed -i "s/name = \\"app-dev\\"/name = \\"app\\"/" wrangler.toml;; esac; echo "BUILD ver=$VITE_APP_VERSION commit=$PUBLIC_COMMIT_SHA cfenv=$CLOUDFLARE_ENV" >> "$STUB_LOG"' -# Manual persistent-dev: legacy vs engine contract -PD = dict(DEV_INPUTS, deployment_strategy="hybrid", persistent_dev_ref="refs/heads/dev", dispatch_target="persistent-dev", shared_dev_on_branch_push=False) +# Manual persistent-dev dispatch: NOT a separate path - the same chain (CI -> Plan -> Migrate -> Deploy -> Smoke -> Rollback) as the automatic push +PD = dict(DEV_INPUTS, deployment_strategy="hybrid", persistent_dev_ref="refs/heads/dev", dispatch_target="persistent-dev", shared_dev_on_branch_push=False, preview_on_branch_push=False) -def manual(inputs=None, env=None, toml=DEV_TOML, gate=True): - ws = make_workspace(toml) - # the REAL call: delivery_persistent_dev (Plan / Migration skipped for a dispatch) -> the `manual` job of the delivery workflow - needs = {"plan": SKIPPED, "migration": SKIPPED} - c = ctx("refs/heads/dev", dict(PD, **(inputs or {})), PROD_SECRETS, needs, event="workflow_dispatch") - pj = doc["jobs"]["delivery_persistent_dev"] - if gate and not ev(pj["if"], c): - return {"result": "skipped", "log": "", "summary": "", "ws": ws, "steps": []} - dinputs = dict(DINPUT_DEFAULTS, **{k: render(v, c) for k, v in pj["with"].items()}) - dinputs["manual"] = dinputs["manual"] == "true" - dc = Ctx({"github": c["github"], "inputs": dinputs, "needs": {}, "jobs": {}}, {k: render(v, c) for k, v in pj["secrets"].items()}) - for n in ("deploy", "smoke", "rollback"): - assert not ev(ddoc["jobs"][n]["if"], dc), f"{n} must not run for the manual dispatch" - assert ev(ddoc["jobs"]["manual"]["if"], dc) - r = run_job(ddoc, "manual", dc, ws, dict({"STUB_TIPS": SHA}, **(env or {}))) - r["ws"] = ws - return r +def manual(inputs=None, env=None, toml=DEV_TOML, ref="refs/heads/dev", needs_over=None, env_by_job=None, **kw): + """The protected workflow_dispatch of persistent Dev through the REAL chain (every job's `if` evaluated against the real needs).""" + return run_chain(ref, inputs=dict(PD, **(inputs or {})), env=env, toml=toml, event="workflow_dispatch", needs_over=needs_over, env_by_job=env_by_job, **kw) + # Every job below ran in its OWN fresh workspace: the hand-off (URL, version ids, Worker identity, ledger id, migration # facts, smoke level) travelled through job outputs only. @@ -949,24 +934,58 @@ def group_shared_dev(): check(f"commit={GOOD}" in rb["log"], "build env referencing the failed SHA (full) is rebound for the restored commit") - r = manual(inputs={"dev_build_cmd": "", "dev_smoke_cmd": 'echo SMOKE >> "$STUB_LOG"', "dev_deploy_cmd": 'echo "PUBLISH" >> "$STUB_LOG"'}) - check(r["result"] == "success" and ran(r, "PUBLISH") and ran(r, "SMOKE") and not ran(r, "BUILD") and "-X POST" not in r["log"] and "statuses" not in r["log"], "legacy persistent-dev: unchanged (deploy + optional smoke), never written to the known-good ledger") - r = manual() - check(r["result"] == "success" and r["log"].index("BUILD") < r["log"].index("PUBLISH") and "state=success" in r["log"], "engine contract: manual run builds first, publishes, smokes and is recorded verified") - r = manual(inputs=D1) - check(r["result"] == "success" and r["log"].index("BUILD") < r["log"].index("migrations apply") < r["log"].index("PUBLISH") and "SHARED-ID" in mig_file(r, "dev"), "engine contract: manual run migrates the Dev D1 between build and publish") - r = manual(inputs={"dev_build_cmd": "false"}) - check(r["result"] == "failure" and not ran(r, "PUBLISH") and "state=success" not in r["log"], "engine contract: manual build failure publishes nothing and records nothing as good") - r = manual(env={"STUB_PROBES": "500 ", **GD}) - check(r["result"] == "failure" and ran(r, "PUBLISH") and "state=success" not in r["log"] and "state=failure" in r["log"] and r["log"].count("PUBLISH") == 1, "engine contract: a manual deploy whose smoke fails is NEVER recorded as known-good and is not auto-recovered") + # ---- the manual dispatch runs the standard pipeline, structurally identical to the automatic Shared Dev push + inner_of = lambda x: {k: v["result"] for k, v in x["res"]["delivery_persistent_dev"]["jobs"].items()} + auto, man = dev(inputs=D1), manual(inputs=D1) + check(man["result"] == "success" and man["res"]["ci"]["result"] == "success" and man["res"]["plan"]["result"] == "success" and man["res"]["migration"]["result"] == "success", + "manual dispatch: CI, Plan and the dedicated Migration job all ran (no bypass)") + check({k: v["result"] for k, v in man["res"].items()} == {k: v["result"] for k, v in auto["res"].items()} and inner_of(man) == inner_of(auto) == {"deploy": "success", "smoke": "success", "rollback": "skipped"}, + "manual dispatch vs automatic Shared Dev push: the SAME jobs with the SAME results (CI, Plan, Migrate, persistent-dev Deploy -> Smoke Tests -> Revert skipped); no `manual` job exists") + check(json.loads(man["res"]["plan"]["outputs"]["kinds"]) == ["persistent-dev"] and man["res"]["plan"]["outputs"]["descriptors"] == auto["res"]["plan"]["outputs"]["descriptors"], + "manual dispatch: the Plan resolves exactly [persistent-dev] with the SAME versioned descriptor as the automatic push") + check(man["log"].index("BUILD") < man["log"].index("migrations apply") < man["log"].index("PUBLISH") and "SHARED-ID" in mig_file(man, "dev") and "PROD-ID" not in mig_file(man, "dev") and man["log"].count("migrations apply") == 1, + "manual dispatch: ONE migration, in the Migrate job, against the Dev D1 only (never Production), before the publish; none inside the Deploy") + check(man["outputs"]["deployed_and_verified"] == "true" and man["outputs"]["verified_url"] == "https://dev.example.com" and "state=success" in man["log"], "manual dispatch: published, smoked at content level and recorded verified through the standard Smoke") + r = manual(inputs={"dev_smoke_cmd": 'echo SMOKE >> "$STUB_LOG"'}) + check(r["result"] == "success" and ran(r, "SMOKE") and not ran(r, "migrations apply"), "manual dispatch: dev_smoke_cmd runs in the standard Smoke") + # ---- rejected before any mutation + nomut = lambda x: not ran(x, "PUBLISH") and not ran(x, "BUILD") and "migrations apply" not in x["log"] and "-X POST" not in x["log"] + for label, kw in (("an unapproved ref", dict(ref="refs/heads/other")), ("a missing persistent_dev_ref", dict(inputs={"persistent_dev_ref": ""})), + ("strategy production-preview", dict(inputs={"deployment_strategy": "production-preview"})), ("an unknown dispatch_target", dict(inputs={"dispatch_target": "production"}))): + r = manual(**dict(kw, inputs=dict(D1, **kw.get("inputs", {})))) + check(r["result"] == "failure" and r["res"]["plan"]["result"] == "failure" and nomut(r) and all(r["res"][f"delivery_{KEYED[k]}"]["result"] == "skipped" for k in KINDS) and r["res"]["migration"]["result"] == "skipped", + f"manual dispatch with {label}: the Plan rejects it - no Migration, no Deploy, nothing built or published") + # ---- from the approved main ref: persistent Dev, never Production + r = manual(ref=MAIN, inputs=dict(D1, persistent_dev_ref=MAIN, release_cmd="pnpm release", production_rollback_policy="automatic")) + check(r["result"] == "success" and json.loads(r["res"]["plan"]["outputs"]["kinds"]) == ["persistent-dev"] and r["res"]["delivery_production"]["result"] == "skipped" and "versions upload" not in r["log"] and "versions deploy" not in r["log"] and r["res"]["delivery_persistent_dev"]["result"] == "success", + "manual dispatch from the approved main ref: the Dev target only, Production is never selected or deployed") + check(not release_runs(r, {"release_cmd": "pnpm release", "dispatch_target": "persistent-dev"}), "manual dispatch from main: Release never runs") + # ---- missing required input / failed gates r = manual(inputs={"dev_worker_name": ""}) - check(r["result"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run also requires the Worker identity") - r = manual(toml=DEV_TOML.replace('name = "app-dev"', 'name = "app"')) - check(r["result"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run applies the same config audit") - r = manual(inputs={"persistent_dev_ref": "refs/heads/other"}) - check(r["result"] == "skipped" and not ran(r, "PUBLISH"), "manual dispatch from a ref other than persistent_dev_ref: the delivery call is skipped (nothing runs)") - r = manual(inputs={"persistent_dev_ref": "refs/heads/other"}, gate=False) - check(r["result"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run from a ref other than persistent_dev_ref is refused by the engine too (defence in depth, even if the call gate were bypassed)") + check(r["result"] == "failure" and not ran(r, "PUBLISH") and r["res"]["migration"]["result"] in ("failure", "skipped"), "manual dispatch without dev_worker_name: rejected before publish (same validation as the automatic path)") + r = manual(inputs=dict(D1, lint_cmd="l"), needs_over={"lint": "failure"}) + check(r["result"] == "failure" and r["res"]["plan"]["result"] == "skipped" and r["res"]["migration"]["result"] == "skipped" and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and nomut(r), "manual dispatch: a failed CI check stops everything - no Plan, Migration or Deploy") + r = manual(inputs=D1, toml=DEV_TOML.replace('name = "app-dev"', 'name = "app"')) + check(r["result"] == "failure" and not ran(r, "PUBLISH") and "migrations apply" not in r["log"], "manual dispatch: the same config audit applies (wrong Worker identity: nothing migrated or published)") + r = manual(inputs=dict(D1, dev_build_cmd="false")) + check(r["result"] == "failure" and not ran(r, "PUBLISH") and "migrations apply" not in r["log"] and r["res"]["delivery_persistent_dev"]["result"] == "skipped", "manual dispatch: a failed build blocks the Migration, so nothing is migrated or deployed") + # ---- smoke failure: the SAME conditional recovery as every other persistent-dev deployment + r = manual(env=dict(GD, STUB_PROBES="500 ;200 ")) + check(r["result"] == "failure" and inner_of(r) == {"deploy": "success", "smoke": "failure", "rollback": "success"} and r["log"].count("PUBLISH") == 2 and f"BUILD ver={GOOD} commit={GOOD} cfenv=dev" in r["log"] and "state=success" not in r["log"], + "manual dispatch, Smoke failure: the standard Revert redeploys the last known-good Dev SHA; the run stays red and the failed SHA is never recorded verified") + r = manual(env={"STUB_PROBES": "500 "}) + check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "manual recovery required" in r["summary"] and "state=success" not in r["log"], "manual dispatch, Smoke failure without a known-good deployment: fails closed, no recovery attempted") + r = manual(env=dict(GD, STUB_PROBES="500 "), env_by_job={"rollback": {"STUB_TIPS": OTHER}}) + check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "superseded" in r["summary"], "manual dispatch: recovery never replaces a newer deployment of the branch") + r = manual(inputs=D1, env={"STUB_PROBES": "500 "}) + check(r["result"] == "failure" and r["log"].count("migrations apply") == 1 and "d1 migrations" in r["log"] and "schema rollback" not in r["log"] and "migrations revert" not in r["log"], "manual dispatch: a failed Smoke never touches the D1 schema (applied once, never reverted)") + # ---- other kinds keep their pipelines + r = deploy(MAIN, inputs={"release_cmd": "pnpm release"}) + check(r["result"] == "success" and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and release_runs(r), "unchanged: a Production push still runs the Production delivery (and Release)") + r = deploy(FEAT) + check(r["result"] == "success" and r["res"]["delivery_native_branch"]["result"] == "success", "unchanged: a Native Branch push still runs the Native Branch delivery") + r = deploy(MAIN, event="workflow_dispatch") + check(r["result"] == "success" and json.loads(r["res"]["plan"]["outputs"]["kinds"]) == ["production"], "unchanged: a workflow_dispatch without a target on main keeps the Production behaviour") def group_chain_smoke_rollback(): diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index 4a727bc..e39e73e 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -26,7 +26,6 @@ djobs = ddoc["jobs"] cjobs = cdoc["jobs"] # CI: six validation jobs + Validation Gate ljobs = ldoc["jobs"] -mjob = djobs["manual"] # the manual persistent Dev dispatch lives in the delivery workflow (one `persistent-dev` delivery job) inputs_def = doc[True]["workflow_call"]["inputs"] fail = 0 @@ -105,9 +104,10 @@ def vres(**over): KK = lambda k: k.replace("-", "_") CALL_JOBS = {f"delivery_{KK(k)}" for k in KINDS} check(set(jobs) == {"ci", "plan", "migration"} | CALL_JOBS | {"release", "config-guard", "lifecycle"}, - "public job set: CI call, Plan, ONE Migrate job, one Delivery CALL per kind (reusable workflow; persistent-dev covers the automatic AND the manual-dispatch delivery), Release + config-guard / lifecycle handlers") + "public job set: CI call, Plan, ONE Migrate job, one Delivery CALL per kind (reusable workflow; persistent-dev serves the automatic Shared Dev push AND the manual dispatch through the SAME call), Release + config-guard / lifecycle handlers") check(set(cjobs) == set(VALIDATION) | {"gate"}, "the CI workflow has exactly the six validation jobs and the Validation Gate") -check(set(djobs) == {"deploy", "smoke", "rollback", "manual"}, "the internal delivery workflow has exactly: deploy, smoke, rollback, manual (ONE target per call; `manual` = the protected persistent Dev dispatch; no Preflight / Migration path)") +check(set(djobs) == {"deploy", "smoke", "rollback"}, "the internal delivery workflow has exactly: deploy, smoke, rollback (ONE target per call; no `manual` job, no Preflight / Migration path)") +check("manual" not in ddoc[True]["workflow_call"]["inputs"] and "inputs.manual" not in open(DWF).read() and "manual: " not in text_all and "mode: manual" not in open(DWF).read(), "no `manual` delivery input, job or routing exists anywhere in the pipeline (a manual dispatch is just another trigger)") check(set(ljobs) == {"lifecycle"} and not os.path.exists(".github/workflows/_cf-worker-persistent-dev.yml"), "the event handler is one small workflow (Preview lifecycle); the separate manual persistent Dev workflow is gone") for gone in ("dev-deploy", "dev-verify", "dev-rollback", "test", "prepare", "migrate", "test-gate", "preview", "production", "verify", "revert", "deploy", "smoke", "rollback", "preflight", "gate") + tuple(VALIDATION) + tuple(f"{s_}_{KK(k)}" for s_ in ("preflight", "migration") for k in KINDS): check(gone not in jobs, f"obsolete / matrix / inlined job '{gone}' is gone from the public workflow (validation lives in _cf-worker-ci.yml; no matrix output merging, no Preflight stage, no per-kind Migration call)") @@ -117,11 +117,11 @@ def vres(**over): check(cjobs["gate"]["name"] == "Validation Gate" and jobs["ci"]["name"] == "CI" and jobs["plan"]["name"] == "Plan" and jobs["migration"]["name"] == "Migrate", "CI, Validation Gate, Plan and Migrate are named jobs") # Displayed check = ` / ...`; consumers call this workflow from a job named `CD`, so names carry no repeated words. check({k: jobs["delivery_" + KK(k)]["name"] for k in KINDS} == {k: k for k in KINDS}, "check names: each Delivery call is named just its kind (production / native-branch / native-dev / persistent-dev)") -check([djobs[j]["name"] for j in ("deploy", "smoke", "rollback", "manual")] == ["Deploy", "Smoke Tests", "Revert on Verify Failure", "manual"], "check names: inner delivery jobs stay Deploy / Smoke Tests / Revert on Verify Failure, plus manual") +check([djobs[j]["name"] for j in ("deploy", "smoke", "rollback")] == ["Deploy", "Smoke Tests", "Revert on Verify Failure"], "check names: inner delivery jobs stay Deploy / Smoke Tests / Revert on Verify Failure") check((jobs["config-guard"]["name"], jobs["lifecycle"]["name"], jobs["release"]["name"]) == ("Guard", "lifecycle", "Release"), "check names: Guard, lifecycle, Release") check(ljobs["lifecycle"]["name"] == "ledger", "check names: the handler inner job (ledger) never repeats its caller's name") _names = [j["name"] for j in jobs.values()] -check(sorted(n for n in set(_names) if _names.count(n) > 1) == [], "check names: every public job name is unique - the engine has exactly ONE `persistent-dev` job (automatic DAG delivery and manual dispatch)") +check(sorted(n for n in set(_names) if _names.count(n) > 1) == [], "check names: every public job name is unique - the engine has exactly ONE `persistent-dev` job, so a manual dispatch has the same check structure as an automatic Shared Dev push") check(not any(re.search(r"Delivery \(|Preview lifecycle|Database Migration|Config guard|Persistent Dev \(Hybrid\)|Delivery plan", str(j.get("name", ""))) for d in (jobs, cjobs, djobs, ljobs) for j in d.values()), "check names: none of the old verbose names remain in any v2 workflow") check(jobs["ci"]["uses"] == "$/.github/workflows/_cf-worker-ci.yml", "CI is one STATIC `uses: $/.github/workflows/_cf-worker-ci.yml` call (this repository at the running commit)") check("strategy" not in jobs["ci"], "no matrix on the CI call (E2E variants are the only matrix, inside the CI workflow)") @@ -153,7 +153,7 @@ def needs_of(name, js=None): for k in KINDS: w = jobs[f"delivery_{KK(k)}"]["with"] check("stage" not in w and "kind" not in w, f"delivery_{KK(k)}: no stage input (the delivery workflow only delivers) and the kind is only ever read from the descriptor") - DESC_EXPR = "${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['" + k + "']) }}" if k != "persistent-dev" else "${{ needs.plan.outputs.descriptors && toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) || '{}' }}" # persistent-dev also serves the manual dispatch (no Plan) + DESC_EXPR = "${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['" + k + "']) }}" # identical for every kind: the Plan descriptor is the only source of target identity check(w["descriptor"] == DESC_EXPR and not set(w) & {"target", "environment", "d1_expected_id", "d1_owner_kind", "deploy_group", "rollback_group"}, f"delivery_{KK(k)}: ONE target descriptor of the plan replaces the per-call target / environment / D1 / owner / concurrency-group copies") check(w["migration_result"] == "${{ needs.migration.result }}" and w["migration_json"] == "${{ needs.migration.outputs.migration_json }}" and not set(w) & {"kinds", "peers", "own_migration_result", "own_migration_json", "owner_migration_result", "owner_migration_json"}, @@ -175,11 +175,11 @@ def needs_of(name, js=None): # ---------------------------------------------------------------- 2. one deployment implementation ENGINE_USE = "$/actions/cf-deploy" engine_jobs = {n: [s for s in j.get("steps", []) if s.get("uses") == ENGINE_USE] for n, j in {**{f"d.{k}": v for k, v in djobs.items()}, **jobs}.items()} -check({n for n, s in engine_jobs.items() if s} == {"d.deploy", "d.smoke", "d.rollback", "d.manual", "migration"} and all(len(s) == 1 for n, s in engine_jobs.items() if s and n != "migration") and len(engine_jobs["migration"]) == 3, - "the four delivery-workflow jobs (deploy, smoke, rollback and the manual persistent Dev) each call the SAME delivery engine action exactly once; the Migration job calls it for the owner's migration and for each non-owner target's validation (no second deployment implementation)") +check({n for n, s in engine_jobs.items() if s} == {"d.deploy", "d.smoke", "d.rollback", "migration"} and all(len(s) == 1 for n, s in engine_jobs.items() if s and n != "migration") and len(engine_jobs["migration"]) == 3, + "the three delivery-workflow jobs (deploy, smoke, rollback) each call the SAME delivery engine action exactly once; the Migration job calls it for the owner's migration and for each non-owner target's validation (no second deployment implementation)") eng = {n.replace("d.", ""): s[0] for n, s in engine_jobs.items() if s and n != "migration"} AUTO_JOBS = ("deploy", "smoke", "rollback") -check({n: eng[n]["with"]["stage"] for n in AUTO_JOBS} == {"deploy": "publish", "smoke": "verify", "rollback": "rollback"} and "stage" not in eng["manual"]["with"], "each automatic delivery job selects its engine stage (publish / verify / rollback); the manual job runs the engine's default one-job stage (all)") +check({n: eng[n]["with"]["stage"] for n in AUTO_JOBS} == {"deploy": "publish", "smoke": "verify", "rollback": "rollback"}, "each delivery job selects its engine stage (publish / verify / rollback), for every kind and every trigger") MIG_STEPS = engine_jobs["migration"] check([s["with"]["stage"] for s in MIG_STEPS] == ["preflight", "preflight", "migrate"] and MIG_STEPS[-1].get("id") == "migrate", "the Migration job validates every non-owner target first (stage preflight: build + effective-config audit + exact D1 binding), then migrates ONCE as its LAST engine step (stage migrate)") @@ -187,7 +187,6 @@ def needs_of(name, js=None): check(all(eng[n]["with"]["target"] == "${{ fromJSON(inputs.descriptor).target }}" and eng[n]["with"]["kind"] == "${{ fromJSON(inputs.descriptor).kind }}" and eng[n]["with"]["descriptor"] == "${{ inputs.descriptor }}" for n in AUTO_JOBS), "the engine target comes from the call's validated descriptor, kind from the static input; the engine re-validates the descriptor") check(all(re.fullmatch(r"\$\{\{ fromJSON\(needs\.plan\.outputs\.descriptors\)\[(fromJSON\(needs\.plan\.outputs\.migration\)\.kind|'[a-z-]+')\]\.target \}\}", s["with"]["target"]) and "toJSON(fromJSON(needs.plan.outputs.descriptors)[" in s["with"]["descriptor"] for s in MIG_STEPS), "Migration job: every engine step takes its target and descriptor from the plan's validated descriptors (the owner's, or the named non-owner's)") -check(eng["manual"]["with"]["target"] == "dev" and eng["manual"]["with"]["mode"] == "manual" and eng["manual"]["with"]["inputs_json"] == "${{ inputs.inputs_json }}" and "descriptor" not in eng["manual"]["with"], "manual persistent Dev binds target=dev, mode=manual (no descriptor: Plan does not run for a dispatch)") check(all(eng[n]["with"]["inputs_json"] == "${{ inputs.inputs_json }}" for n in AUTO_JOBS) and all(eng[n]["with"]["d1_expected_id"] == "${{ fromJSON(inputs.descriptor).d1_expected_id }}" for n in AUTO_JOBS) and all(s["with"]["inputs_json"] == "${{ toJSON(inputs) }}" and s["with"]["d1_expected_id"] for s in MIG_STEPS), "engine inputs are passed as one JSON data blob; every stage binds the plan's D1 id (preflight, migrate, publish, verify, rollback)") VERIFY_BLOB = "${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).inputs) || toJSON(inputs) }}" @@ -203,7 +202,7 @@ def needs_of(name, js=None): check(not runs("ci", scenario("delete", FEAT, ev_ref_type="branch")), "ci: not on branch delete") check(not runs("ci", scenario("pull_request", "refs/pull/1/merge", head_repo="o/r")), "ci: a same-repo PR is covered by the push run of the same SHA (no duplicate CI)") check(runs("ci", scenario("pull_request", "refs/pull/1/merge", head_repo="fork/r")), "ci: fork PR runs (credential-free)") -check(not runs("ci", scenario("workflow_dispatch", "refs/heads/dev", {"dispatch_target": "persistent-dev"})), "ci: not on the persistent-dev dispatch") +check(runs("ci", scenario("workflow_dispatch", "refs/heads/dev", {"dispatch_target": "persistent-dev"})) and "dispatch_target" not in jobs["ci"]["if"], "ci: the persistent-dev dispatch runs the SAME CI call (no manual CI bypass)") check(not runs("ci", scenario("push", FEAT, {"pipeline_phase": "verify"})), "ci: not in a verify call (it never re-validates)") check(runs("ci", scenario("workflow_dispatch", FEAT)), "ci: runs on a regular workflow_dispatch") # Inside the CI workflow the only per-job switch is "is the command configured" @@ -361,16 +360,14 @@ def dout(kind, **kw): check(runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="o/r")) and not runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="fork/r")), "lifecycle: same-repo PR comments only") check(not runs("lifecycle", scenario("push", FEAT)), "lifecycle: not on push") dev_ok = dict(inputs={"dispatch_target": "persistent-dev", "deployment_strategy": "hybrid", "persistent_dev_ref": "refs/heads/dev"}) -SKIP_PM = {"plan": {"result": "skipped", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}} # Plan / Migration never run for a dispatch -check(runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, **dev_ok)) and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/other", needs=SKIP_PM, **dev_ok)) - and not runs("delivery_persistent_dev", scenario("push", "refs/heads/dev", needs=SKIP_PM, **dev_ok)), "persistent Dev: the manual entry runs on the approved ref via dispatch only, never on push") -check(not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], deployment_strategy="production-only"))) - and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], persistent_dev_ref=""))) - and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], pipeline_phase="verify"))), "persistent Dev manual entry: hybrid strategy, a non-empty persistent_dev_ref and a non-verify phase are required") -pd_with = {k: render(v, scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, **dev_ok)) for k, v in jobs["delivery_persistent_dev"]["with"].items() if k in ("manual", "descriptor")} -check(pd_with == {"manual": "true", "descriptor": "{}"}, "persistent Dev manual entry: the delivery call gets manual=true and an empty descriptor (no Plan ran)") -auto_with = {k: render(v, scenario("push", FEAT, needs={"plan": pl(("persistent_dev",), False), "migration": {"result": "skipped", "outputs": {}}})) for k, v in jobs["delivery_persistent_dev"]["with"].items() if k == "manual"} -check(auto_with == {"manual": "false"}, "persistent Dev automatic entry (push): manual=false") +PD_PLAN = {"plan": {"result": "success", "outputs": {"on_persistent_dev": "true"}}, "migration": {"result": "skipped", "outputs": {}}} +check(runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=PD_PLAN, **dev_ok)) and runs("delivery_persistent_dev", scenario("push", FEAT, needs=PD_PLAN)), + "persistent Dev: a manual dispatch and an automatic push run through the SAME delivery call, gated only by the plan") +check(not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs={"plan": {"result": "skipped", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}}, **dev_ok)) + and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs={"plan": {"result": "failure", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}}, **dev_ok)), + "persistent Dev: no delivery without a successful Plan (CI failed, or the Plan rejected the dispatch) - the old Plan-less manual entry is gone") +check(not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=dict(PD_PLAN, migration={"result": "failure", "outputs": {}}), **dev_ok)), "persistent Dev: a failed Migration blocks the dispatch's delivery too") +check(list(jobs["delivery_persistent_dev"]["with"]) == list(jobs["delivery_native_dev"]["with"]) and jobs["delivery_persistent_dev"]["permissions"] == jobs["delivery_native_dev"]["permissions"], "persistent Dev: the call is structurally identical to the other kinds (same inputs, same permissions)") BAD = {"deployment_strategy": "production-only", "shared_dev_on_branch_push": True} check(runs("config-guard", scenario("push", MAIN, BAD, GREEN)) and runs("config-guard", scenario("push", FEAT, dict(BAD, shared_dev_on_branch_push=False, shared_dev_native_preview_on_branch_push=True), GREEN)), "Config guard: production-only + (persistent Dev | Native Dev) is reported") @@ -421,8 +418,6 @@ def dout(kind, **kw): c = Ctx({"inputs": {"kind": k, "descriptor": json.dumps(desc(k))}}, SECRETS) got = render(w["preview_secrets"], c) check((got == "K=v") == (k in ("native-branch", "native-dev")) and (k in ("native-branch", "native-dev") or "PREVIEW_SECRETS" not in c.secret_reads), f"{n}: Preview-only secrets reach {k} only if it is a Preview kind") -ptxt = yaml.dump(mjob) -check("secrets.CLOUDFLARE_API_TOKEN" in ptxt and "secrets.CLOUDFLARE_ACCOUNT_ID" in ptxt and "CLOUDFLARE_PREVIEW" not in ptxt and "PREVIEW_SECRETS" not in ptxt, "manual persistent Dev reuses account credentials, not Preview secrets") check(not any(k in j.get("env", {}) for j in list(jobs.values()) + list(djobs.values()) for k in ("TARGET", "BUILD_ENV", "SMOKE_ENV", "DEV_ENV", "EXPECTED_SHA", "RUN_SHA")), "no job exports TARGET / BUILD_ENV / DEV_ENV / EXPECTED_SHA ...: the delivery target is bound by the engine action input, not by environment") ENVEXPR = "${{ fromJSON(inputs.descriptor).environment }}" @@ -430,7 +425,6 @@ def dout(kind, **kw): check(all(render(ENVEXPR, Ctx({"inputs": {"descriptor": json.dumps(desc(k))}}, SECRETS)) == DESC_FIX[k][1] for k in KINDS), "descriptor environments are /prod | preview | native-dev | dev") check("environment" not in djobs["smoke"], "Smoke: no Environment (no protection prompt; it mutates nothing)") check(djobs["deploy"]["environment"]["url"] == "${{ steps.engine.outputs.url }}", "Deploy: the job shows the actual deployed URL (environment.url)") -check(mjob["environment"] == "${{ fromJSON(inputs.inputs_json).app_name }}/dev" and render(mjob["environment"], Ctx({"inputs": {"inputs_json": json.dumps({"app_name": "app"})}}, {})) == DESC_FIX["persistent-dev"][1], "manual persistent Dev runs in the /dev Environment (same as the automatic persistent-dev kind)") allj = {**jobs, **{f"d.{k}": v for k, v in djobs.items()}, **{f"ci.{k}": v for k, v in cjobs.items()}, **{f"l.{k}": v for k, v in ljobs.items()}, } check(all(j.get("permissions", {}).get("contents", "read") == "read" for n, j in allj.items() if n != "release"), "no job except Release can write repository contents") check(jobs["migration"]["permissions"].get("deployments") == "read", "Migration can READ (not write) Deployments: the Preview identity guard reads the ledger owner") @@ -442,11 +436,9 @@ def dout(kind, **kw): check(not ({"secrets"} & set(cdoc[True]["workflow_call"])) and not ({"secrets"} & set(ldoc[True]["workflow_call"])) and "secrets" not in jobs["ci"] and "secrets" not in jobs["lifecycle"], "the CI and lifecycle calls pass NO secrets at all (credential-free by construction)") check("secrets.SMOKE_PASSWORD" not in yaml.dump(cjobs) and eng["smoke"]["with"]["smoke_password"] == "${{ secrets.SMOKE_PASSWORD }}" and "smoke_password" not in eng["deploy"]["with"] and not any("smoke_password" in x["with"] for x in MIG_STEPS), "smoke credentials reach only the engine actions that smoke (Smoke Tests, Rollback re-smoke, Dev)") -for n in ("deploy", "smoke", "rollback", "manual", "migration"): +for n in ("deploy", "smoke", "rollback", "migration"): cks = [s for s in (djobs.get(n) or jobs[n])["steps"] if str(s.get("uses", "")).startswith("actions/checkout")] check(cks and all(s["with"].get("persist-credentials") is False for s in cks), f"{n}: checkouts do not persist the GitHub token into the workspace") -check(all(s["with"].get("persist-credentials") is False for s in mjob["steps"] if str(s.get("uses", "")).startswith("actions/checkout")), "manual persistent Dev: the checkout does not persist the GitHub token") - # ---------------------------------------------------------------- 6. cross-repository reusable workflow + local composite action references USES_ALL = [(n, i, s_["uses"]) for n, j in allj.items() for i, s_ in enumerate(j.get("steps", [])) if "uses" in s_] + [(n, -1, j["uses"]) for n, j in allj.items() if "uses" in j] SELF = [(n, i, u) for n, i, u in USES_ALL if u.startswith("$/")] @@ -569,8 +561,8 @@ def dout(kind, **kw): check("steps.rbfinal.outputs.ok != 'true'" in fin["if"] and "exit 1" in fin["run"], "engine: the Rollback job stays RED unless the previous deployment was restored AND content-verified") check(aidx("Rollback - restore") < aidx("Rollback - re-run") < aidx("Rollback - verdict") < aidx("Rollback - fail unless"), "engine: rollback -> re-smoke -> verdict -> fail-unless-verified") rf = astep("Recovery - find") -check("inputs.mode == 'auto'" in rf["if"] and "inputs.target != 'prod'" in rf["if"] and "steps.publish.outcome == 'success' && steps.smoke.outcome == 'failure'" in rf["if"] and "inputs.stage == 'rollback' && inputs.target != 'prod'" in rf["if"], - "engine: automatic Preview/Dev recovery only in auto mode, only after a published URL failed smoke (Shared Dev: same job; Preview: the Rollback job), never for Production") +check("inputs.mode" not in rf["if"] and "inputs.target != 'prod'" in rf["if"] and "steps.publish.outcome == 'success' && steps.smoke.outcome == 'failure'" in rf["if"] and "inputs.stage == 'rollback' && inputs.target != 'prod'" in rf["if"], + "engine: automatic Preview/Dev recovery only after a published URL failed smoke (Shared Dev: same job; Preview: the Rollback job), never for Production") check("recover-run" in astep("Recovery - rebuild")["run"] and "migrate" not in astep("Recovery - rebuild")["run"], "engine: recovery rebuilds, audits and republishes the last good SHA and never migrates (D1 is never rolled back)") check(astep("Recovery - verify")["env"]["GOOD_SHA"] == "${{ steps.good.outputs.sha }}" and "recover-verify" in astep("Recovery - verify")["run"], "engine: the restored deployment is verified against the LAST-GOOD sha") check(astep("Recovery - checkout")["with"]["path"] == "recover" and astep("Recovery - checkout")["with"]["persist-credentials"] is False, "engine: the last-good tree is checked out beside the failed one, without persisting the token") @@ -580,7 +572,7 @@ def dout(kind, **kw): check(aidx("Remove staged env files") == len(ASTEPS) - 1, "engine: cleanup is the last step") flat = yaml.dump(ASTEPS) check("CLOUDFLARE" not in flat and "secrets." not in yaml.dump(DEPLOY_ACTION), "engine: contains no credentials; the caller job owns which Cloudflare credentials exist") -check(set(DEPLOY_ACTION["inputs"]) >= {"target", "stage", "mode", "inputs_json", "preview_secrets", "smoke_username", "smoke_password", "deploy_outputs", "migration_outputs", "smoke_outputs"}, "engine interface: target, stage, mode, inputs_json, the secret-bearing inputs and the job hand-off blobs") +check(set(DEPLOY_ACTION["inputs"]) >= {"target", "stage", "inputs_json", "preview_secrets", "smoke_username", "smoke_password", "deploy_outputs", "migration_outputs", "smoke_outputs"}, "engine interface: target, stage, inputs_json, the secret-bearing inputs and the job hand-off blobs") check(DEPLOY_ACTION["inputs"]["stage"]["default"] == "all", "engine: stage defaults to all (Shared Dev / persistent Dev callers are unchanged)") # ---------------------------------------------------------------- 10. concurrency: the identity is the shared RESOURCE @@ -609,7 +601,10 @@ def dout(kind, **kw): check(g(FEAT, dict(OFF, shared_dev_on_branch_push=True, d1_migrations=False)) == "cfv2-shared-dev-app", "group: Shared Dev without a D1 keeps the one-Dev-Worker group") check(g(MAIN, {"shared_dev_on_branch_push": True}) == "cfv2-prod-app", "group: Production is never serialized behind Dev") check(g("refs/pull/1/merge", event="pull_request").startswith("cfv2-run-") and g("refs/heads/x", event="delete").startswith("cfv2-run-"), "group: PR / delete runs get a private group") -check(g("refs/heads/dev", {"dispatch_target": "persistent-dev"}, "workflow_dispatch").startswith("cfv2-run-"), "group: persistent-dev dispatch does not queue behind deploys") +PDD = {"dispatch_target": "persistent-dev", "deployment_strategy": "hybrid", "persistent_dev_ref": MAIN} +check(g("refs/heads/dev", dict(OFF, **PDD, shared_dev_on_branch_push=True, d1_migrations=True), "workflow_dispatch") == g(FEAT, dict(OFF, shared_dev_on_branch_push=True, d1_migrations=True)) == "cfv2-nonprod-d1-app" + and g(MAIN, dict(OFF, **PDD, d1_migrations=False), "workflow_dispatch") == "cfv2-shared-dev-app" == g(FEAT, dict(OFF, shared_dev_on_branch_push=True, d1_migrations=False)), + "group: a persistent-dev dispatch takes the SAME run-level group as a feature-branch Shared Dev push (serialised, with or without D1) - and from main it is never the Production group") check(g("refs/tags/v1", event="push").startswith("cfv2-run-"), "group: tag pushes get a private group") check("github.workflow" not in cc["group"] and all(g(r).startswith("cfv2-") for r in (MAIN, FEAT)), "group has a static prefix and never uses github.workflow (no caller/callee deadlock)") import subprocess # noqa: E402 @@ -622,15 +617,11 @@ def js_group(scope, app): check(djobs["deploy"]["concurrency"]["group"] == "${{ fromJSON(inputs.descriptor).deploy_group }}" and djobs["rollback"]["concurrency"]["group"] == "${{ fromJSON(inputs.descriptor).rollback_group }}", "delivery jobs take their concurrency groups from the descriptor") -manual_group = lambda scope, app: render(mjob["concurrency"]["group"], Ctx({"github": {"repository": "o/r"}, "inputs": {"inputs_json": json.dumps({"app_name": app, "d1_lock_scope": scope})}}, {})) -for scope, app in (("shared-db", "blog"), ("", "blog"), ("", "wiki")): - check(js_group(scope, app) == manual_group(scope, app), f"persistent-dev: the descriptor's Deploy/Rollback group equals the manual dispatch's group (scope={scope or '-'}, app={app}) - one serialisation domain per shared D1") -check(js_group("", "blog") != js_group("", "wiki"), "without a scope the Dev group is keyed by app") +check(js_group("", "blog") != js_group("", "wiki") and js_group("shared-db", "blog") == js_group("shared-db", "wiki") == "cfv2-dev-o/r-shared-db", "persistent-dev: the descriptor's Deploy/Rollback group (the ONE lock of every automatic AND manual Dev deployment) is keyed by the shared-D1 scope, else by app") for n in ("deploy", "rollback"): check(djobs[n]["concurrency"]["cancel-in-progress"] is False, f"{n}: job-level groups never cancel in-flight work") for j in ("smoke", "migration") + tuple(VALIDATION): check("concurrency" not in (djobs.get(j) or jobs.get(j) or cjobs.get(j)), f"{j}: no job-level concurrency (a second, nested group could deadlock with the workflow-level one)") -check(mjob["concurrency"]["group"] == "cfv2-dev-${{ github.repository }}-${{ fromJSON(inputs.inputs_json).d1_lock_scope != '' && fromJSON(inputs.inputs_json).d1_lock_scope || fromJSON(inputs.inputs_json).app_name }}" and mjob["concurrency"]["cancel-in-progress"] is False, "manual persistent Dev uses the same job-level group (automatic and manual Dev never overlap)") check(all(not g(r, {"d1_migrations": True}).startswith("cfv2-dev-") for r in (MAIN, FEAT)), "workflow-level groups can never equal the job-level Dev group") ND_ON = {"preview_on_branch_push": False, "shared_dev_native_preview_on_branch_push": True} check(g(FEAT, ND_ON) == "cfv2-shared-dev-app" == g("refs/heads/other", ND_ON), "group: Native Dev (fixed target `dev`) serializes ALL branches of the app on one group") @@ -653,20 +644,9 @@ def js_group(scope, app): check("delivery_persistent_dev" in OUTS["dev_url"]["value"] and "delivery_native_dev" in OUTS["native_dev_url"]["value"] and "delivery_native_dev" not in OUTS["dev_url"]["value"], "outputs: dev_* stay persistent Dev; native_dev_* are Native Dev") check(all("outputs.smoke_json" in OUTS[k]["value"] and ".handoff" not in OUTS[k]["value"] for k in ("deploy_verified_url", "deploy_verified", "native_dev_verified", "dev_verified_url")), "outputs: *_verified come from the Smoke result only (the only place a deployment becomes verified)") check(all(re.search(r"jobs\.plan\.outputs\.on_\w+ == 'true'", OUTS[k]["value"]) for k in ("deploy_url", "deploy_verified", "rollback_result")) and "jobs.migration.outputs.migration_status" in OUTS["migration_status"]["value"], "P1-A: every per-kind public output SELECTS its source by the plan (on_); migration_status is the ONE Migration job's result") -pd = mjob -MANUAL_IF = "(!cancelled() && inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && inputs.deployment_strategy == 'hybrid' && inputs.persistent_dev_ref != '' && github.ref == inputs.persistent_dev_ref)" -check(jobs["delivery_persistent_dev"]["if"].endswith(" || " + MANUAL_IF) and mjob["if"] == "inputs.manual" and pd["environment"] == "${{ fromJSON(inputs.inputs_json).app_name }}/dev", - "persistent-dev keeps the approved-ref gate and protected Environment") -check(mjob["permissions"] == {"contents": "read", "deployments": "write"}, "manual persistent Dev keeps its narrower permissions (contents read, deployments write; no pull-requests write)") -check(all("!inputs.manual" in djobs[n]["if"] for n in AUTO_JOBS), "automatic deploy / smoke / rollback never run for the manual dispatch") -check(all(not ev(djobs[n]["if"], Ctx({"inputs": {"phase": "all", "manual": True}, "needs": {"deploy": {"result": "success", "outputs": {}}, "smoke": {"result": "failure", "outputs": {"smoke_status": "failure"}}}}, {})) for n in AUTO_JOBS) - and ev(mjob["if"], Ctx({"inputs": {"manual": True}}, {})) and not ev(mjob["if"], Ctx({"inputs": {"manual": False}}, {})), "delivery: manual=true runs ONLY the manual job; manual=false never runs it") -check("recovery" not in yaml.dump(mjob).lower() and "rollback" not in yaml.dump(mjob).lower(), "manual persistent Dev has no automatic recovery step") -legacy = [x for x in pd["steps"] if x.get("if", "").startswith("fromJSON(inputs.inputs_json).dev_build_cmd == ''")] -engine_side = [x for x in pd["steps"] if x.get("if") == "fromJSON(inputs.inputs_json).dev_build_cmd != ''"] -check(len(legacy) == 3 and len(engine_side) == 1 and all("gh.sh" not in x.get("run", "") for x in legacy), "persistent-dev: legacy (dev_build_cmd empty) and engine (set) paths are mutually exclusive; legacy never writes the known-good ledger") -check(next(x for x in pd["steps"] if x.get("name") == "Deploy persistent Dev")["env"]["CMD"] == "${{ fromJSON(inputs.inputs_json).dev_deploy_cmd }}", "persistent-dev manual dispatch (legacy contract) still runs dev_deploy_cmd") -check("shared_dev_on_branch_push" not in ptxt, "persistent-dev does not depend on the automatic Shared Dev switch") +check(jobs["delivery_persistent_dev"]["if"] == jobs["delivery_native_dev"]["if"].replace("native_dev", "persistent_dev") and "github.event_name" not in jobs["delivery_persistent_dev"]["if"] and "dispatch_target" not in jobs["delivery_persistent_dev"]["if"], "persistent-dev: gated by the plan only (the approved-ref / hybrid checks are made by the Plan; the Environment /dev comes from the descriptor)") +check(all("manual" not in djobs[n]["if"] for n in AUTO_JOBS), "delivery: the deploy / smoke / rollback conditions know nothing about a manual mode") +check("dispatch_target != 'persistent-dev'" in jobs["release"]["if"], "Release: reads only the Production delivery; a persistent-dev dispatch is additionally excluded") check("wrangler preview delete" not in "\n".join(s.get("run", "").replace("\\`wrangler preview delete", "") for s in ljobs["lifecycle"]["steps"] if "echo" not in s.get("run", "")), "branch delete never executes `wrangler preview delete` (Native Preview is retained)") retire = next(s for s in ljobs["lifecycle"]["steps"] if "Retire GitHub ledger" in s.get("name", "")) check('gh.sh" retire' in retire["run"] and "Branch exists again" in retire["run"] and "Name is owned by" in retire["run"], "branch delete retires only this branch's ledger (and not if recreated / owned by another ref)") From 228f59bf44fbaccc6d8e7f716909495aeb2625e3 Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 6 Nov 2026 03:56:52 +0000 Subject: [PATCH 2/4] docs(cf-v2): align docs/openspec with the removal of the manual job; add manual-dispatch failure tests Co-Authored-By: Claude Sonnet 5.5 --- docs/cf-worker-v2-refactor.md | 4 ++-- docs/cf-worker-v2-rollout.md | 2 +- docs/cf-worker-v2-unified-delivery.md | 4 ++-- .../add-cloudflare-shared-dev-channel/proposal.md | 2 +- .../specs/cloudflare-worker-delivery/spec.md | 8 ++++---- .../changes/unify-cf-worker-v2-delivery-targets/tasks.md | 2 +- scripts/cf-preview/test-jobs.py | 9 +++++++++ 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/cf-worker-v2-refactor.md b/docs/cf-worker-v2-refactor.md index 721e4ff..98599da 100644 --- a/docs/cf-worker-v2-refactor.md +++ b/docs/cf-worker-v2-refactor.md @@ -358,7 +358,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 | +| Trigger | main push / dispatch | non-main push (`preview_on_branch_push`) | non-main push (`shared_dev_on_branch_push`) or protected dispatch of the approved ref (same pipeline) | | 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` | @@ -498,7 +498,7 @@ The former manual-only "legacy contract" (`dev_build_cmd` empty, `dev_deploy_cmd ### Recovery limits -After a failed Shared Dev smoke the job redeploys the last verified SHA (from the Dev ledger, any branch). It rebinds the commit identity: occurrences of the failed run's SHA (full and 7-character) in `build_env_dev` and `app_version` are replaced with the last-good SHA; `app_version` values not derived from the commit SHA are kept and flagged with a warning. It then repeats build, Worker-identity/resource audit, publish and the full smoke. Only with `sha_path` set is the restored **commit identity** verified; without it the summary says "commit identity NOT verified". It never rolls back D1 (a schema newer than the restored code is possible), a caller whose build parameters embed the commit in ways other than the exact SHA cannot be rebound, and a failed recovery is reported as such; the run is always red. Manual runs have no recovery. +After a failed Shared Dev smoke the job redeploys the last verified SHA (from the Dev ledger, any branch). It rebinds the commit identity: occurrences of the failed run's SHA (full and 7-character) in `build_env_dev` and `app_version` are replaced with the last-good SHA; `app_version` values not derived from the commit SHA are kept and flagged with a warning. It then repeats build, Worker-identity/resource audit, publish and the full smoke. Only with `sha_path` set is the restored **commit identity** verified; without it the summary says "commit identity NOT verified". It never rolls back D1 (a schema newer than the restored code is possible), a caller whose build parameters embed the commit in ways other than the exact SHA cannot be rebound, and a failed recovery is reported as such; the run is always red. A manual persistent-dev dispatch gets the same recovery (it runs the standard pipeline). ### Required follow-up in Blog #448 (after this PR is approved) diff --git a/docs/cf-worker-v2-rollout.md b/docs/cf-worker-v2-rollout.md index e77417a..ed18406 100644 --- a/docs/cf-worker-v2-rollout.md +++ b/docs/cf-worker-v2-rollout.md @@ -41,7 +41,7 @@ Status at the time of writing (2026-11-03): **PR #89 is ready to merge; the floa | ` / Migration` | ` / Migrate` | | ` / Deploy`, `Smoke Tests`, `Revert on Verify Failure`, `Shared Dev`, `Shared Dev Smoke Tests`, `Shared Dev Revert on Verify Failure` | ` / / Deploy\|Smoke Tests\|Revert on Verify Failure` (`` = `production\|native-branch\|native-dev\|persistent-dev`) | | `Release` | unchanged | -| `Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | `Guard`, `lifecycle / ledger`, `persistent-dev / manual` | +| `Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | `Guard`, `lifecycle / ledger`; the manual dispatch of Persistent Dev is now `persistent-dev / Deploy` (the same checks as a Shared Dev push). A repository that lists `persistent-dev / manual` as a required check must replace it with `persistent-dev / Deploy` | ## Procedure diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md index 97be8bc..331b825 100644 --- a/docs/cf-worker-v2-unified-delivery.md +++ b/docs/cf-worker-v2-unified-delivery.md @@ -5,7 +5,7 @@ It supersedes the job topology in `cf-worker-v2-refactor.md` ("Job DAG", "Shared and recovery rules described there still apply. `_cf-worker-v2.yml` remains the only public entrypoint; `_cf-worker-delivery.yml` is an **internal** reusable workflow called only by it. v1 / v1.5 and the floating `@v2` tag are untouched by this change. -> **Revised again (PR #89 Phase 1 simplification):** validation lives in `_cf-worker-ci.yml`, the Preview lifecycle in its own workflow, the manual persistent Dev dispatch as the `manual` job of the delivery workflow (one `persistent-dev` delivery job), every shared action/workflow is referenced with `$/` — see [cf-worker-v2-refactor.md](cf-worker-v2-refactor.md#phase-1-simplification-pr-89-refactor--current-structure). +> **Revised again (PR #89 Phase 1 simplification):** validation lives in `_cf-worker-ci.yml`, the Preview lifecycle in its own workflow, the manual persistent Dev dispatch as just another trigger of the `persistent-dev` kind (no separate job; see "Manual dispatch" below), every shared action/workflow is referenced with `$/` — see [cf-worker-v2-refactor.md](cf-worker-v2-refactor.md#phase-1-simplification-pr-89-refactor--current-structure). > > **Revised (PR #89 simplification).** No separate Preflight stage. **One Migration job with integrated safety validation; reusable target-driven Delivery.** > The earlier Preflight-per-kind / Migration-per-D1-owner topology, and the real-runner tables further down that exercised it, describe the superseded design; @@ -41,7 +41,7 @@ matrix anywhere in the delivery graph: every `needs..outputs.*` belongs to | --- | --- | | trusted push to `main` | `production` | | trusted push to another branch | `native-branch` if `preview_on_branch_push`; `native-dev` if `shared_dev_native_preview_on_branch_push`; `persistent-dev` if `shared_dev_on_branch_push` | -| protected `workflow_dispatch` persistent-dev | the same `persistent-dev` delivery call, `manual: true` (Plan and Migration skipped): its `manual` job runs the engine in manual mode (or the legacy two-command contract), no automatic recovery | +| protected `workflow_dispatch` persistent-dev | the same standard pipeline and the same `persistent-dev` delivery call as a push (CI, Plan, Migrate, Deploy, Smoke, conditional recovery); the Plan rejects an unapproved dispatch | | PR / delete / tag | none (lifecycle handler only) | Rejected (plan fails closed, `config-guard` also reports): `production-only` with any Dev channel; `shared_dev_on_branch_push` together with diff --git a/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md b/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md index bdb1313..0f21ad5 100644 --- a/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md +++ b/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md @@ -10,7 +10,7 @@ The first implementation added Shared Dev by duplicating the Preview/Production ## What changes -- **Thin workflow, one engine.** `_cf-worker-v2.yml` keeps events, gating, Environments, credentials, permissions, concurrency and Release (≈800 lines incl. the ~300-line public interface). All delivery logic is the composite action `actions/cf-deploy` (phases of `actions/cf-preview/engine.sh`, `build.sh`, `publish.sh`, `migrate.sh`, `smoke.sh`, `guard.sh`, `gh.sh`, `cli.mjs`). Production, Native Preview and Shared Dev are the same pipeline with `target = prod | preview | dev`; manual persistent Dev is the same engine in `mode: manual`. Validation jobs share `actions/cf-ci-check`. +- **Thin workflow, one engine.** `_cf-worker-v2.yml` keeps events, gating, Environments, credentials, permissions, concurrency and Release (≈800 lines incl. the ~300-line public interface). All delivery logic is the composite action `actions/cf-deploy` (phases of `actions/cf-preview/engine.sh`, `build.sh`, `publish.sh`, `migrate.sh`, `smoke.sh`, `guard.sh`, `gh.sh`, `cli.mjs`). Production, Native Preview and Shared Dev are the same pipeline with `target = prod | preview | dev`; a manual persistent Dev dispatch is another trigger of the same pipeline (it only selects the persistent-dev kind; superseded the former `mode: manual`). Validation jobs share `actions/cf-ci-check`. - **Migration is a step, not a job.** Order: validate CI → build the deployable → audit the effective Wrangler config → validate the exact migration target (and that the deployed D1 is that D1) → stale check → apply pending D1 migrations → stale check → publish the audited artifact → smoke → mark verified / recover. No Prepare, Plan, Test-Gate or Migration job. - New input `shared_dev_on_branch_push` (default `false`) next to the preserved `preview_on_branch_push`; four combinations. New inputs `app_url_dev`, `build_env_dev`, `dev_build_cmd`, `dev_wrangler_env`, `dev_worker_name`, `dev_binding_allow`, `dev_deny_resources`; `dev_deploy_cmd` becomes an optional publish-only command (default `wrangler deploy --config `). - Review fixes: protected deployment-control env names, complete Dev resource isolation, deployed-D1 == migrated-D1, publish of exactly the audited artifact, shared-D1 concurrency identity, stale re-check after the migration. diff --git a/openspec/changes/add-cloudflare-shared-dev-channel/specs/cloudflare-worker-delivery/spec.md b/openspec/changes/add-cloudflare-shared-dev-channel/specs/cloudflare-worker-delivery/spec.md index 447127f..946542a 100644 --- a/openspec/changes/add-cloudflare-shared-dev-channel/specs/cloudflare-worker-delivery/spec.md +++ b/openspec/changes/add-cloudflare-shared-dev-channel/specs/cloudflare-worker-delivery/spec.md @@ -30,9 +30,9 @@ All eligible branches SHALL target the same configured Dev Worker and `app_url_d Shared Dev SHALL use the existing `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` pair in the `/dev` Environment by the 2026-10-28 owner decision; no dedicated Dev token is required. It SHALL keep the Dev build environment, named Wrangler environment, and `env..d1_databases` for migrations (no fallback to top-level/Production D1). Its effective Wrangler config SHALL name the expected Dev Worker and bind no Production resource. Dev and Native Preview MAY share one non-Production D1; Production SHALL NOT. -### Requirement: Manual Persistent Dev dispatch SHALL remain available and distinct +### Requirement: Manual Persistent Dev dispatch SHALL remain available through the standard pipeline -The `persistent-dev` workflow_dispatch path SHALL keep its approved-ref gate, Environment, concurrency group and behaviour, and SHALL NOT be triggered by the automatic Shared Dev switch. +The `persistent-dev` workflow_dispatch SHALL keep its approved-ref gate and `/dev` Environment, SHALL NOT be triggered by the automatic Shared Dev switch, and SHALL run the same CI, Plan, Migration, Deploy, Smoke and conditional recovery as an automatic Shared Dev push (no separate job or mode); an unapproved or invalid dispatch SHALL fail the Plan before any mutation. ### Requirement: Shared Dev resource sharing SHALL be exact, explicit and independent of Preview policy @@ -48,7 +48,7 @@ Queue producers, consumers and dead-letter queues, Service Bindings, routes/cust ### Requirement: Manual and automatic Dev deployments SHALL share one command contract and one verification rule -With `dev_build_cmd` set, build, publish and smoke SHALL be separate commands and both paths SHALL run them with the same audit. A deployment SHALL enter the known-good ledger only after its smoke passes; legacy manual runs SHALL never enter it. +With `dev_build_cmd` set, build, publish and smoke SHALL be separate commands and both triggers SHALL run them with the same audit. A deployment SHALL enter the known-good ledger only after its smoke passes. (The former legacy manual contract, `dev_build_cmd` empty, was removed.) ### Requirement: Dev recovery SHALL rebuild the last-good commit with that commit's identity @@ -80,7 +80,7 @@ A D1-capable Worker SHALL apply remote migrations from one explicit step of the ### Requirement: Production, Native Preview and Shared Dev SHALL use one delivery implementation -The workflow SHALL contain a single Deploy job for `target = prod | preview` and SHALL call the same composite delivery engine from the Shared Dev job (`target = dev`) and the manual persistent-Dev job (`mode = manual`). Build, audit, migration, publish, smoke and recovery logic SHALL NOT be duplicated per target; target differences SHALL be explicit, validated parameters. Complex logic SHALL live in tested scripts, not inline workflow YAML. +The workflow SHALL contain a single Deploy job for `target = prod | preview` and SHALL call the same composite delivery engine from the Shared Dev job (`target = dev`); a manual persistent-Dev dispatch is the same Shared Dev delivery (no separate job). Build, audit, migration, publish, smoke and recovery logic SHALL NOT be duplicated per target; target differences SHALL be explicit, validated parameters. Complex logic SHALL live in tested scripts, not inline workflow YAML. #### Scenario: Cross-repository reusable workflow - **WHEN** the workflow is called from another repository diff --git a/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md b/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md index 3c8a09b..5e0d6e5 100644 --- a/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md +++ b/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md @@ -51,7 +51,7 @@ - [x] `$/` self-repository references replace every `.cf-tools` checkout; `tools_ref` stays accepted (optional, ignored); runner floor 2.336.0 documented. - [x] `_cf-worker-ci.yml`: six parallel checks + fail-closed Validation Gate with a `validation=passed` verdict; one CI call; no duplicate CI between push and same-repo PR. - [x] ONE Migration job unchanged; Migration and Delivery depend on the plan only (the plan requires a passed CI). -- [x] Preview lifecycle and manual persistent Dev extracted (`_cf-worker-lifecycle.yml`, `_cf-worker-persistent-dev.yml`); one shared Delivery engine kept; four thin calls kept (no matrix, rationale in docs). +- [x] Preview lifecycle and manual persistent Dev extracted (`_cf-worker-lifecycle.yml`, `_cf-worker-persistent-dev.yml`); one shared Delivery engine kept; four thin calls kept (no matrix, rationale in docs). (Superseded: `_cf-worker-persistent-dev.yml` and the later `manual` delivery job were removed; a manual dispatch now runs the standard pipeline, see docs/cf-worker-v2-unified-delivery.md.) - [x] One scheduling-time `concurrency.group` expression; canonical `runLockGroup()` emitted by the Plan; `phase_state` without the redundant `d1` map. - [x] P1 review fix: `resolveMigrationUsed` rejects `stale` migration results (no stale-skipped migration authorizes a publish); regression tests incl. A→B→A. - [x] Contract + chain tests ported; cross-repository real-runner scenarios r1–r11 recorded in `docs/cf-worker-v2-refactor.md`. diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index f46a8c5..078b006 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -979,6 +979,15 @@ def group_shared_dev(): check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "superseded" in r["summary"], "manual dispatch: recovery never replaces a newer deployment of the branch") r = manual(inputs=D1, env={"STUB_PROBES": "500 "}) check(r["result"] == "failure" and r["log"].count("migrations apply") == 1 and "d1 migrations" in r["log"] and "schema rollback" not in r["log"] and "migrations revert" not in r["log"], "manual dispatch: a failed Smoke never touches the D1 schema (applied once, never reverted)") + # ---- failing Migrate / failing publish of a manual dispatch + r = manual(inputs=D1, env={"STUB_APPLY_FAIL": "1"}) + check(r["result"] == "failure" and r["res"]["migration"]["result"] == "failure" and ran(r, "migrations apply") and not ran(r, "PUBLISH") and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and r["outputs"]["migration"] == "failed", + "manual dispatch: the migrate step itself fails -> the Migrate job is red and Deploy / Smoke / Revert never run, nothing is published") + r = manual(inputs={"dev_deploy_cmd": 'echo "PUBLISH" >> "$STUB_LOG"; exit 1', "release_cmd": "pnpm release"}, env=dict(GD)) + dres = r["res"]["delivery_persistent_dev"] + check(r["result"] == "failure" and inner_of(r)["deploy"] == "failure" and inner_of(r)["rollback"] == "skipped" and dres["outputs"]["smoke_status"] == "none" and r["log"].count("PUBLISH") == 1 + and r["outputs"]["deployed_and_verified"] == "false" and not ran(r, "SMOKE") and "state=success" not in r["log"] and not release_runs(r, {"release_cmd": "pnpm release", "dispatch_target": "persistent-dev"}), + "manual dispatch: a failing publish -> Deploy red, nothing is smoked or verified (smoke_status none), no recovery runs, never recorded known-good, no Release") # ---- other kinds keep their pipelines r = deploy(MAIN, inputs={"release_cmd": "pnpm release"}) check(r["result"] == "success" and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and release_runs(r), "unchanged: a Production push still runs the Production delivery (and Release)") From 544723199ce77bb76465fe9410b0a16821f65f08 Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 6 Nov 2026 06:04:09 +0000 Subject: [PATCH 3/4] fix(cf-v2): CI env follows the delivery target, fail-closed dispatch_target, non-production D1 recovery guard, publish-only dev_deploy_cmd guard Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/_cf-worker-ci.yml | 17 +++ .github/workflows/_cf-worker-v2.yml | 10 +- actions/cf-ci-check/action.yml | 26 +++- actions/cf-deploy/action.yml | 4 +- actions/cf-preview/cli.mjs | 26 +++- actions/cf-preview/delivery.mjs | 135 ++++++++++++++++++- actions/cf-preview/engine.sh | 37 ++++- actions/cf-preview/publish.sh | 38 +++++- actions/cf-preview/wrangler-guard.sh | 31 +++++ docs/cf-worker-v2-unified-delivery.md | 7 + scripts/cf-preview/delivery.test.mjs | 96 ++++++++++++- scripts/cf-preview/run-job-groups.py | 7 +- scripts/cf-preview/test-deploy.sh | 29 ++++ scripts/cf-preview/test-jobs.py | 107 ++++++++++++++- scripts/cf-preview/test-workflow-contract.py | 31 ++++- 15 files changed, 568 insertions(+), 33 deletions(-) create mode 100755 actions/cf-preview/wrangler-guard.sh diff --git a/.github/workflows/_cf-worker-ci.yml b/.github/workflows/_cf-worker-ci.yml index 30c091b..f98523c 100644 --- a/.github/workflows/_cf-worker-ci.yml +++ b/.github/workflows/_cf-worker-ci.yml @@ -54,6 +54,13 @@ on: build_env_preview: type: string default: '' + build_env_dev: + type: string + default: '' + ci_target_env: + description: prod | dev | preview - which build env the validation commands import (resolved by the caller from event/ref/dispatch_target) + type: string + default: '' test_e2e_cmd: type: string default: '' @@ -99,6 +106,8 @@ jobs: app_path: ${{ inputs.app_path }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + target_env: ${{ inputs.ci_target_env }} ci_env: ${{ inputs.ci_env }} app_version: ${{ inputs.app_version }} typecheck: @@ -115,6 +124,8 @@ jobs: app_path: ${{ inputs.app_path }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + target_env: ${{ inputs.ci_target_env }} ci_env: ${{ inputs.ci_env }} app_version: ${{ inputs.app_version }} unit: @@ -131,6 +142,8 @@ jobs: app_path: ${{ inputs.app_path }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + target_env: ${{ inputs.ci_target_env }} ci_env: ${{ inputs.ci_env }} app_version: ${{ inputs.app_version }} use_mailpit: ${{ inputs.use_mailpit }} @@ -148,6 +161,8 @@ jobs: app_path: ${{ inputs.app_path }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + target_env: ${{ inputs.ci_target_env }} ci_env: ${{ inputs.ci_env }} app_version: ${{ inputs.app_version }} use_mailpit: ${{ inputs.use_mailpit }} @@ -164,6 +179,8 @@ jobs: app_path: ${{ inputs.app_path }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + target_env: ${{ inputs.ci_target_env }} ci_env: ${{ inputs.ci_env }} app_version: ${{ inputs.app_version }} diff --git a/.github/workflows/_cf-worker-v2.yml b/.github/workflows/_cf-worker-v2.yml index dc72f88..d6312f9 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -476,6 +476,11 @@ jobs: use_mailpit: ${{ inputs.use_mailpit }} build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} + build_env_dev: ${{ inputs.build_env_dev }} + # The CI environment follows the INTENDED deployment target, not only the ref: a persistent-dev dispatch is validated against the + # Dev build env even from `main` (never Production env vars). Pure event/input resolution (CI runs before the Plan; the Plan stays + # the authorization gate). JS twin: delivery.mjs ciTargetEnv. + ci_target_env: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && 'dev' || github.ref == 'refs/heads/main' && 'prod' || 'preview' }} test_e2e_cmd: ${{ inputs.test_e2e_cmd }} test_e2e_setup_cmd: ${{ inputs.test_e2e_setup_cmd }} test_e2e_build_cmd: ${{ inputs.test_e2e_build_cmd }} @@ -490,7 +495,10 @@ jobs: plan: name: Plan needs: [ci] - if: always() && (inputs.pipeline_phase == 'verify' || (needs.ci.result == 'success' && needs.ci.outputs.validation == 'passed' && github.ref_type == 'branch' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) + # The last clause makes an explicit but unservable request (a dispatch_target on a tag / PR / push / delete event) reach the Plan even + # without a passed CI: the Plan then ALWAYS fails (resolveDeliveryTargets), so such a run is red instead of silently green, and it + # can never emit a target (no downstream job runs). + if: always() && (inputs.pipeline_phase == 'verify' || (needs.ci.result == 'success' && needs.ci.outputs.validation == 'passed' && github.ref_type == 'branch' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')) || (inputs.dispatch_target != '' && (github.event_name != 'workflow_dispatch' || github.ref_type != 'branch'))) runs-on: ${{ inputs.runner }} timeout-minutes: 10 outputs: diff --git a/actions/cf-ci-check/action.yml b/actions/cf-ci-check/action.yml index d1fe683..9aa9964 100644 --- a/actions/cf-ci-check/action.yml +++ b/actions/cf-ci-check/action.yml @@ -15,10 +15,16 @@ inputs: description: Working directory of the app default: . build_env_prod: - description: Production build env (KEY=VALUE lines); used on main + description: Production build env (KEY=VALUE lines); imported only when target_env is prod default: '' build_env_preview: - description: Preview build env (KEY=VALUE lines); used on every other ref + description: Preview build env (KEY=VALUE lines); imported only when target_env is preview + default: '' + build_env_dev: + description: Dev build env (KEY=VALUE lines); imported only when target_env is dev + default: '' + target_env: + description: prod | dev | preview - the INTENDED deployment target (resolved by the caller from event, ref and dispatch_target). Empty = derived from the ref (main -> prod, else preview). default: '' ci_env: description: Extra nonsecret env applied after the target build env @@ -32,10 +38,22 @@ inputs: runs: using: composite steps: + - name: Resolve the CI environment + id: target + shell: bash + env: + TARGET_ENV_IN: ${{ inputs.target_env }} + REF: ${{ github.ref }} + run: | + t="$TARGET_ENV_IN" + if [ -z "$t" ]; then if [ "$REF" = refs/heads/main ]; then t=prod; else t=preview; fi; fi + case "$t" in prod|dev|preview) ;; *) echo "::error::unknown CI target environment '$t' (expected prod, dev or preview)"; exit 1;; esac + echo "env=$t" >> "$GITHUB_OUTPUT" - name: Import validation env (target env, then ci_env) shell: bash env: - TARGET_BUILD_ENV: ${{ github.ref == 'refs/heads/main' && inputs.build_env_prod || github.ref != 'refs/heads/main' && inputs.build_env_preview || '' }} + # Exactly ONE build env is ever imported: the one of the intended target. Another target's env (Production's above all) never is. + TARGET_BUILD_ENV: ${{ steps.target.outputs.env == 'prod' && inputs.build_env_prod || steps.target.outputs.env == 'dev' && inputs.build_env_dev || steps.target.outputs.env == 'preview' && inputs.build_env_preview || '' }} CI_ENV: ${{ inputs.ci_env }} run: | node "$GITHUB_ACTION_PATH/../cf-preview/parse-env.mjs" TARGET_BUILD_ENV "$GITHUB_ENV" @@ -52,7 +70,7 @@ runs: working-directory: ${{ inputs.app_path }} env: CMD: ${{ inputs.cmd }} - VITE_APP_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} + VITE_APP_ENV: ${{ steps.target.outputs.env }} VITE_APP_VERSION: ${{ inputs.app_version }} run: bash -c "$CMD" - name: Summary diff --git a/actions/cf-deploy/action.yml b/actions/cf-deploy/action.yml index d919c70..2b422b7 100644 --- a/actions/cf-deploy/action.yml +++ b/actions/cf-deploy/action.yml @@ -283,6 +283,8 @@ runs: env: PREVIEW_MUTATED: ${{ steps.publish.outputs.preview_mutated }} PUBLISH_STARTED: ${{ steps.publish.outputs.publish_started }} + PUBLISH_BLOCKED: ${{ steps.publish.outputs.publish_blocked }} + PUBLISH_INTEGRITY_FAILED: ${{ steps.publish.outputs.publish_integrity_failed }} run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" fail-prepublish' - name: Published but smoke failed - mark unverified if: failure() && steps.smoke.outcome == 'failure' && (inputs.stage == 'verify' || steps.publish.outcome == 'success') @@ -358,7 +360,7 @@ runs: if: '!cancelled() && inputs.stage == ''rollback''' shell: bash env: - RB_RESULT: ${{ steps.prodrb.outputs.rollback_result }} + RB_RESULT: ${{ steps.prodrb.outputs.rollback_result || (steps.good.outputs.blocked == 'true' && 'blocked' || '') }} RB_VERIFY: ${{ steps.prodverify.outcome }} RB_LEVEL: ${{ steps.prodverify.outputs.smoke_level }} GOOD_SHA: ${{ steps.good.outputs.sha }} diff --git a/actions/cf-preview/cli.mjs b/actions/cf-preview/cli.mjs index ebc6ddb..10afc4d 100644 --- a/actions/cf-preview/cli.mjs +++ b/actions/cf-preview/cli.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // CLI used by _cf-worker-v2.yml. stdout carries ONLY key=value records (it is piped into $GITHUB_OUTPUT / captured by the shell); every // diagnostic and workflow annotation (::error:: / ::warning:: / ::notice:: / ::add-mask::) goes to stderr. Failure = non-zero exit. -import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { assertWranglerSupported, auditDevConfig, previewHostReadiness, auditPreviewConfig, auditProdConfig, classifyEvent, extractPreviewUrl, @@ -9,7 +9,7 @@ import { } from './lib.mjs'; import { DELIVERY_KINDS, ENGINE_TARGET, assertKind, branchPreviewIdentity, canonicalOrigin, checkBuildEnvOrigins, environmentName, foreignUrlsInBuildEnv, planMigrations, resolveCanonicalUrl, resolveDeliveryTargets, resolveSmokeMode, - buildDescriptors, parseDescriptor, resolveMigrationUsed, verificationOutcome, prodRollbackBlock, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, + buildDescriptors, parseDescriptor, resolveMigrationUsed, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, wranglerPublishBlock, devPublishCmdBlock, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, } from './delivery.mjs'; const [cmd, ...rest] = process.argv.slice(2); @@ -291,6 +291,28 @@ try { out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); break; } + case 'wrangler-guard': { // --config -- ; prints block_code / block_why (always exits 0) + const norm = (x) => { try { return realpathSync(resolve(String(x))); } catch { return resolve(String(x)); } }; + const at = process.argv.indexOf('--', 3); + const b = wranglerPublishBlock({ args: at < 0 ? [] : process.argv.slice(at + 1), auditedConfig: opt('config'), normalize: norm }); + out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); + break; + } + case 'publish-cmd-check': { // env: CMD; --package (optional). Early static check of dev_deploy_cmd. + let scripts = {}; + try { scripts = JSON.parse(readFileSync(opt('package'), 'utf8')).scripts ?? {}; } catch { scripts = {}; } + const b = devPublishCmdBlock(process.env.CMD ?? '', scripts); + out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); + break; + } + case 'recovery-guard': { // non-Production recovery; env: D1_MIGRATIONS MIGRATION_JSON D1_EXPECT D1_COMPAT + const e = process.env; + let migration = null; + try { migration = JSON.parse(e.MIGRATION_JSON || 'null'); } catch { migration = null; } + const b = nonProdRecoveryBlock({ d1Migrations: e.D1_MIGRATIONS === 'true', migration, expectedId: e.D1_EXPECT ?? '', d1Compat: e.D1_COMPAT ?? '' }); + out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); + break; + } case 'rollback-verdict': { // env: TARGET RB_RESULT RB_VERIFY RB_LEVEL GOOD_SHA RECOVERED VERIFIED SMOKE_LEVEL const e = process.env; const v = rollbackVerdict({ diff --git a/actions/cf-preview/delivery.mjs b/actions/cf-preview/delivery.mjs index 51bab21..d75aa0e 100644 --- a/actions/cf-preview/delivery.mjs +++ b/actions/cf-preview/delivery.mjs @@ -57,6 +57,9 @@ export function resolveDeliveryTargets({ if (sharedDevOnPush && sharedDevNativeOnPush) { throw new Error('shared_dev_on_branch_push (separate persistent Dev Worker) and shared_dev_native_preview_on_branch_push (named Native Preview "dev") are conflicting automatic Dev channels; enable only one'); } + // Fail closed: an explicit deployment request is honoured exactly or refused - never ignored. A non-empty dispatch_target is only + // meaningful for a workflow_dispatch (push / pull_request / delete / anything else with a target is a conflicting request). + if (dispatchTarget !== '' && event !== 'workflow_dispatch') throw new Error(`dispatch_target '${dispatchTarget}' is only valid for a workflow_dispatch (event: ${event || 'unknown'}); refusing to ignore it or fall back to another target (nothing was changed)`); if (event === 'workflow_dispatch' && dispatchTarget !== '') { if (dispatchTarget !== 'persistent-dev') throw new Error(`dispatch_target '${dispatchTarget}' is not valid (expected persistent-dev or empty); refusing to fall back to another target`); const r = String(ref).startsWith('refs/heads/') ? classifyEvent({ event, ref, strategy, devRef, dispatchTarget }) : { action: 'skip', reason: 'not a branch ref' }; @@ -345,6 +348,129 @@ export function verificationOutcome(level) { return { verified: false, kind: level === 'skipped' ? 'skipped' : 'liveness' }; } +// --------------------------------------------------------------------------------------------------------------------- +// Publish-only contract of a caller's `dev_deploy_cmd`. Two layers, neither relies on matching the command STRING alone: +// 1. wranglerPublishBlock: judges the ARGUMENT VECTOR of every Wrangler invocation the command actually makes (the guard shim +// calls it before delegating) - so it applies to `pnpm run deploy`, scripts, nested shells, anything that reaches Wrangler. +// 2. devPublishCmdBlock: an EARLY static rejection (before the migration) of the unambiguous legacy shapes, over the command and +// the package.json scripts it runs. Supplementary: an opaque command is still caught by layer 1 before any mutation. +// --------------------------------------------------------------------------------------------------------------------- +const WRANGLER_VALUE_FLAGS = new Set(['--config', '-c', '--env', '-e', '--name', '--cwd', '--message', '--tag', '--var', '--define', '--assets', '--compatibility-date', '--compatibility-flags', '--site', '--outdir', '--routes', '--route', '--triggers']); +const WRANGLER_ALLOWED = new Set(['deploy', 'versions', 'deployments', 'whoami', 'version']); +const WRANGLER_MUTATING = (cmd, sub) => cmd === 'deploy' || (cmd === 'versions' && (sub === 'upload' || sub === 'deploy')); + +/** null = the Wrangler call may proceed; else why it is refused BEFORE it can change anything. `auditedConfig` and `config` values are compared after path normalisation by the caller. */ +export function wranglerPublishBlock({ args = [], auditedConfig = '', normalize = (x) => x }) { + const positional = []; + const configs = []; + let env = false; + let name = false; + for (let i = 0; i < args.length; i++) { + const a = String(args[i]); + if (a === '--') { positional.push(...args.slice(i + 1).map(String)); break; } + const eq = a.indexOf('='); + const flag = a.startsWith('--') && eq > 0 ? a.slice(0, eq) : a; + const inline = a.startsWith('--') && eq > 0 ? a.slice(eq + 1) : undefined; + if (a.startsWith('-') && a.length > 1) { + let val = inline; + if (val === undefined && WRANGLER_VALUE_FLAGS.has(flag)) { val = String(args[i + 1] ?? ''); i++; } + if (flag === '--config' || flag === '-c') configs.push(val ?? ''); + else if (flag === '--env' || flag === '-e') env = true; + else if (flag === '--name') name = true; + continue; + } + positional.push(a); + } + const [cmd = '', sub = ''] = positional; + if (!cmd || cmd === 'help') return null; + if (!WRANGLER_ALLOWED.has(cmd) && !args.some((a) => ['--version', '-v', '--help', '-h'].includes(String(a)))) { + return { code: 'not_publish_only', why: `dev_deploy_cmd must only publish: \`wrangler ${cmd}\` is not allowed (migrations are applied by the Migrate job; nothing else may change the Worker or its resources from the publish command).` }; + } + if (!WRANGLER_MUTATING(cmd, sub)) return null; + if (env) return { code: 'env_selection', why: 'the publish command selects a Wrangler environment (--env / -e) itself. The audited config is already the one effective configuration; publish with `wrangler deploy --config "$CF_AUDITED_CONFIG"` only.' }; + if (name) return { code: 'name_override', why: 'the publish command overrides the Worker name (--name); the audited config alone defines the deployed Worker.' }; + if (configs.length !== 1) return { code: 'config_not_audited', why: `the publish command must pass exactly one --config and it must be the audited config ("$CF_AUDITED_CONFIG"); it passed ${configs.length}.` }; + if (normalize(configs[0]) !== normalize(auditedConfig)) return { code: 'config_not_audited', why: `the publish command uses --config ${configs[0]}, not the audited config ${auditedConfig}.` }; + return null; +} + +/** + * Early static check of a caller's dev_deploy_cmd (command text + the package.json scripts it runs, resolved recursively). + * Rejects the unambiguous legacy shapes - a Wrangler call that selects its own environment / name, or a command that BUILDS - before the + * Dev D1 is migrated. `scripts` = package.json "scripts". null = nothing objectionable found (the runtime guard still applies). + */ +export function devPublishCmdBlock(cmd, scripts = {}) { + const seen = new Set(); + const expand = (text, depth) => { + if (depth > 6) return text; + let out = text; + for (const m of text.matchAll(/\b(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([A-Za-z0-9:_.-]+)/g)) { + const sc = m[1]; + if (Object.prototype.hasOwnProperty.call(scripts, sc) && !seen.has(sc)) { seen.add(sc); out += `\n${expand(String(scripts[sc]), depth + 1)}`; } + } + return out; + }; + const text = expand(String(cmd), 0); + for (const part of text.split(/&&|\|\||[;|\n]/)) { + const w = part.trim().split(/\s+/); + const at = w.findIndex((x) => /(^|\/)wrangler$/.test(x)); + if (at >= 0) { + const b = wranglerPublishBlock({ args: w.slice(at + 1), auditedConfig: '$CF_AUDITED_CONFIG', normalize: (x) => String(x).replace(/^"|"$/g, '').replace(/^\$\{(\w+)\}$/, '$$$1') }); + if (b && (b.code === 'env_selection' || b.code === 'name_override' || b.code === 'not_publish_only')) return b; + } + if (/(^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?build(?:[:\w.-]*)(\s|$)|\b(?:vite|tsc|next|astro|webpack|rollup|esbuild)\s+build\b/.test(part)) { + return { code: 'rebuilds', why: 'dev_deploy_cmd builds. The audited artifact is produced by dev_build_cmd (build + audit); the publish command must only publish it (`wrangler deploy --config "$CF_AUDITED_CONFIG"`).' }; + } + } + return null; +} + +/** + * Which build env the CI validation commands import (and the value of VITE_APP_ENV): the INTENDED deployment target, resolved from + * pure event/input data because CI runs before the Plan (the Plan stays the authorization gate). JS twin of the `ci_target_env` + * expression of _cf-worker-v2.yml (the contract test compares them). + * workflow_dispatch + dispatch_target persistent-dev (any ref, `main` included) -> dev + * otherwise: refs/heads/main -> prod; everything else (feature branches, PRs, tags) -> preview + */ +export function ciTargetEnv({ event = '', ref = '', dispatchTarget = '' }) { + if (event === 'workflow_dispatch' && dispatchTarget === 'persistent-dev') return 'dev'; + return ref === 'refs/heads/main' ? 'prod' : 'preview'; +} + +/** + * The ONE D1 compatibility rule for restoring older Worker code (Production rollback and non-Production recovery alike): + * after an applied schema change the older code must be attested (d1_rollback_compat) AND the applied migrations provably + * additive. D1 itself is never reverted. null = allowed. + */ +export function d1SchemaBlock({ d1Pending = false, d1Compat = '', d1Scan = '' }) { + if (!d1Pending) return null; + if (d1Compat !== 'expand-contract') return { code: 'd1_unattested', why: "This run applied D1 migrations and d1_rollback_compat is not 'expand-contract': compatibility of the previous Worker version with the new schema is unproven." }; + if (d1Scan !== 'additive') return { code: 'd1_not_additive', why: `This run applied D1 migrations that are not provably additive (scan: ${d1Scan || 'unknown'}); the previous Worker version may not work on the new schema.` }; + return null; +} + +/** + * Non-Production recovery (Native Branch / Native Dev / Persistent Dev): may the last known-good code be redeployed? + * Fails closed on everything it cannot prove, using the migration result of THIS pipeline. + * d1Migrations false -> allowed (no D1 involved) + * migration result missing/bad -> blocked (state of D1 unknown) + * result for another D1 -> blocked + * schema_changed not true/false -> blocked; false -> allowed; true -> d1SchemaBlock + * `migration` = the parsed hand-off of the Migration job (or null). null = allowed. + */ +export function nonProdRecoveryBlock({ d1Migrations = false, migration = null, expectedId = '', d1Compat = '' }) { + if (!d1Migrations) return null; + if (migration === null || typeof migration !== 'object' || Array.isArray(migration) || migration.migration !== 'applied') { + return { code: 'migration_result_missing', why: 'D1 migrations are enabled but this pipeline handed over no valid applied-migration result: the D1 schema state is unknown, so older code cannot be proven compatible.' }; + } + if (!expectedId || migration.d1_id !== expectedId) { + return { code: 'd1_identity_mismatch', why: `The migration result is for D1 '${migration.d1_id || 'unknown'}', not this target's D1 '${expectedId || 'unknown'}'.` }; + } + const changed = String(migration.schema_changed ?? ''); + if (changed !== 'true' && changed !== 'false') return { code: 'migration_result_invalid', why: `The migration result has no valid schema_changed value ('${changed || 'missing'}').` }; + return d1SchemaBlock({ d1Pending: changed === 'true', d1Compat, d1Scan: String(migration.d1_scan ?? '') }); +} + /** * Production automatic rollback: why it must NOT happen (null = allowed so far). Checked in this order, fail closed. * `live` = the version Cloudflare reports as live; pass undefined to skip that last, observation-based check. @@ -355,10 +481,8 @@ export function prodRollbackBlock({ policy, previous = '', current = '', worker if (!previous) return { code: 'no_previous_version', why: 'No previous Worker version id was recorded (first deployment, or the deployment list was unreadable); there is nothing safe to roll back to.' }; if (!current || !worker) return { code: 'no_identity', why: 'The deployed version id or Worker name was not handed over.' }; if (previous === current) return { code: 'same_version', why: 'The previous and the new version id are identical.' }; - if (d1Pending) { - if (d1Compat !== 'expand-contract') return { code: 'd1_unattested', why: "This run applied D1 migrations and d1_rollback_compat is not 'expand-contract': compatibility of the previous Worker version with the new schema is unproven." }; - if (d1Scan !== 'additive') return { code: 'd1_not_additive', why: `This run applied D1 migrations that are not provably additive (scan: ${d1Scan || 'unknown'}); the previous Worker version may not work on the new schema.` }; - } + const d1 = d1SchemaBlock({ d1Pending, d1Compat, d1Scan }); + if (d1) return d1; if (live !== undefined && live !== current) { return { code: 'live_version_changed', why: `The live version is '${live || 'unknown'}', not the version \`${current}\` this run deployed; refusing to replace someone else's deployment.` }; } @@ -376,7 +500,8 @@ export function rollbackVerdict({ target, rbResult = '', rbVerify = '', rbLevel if (rbResult === 'rolled_back') { if (rbVerify === 'success') { result = isContentLevel(rbLevel) ? 'restored_verified' : 'restored_unverified'; level = rbLevel; } else result = 'restored_unhealthy'; } else result = rbResult === 'blocked' ? 'blocked' : 'failed'; - } else if (!goodSha) result = 'no_known_good'; + } else if (rbResult === 'blocked') result = 'blocked'; + else if (!goodSha) result = 'no_known_good'; else if (recovered === 'success' && verified === 'success') { result = isContentLevel(smokeLevel) ? 'restored_verified' : 'restored_unverified'; level = smokeLevel; } else result = 'failed'; return { result, level, ok: result === 'restored_verified' }; } diff --git a/actions/cf-preview/engine.sh b/actions/cf-preview/engine.sh index 000e60a..dd45e7d 100755 --- a/actions/cf-preview/engine.sh +++ b/actions/cf-preview/engine.sh @@ -183,6 +183,13 @@ case "$PHASE" in build) ctx + # Early, pre-mutation rejection of a legacy dev_deploy_cmd that builds or selects its own Wrangler environment (before the build and + # long before the Dev D1 is migrated). The runtime guard in publish.sh still judges every actual Wrangler call. + if [ "$TARGET" = dev ] && [ -n "$(inp dev_deploy_cmd)" ]; then + PG="$(CMD="$(inp dev_deploy_cmd)" node "$TOOLS_DIR/cli.mjs" publish-cmd-check --package "$APP_DIR/package.json")" + PC="$(printf '%s\n' "$PG" | sed -n 's/^block_code=//p')" + [ -z "$PC" ] || die "dev_deploy_cmd is not publish-only ($PC): $(printf '%s\n' "$PG" | sed -n 's/^block_why=//p'). Nothing was built, migrated or published." + fi resolve_target_url "$S/build.env" run_build "$S" "$S/build.env" "$(inp app_version)" "$(inp preview_host_check)";; @@ -213,7 +220,9 @@ case "$PHASE" in printf '%s' "$PENDING" > "$S/migration" echo "schema_changed=$PENDING" echo "kind=$KIND"; echo "d1_id=$(rd d1_id)" - echo "migration=applied";; + echo "migration=applied" + SCAN_L="$(printf '%s\n' "$OUT" | sed -n 's/^d1_scan=//p' | head -1)" + jq -cn --arg c "$PENDING" --arg s "$SCAN_L" --arg k "$KIND" --arg i "$(rd d1_id)" '{migration:"applied",schema_changed:$c,d1_scan:$s,kind:$k,d1_id:$i}' > "$S/migration.json";; report) # final, always-run state report of a stage (data only) ctx @@ -271,6 +280,8 @@ case "$PHASE" in fi [ -z "$H_PUBSHA" ] || [ "$H_PUBSHA" = "$RUN_SHA" ] || die "hand-off was published from $H_PUBSHA, this run is $RUN_SHA; refusing to act on another deployment" [ "$TARGET" = prod ] || [ -n "$H_URL" ] || die "$TARGET hand-off has no published URL" + # The Migration job's validated result, kept for the non-Production recovery guard (recover-find). Absent when none was handed over. + if [ -n "${HANDOFF_MIGRATION:-}" ] && [ "$HANDOFF_MIGRATION" != '{}' ]; then printf '%s' "$M" > "$S/migration.json"; else rm -f "$S/migration.json"; fi printf '%s' "$H_LEDGER" > "$S/ledger_id"; printf '%s' "$H_PNAME" > "$S/preview_name" case "$KIND" in native-branch) printf '%s' "$APP_NAME/preview/$H_PNAME" > "$S/ledger_env";; native-dev) printf '%s' "$APP_NAME/native-dev/dev" > "$S/ledger_env";; persistent-dev) printf '%s' "$APP_NAME/dev/shared" > "$S/ledger_env";; esac [ "$TARGET" != preview ] || [ -n "$H_PNAME" ] || die "preview hand-off has no Preview identity" @@ -359,7 +370,13 @@ case "$PHASE" in summary "### ❌ Failed before publish — existing Preview unchanged." fi;; dev) - if [ "${PUBLISH_STARTED:-}" = true ]; then + if [ "${PUBLISH_INTEGRITY_FAILED:-}" = true ]; then + [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish integrity check failed; Dev potentially published but UNVERIFIED" || true + summary "### 🚨 Dev publish integrity check FAILED — the publish command changed the audited config or artifact after publishing. Dev is **potentially published but UNVERIFIED** (it may serve an unaudited build). Manual recovery: deploy a known-good commit with the standard pipeline (push a fix); do not assume Dev is unchanged." + elif [ "${PUBLISH_BLOCKED:-}" = true ]; then + [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish command refused before any Wrangler publish call; Dev unchanged" || true + summary "### ❌ dev_deploy_cmd refused before any Wrangler publish call — it is not publish-only (see the job log for the reason). No Wrangler call changed Dev. Fix the command: \`wrangler deploy --config \"\$CF_AUDITED_CONFIG\"\` only, and build in \`dev_build_cmd\`." + elif [ "${PUBLISH_STARTED:-}" = true ]; then [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish failed part-way; Dev UNVERIFIED" || true summary "### ❌ Shared Dev publish failed part-way — Dev may serve an unverified deployment." else @@ -391,6 +408,22 @@ case "$PHASE" in recover-find) ctx ROW="" + # D1 compatibility guard (before anything is looked up or published): older code is only redeployed when the D1 state of + # THIS pipeline is known and the code is proven to cope. D1 is never reverted. Decision: delivery.mjs nonProdRecoveryBlock. + G="$(env D1_MIGRATIONS="$(inp d1_migrations)" MIGRATION_JSON="$([ -s "$S/migration.json" ] && cat "$S/migration.json" || echo null)" D1_EXPECT="$(rd d1_expect)" D1_COMPAT="$(inp d1_rollback_compat)" \ + node "$TOOLS_DIR/cli.mjs" recovery-guard)" + BC="$(printf '%s\n' "$G" | sed -n 's/^block_code=//p')" + if [ -n "$BC" ]; then + BW="$(printf '%s\n' "$G" | sed -n 's/^block_why=//p')" + echo "::error::Automatic recovery of $TARGET is BLOCKED ($BC): $BW" >&2 + summary "### 🛑 Automatic recovery of $TARGET **blocked** (\`$BC\`) — $TARGET serves \`$RUN_SHA\`, which FAILED its smoke, and is **UNVERIFIED**." + summary "- Why: $BW" + summary "- Nothing was redeployed and the D1 schema was NOT reverted. Required manual recovery: push a forward fix (preferred); or restore the last verified commit only after confirming it runs on the CURRENT D1 schema." + [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" failure "" "Smoke failed for $RUN_SHA; recovery blocked ($BC) — $TARGET UNVERIFIED" || true + "$TOOLS_DIR/gh.sh" mark-env-deployment "$(gh_env)" "$RUN_SHA" failure "Smoke failed, recovery blocked ($BC) — UNVERIFIED (served $RUN_SHA)" || echo "::warning::could not annotate the Environment deployment as unverified" >&2 + echo "sha="; echo "url="; echo "blocked=true"; echo "block_code=$BC" + exit 0 + fi case "$KIND" in native-branch) ROW="$("$TOOLS_DIR/gh.sh" last-good-preview "$(rd ledger_env)" "$LEDGER_ID" "$BRANCH_REF" "$HOST_WORKER" "$APP_NAME" || true)";; native-dev) ROW="$("$TOOLS_DIR/gh.sh" last-good-native-dev "$(rd ledger_env)" "$LEDGER_ID" "$HOST_WORKER" "$APP_NAME" || true)";; diff --git a/actions/cf-preview/publish.sh b/actions/cf-preview/publish.sh index 52923a8..6f54e7a 100755 --- a/actions/cf-preview/publish.sh +++ b/actions/cf-preview/publish.sh @@ -70,12 +70,40 @@ if [ "$TARGET" = dev ]; then if [ -z "${PUBLISH_CMD:-}" ]; then pnpm exec wrangler deploy "${CFG_ARGS[@]}" --message "$MSG" >&2 else - # Caller customization: runs with the audited config exported. It must only publish; any change to the audited - # config or deployable is detected below and fails the run (the Dev deployment is then reported unverified). - CF_AUDITED_CONFIG="$CONFIG_PATH" CLOUDFLARE_ENV="$DEV_ENV" bash -eo pipefail -c "$PUBLISH_CMD" >&2 - [ "$(sha256sum "$CONFIG_PATH" | cut -d' ' -f1)" = "$(cat "$STATE_DIR/config.sha")" ] || { echo "::error::the publish command modified the audited Wrangler config" >&2; exit 1; } + # The caller's publish command is PUBLISH-ONLY. It runs with the audited config exported and every Wrangler call it makes goes through + # wrangler-guard.sh, which refuses (before the real Wrangler runs) any non-publish subcommand, any --env / --name / non-audited --config, + # and any call made after the audited config or artifact changed. The post-run checks below stay as the second, independent layer. + GUARD="$STATE_DIR/guard"; rm -rf "$GUARD"; mkdir -p "$GUARD/bin" + REAL_BIN="$PWD/node_modules/.bin/wrangler"; PROJECT_BIN_MOVED="" + cp "$TOOLS_DIR/wrangler-guard.sh" "$GUARD/bin/wrangler"; chmod +x "$GUARD/bin/wrangler" + if [ -e "$REAL_BIN" ] || [ -L "$REAL_BIN" ]; then + mv "$REAL_BIN" "$REAL_BIN.cfv2-real"; cp "$GUARD/bin/wrangler" "$REAL_BIN"; PROJECT_BIN_MOVED=1; REAL_WRANGLER="$REAL_BIN.cfv2-real" + else + REAL_WRANGLER="$(PATH="${PATH//$GUARD\/bin:/}" command -v wrangler || echo /nonexistent/wrangler)" + fi + restore_bin() { [ -z "$PROJECT_BIN_MOVED" ] || { rm -f "$REAL_BIN"; mv "$REAL_BIN.cfv2-real" "$REAL_BIN"; PROJECT_BIN_MOVED=""; }; } + trap restore_bin EXIT + CENV=""; [ "$MODE" != source ] || CENV="$DEV_ENV" # a flattened audited config never takes a named env; a SOURCE config's audited env is DEV_ENV + set +e + PATH="$GUARD/bin:$PATH" CFV2_REAL_WRANGLER="$REAL_WRANGLER" CFV2_GUARD_DIR="$GUARD" CFV2_STATE_DIR="$STATE_DIR" CFV2_TOOLS_DIR="$TOOLS_DIR" \ + CF_AUDITED_CONFIG="$CONFIG_PATH" CLOUDFLARE_ENV="$CENV" bash -eo pipefail -c "$PUBLISH_CMD" >&2 + RC=$? + set -e + restore_bin + if [ "$RC" -ne 0 ]; then + if [ -f "$GUARD/blocked" ] && [ ! -f "$GUARD/mutating" ]; then + # Refused by the guard before any Wrangler call could change the Worker. + echo "publish_blocked=true" + echo "::error::dev_deploy_cmd was refused before any Wrangler publish call was made ($(sed -n 's/^code=//p' "$GUARD/blocked")); Dev was not changed by Wrangler" >&2 + fi + exit 1 + fi + [ -f "$GUARD/mutating" ] || echo "::warning::dev_deploy_cmd made no Wrangler publish call; nothing was published through the audited config" >&2 + # Integrity after the fact: from here on a failure means the Worker MAY already have been replaced - never "unchanged". + integrity_fail() { echo "publish_integrity_failed=true"; echo "::error::$1" >&2; exit 1; } + [ "$(sha256sum "$CONFIG_PATH" | cut -d' ' -f1)" = "$(cat "$STATE_DIR/config.sha")" ] || integrity_fail "the publish command modified the audited Wrangler config; Dev is potentially published but UNVERIFIED" NOW="$(node "$TOOLS_DIR/cli.mjs" artifact-digest --config "$CONFIG_PATH" | sed -n 's/^artifact_digest=//p')" - [ "$NOW" = "$(cat "$STATE_DIR/artifact.sha")" ] || { echo "::error::the publish command rebuilt or replaced the audited artifact; Dev may serve an unaudited build" >&2; exit 1; } + [ "$NOW" = "$(cat "$STATE_DIR/artifact.sha")" ] || integrity_fail "the publish command rebuilt or replaced the audited artifact; Dev is potentially published from an unaudited build and UNVERIFIED" fi echo "url=${APP_URL:-}" exit 0 diff --git a/actions/cf-preview/wrangler-guard.sh b/actions/cf-preview/wrangler-guard.sh new file mode 100755 index 0000000..57b648d --- /dev/null +++ b/actions/cf-preview/wrangler-guard.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Stand-in for `wrangler` while a caller's dev_deploy_cmd runs (publish.sh installs it first on PATH AND over the project's +# node_modules/.bin/wrangler, so `wrangler`, `npx wrangler`, `pnpm exec wrangler` and package scripts all reach it). +# It enforces the publish-only contract BEFORE the real Wrangler can change anything: +# - only publish-related subcommands (deploy / versions / deployments / whoami / version); everything else is refused +# - a mutating call must pass exactly one --config and it must be the audited config; no --env / -e, no --name +# - at the moment of the call, the audited config and the audited artifact must still be byte-identical to what build.sh audited +# (a command that rebuilt first is refused before the upload, not detected afterwards) +# Facts for publish.sh: $CFV2_GUARD_DIR/blocked (refused, nothing changed) and /mutating (the real Wrangler was invoked). +# Env: CFV2_REAL_WRANGLER CFV2_GUARD_DIR CF_AUDITED_CONFIG CFV2_STATE_DIR CFV2_TOOLS_DIR +set -uo pipefail +: "${CFV2_REAL_WRANGLER:?}" "${CFV2_GUARD_DIR:?}" "${CF_AUDITED_CONFIG:?}" "${CFV2_STATE_DIR:?}" "${CFV2_TOOLS_DIR:?}" +refuse() { # + printf 'code=%s\n' "$1" > "$CFV2_GUARD_DIR/blocked" + echo "::error::dev_deploy_cmd refused before anything was published ($1): $2" >&2 + exit 87 +} +G="$(node "$CFV2_TOOLS_DIR/cli.mjs" wrangler-guard --config "$CF_AUDITED_CONFIG" -- "$@")" || refuse guard_error "the publish guard could not evaluate the Wrangler call" +CODE="$(printf '%s\n' "$G" | sed -n 's/^block_code=//p')" +[ -z "$CODE" ] || refuse "$CODE" "$(printf '%s\n' "$G" | sed -n 's/^block_why=//p')" +# Only calls that can change the Worker need the integrity proof; read-only ones (whoami, deployments list, --version) pass. +case "${1:-}" in + deploy|versions) + [ "${1:-}" != versions ] || case "${2:-}" in upload|deploy) ;; *) exec "$CFV2_REAL_WRANGLER" "$@";; esac + [ "$(sha256sum "$CF_AUDITED_CONFIG" | cut -d' ' -f1)" = "$(cat "$CFV2_STATE_DIR/config.sha")" ] || refuse config_changed "the audited Wrangler config differs from what was audited." + NOW="$(node "$CFV2_TOOLS_DIR/cli.mjs" artifact-digest --config "$CF_AUDITED_CONFIG" | sed -n 's/^artifact_digest=//p')" + [ -n "$NOW" ] && [ "$NOW" = "$(cat "$CFV2_STATE_DIR/artifact.sha")" ] || refuse artifact_changed "the deployable artifact differs from the one that was built and audited (the command rebuilt or replaced it)." + : > "$CFV2_GUARD_DIR/mutating" + ;; +esac +exec "$CFV2_REAL_WRANGLER" "$@" diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md index 331b825..4afae6e 100644 --- a/docs/cf-worker-v2-unified-delivery.md +++ b/docs/cf-worker-v2-unified-delivery.md @@ -377,3 +377,10 @@ identical to a feature-branch Shared Dev push. The delivery workflow has exactly Concurrency: the run-level group of a persistent-dev dispatch is the same as for the automatic Shared Dev push (`cfv2-nonprod-d1-` with `d1_migrations`, else `cfv2-shared-dev-`; never the Production group, even from `main`), so the Migrate job cannot race a feature-branch Dev push. Deploy and Revert keep the descriptor's job-level group `cfv2-dev--` (a different name, so no nested-group deadlock). Behaviour changes versus the old manual path: it now runs CI, Plan and the dedicated Migrate job; a failed Smoke triggers the same conditional recovery to the last known-good Dev deployment (it previously had none); D1 is still never rolled back. Callers that relied on `dev_build_cmd` being empty (one `dev_deploy_cmd` that builds and publishes) must add `dev_build_cmd` and make `dev_deploy_cmd` publish only (see `docs/cf-worker-v2-refactor.md`). + +## Safety rules added in the PR #93 review round + +- **CI environment follows the intended target** (`ci_target_env`, JS twin `ciTargetEnv`): push `main` → `prod`; push feature / PR → `preview`; `workflow_dispatch` with `dispatch_target=persistent-dev` (any ref, `main` included) → `dev` (`build_env_dev`, `VITE_APP_ENV=dev`); dispatch of `main` without a target → `prod`. Exactly one build env is imported; `ci_env` still applies afterwards. No extra job, no dependency on the Plan (which stays the authorization gate). +- **`dispatch_target` fails closed**: non-empty is valid only for `workflow_dispatch` and only as exactly `persistent-dev`; unapproved ref, tag, missing `persistent_dev_ref`, non-hybrid strategy or any other value fails the Plan. The Plan's `if:` also runs for an explicit target on a tag / PR / push / delete event so the run ends red, never green without deploying. +- **Non-Production recovery D1 guard** (`nonProdRecoveryBlock`, same D1 rule as Production via `d1SchemaBlock`): before the last known-good code of Native Branch / Native Dev / Persistent Dev is republished, the Migration job's result of the same pipeline must be present, for this target's D1, and either `schema_changed=false` or (`d1_rollback_compat=expand-contract` and scan `additive`). Otherwise recovery is blocked: nothing is redeployed, the deployment is marked UNVERIFIED, the summary names the reason and the manual recovery; D1 is never reverted. +- **`dev_deploy_cmd` is publish-only**: every Wrangler call it makes goes through `wrangler-guard.sh` (installed on `PATH` and over the project's `node_modules/.bin/wrangler`) which refuses non-publish subcommands, `--env`/`-e`, `--name`, any `--config` other than the audited one, and any call after the audited config/artifact changed — before the real Wrangler runs. An early static check (command + resolved package scripts) rejects legacy shapes (own `--env`, builds) before the build and migration. Post-run integrity checks remain; their failure is reported as "potentially published but UNVERIFIED". diff --git a/scripts/cf-preview/delivery.test.mjs b/scripts/cf-preview/delivery.test.mjs index ed21d2b..2e98b73 100644 --- a/scripts/cf-preview/delivery.test.mjs +++ b/scripts/cf-preview/delivery.test.mjs @@ -4,7 +4,7 @@ import { DELIVERY_KINDS, resolveDeliveryTargets, resolveSmokeMode, parseSmokeOverrides, planMigrations, branchPreviewIdentity, ENGINE_TARGET, canonicalOrigin, previewUrlFromSuffix, resolveCanonicalUrl, checkBuildEnvOrigins, targetDescriptor, validateDescriptor, buildDescriptors, parseDescriptor, resolveMigrationUsed, - isContentLevel, verificationOutcome, prodRollbackBlock, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, + isContentLevel, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, wranglerPublishBlock, devPublishCmdBlock, ciTargetEnv, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, } from '../../actions/cf-preview/delivery.mjs'; const base = { event: 'push', ref: 'refs/heads/feat/x', strategy: 'production-preview', previewOnPush: true }; @@ -39,8 +39,36 @@ test('manual persistent-dev dispatch only SELECTS the persistent-dev kind (appro // dispatch without a target keeps the existing behaviour (main -> production, branch -> the enabled automatic previews) assert.deepEqual(T({ event: 'workflow_dispatch', strategy: 'hybrid', ref: 'refs/heads/main', devRef: 'refs/heads/main' }), ['production']); assert.deepEqual(T({ event: 'workflow_dispatch', strategy: 'hybrid', ref: 'refs/heads/x', sharedDevOnPush: true }), ['native-branch', 'persistent-dev']); - // dispatch_target is meaningless on other events (never selects anything there) - assert.deepEqual(T({ event: 'push', strategy: 'hybrid', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), ['production']); +}); +test('dispatch_target fails closed: the full event / ref / target matrix', () => { + const H = { strategy: 'hybrid', devRef: 'refs/heads/dev-ref', sharedDevOnPush: true }; + const rej = /only valid for a workflow_dispatch|rejected|not valid/; + // push / PR / delete: a non-empty target is a conflicting request - never ignored, never a Production fallback + assert.deepEqual(T({ ...H, event: 'push', ref: 'refs/heads/main' }), ['production'], 'push main, no target'); + assert.throws(() => T({ ...H, event: 'push', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), /only valid for a workflow_dispatch/); + assert.deepEqual(T({ ...H, event: 'push', ref: 'refs/heads/feat' }), ['native-branch', 'persistent-dev'], 'push feature, no target: the existing enabled targets'); + assert.throws(() => T({ ...H, event: 'push', ref: 'refs/heads/feat', dispatchTarget: 'persistent-dev' }), /only valid for a workflow_dispatch/); + assert.throws(() => T({ ...H, event: 'pull_request', ref: 'refs/pull/1/merge', dispatchTarget: 'persistent-dev' }), /only valid for a workflow_dispatch/); + assert.throws(() => T({ ...H, event: 'delete', ref: 'refs/heads/feat', dispatchTarget: 'persistent-dev' }), /only valid for a workflow_dispatch/); + assert.throws(() => T({ ...H, event: 'push', ref: 'refs/tags/v1', dispatchTarget: 'persistent-dev' }), /only valid for a workflow_dispatch/); + for (const ev of ['push', 'pull_request', 'delete', 'schedule', '']) assert.throws(() => T({ ...H, event: ev, ref: 'refs/heads/main', dispatchTarget: 'production' }), /only valid for a workflow_dispatch/); + // dispatch + assert.deepEqual(T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/dev-ref', dispatchTarget: 'persistent-dev' }), ['persistent-dev'], 'approved feature'); + assert.deepEqual(T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/main', devRef: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), ['persistent-dev'], 'approved main'); + assert.throws(() => T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/other', dispatchTarget: 'persistent-dev' }), rej, 'unapproved ref'); + assert.throws(() => T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), rej, 'main is not the approved ref'); + assert.throws(() => T({ ...H, event: 'workflow_dispatch', ref: 'refs/tags/v1', devRef: 'refs/tags/v1', dispatchTarget: 'persistent-dev' }), rej, 'tag'); + assert.throws(() => T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/dev-ref', devRef: '', dispatchTarget: 'persistent-dev' }), rej, 'missing persistent_dev_ref'); + for (const strategy of ['production-only', 'production-preview']) assert.throws(() => T({ event: 'workflow_dispatch', strategy, ref: 'refs/heads/dev-ref', devRef: 'refs/heads/dev-ref', dispatchTarget: 'persistent-dev' }), rej, `strategy ${strategy}`); + for (const bad of ['production', 'native-dev', 'Persistent-Dev', ' persistent-dev', 'persistent-dev ', 'unknown']) { + for (const ref of ['refs/heads/main', 'refs/heads/dev-ref', 'refs/heads/x']) assert.throws(() => T({ ...H, event: 'workflow_dispatch', ref, dispatchTarget: bad }), /not valid/, `${bad} on ${ref}`); + } + assert.deepEqual(T({ ...H, event: 'workflow_dispatch', ref: 'refs/heads/main', devRef: 'refs/heads/main' }), ['production'], 'dispatch main, no target: existing default'); + // no combination ever falls back to Production + for (const ev of ['push', 'workflow_dispatch', 'pull_request', 'delete']) for (const ref of ['refs/heads/main', 'refs/heads/x', 'refs/tags/v1']) for (const t of ['persistent-dev', 'x']) { + let r; try { r = T({ ...H, event: ev, ref, dispatchTarget: t }); } catch { r = 'throws'; } + assert.ok(r === 'throws' || (Array.isArray(r) && r.length === 1 && r[0] === 'persistent-dev' && ev === 'workflow_dispatch'), `${ev} ${ref} ${t} -> ${JSON.stringify(r)}`); + } }); test('production-only rejects every Dev channel; conflicting automatic Dev channels are rejected', () => { assert.throws(() => T({ strategy: 'production-only', sharedDevNativeOnPush: true }), /production-only/); @@ -682,3 +710,65 @@ test('legacy migration ownership: D1 identity mismatch is rejected; the descript const withDesc = deployState(); assert.deepEqual(R(withDesc)['native-dev'], { ...withDesc.descriptors['native-dev'], phase: 'verify' }); }); + +test('nonProdRecoveryBlock: non-Production recovery is fail-closed against the migration result of the same pipeline', () => { + const M = { migration: 'applied', schema_changed: 'true', d1_scan: 'additive', kind: 'persistent-dev', d1_id: 'D1' }; + const b = (o) => nonProdRecoveryBlock({ d1Migrations: true, migration: M, expectedId: 'D1', d1Compat: 'expand-contract', ...o }); + assert.equal(nonProdRecoveryBlock({ d1Migrations: false, migration: null }), null, 'no D1 migrations: allowed'); + assert.equal(b({ migration: { ...M, schema_changed: 'false', d1_scan: 'none' }, d1Compat: '' }), null, 'no schema change: allowed without attestation'); + assert.equal(b({}), null, 'additive + attested: allowed'); + assert.equal(b({ d1Compat: '' }).code, 'd1_unattested'); + assert.equal(b({ d1Compat: 'none' }).code, 'd1_unattested'); + for (const scan of ['non-additive', 'unknown', '', 'none']) assert.equal(b({ migration: { ...M, d1_scan: scan } }).code, 'd1_not_additive', `scan ${scan}`); + for (const bad of [null, {}, [], 'x', { ...M, migration: 'failed' }, { ...M, migration: 'none' }]) assert.equal(b({ migration: bad }).code, 'migration_result_missing'); + assert.equal(b({ migration: { ...M, d1_id: 'OTHER' } }).code, 'd1_identity_mismatch'); + assert.equal(b({ expectedId: '' }).code, 'd1_identity_mismatch'); + for (const v of ['', undefined, 'maybe', 'TRUE']) assert.equal(b({ migration: { ...M, schema_changed: v } }).code, 'migration_result_invalid'); + // identical D1 rule as Production + assert.equal(prodRollbackBlock({ policy: 'automatic', previous: 'a', current: 'b', worker: 'w', d1Pending: true, d1Compat: '', d1Scan: 'additive' }).code, 'd1_unattested'); + assert.equal(rollbackVerdict({ target: 'dev', rbResult: 'blocked', goodSha: 'abc' }).result, 'blocked'); + assert.equal(rollbackVerdict({ target: 'dev', rbResult: 'blocked', goodSha: '' }).ok, false); +}); + +test('wranglerPublishBlock: the argument vector of every Wrangler call of dev_deploy_cmd is judged, not the command string', () => { + const A = '/w/audited.json'; + const b = (...args) => wranglerPublishBlock({ args, auditedConfig: A })?.code ?? null; + assert.equal(b('deploy', '--config', A), null); + assert.equal(b('deploy', `--config=${A}`, '--message', 'x'), null); + assert.equal(b('versions', 'upload', '-c', A), null); + assert.equal(b('deployments', 'list', '--json'), null, 'read-only subcommands are fine'); + assert.equal(b('--version'), null); + assert.equal(b('deploy', '--env', 'dev'), 'env_selection'); + assert.equal(b('deploy', '--config', A, '-e', 'dev'), 'env_selection'); + assert.equal(b('deploy', `--env=dev`, '--config', A), 'env_selection'); + assert.equal(b('deploy', '--config', A, '--name', 'other'), 'name_override'); + assert.equal(b('deploy'), 'config_not_audited'); + assert.equal(b('deploy', '--config', '/other.json'), 'config_not_audited'); + assert.equal(b('deploy', '--config', A, '--config', A), 'config_not_audited'); + for (const sub of [['d1', 'migrations', 'apply', 'DB'], ['d1', 'execute', 'DB'], ['delete'], ['rollback'], ['secret', 'put', 'X'], ['kv', 'key', 'put'], ['r2', 'bucket', 'delete', 'x']]) assert.equal(b(...sub), 'not_publish_only', sub.join(' ')); +}); +test('devPublishCmdBlock: early static rejection of legacy publish commands (package scripts resolved), supplementary to the runtime guard', () => { + const scripts = { 'deploy:dev': 'pnpm run build:frontend && pnpm exec wrangler deploy --env dev', 'build:frontend': 'vite build', ship: 'pnpm exec wrangler deploy --config "$CF_AUDITED_CONFIG"', hop: 'pnpm run ship' }; + assert.equal(devPublishCmdBlock('pnpm run deploy:dev', scripts)?.code, 'rebuilds'); + assert.equal(devPublishCmdBlock('pnpm exec wrangler deploy --env dev').code, 'env_selection'); + assert.equal(devPublishCmdBlock('wrangler deploy -e dev --config x').code, 'env_selection'); + assert.equal(devPublishCmdBlock('pnpm run build && wrangler deploy --config "$CF_AUDITED_CONFIG"').code, 'rebuilds'); + assert.equal(devPublishCmdBlock('npm run build:prod').code, 'rebuilds'); + assert.equal(devPublishCmdBlock('vite build').code, 'rebuilds'); + assert.equal(devPublishCmdBlock('pnpm exec wrangler d1 migrations apply DB').code, 'not_publish_only'); + assert.equal(devPublishCmdBlock('pnpm exec wrangler deploy --config "$CF_AUDITED_CONFIG"'), null); + assert.equal(devPublishCmdBlock('pnpm run hop', scripts), null, 'a script that only publishes with the audited config passes'); + assert.equal(devPublishCmdBlock('echo publish'), null); + assert.equal(devPublishCmdBlock('./opaque.sh'), null, 'an opaque command is NOT judged here: the runtime guard still sees every Wrangler call'); +}); +test('ciTargetEnv: the CI environment follows the intended target', () => { + const E = (event, ref, dispatchTarget = '') => ciTargetEnv({ event, ref, dispatchTarget }); + assert.equal(E('push', 'refs/heads/main'), 'prod'); + assert.equal(E('push', 'refs/heads/feat'), 'preview'); + assert.equal(E('workflow_dispatch', 'refs/heads/dev', 'persistent-dev'), 'dev'); + assert.equal(E('workflow_dispatch', 'refs/heads/main', 'persistent-dev'), 'dev'); + assert.equal(E('workflow_dispatch', 'refs/heads/main'), 'prod'); + assert.equal(E('pull_request', 'refs/pull/1/merge'), 'preview'); + assert.equal(E('push', 'refs/heads/main', 'persistent-dev'), 'prod', 'a push never becomes dev (the Plan rejects the conflicting target)'); + assert.equal(E('workflow_dispatch', 'refs/heads/main', 'Persistent-Dev'), 'prod', 'only the exact value selects dev (the Plan rejects the rest)'); +}); diff --git a/scripts/cf-preview/run-job-groups.py b/scripts/cf-preview/run-job-groups.py index 7910f1b..28a21aa 100755 --- a/scripts/cf-preview/run-job-groups.py +++ b/scripts/cf-preview/run-job-groups.py @@ -22,13 +22,14 @@ # group id -> minimum number of checks (raise it when scenarios are added; never lower it to make a run pass) MANIFEST = { "production-release": 43, - "preview-deploy": 46, + "preview-deploy": 48, "migration-d1": 69, - "shared-dev": 74, + "shared-dev": 112, "chain-smoke-rollback": 54, "deferred-verification": 50, - "native-dev-smoke-modes": 48, + "native-dev-smoke-modes": 50, "review-regressions": 36, + "ci-environment": 14, } diff --git a/scripts/cf-preview/test-deploy.sh b/scripts/cf-preview/test-deploy.sh index 92ed840..d076291 100755 --- a/scripts/cf-preview/test-deploy.sh +++ b/scripts/cf-preview/test-deploy.sh @@ -219,4 +219,33 @@ sed -i 's/x-evil/x-dev/' "$T/app/wrangler.toml" mkdir -p "$T/app/dist"; echo orig > "$T/app/dist/index.js" sed -i 's/^name = "x"$/name = "x"\nmain = "dist\/index.js"/' "$T/app/wrangler.toml" : > "$T/log"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD='echo rebuilt > dist/index.js' bash -c "$(declare -f D); D" >/dev/null 2>&1 && bad "publish command replacing the artifact accepted" || ok "dev: a publish command that rebuilds/replaces the audited artifact fails the run" +# ---- publish-only contract of dev_deploy_cmd: every Wrangler call the command makes goes through the guard (BEFORE the real Wrangler) ---- +echo orig > "$T/app/dist/index.js" +mkdir -p "$T/app/node_modules/.bin" +cat > "$T/app/node_modules/.bin/wrangler" <<'REAL' +#!/usr/bin/env bash +echo "REAL $*" >> "$STUB_REAL_LOG" +REAL +chmod +x "$T/app/node_modules/.bin/wrangler"; ORIG_SUM="$(sha256sum "$T/app/node_modules/.bin/wrangler" | cut -d' ' -f1)" +export STUB_REAL_LOG="$T/real.log" +Dg() { : > "$T/log"; : > "$STUB_REAL_LOG"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD="$1" bash -c "$(declare -f D); D" 2>"$T/err"; } +OUT="$(Dg './node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"')" && grep -q '^REAL deploy --config' "$STUB_REAL_LOG" && ok "dev publish guard: wrangler deploy --config reaches the real Wrangler" || bad "guarded audited deploy should pass" +[ "$(sha256sum "$T/app/node_modules/.bin/wrangler" | cut -d' ' -f1)" = "$ORIG_SUM" ] && ok "dev publish guard: the project's node_modules/.bin/wrangler is restored after the command" || bad "project wrangler not restored" +for label in '--env dev' '--config "$CF_AUDITED_CONFIG" --env dev' '--config "$CF_AUDITED_CONFIG" -e dev' '--config "$CF_AUDITED_CONFIG" --name other' '' '--config other.toml'; do + OUT="$(Dg "./node_modules/.bin/wrangler deploy $label")" && bad "guard accepted: deploy $label" || { [ ! -s "$STUB_REAL_LOG" ] && printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: 'deploy $label' is refused BEFORE the real Wrangler runs (nothing changed, reported as blocked)" || bad "guard: deploy $label reached Wrangler or was not reported blocked"; } +done +OUT="$(Dg './node_modules/.bin/wrangler d1 migrations apply DB --remote')" && bad "d1 migrations accepted" || { [ ! -s "$STUB_REAL_LOG" ] && ok "dev publish guard: a D1 migration from the publish command is refused (the Migrate job owns migrations)" || bad "d1 reached wrangler"; } +OUT="$(Dg 'echo rebuilt > dist/index.js; ./node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"')" && bad "rebuild-then-deploy accepted" || { [ ! -s "$STUB_REAL_LOG" ] && printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: a command that REBUILT first is refused before the upload (artifact differs from the audited one) - not detected afterwards" || bad "rebuild then deploy reached wrangler"; } +echo orig > "$T/app/dist/index.js" +OUT="$(Dg './node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"; echo changed > dist/index.js')" && bad "post-publish artifact change accepted" || { grep -q '^REAL deploy' "$STUB_REAL_LOG" && printf '%s\n' "$OUT" | grep -qx 'publish_integrity_failed=true' && ! printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: an artifact swapped AFTER the real publish is reported as integrity failure (potentially published, never 'unchanged')" || bad "post-publish integrity reporting"; } +echo orig > "$T/app/dist/index.js" +OUT="$(Dg 'exit 3')" && bad "failing publish command accepted" || { printf '%s\n' "$OUT" | grep -qx 'publish_started=true' && ! printf '%s\n' "$OUT" | grep -q 'publish_blocked\|publish_integrity_failed' && ok "dev publish guard: a plain failing command stays 'publish started, unverified' (not claimed unchanged, not claimed blocked)" || bad "plain failure reporting"; } +rm -rf "$T/app/node_modules" +cat > "$T/bin/wrangler" <<'REAL' +#!/usr/bin/env bash +echo "REAL $*" >> "$STUB_REAL_LOG" +REAL +chmod +x "$T/bin/wrangler" +OUT="$(Dg 'wrangler deploy --env dev')" && bad "bare wrangler --env accepted" || { [ ! -s "$STUB_REAL_LOG" ] && ok "dev publish guard: a bare wrangler (npx / global, no project bin) is guarded through PATH too" || bad "bare wrangler reached real"; } +rm -f "$T/bin/wrangler" exit $fail diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index 078b006..32738c3 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -55,7 +55,7 @@ def ctx(ref, inputs=None, secrets=None, needs=None, event="push"): # Preview URL is derived from preview_url_suffix exactly like the real one is derived from Cloudflare's preview_url_suffix. i = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", app_url_prod="https://app.example.com", preview_host_check=False, preview_url_suffix=SUFFIX) i.update(inputs or {}) - return Ctx({"github": {"event_name": event, "ref": ref, "ref_type": "branch", "ref_name": ref.split("/", 2)[-1], + return Ctx({"github": {"event_name": event, "ref": ref, "ref_type": "tag" if ref.startswith("refs/tags/") else "branch", "ref_name": ref.split("/", 2)[-1], "repository": "o/r", "sha": SHA, "run_id": "1", "event": {"head_commit": {"message": "feat: x\nbody"}}}, "inputs": i, "needs": needs or {}}, secrets or {}) @@ -370,9 +370,9 @@ def good_dep(**over): } -def dev(inputs=None, env=None, toml=DEV_TOML, needs=None, secrets=None, ref=FEAT, env_by_job=None): +def dev(inputs=None, env=None, toml=DEV_TOML, needs=None, secrets=None, ref=FEAT, env_by_job=None, files=None): """persistent-dev (the separate Dev Worker) through the real chain: Gate -> Plan -> Migration -> Deploy -> Smoke -> Rollback.""" - return run_chain(ref, inputs=dict(DEV_INPUTS, preview_on_branch_push=False, **(inputs or {})), secrets=secrets, env=env, toml=toml, env_by_job=env_by_job) + return run_chain(ref, inputs=dict(DEV_INPUTS, preview_on_branch_push=False, **(inputs or {})), secrets=secrets, env=env, toml=toml, env_by_job=env_by_job, files=files) def ran(r, what): @@ -629,6 +629,12 @@ def group_preview_deploy(): check("state=inactive" in r["log"] and "state=success" not in r["log"], "Preview liveness is never recorded as a success (known-good) ledger status") r = deploy(FEAT, inputs={"smoke_env": "PROBE_HEADERS=CF-Access-Client-Id: x"}, env={"STUB_PROBES": "200 "}) check(r["outputs"]["smoke_level"] == "content" and r["outputs"]["deployed_and_verified"] == "true" and r["outputs"]["verified_url"] != "" and "state=success" in r["log"], "Preview with authenticated content smoke -> verified, success ledger status") + PD1 = dict(PREV_IN, **D1) + r = deploy(FEAT, inputs=dict(PD1, d1_rollback_compat="expand-contract"), files=DROP_SQL, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and r["log"].count("preview --name feat-x") == 1 and "`d1_not_additive`" in r["summary"] and "UNVERIFIED" in r["summary"] and "migrations revert" not in r["log"], + "Native Branch: destructive migration + failed Smoke -> recovery BLOCKED, last-good code not republished, D1 never reverted") + r = deploy(FEAT, inputs=dict(PD1, d1_rollback_compat="expand-contract"), files=ADD_SQL, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep()}) + check(r["log"].count("preview --name feat-x") == 2, "Native Branch: additive + attested -> recovery proceeds") r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep()}) check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "preview smoke failure: job red, not verified (the failure stays visible although recovery succeeded)") check(r["log"].count("preview --name feat-x") == 2, "preview recovery redeploys the last good deployment") @@ -955,6 +961,20 @@ def group_shared_dev(): r = manual(**dict(kw, inputs=dict(D1, **kw.get("inputs", {})))) check(r["result"] == "failure" and r["res"]["plan"]["result"] == "failure" and nomut(r) and all(r["res"][f"delivery_{KEYED[k]}"]["result"] == "skipped" for k in KINDS) and r["res"]["migration"]["result"] == "skipped", f"manual dispatch with {label}: the Plan rejects it - no Migration, no Deploy, nothing built or published") + # ---- fail closed: an explicit dispatch_target on ANY event/ref that cannot serve it ends RED (never green with no deployment) and mutates nothing + HY = dict(PD, release_cmd="pnpm release", production_rollback_policy="automatic") + for label, ref, event, extra in ( + ("a workflow_dispatch from a TAG ref", "refs/tags/v1", "workflow_dispatch", {"persistent_dev_ref": "refs/tags/v1"}), + ("a PUSH to main", MAIN, "push", {}), ("a PUSH to a feature branch", FEAT, "push", {}), ("a PUSH to the approved ref", "refs/heads/dev", "push", {}), + ("a pull_request event", "refs/pull/1/merge", "pull_request", {}), ("a branch delete event", FEAT, "delete", {})): + r = run_chain(ref, inputs=dict(HY, **D1, **extra), event=event) + check(r["result"] == "failure" and r["res"]["plan"]["result"] == "failure" and nomut(r) and r["res"]["migration"]["result"] == "skipped" and not release_runs(r, {"release_cmd": "pnpm release", "dispatch_target": "persistent-dev"}) + and all(r["res"][f"delivery_{KEYED[k]}"]["result"] == "skipped" for k in KINDS) and "versions upload" not in r["log"], + f"explicit persistent-dev request via {label}: the Plan fails the run - no CI-green-and-nothing-deployed, no Production fallback, nothing built, migrated or published") + r = run_chain("refs/tags/v1", inputs=dict(PD, dispatch_target=""), event="workflow_dispatch") + check(r["result"] == "success" and r["res"]["plan"]["result"] == "skipped" and nomut(r), "a tag dispatch WITHOUT a target is unchanged: Plan skipped, nothing deployed (no explicit request was made)") + r = run_chain(MAIN, inputs=dict(HY, dispatch_target=""), event="push") + check(r["result"] == "success" and json.loads(r["res"]["plan"]["outputs"]["kinds"]) == ["production"], "a push to main without a target is unchanged: Production") # ---- from the approved main ref: persistent Dev, never Production r = manual(ref=MAIN, inputs=dict(D1, persistent_dev_ref=MAIN, release_cmd="pnpm release", production_rollback_policy="automatic")) check(r["result"] == "success" and json.loads(r["res"]["plan"]["outputs"]["kinds"]) == ["persistent-dev"] and r["res"]["delivery_production"]["result"] == "skipped" and "versions upload" not in r["log"] and "versions deploy" not in r["log"] and r["res"]["delivery_persistent_dev"]["result"] == "success", @@ -988,6 +1008,32 @@ def group_shared_dev(): check(r["result"] == "failure" and inner_of(r)["deploy"] == "failure" and inner_of(r)["rollback"] == "skipped" and dres["outputs"]["smoke_status"] == "none" and r["log"].count("PUBLISH") == 1 and r["outputs"]["deployed_and_verified"] == "false" and not ran(r, "SMOKE") and "state=success" not in r["log"] and not release_runs(r, {"release_cmd": "pnpm release", "dispatch_target": "persistent-dev"}), "manual dispatch: a failing publish -> Deploy red, nothing is smoked or verified (smoke_status none), no recovery runs, never recorded known-good, no Release") + # ---- D1 compatibility guard for NON-Production recovery (the real migration -> deploy -> smoke -> revert hand-off) + ATT = dict(D1, d1_rollback_compat="expand-contract") + FAIL_GOOD = dict(GD, STUB_PROBES="500 ;200 ") + for label, runner in (("manual dispatch", manual), ("automatic Shared Dev push", lambda **k: dev(**k))): + r = runner(inputs=ATT, files=ADD_SQL, env=FAIL_GOOD) + check(inner_of(r) == {"deploy": "success", "smoke": "failure", "rollback": "success"} and r["log"].count("PUBLISH") == 2 and r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "restored_verified", + f"{label}: additive migration + attested expand-contract + failed Smoke -> recovery allowed and verified") + r = runner(inputs=ATT, files=DROP_SQL, env=FAIL_GOOD) + check(inner_of(r)["rollback"] == "failure" and r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "blocked" and r["log"].count("PUBLISH") == 1 and "`d1_not_additive`" in r["summary"] + and "UNVERIFIED" in r["summary"] and "NOT reverted" in r["summary"] and "state=success" not in r["log"] and "migrations revert" not in r["log"] and r["log"].count("migrations apply") == 1 and r["result"] == "failure", + f"{label}: destructive migration + failed Smoke -> recovery BLOCKED: old Worker not published, reason + manual recovery in the summary, run red, D1 never reverted") + r = runner(inputs=D1, files=ADD_SQL, env=FAIL_GOOD) + check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "blocked" and r["log"].count("PUBLISH") == 1 and "`d1_unattested`" in r["summary"], + f"{label}: additive but compatibility NOT attested (unknown) + failed Smoke -> blocked") + r = runner(inputs=ATT, files=ADD_SQL, env=dict(FAIL_GOOD, STUB_PENDING_FILES="")) + check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "blocked" and r["log"].count("PUBLISH") == 1 and "scan: unknown" in r["summary"], + f"{label}: scan unknown (pending files undeterminable) -> blocked even when attested") + r = runner(inputs=D1, env=dict(FAIL_GOOD, STUB_PENDING="0")) + check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "restored_verified" and r["log"].count("PUBLISH") == 2, f"{label}: nothing pending this run (schema unchanged) -> recovery allowed without attestation") + r = manual(env=FAIL_GOOD) + check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "restored_verified" and r["log"].count("PUBLISH") == 2, "no D1 migrations configured -> recovery unchanged") + # ---- a legacy dev_deploy_cmd (builds / selects its own Wrangler environment) is rejected before ANYTHING is built, migrated or published + for label, cmd_ in (("selects its own Wrangler environment", "pnpm exec wrangler deploy --env dev"), ("builds before publishing", "pnpm run build && pnpm exec wrangler deploy --config \"$CF_AUDITED_CONFIG\"")): + r = manual(inputs=dict(D1, dev_deploy_cmd=cmd_)) + check(r["result"] == "failure" and not ran(r, "BUILD") and not ran(r, "PUBLISH") and "migrations apply" not in r["log"] and r["res"]["migration"]["result"] == "failure" and r["res"]["delivery_persistent_dev"]["result"] == "skipped", + f"manual dispatch with a legacy dev_deploy_cmd that {label}: rejected during the audit - nothing built, no D1 migration, nothing published") # ---- other kinds keep their pipelines r = deploy(MAIN, inputs={"release_cmd": "pnpm release"}) check(r["result"] == "success" and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and release_runs(r), "unchanged: a Production push still runs the Production delivery (and Release)") @@ -1258,6 +1304,12 @@ def group_native_dev_smoke_modes(): r = deploy(FEAT, inputs=ND, env=dict(GDN, STUB_PROBES="500 ;200 ")) check(r["result"] == "failure" and r["raw"]["rollback"]["outputs"].get("rb_native_dev") == "restored_verified" and r["log"].count("preview --name dev") == 2 and r["log"].count("BUILD kind=native-dev") == 2 and "versions deploy" not in r["log"], "Native Dev: failed smoke -> last known-good Native Dev deployment rebuilt at the LAST-GOOD sha, republished to `dev`, re-verified; the run stays red; never a Production version") + NDD = dict(ND, **D1, d1_rollback_compat="expand-contract") + r = deploy(FEAT, inputs=NDD, files=ADD_SQL, env=dict(GDN, STUB_PROBES="500 ;200 ")) + check(r["raw"]["rollback"]["outputs"].get("rb_native_dev") == "restored_verified" and r["log"].count("preview --name dev") == 2, "Native Dev: additive migration + attested + failed Smoke -> recovery allowed") + r = deploy(FEAT, inputs=NDD, files=DROP_SQL, env=dict(GDN, STUB_PROBES="500 ;200 ")) + check(r["result"] == "failure" and r["raw"]["rollback"]["outputs"].get("rb_native_dev") == "blocked" and r["log"].count("preview --name dev") == 1 and "`d1_not_additive`" in r["summary"] and "migrations revert" not in r["log"], + "Native Dev: destructive migration + failed Smoke -> recovery BLOCKED, old Preview code never published, D1 never reverted") r = deploy(FEAT, inputs=ND, env=dict(GDN, STUB_PROBES="500 ;200 ", STUB_NEWEST="9999")) check(r["result"] == "failure" and r["log"].count("preview --name dev") == 1 and "newer native-dev deployment" in r["summary"], "Native Dev: a NEWER deployment of `dev` exists -> no rollback over it") r = deploy(FEAT, inputs=ND, env={"STUB_PROBES": "500 ;200 "}) @@ -1422,6 +1474,54 @@ def group_review_regressions(): +def group_ci_environment(): + """CI environment follows the intended deployment target (executed cf-ci-check, real env values)""" + PRODV, DEVV, PREVV = "MARK=prod\nVITE_MODE=PRODCFG", "MARK=dev\nVITE_MODE=DEVCFG", "MARK=preview\nVITE_MODE=PREVCFG" + cmd = 'echo "CI app_env=$VITE_APP_ENV mark=${MARK:-none} mode=${VITE_MODE:-none} ci_env=${CI_ONLY:-none} prodsecret=${CLOUDFLARE_API_TOKEN:-none}" >> "$STUB_LOG"' + + def ci_run(ref, event="push", dt="", job="lint", **extra): + inp = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", lint_cmd=cmd, typecheck_cmd=cmd, test_unit_cmd=cmd, test_integration_cmd=cmd, dispatch_target=dt, + build_env_prod=PRODV, build_env_dev=DEVV, build_env_preview=PREVV, ci_env="CI_ONLY=yes") + inp.update(extra) + c = ctx(ref, inp, PROD_SECRETS, {}, event) + c["github"]["ref_type"] = "tag" if ref.startswith("refs/tags/") else "branch" + ws = make_workspace(APP_TOML) + r = called(cdoc, "ci", job, c, ws) + return r["result"], [l for l in r["log"].splitlines() if l.startswith("CI ")] + + table = [ # (event, ref, dispatch_target) -> (VITE_APP_ENV, imported build env) + (("push", MAIN, ""), ("prod", "prod")), (("push", FEAT, ""), ("preview", "preview")), + (("workflow_dispatch", "refs/heads/dev", "persistent-dev"), ("dev", "dev")), + (("workflow_dispatch", MAIN, "persistent-dev"), ("dev", "dev")), + (("workflow_dispatch", MAIN, ""), ("prod", "prod")), (("workflow_dispatch", FEAT, ""), ("preview", "preview")), + (("pull_request", "refs/pull/1/merge", ""), ("preview", "preview")), + ] + for (event, ref, dt), (app_env, mark) in table: + res, lines = ci_run(ref, event, dt) + want = f"CI app_env={app_env} mark={mark} mode={ {"prod": "PRODCFG", "dev": "DEVCFG", "preview": "PREVCFG"}[mark] } ci_env=yes prodsecret=none" + check(res == "success" and lines == [want], f"CI env: {event} {ref} target={dt or '-'} -> VITE_APP_ENV={app_env}, the {mark} build env only (got {lines})") + # every validation command receives the same environment + for job in ("typecheck", "unit", "integration"): + res, lines = ci_run(MAIN, "workflow_dispatch", "persistent-dev", job=job) + check(lines == ["CI app_env=dev mark=dev mode=DEVCFG ci_env=yes prodsecret=none"], f"CI env: the {job} job of a persistent-dev dispatch from main sees Dev values, never Production's") + # a Dev dispatch never imports the Production env even when only the Production env is configured + res, lines = ci_run(MAIN, "workflow_dispatch", "persistent-dev", build_env_dev="") + check(lines == ["CI app_env=dev mark=none mode=none ci_env=yes prodsecret=none"], "CI env: persistent-dev dispatch with no build_env_dev imports NOTHING (Production env is never a fallback)") + # ci_env keeps its precedence over the target env + res, lines = ci_run(MAIN, "workflow_dispatch", "persistent-dev", build_env_dev="MARK=dev\nCI_ONLY=fromtarget") + check(lines == ["CI app_env=dev mark=dev mode=none ci_env=yes prodsecret=none"], "CI env: ci_env is applied after (and wins over) the target build env") + # an unknown resolved environment fails closed + saved = doc["jobs"]["ci"]["with"]["ci_target_env"] + doc["jobs"]["ci"]["with"]["ci_target_env"] = "staging" + try: + res, lines = ci_run(FEAT, "push", "") + finally: + doc["jobs"]["ci"]["with"]["ci_target_env"] = saved + check(res == "failure" and lines == [], "CI env: an unknown target environment fails the check instead of guessing") + # no Cloudflare credentials in the CI job + check(all("CLOUDFLARE" not in str(cdoc["jobs"][j]) for j in ("lint", "typecheck", "unit", "integration", "build", "e2e")), "CI jobs reference no Cloudflare credential") + + GROUPS = { "production-release": group_production_release, "preview-deploy": group_preview_deploy, @@ -1431,6 +1531,7 @@ def group_review_regressions(): "deferred-verification": group_deferred_verification, "native-dev-smoke-modes": group_native_dev_smoke_modes, "review-regressions": group_review_regressions, + "ci-environment": group_ci_environment, } diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index e39e73e..08cf7e3 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -242,7 +242,12 @@ def gate_run(unconfigured=(), **o): for bad in ("failure", "cancelled", "skipped"): check(not plan_if(needs={"ci": {"result": bad, "outputs": {"validation": "passed"}}}), f"plan: blocked when the CI call is {bad}") check(not plan_if(needs={"ci": {"result": "success", "outputs": {"validation": ""}}}) and not plan_if(needs={"ci": {"result": "success", "outputs": {}}}), "plan: a green CI call without the gate's `validation=passed` verdict is still blocked (fail closed)") -check(not plan_if(event="pull_request", ref="refs/pull/1/merge") and not plan_if(event="delete") and not plan_if(ref="refs/tags/v1"), "plan: never for PRs, deletes or tags") +check(not plan_if(event="pull_request", ref="refs/pull/1/merge") and not plan_if(event="delete") and not plan_if(ref="refs/tags/v1"), "plan: never for PRs, deletes or tags WITHOUT an explicit dispatch_target") +check(all(plan_if(inputs={"dispatch_target": "persistent-dev"}, event=e, ref=r, needs=n) for e, r, n in ( + ("workflow_dispatch", "refs/tags/v1", {"ci": {"result": "success", "outputs": {"validation": "passed"}}}), + ("pull_request", "refs/pull/1/merge", {"ci": {"result": "success", "outputs": {"validation": "passed"}}}), + ("delete", FEAT, {"ci": {"result": "skipped", "outputs": {}}}), ("push", "refs/tags/v1", {"ci": {"result": "failure", "outputs": {}}}))), + "plan: an EXPLICIT dispatch_target on a tag / PR / delete / push always reaches the Plan, which then fails it (an invalid explicit request cannot end green without deploying)") check(plan_if(inputs={"pipeline_phase": "verify", "phase_state": "{}"}, needs={"ci": {"result": "skipped", "outputs": {}}}), "plan: a verify call plans from the hand-off, not from CI") # ---------------------------------------------------------------- call-job gating (plan outputs are the only per-kind switch) # Routing stub of the plan's descriptor: the call jobs only ROUTE it (delivery.test.mjs proves its content). @@ -472,7 +477,9 @@ def dout(kind, **kw): check(set(given) <= set(decl) and all(k in given for k, v in decl.items() if v.get("required")), f"{n}: its `with:` keys are declared inputs of the called workflow and every required input is supplied") sec_decl = set(called_doc[True]["workflow_call"].get("secrets", {})) check(set(jobs[n].get("secrets", {})) <= sec_decl, f"{n}: only secrets the called workflow declares are passed") -check(all(jobs["ci"]["with"][k] == "${{ inputs." + k + " }}" for k in cdoc[True]["workflow_call"]["inputs"]), "ci: every CI input is wired 1:1 from the SAME-named public input (no renamed or re-derived command)") +check(all(jobs["ci"]["with"][k] == "${{ inputs." + k + " }}" for k in cdoc[True]["workflow_call"]["inputs"] if k != "ci_target_env"), "ci: every CI input except the resolved `ci_target_env` is wired 1:1 from the SAME-named public input (no renamed or re-derived command)") +check(set(jobs["ci"]["with"]) - set(cdoc[True]["workflow_call"]["inputs"]) == set() and "ci_target_env" in jobs["ci"]["with"] and jobs["ci"].get("needs") is None, + "ci: the CI environment is resolved by an expression on the CI call itself - no extra job and no dependency on the Plan") ALINT = yaml.safe_load(open(".github/actionlint.yaml"))["paths"] check(sorted(len(v["ignore"]) for v in ALINT.values()) == [1, 2] and all("queue" in x for x in ALINT[WF]["ignore"]) and all("$/" in x or "\\$/" in x for x in ALINT[".github/workflows/_cf-worker-*.yml"]["ignore"]), "actionlint: exactly three message ignores - `concurrency.queue` and the two `$/` formats - everything else is still linted") @@ -491,7 +498,7 @@ def dout(kind, **kw): check(cjobs[j].get("continue-on-error") is None, f"{j}: failures are never hidden by continue-on-error") check(steps[0].get("uses") == "actions/checkout@v6" and steps[1].get("uses") == "$/actions/cf-ci-check", f"{j}: checks out the app, then runs the CI action (nothing else)") ci_steps = CI_ACTION["runs"]["steps"] -check(ci_steps[0]["name"].startswith("Import validation env") and "parse-env.mjs\" TARGET_BUILD_ENV" in ci_steps[0]["run"] and ci_steps[0]["run"].index("TARGET_BUILD_ENV") < ci_steps[0]["run"].index("CI_ENV"), +check(ci_steps[1]["name"].startswith("Import validation env") and "parse-env.mjs\" TARGET_BUILD_ENV" in ci_steps[1]["run"] and ci_steps[1]["run"].index("TARGET_BUILD_ENV") < ci_steps[1]["run"].index("CI_ENV"), "cf-ci-check imports the target build env, then ci_env, through the safe importer (precedence preserved)") ck = next(s for s in ci_steps if s.get("id") == "check") check(ck["env"]["CMD"] == "${{ inputs.cmd }}" and ck["run"] == 'bash -c "$CMD"' and "continue-on-error" not in ck, "cf-ci-check runs its one command via env (never interpolated) and never hides a failure") @@ -499,7 +506,11 @@ def dout(kind, **kw): check(any(s.get("uses", "").startswith("$/actions/mailpit") and s.get("if") == "inputs.use_mailpit == 'true'" for s in ci_steps) and all(("use_mailpit" in str(next(s for s in cjobs[j]["steps"] if s.get("uses") == "$/actions/cf-ci-check")["with"])) == (j in ("unit", "integration")) for j in CMDS), "Mailpit starts only for Unit and Integration tests") -check("build_env_prod" in ci_steps[0]["env"]["TARGET_BUILD_ENV"] and "build_env_preview" in ci_steps[0]["env"]["TARGET_BUILD_ENV"], "validation jobs use the Production build env on main and the Preview build env elsewhere") +_tb = ci_steps[1]["env"]["TARGET_BUILD_ENV"] +check(all(f"inputs.build_env_{k}" in _tb for k in ("prod", "dev", "preview")) and "github.ref" not in _tb, "validation jobs import exactly ONE build env, selected by the resolved target environment (never by the ref alone)") +check(all(cjobs[j]["steps"][1]["with"]["target_env"] == "${{ inputs.ci_target_env }}" and cjobs[j]["steps"][1]["with"]["build_env_dev"] == "${{ inputs.build_env_dev }}" for j in CMDS), "every validation job passes the resolved target environment and the Dev build env to the CI action") +_ck = next(s_ for s_ in ci_steps if s_.get("id") == "check") +check(_ck["env"]["VITE_APP_ENV"] == "${{ steps.target.outputs.env }}", "VITE_APP_ENV is the resolved target environment (dev for a persistent-dev dispatch), not a function of the ref") e2e_text = yaml.dump(cjobs["e2e"]) check("test_e2e_build_cmd" in e2e_text and "test_e2e_env" in e2e_text and "inputs.build_cmd" not in e2e_text, "E2E builds with its own test build command and env") uploads = [(j, s) for j, job in allj.items() for s in job.get("steps", []) if "upload-artifact" in str(s.get("uses", ""))] @@ -757,3 +768,15 @@ def sup_run(handoff, kind="production", env_name="app/prod", sha="abc1234"): sys.exit(fail) + + +# ---------------------------------------------------------------- CI environment contract: expression == delivery.mjs ciTargetEnv, and the spec table +CI_EXPR = jobs["ci"]["with"]["ci_target_env"] +ci_cases = [(e, r, dt) for e in ("push", "workflow_dispatch", "pull_request", "delete") for r in ("refs/heads/main", "refs/heads/feat/x", "refs/heads/dev", "refs/pull/1/merge", "refs/tags/v1") for dt in ("", "persistent-dev", "Persistent-Dev", "production")] +yaml_ci = [render(CI_EXPR, scenario(e, r, {"dispatch_target": dt})) for e, r, dt in ci_cases] +js_ci = json.loads(subprocess.run(["node", "-e", "import('./actions/cf-preview/delivery.mjs').then(async m=>{let t='';for await(const c of process.stdin)t+=c;console.log(JSON.stringify(JSON.parse(t).map(([e,r,d])=>m.ciTargetEnv({event:e,ref:r,dispatchTarget:d}))))})"], input=json.dumps(ci_cases), capture_output=True, text=True, check=True).stdout) +check(yaml_ci == js_ci and len(ci_cases) == 80, f"CI environment: the workflow expression and delivery.mjs ciTargetEnv agree on all {len(ci_cases)} event/ref/target scenarios") +ci_of = lambda e, r, dt="": render(CI_EXPR, scenario(e, r, {"dispatch_target": dt})) +check(ci_of("push", MAIN) == "prod" and ci_of("push", FEAT) == "preview" and ci_of("workflow_dispatch", "refs/heads/dev", "persistent-dev") == "dev" + and ci_of("workflow_dispatch", MAIN, "persistent-dev") == "dev" and ci_of("workflow_dispatch", MAIN) == "prod" and ci_of("pull_request", "refs/pull/1/merge") == "preview", + "CI environment table: push main -> prod, push feature -> preview, dispatch persistent-dev (approved feature OR main) -> dev, dispatch main without target -> prod, PR -> preview") From afff31bc39c6b8c61ebb8329ff0fe833179f0695 Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 6 Nov 2026 06:58:34 +0000 Subject: [PATCH 4/4] refactor(cf-v2): the engine is the only publisher (retire dev_deploy_cmd and the Wrangler guard); Dev CI env for automatic Persistent Dev Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/_cf-worker-v2.yml | 14 +-- actions/cf-deploy/action.yml | 2 - actions/cf-preview/cli.mjs | 21 +---- actions/cf-preview/delivery.mjs | 92 ++----------------- actions/cf-preview/engine.sh | 17 +--- actions/cf-preview/publish.sh | 43 +-------- actions/cf-preview/wrangler-guard.sh | 31 ------- docs/cf-worker-v2-refactor.md | 12 +-- docs/cf-worker-v2-unified-delivery.md | 12 +-- .../proposal.md | 2 +- .../tasks.md | 2 +- scripts/cf-preview/delivery.test.mjs | 56 +++-------- scripts/cf-preview/ghexpr.py | 1 + scripts/cf-preview/jobrunner.py | 1 + scripts/cf-preview/run-job-groups.py | 2 +- scripts/cf-preview/test-deploy.sh | 38 +------- scripts/cf-preview/test-jobs.py | 38 ++++---- scripts/cf-preview/test-workflow-contract.py | 32 +++++-- 18 files changed, 104 insertions(+), 312 deletions(-) delete mode 100755 actions/cf-preview/wrangler-guard.sh diff --git a/.github/workflows/_cf-worker-v2.yml b/.github/workflows/_cf-worker-v2.yml index d6312f9..5b23621 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -332,10 +332,9 @@ on: default: '' dev_deploy_cmd: description: > - OPTIONAL publish-only command for the Dev Worker (default: `wrangler deploy --config `). - The engine builds with dev_build_cmd (or build_cmd) and audits the effective config FIRST; this command runs - afterwards with CF_AUDITED_CONFIG exported, must publish THAT config (`wrangler deploy --config - "$CF_AUDITED_CONFIG"`) and must not rebuild: a changed config or deployable fails the run. + RETIRED - must be empty. The engine is the only publisher (it publishes the audited Dev Wrangler config itself); a non-empty + value fails the Plan before anything is built, migrated or published. Build in dev_build_cmd (or build_cmd). The input is kept + declared only so a stale caller gets this explicit error instead of a workflow-syntax failure. type: string default: '' dev_smoke_cmd: @@ -477,10 +476,11 @@ jobs: build_env_prod: ${{ inputs.build_env_prod }} build_env_preview: ${{ inputs.build_env_preview }} build_env_dev: ${{ inputs.build_env_dev }} - # The CI environment follows the INTENDED deployment target, not only the ref: a persistent-dev dispatch is validated against the - # Dev build env even from `main` (never Production env vars). Pure event/input resolution (CI runs before the Plan; the Plan stays + # The CI environment follows the INTENDED deployment target, not only the ref: a persistent-dev dispatch (even from `main`) and an + # automatic push whose ONLY target is Shared Dev are validated against the Dev build env (never Production env vars); several kinds + # at once keep the Preview contract. Pure event/input resolution (CI runs before the Plan; the Plan stays # the authorization gate). JS twin: delivery.mjs ciTargetEnv. - ci_target_env: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && 'dev' || github.ref == 'refs/heads/main' && 'prod' || 'preview' }} + ci_target_env: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && 'dev' || (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && startsWith(github.ref, 'refs/heads/') && github.ref != 'refs/heads/main' && inputs.dispatch_target == '' && inputs.deployment_strategy != 'production-only' && inputs.shared_dev_on_branch_push && !inputs.preview_on_branch_push && !inputs.shared_dev_native_preview_on_branch_push && 'dev' || github.ref == 'refs/heads/main' && 'prod' || 'preview' }} test_e2e_cmd: ${{ inputs.test_e2e_cmd }} test_e2e_setup_cmd: ${{ inputs.test_e2e_setup_cmd }} test_e2e_build_cmd: ${{ inputs.test_e2e_build_cmd }} diff --git a/actions/cf-deploy/action.yml b/actions/cf-deploy/action.yml index 2b422b7..9804d5b 100644 --- a/actions/cf-deploy/action.yml +++ b/actions/cf-deploy/action.yml @@ -283,8 +283,6 @@ runs: env: PREVIEW_MUTATED: ${{ steps.publish.outputs.preview_mutated }} PUBLISH_STARTED: ${{ steps.publish.outputs.publish_started }} - PUBLISH_BLOCKED: ${{ steps.publish.outputs.publish_blocked }} - PUBLISH_INTEGRITY_FAILED: ${{ steps.publish.outputs.publish_integrity_failed }} run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" fail-prepublish' - name: Published but smoke failed - mark unverified if: failure() && steps.smoke.outcome == 'failure' && (inputs.stage == 'verify' || steps.publish.outcome == 'success') diff --git a/actions/cf-preview/cli.mjs b/actions/cf-preview/cli.mjs index 10afc4d..6b0a808 100644 --- a/actions/cf-preview/cli.mjs +++ b/actions/cf-preview/cli.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // CLI used by _cf-worker-v2.yml. stdout carries ONLY key=value records (it is piped into $GITHUB_OUTPUT / captured by the shell); every // diagnostic and workflow annotation (::error:: / ::warning:: / ::notice:: / ::add-mask::) goes to stderr. Failure = non-zero exit. -import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { assertWranglerSupported, auditDevConfig, previewHostReadiness, auditPreviewConfig, auditProdConfig, classifyEvent, extractPreviewUrl, @@ -9,7 +9,7 @@ import { } from './lib.mjs'; import { DELIVERY_KINDS, ENGINE_TARGET, assertKind, branchPreviewIdentity, canonicalOrigin, checkBuildEnvOrigins, environmentName, foreignUrlsInBuildEnv, planMigrations, resolveCanonicalUrl, resolveDeliveryTargets, resolveSmokeMode, - buildDescriptors, parseDescriptor, resolveMigrationUsed, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, wranglerPublishBlock, devPublishCmdBlock, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, + buildDescriptors, parseDescriptor, resolveMigrationUsed, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, } from './delivery.mjs'; const [cmd, ...rest] = process.argv.slice(2); @@ -222,6 +222,9 @@ try { const i = JSON.parse(process.env.INPUTS_JSON ?? '{}'); const str = (k) => (i[k] == null ? '' : String(i[k])); const bool = (k) => i[k] === true || i[k] === 'true'; + // The engine is the ONLY publisher of a Worker. The former caller-supplied publish command is retired: refuse it before anything runs + // (an explicit, actionable error instead of a silently different deployment). + if (str('dev_deploy_cmd').trim() !== '') throw new Error('dev_deploy_cmd is no longer supported: the shared engine publishes the audited Wrangler config itself. Remove the input and build in dev_build_cmd (or build_cmd); nothing was built, migrated or published.'); let kinds; let restored = null; // verify: the DEPLOY call's own descriptors (validated); nothing is re-derived from this call's inputs if (opt('phase') === 'verify') { @@ -291,20 +294,6 @@ try { out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); break; } - case 'wrangler-guard': { // --config -- ; prints block_code / block_why (always exits 0) - const norm = (x) => { try { return realpathSync(resolve(String(x))); } catch { return resolve(String(x)); } }; - const at = process.argv.indexOf('--', 3); - const b = wranglerPublishBlock({ args: at < 0 ? [] : process.argv.slice(at + 1), auditedConfig: opt('config'), normalize: norm }); - out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); - break; - } - case 'publish-cmd-check': { // env: CMD; --package (optional). Early static check of dev_deploy_cmd. - let scripts = {}; - try { scripts = JSON.parse(readFileSync(opt('package'), 'utf8')).scripts ?? {}; } catch { scripts = {}; } - const b = devPublishCmdBlock(process.env.CMD ?? '', scripts); - out({ block_code: b ? b.code : '', block_why: b ? b.why : '' }); - break; - } case 'recovery-guard': { // non-Production recovery; env: D1_MIGRATIONS MIGRATION_JSON D1_EXPECT D1_COMPAT const e = process.env; let migration = null; diff --git a/actions/cf-preview/delivery.mjs b/actions/cf-preview/delivery.mjs index d75aa0e..8159f52 100644 --- a/actions/cf-preview/delivery.mjs +++ b/actions/cf-preview/delivery.mjs @@ -348,92 +348,20 @@ export function verificationOutcome(level) { return { verified: false, kind: level === 'skipped' ? 'skipped' : 'liveness' }; } -// --------------------------------------------------------------------------------------------------------------------- -// Publish-only contract of a caller's `dev_deploy_cmd`. Two layers, neither relies on matching the command STRING alone: -// 1. wranglerPublishBlock: judges the ARGUMENT VECTOR of every Wrangler invocation the command actually makes (the guard shim -// calls it before delegating) - so it applies to `pnpm run deploy`, scripts, nested shells, anything that reaches Wrangler. -// 2. devPublishCmdBlock: an EARLY static rejection (before the migration) of the unambiguous legacy shapes, over the command and -// the package.json scripts it runs. Supplementary: an opaque command is still caught by layer 1 before any mutation. -// --------------------------------------------------------------------------------------------------------------------- -const WRANGLER_VALUE_FLAGS = new Set(['--config', '-c', '--env', '-e', '--name', '--cwd', '--message', '--tag', '--var', '--define', '--assets', '--compatibility-date', '--compatibility-flags', '--site', '--outdir', '--routes', '--route', '--triggers']); -const WRANGLER_ALLOWED = new Set(['deploy', 'versions', 'deployments', 'whoami', 'version']); -const WRANGLER_MUTATING = (cmd, sub) => cmd === 'deploy' || (cmd === 'versions' && (sub === 'upload' || sub === 'deploy')); - -/** null = the Wrangler call may proceed; else why it is refused BEFORE it can change anything. `auditedConfig` and `config` values are compared after path normalisation by the caller. */ -export function wranglerPublishBlock({ args = [], auditedConfig = '', normalize = (x) => x }) { - const positional = []; - const configs = []; - let env = false; - let name = false; - for (let i = 0; i < args.length; i++) { - const a = String(args[i]); - if (a === '--') { positional.push(...args.slice(i + 1).map(String)); break; } - const eq = a.indexOf('='); - const flag = a.startsWith('--') && eq > 0 ? a.slice(0, eq) : a; - const inline = a.startsWith('--') && eq > 0 ? a.slice(eq + 1) : undefined; - if (a.startsWith('-') && a.length > 1) { - let val = inline; - if (val === undefined && WRANGLER_VALUE_FLAGS.has(flag)) { val = String(args[i + 1] ?? ''); i++; } - if (flag === '--config' || flag === '-c') configs.push(val ?? ''); - else if (flag === '--env' || flag === '-e') env = true; - else if (flag === '--name') name = true; - continue; - } - positional.push(a); - } - const [cmd = '', sub = ''] = positional; - if (!cmd || cmd === 'help') return null; - if (!WRANGLER_ALLOWED.has(cmd) && !args.some((a) => ['--version', '-v', '--help', '-h'].includes(String(a)))) { - return { code: 'not_publish_only', why: `dev_deploy_cmd must only publish: \`wrangler ${cmd}\` is not allowed (migrations are applied by the Migrate job; nothing else may change the Worker or its resources from the publish command).` }; - } - if (!WRANGLER_MUTATING(cmd, sub)) return null; - if (env) return { code: 'env_selection', why: 'the publish command selects a Wrangler environment (--env / -e) itself. The audited config is already the one effective configuration; publish with `wrangler deploy --config "$CF_AUDITED_CONFIG"` only.' }; - if (name) return { code: 'name_override', why: 'the publish command overrides the Worker name (--name); the audited config alone defines the deployed Worker.' }; - if (configs.length !== 1) return { code: 'config_not_audited', why: `the publish command must pass exactly one --config and it must be the audited config ("$CF_AUDITED_CONFIG"); it passed ${configs.length}.` }; - if (normalize(configs[0]) !== normalize(auditedConfig)) return { code: 'config_not_audited', why: `the publish command uses --config ${configs[0]}, not the audited config ${auditedConfig}.` }; - return null; -} - -/** - * Early static check of a caller's dev_deploy_cmd (command text + the package.json scripts it runs, resolved recursively). - * Rejects the unambiguous legacy shapes - a Wrangler call that selects its own environment / name, or a command that BUILDS - before the - * Dev D1 is migrated. `scripts` = package.json "scripts". null = nothing objectionable found (the runtime guard still applies). - */ -export function devPublishCmdBlock(cmd, scripts = {}) { - const seen = new Set(); - const expand = (text, depth) => { - if (depth > 6) return text; - let out = text; - for (const m of text.matchAll(/\b(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([A-Za-z0-9:_.-]+)/g)) { - const sc = m[1]; - if (Object.prototype.hasOwnProperty.call(scripts, sc) && !seen.has(sc)) { seen.add(sc); out += `\n${expand(String(scripts[sc]), depth + 1)}`; } - } - return out; - }; - const text = expand(String(cmd), 0); - for (const part of text.split(/&&|\|\||[;|\n]/)) { - const w = part.trim().split(/\s+/); - const at = w.findIndex((x) => /(^|\/)wrangler$/.test(x)); - if (at >= 0) { - const b = wranglerPublishBlock({ args: w.slice(at + 1), auditedConfig: '$CF_AUDITED_CONFIG', normalize: (x) => String(x).replace(/^"|"$/g, '').replace(/^\$\{(\w+)\}$/, '$$$1') }); - if (b && (b.code === 'env_selection' || b.code === 'name_override' || b.code === 'not_publish_only')) return b; - } - if (/(^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?build(?:[:\w.-]*)(\s|$)|\b(?:vite|tsc|next|astro|webpack|rollup|esbuild)\s+build\b/.test(part)) { - return { code: 'rebuilds', why: 'dev_deploy_cmd builds. The audited artifact is produced by dev_build_cmd (build + audit); the publish command must only publish it (`wrangler deploy --config "$CF_AUDITED_CONFIG"`).' }; - } - } - return null; -} - /** - * Which build env the CI validation commands import (and the value of VITE_APP_ENV): the INTENDED deployment target, resolved from + * Which build env the CI validation commands import (and the value of VITE_APP_ENV): the env of the INTENDED deployment, resolved from * pure event/input data because CI runs before the Plan (the Plan stays the authorization gate). JS twin of the `ci_target_env` - * expression of _cf-worker-v2.yml (the contract test compares them). - * workflow_dispatch + dispatch_target persistent-dev (any ref, `main` included) -> dev - * otherwise: refs/heads/main -> prod; everything else (feature branches, PRs, tags) -> preview + * expression of _cf-worker-v2.yml (the contract test compares them and checks `dev` against resolveDeliveryTargets). + * dev : the run's only automatic target is persistent-dev - a manual dispatch with dispatch_target persistent-dev (any ref, `main` + * included), or a push / dispatch to a non-main branch where Shared Dev is the only enabled channel + * prod : refs/heads/main otherwise + * preview : everything else - Native Branch, Native Dev, several kinds at once (their shared validation keeps the Preview contract; + * each kind's own build and audit still validate its actual deployment configuration downstream), PRs, tags */ -export function ciTargetEnv({ event = '', ref = '', dispatchTarget = '' }) { +export function ciTargetEnv({ event = '', ref = '', dispatchTarget = '', strategy = '', previewOnPush = true, sharedDevOnPush = false, sharedDevNativeOnPush = false }) { if (event === 'workflow_dispatch' && dispatchTarget === 'persistent-dev') return 'dev'; + if ((event === 'push' || event === 'workflow_dispatch') && String(ref).startsWith('refs/heads/') && ref !== 'refs/heads/main' && dispatchTarget === '' + && strategy !== 'production-only' && sharedDevOnPush && !previewOnPush && !sharedDevNativeOnPush) return 'dev'; return ref === 'refs/heads/main' ? 'prod' : 'preview'; } diff --git a/actions/cf-preview/engine.sh b/actions/cf-preview/engine.sh index dd45e7d..303c805 100755 --- a/actions/cf-preview/engine.sh +++ b/actions/cf-preview/engine.sh @@ -103,7 +103,7 @@ run_build() { # } run_publish() { # STATE_DIR="$1" APP_URL="$(target_url)" OUT_FILE="$2" APP_VERSION="$3" COMMIT_MSG="$4" \ - WORKER_NAME="$(inp preview_worker_name)" PUBLISH_CMD="$([ "$TARGET" = dev ] && inp dev_deploy_cmd || true)" \ + WORKER_NAME="$(inp preview_worker_name)" \ PREVIEW_NAME="$PREVIEW_NAME" EXPECTED_URL="$([ "$TARGET" = preview ] && target_url || true)" "$TOOLS_DIR/publish.sh" } @@ -183,13 +183,6 @@ case "$PHASE" in build) ctx - # Early, pre-mutation rejection of a legacy dev_deploy_cmd that builds or selects its own Wrangler environment (before the build and - # long before the Dev D1 is migrated). The runtime guard in publish.sh still judges every actual Wrangler call. - if [ "$TARGET" = dev ] && [ -n "$(inp dev_deploy_cmd)" ]; then - PG="$(CMD="$(inp dev_deploy_cmd)" node "$TOOLS_DIR/cli.mjs" publish-cmd-check --package "$APP_DIR/package.json")" - PC="$(printf '%s\n' "$PG" | sed -n 's/^block_code=//p')" - [ -z "$PC" ] || die "dev_deploy_cmd is not publish-only ($PC): $(printf '%s\n' "$PG" | sed -n 's/^block_why=//p'). Nothing was built, migrated or published." - fi resolve_target_url "$S/build.env" run_build "$S" "$S/build.env" "$(inp app_version)" "$(inp preview_host_check)";; @@ -370,13 +363,7 @@ case "$PHASE" in summary "### ❌ Failed before publish — existing Preview unchanged." fi;; dev) - if [ "${PUBLISH_INTEGRITY_FAILED:-}" = true ]; then - [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish integrity check failed; Dev potentially published but UNVERIFIED" || true - summary "### 🚨 Dev publish integrity check FAILED — the publish command changed the audited config or artifact after publishing. Dev is **potentially published but UNVERIFIED** (it may serve an unaudited build). Manual recovery: deploy a known-good commit with the standard pipeline (push a fix); do not assume Dev is unchanged." - elif [ "${PUBLISH_BLOCKED:-}" = true ]; then - [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish command refused before any Wrangler publish call; Dev unchanged" || true - summary "### ❌ dev_deploy_cmd refused before any Wrangler publish call — it is not publish-only (see the job log for the reason). No Wrangler call changed Dev. Fix the command: \`wrangler deploy --config \"\$CF_AUDITED_CONFIG\"\` only, and build in \`dev_build_cmd\`." - elif [ "${PUBLISH_STARTED:-}" = true ]; then + if [ "${PUBLISH_STARTED:-}" = true ]; then [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" error "" "Publish failed part-way; Dev UNVERIFIED" || true summary "### ❌ Shared Dev publish failed part-way — Dev may serve an unverified deployment." else diff --git a/actions/cf-preview/publish.sh b/actions/cf-preview/publish.sh index 6f54e7a..9195265 100755 --- a/actions/cf-preview/publish.sh +++ b/actions/cf-preview/publish.sh @@ -3,10 +3,10 @@ # explicit); it never builds. Prints key=value lines on stdout (progress goes to stderr). # TARGET=preview -> `wrangler preview --name --json` -> url= # TARGET=prod -> `wrangler versions upload` + `versions deploy @100` -> url= version_id= previous_version_id= -# TARGET=dev -> `wrangler deploy` (built in) or the caller's PUBLISH_CMD -> url= +# TARGET=dev -> `wrangler deploy` of the audited config (the engine is the only publisher) -> url= # ROLLBACK_TO= (prod only): `wrangler versions deploy @100` with the same audited config. # Env: TARGET APP_DIR TOOLS_DIR STATE_DIR [OUT_FILE] [WRANGLER_CONFIG] [APP_URL] [APP_VERSION] [COMMIT_MSG] -# preview: PREVIEW_NAME [WORKER_NAME] dev: [PUBLISH_CMD] [DEV_ENV] +# preview: PREVIEW_NAME [WORKER_NAME] dev: [DEV_ENV] set -euo pipefail : "${TARGET:?}" "${APP_DIR:?}" "${TOOLS_DIR:?}" "${STATE_DIR:?}" case "$TARGET" in prod|preview|dev) ;; *) echo "::error::bad TARGET: $TARGET" >&2; exit 1;; esac @@ -67,44 +67,7 @@ fi if [ "$TARGET" = dev ]; then echo "publish_started=true" - if [ -z "${PUBLISH_CMD:-}" ]; then - pnpm exec wrangler deploy "${CFG_ARGS[@]}" --message "$MSG" >&2 - else - # The caller's publish command is PUBLISH-ONLY. It runs with the audited config exported and every Wrangler call it makes goes through - # wrangler-guard.sh, which refuses (before the real Wrangler runs) any non-publish subcommand, any --env / --name / non-audited --config, - # and any call made after the audited config or artifact changed. The post-run checks below stay as the second, independent layer. - GUARD="$STATE_DIR/guard"; rm -rf "$GUARD"; mkdir -p "$GUARD/bin" - REAL_BIN="$PWD/node_modules/.bin/wrangler"; PROJECT_BIN_MOVED="" - cp "$TOOLS_DIR/wrangler-guard.sh" "$GUARD/bin/wrangler"; chmod +x "$GUARD/bin/wrangler" - if [ -e "$REAL_BIN" ] || [ -L "$REAL_BIN" ]; then - mv "$REAL_BIN" "$REAL_BIN.cfv2-real"; cp "$GUARD/bin/wrangler" "$REAL_BIN"; PROJECT_BIN_MOVED=1; REAL_WRANGLER="$REAL_BIN.cfv2-real" - else - REAL_WRANGLER="$(PATH="${PATH//$GUARD\/bin:/}" command -v wrangler || echo /nonexistent/wrangler)" - fi - restore_bin() { [ -z "$PROJECT_BIN_MOVED" ] || { rm -f "$REAL_BIN"; mv "$REAL_BIN.cfv2-real" "$REAL_BIN"; PROJECT_BIN_MOVED=""; }; } - trap restore_bin EXIT - CENV=""; [ "$MODE" != source ] || CENV="$DEV_ENV" # a flattened audited config never takes a named env; a SOURCE config's audited env is DEV_ENV - set +e - PATH="$GUARD/bin:$PATH" CFV2_REAL_WRANGLER="$REAL_WRANGLER" CFV2_GUARD_DIR="$GUARD" CFV2_STATE_DIR="$STATE_DIR" CFV2_TOOLS_DIR="$TOOLS_DIR" \ - CF_AUDITED_CONFIG="$CONFIG_PATH" CLOUDFLARE_ENV="$CENV" bash -eo pipefail -c "$PUBLISH_CMD" >&2 - RC=$? - set -e - restore_bin - if [ "$RC" -ne 0 ]; then - if [ -f "$GUARD/blocked" ] && [ ! -f "$GUARD/mutating" ]; then - # Refused by the guard before any Wrangler call could change the Worker. - echo "publish_blocked=true" - echo "::error::dev_deploy_cmd was refused before any Wrangler publish call was made ($(sed -n 's/^code=//p' "$GUARD/blocked")); Dev was not changed by Wrangler" >&2 - fi - exit 1 - fi - [ -f "$GUARD/mutating" ] || echo "::warning::dev_deploy_cmd made no Wrangler publish call; nothing was published through the audited config" >&2 - # Integrity after the fact: from here on a failure means the Worker MAY already have been replaced - never "unchanged". - integrity_fail() { echo "publish_integrity_failed=true"; echo "::error::$1" >&2; exit 1; } - [ "$(sha256sum "$CONFIG_PATH" | cut -d' ' -f1)" = "$(cat "$STATE_DIR/config.sha")" ] || integrity_fail "the publish command modified the audited Wrangler config; Dev is potentially published but UNVERIFIED" - NOW="$(node "$TOOLS_DIR/cli.mjs" artifact-digest --config "$CONFIG_PATH" | sed -n 's/^artifact_digest=//p')" - [ "$NOW" = "$(cat "$STATE_DIR/artifact.sha")" ] || integrity_fail "the publish command rebuilt or replaced the audited artifact; Dev is potentially published from an unaudited build and UNVERIFIED" - fi + pnpm exec wrangler deploy "${CFG_ARGS[@]}" --message "$MSG" >&2 echo "url=${APP_URL:-}" exit 0 fi diff --git a/actions/cf-preview/wrangler-guard.sh b/actions/cf-preview/wrangler-guard.sh deleted file mode 100755 index 57b648d..0000000 --- a/actions/cf-preview/wrangler-guard.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Stand-in for `wrangler` while a caller's dev_deploy_cmd runs (publish.sh installs it first on PATH AND over the project's -# node_modules/.bin/wrangler, so `wrangler`, `npx wrangler`, `pnpm exec wrangler` and package scripts all reach it). -# It enforces the publish-only contract BEFORE the real Wrangler can change anything: -# - only publish-related subcommands (deploy / versions / deployments / whoami / version); everything else is refused -# - a mutating call must pass exactly one --config and it must be the audited config; no --env / -e, no --name -# - at the moment of the call, the audited config and the audited artifact must still be byte-identical to what build.sh audited -# (a command that rebuilt first is refused before the upload, not detected afterwards) -# Facts for publish.sh: $CFV2_GUARD_DIR/blocked (refused, nothing changed) and /mutating (the real Wrangler was invoked). -# Env: CFV2_REAL_WRANGLER CFV2_GUARD_DIR CF_AUDITED_CONFIG CFV2_STATE_DIR CFV2_TOOLS_DIR -set -uo pipefail -: "${CFV2_REAL_WRANGLER:?}" "${CFV2_GUARD_DIR:?}" "${CF_AUDITED_CONFIG:?}" "${CFV2_STATE_DIR:?}" "${CFV2_TOOLS_DIR:?}" -refuse() { # - printf 'code=%s\n' "$1" > "$CFV2_GUARD_DIR/blocked" - echo "::error::dev_deploy_cmd refused before anything was published ($1): $2" >&2 - exit 87 -} -G="$(node "$CFV2_TOOLS_DIR/cli.mjs" wrangler-guard --config "$CF_AUDITED_CONFIG" -- "$@")" || refuse guard_error "the publish guard could not evaluate the Wrangler call" -CODE="$(printf '%s\n' "$G" | sed -n 's/^block_code=//p')" -[ -z "$CODE" ] || refuse "$CODE" "$(printf '%s\n' "$G" | sed -n 's/^block_why=//p')" -# Only calls that can change the Worker need the integrity proof; read-only ones (whoami, deployments list, --version) pass. -case "${1:-}" in - deploy|versions) - [ "${1:-}" != versions ] || case "${2:-}" in upload|deploy) ;; *) exec "$CFV2_REAL_WRANGLER" "$@";; esac - [ "$(sha256sum "$CF_AUDITED_CONFIG" | cut -d' ' -f1)" = "$(cat "$CFV2_STATE_DIR/config.sha")" ] || refuse config_changed "the audited Wrangler config differs from what was audited." - NOW="$(node "$CFV2_TOOLS_DIR/cli.mjs" artifact-digest --config "$CF_AUDITED_CONFIG" | sed -n 's/^artifact_digest=//p')" - [ -n "$NOW" ] && [ "$NOW" = "$(cat "$CFV2_STATE_DIR/artifact.sha")" ] || refuse artifact_changed "the deployable artifact differs from the one that was built and audited (the command rebuilt or replaced it)." - : > "$CFV2_GUARD_DIR/mutating" - ;; -esac -exec "$CFV2_REAL_WRANGLER" "$@" diff --git a/docs/cf-worker-v2-refactor.md b/docs/cf-worker-v2-refactor.md index 98599da..72712fe 100644 --- a/docs/cf-worker-v2-refactor.md +++ b/docs/cf-worker-v2-refactor.md @@ -364,7 +364,7 @@ detect ─┬─ A (deploy call): CI ‖ E2E ─► Migration ─► Deploy | 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` | | Config audit on the published config | Preview-resource ban | Preview binding / auth isolation | Dev Worker identity + resource policy | -| Publish | `versions upload` + `versions deploy @100` | `wrangler preview --name ` | `wrangler deploy --config --env ` or the caller's `dev_deploy_cmd` | +| Publish | `versions upload` + `versions deploy @100` | `wrangler preview --name ` | `wrangler deploy --config [--env for a source config]` (engine-owned) | | Smoke URL | `app_url_prod` | discovered Preview URL | `app_url_dev` | | Recovery | policy-driven rollback to the previous version | redeploy last-good of THIS branch + host Worker | redeploy last verified Dev SHA (any branch) | @@ -402,8 +402,8 @@ detect ─┬─ A (deploy call): CI ‖ E2E ─► Migration ─► Deploy | Deploy | `build_env_prod`, `build_env_preview`, `smoke_cmd`, `smoke_env`, `probe_path`, `probe_expect_status`, `sha_path`, `app_url_prod`, `access_protected_targets`, `production_rollback_policy`, `preview_binding_allow`, `preview_deny_resources` | | Migration | `d1_migrations`, `d1_binding`, `d1_lock_scope` (names the shared-D1 concurrency group; no command override) | | Release | `release_cmd` (+ secret `RELEASE_TOKEN`) | -| 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` | +| Hybrid | `persistent_dev_ref`, `dev_smoke_cmd`, `dispatch_target` | +| Shared Dev | `shared_dev_on_branch_push`, `app_url_dev`, `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), `PREVIEW_SECRETS`, `SMOKE_USERNAME/PASSWORD`, `RELEASE_TOKEN`. @@ -445,7 +445,7 @@ Two independent switches decide what a non-main push deploys. Neither requires t * A failed build, audit or stale run never changes the running Dev Worker or its D1. Production, Preview and Dev all build **before** they migrate. * Migration reuses `migrate.sh` with the engine-bound target `dev`: D1 = `env..d1_databases`, never top-level (Production); no fallback. The Preview D1 and the Dev D1 **may** be the same physical database (Blog); Production's may not. `migrations apply` is pending-only. Shared Dev has `needs: deploy` purely for ordering, so the two channels never migrate one shared D1 concurrently, plus the shared concurrency group below. * `dev_worker_name` is **required** and the audit runs on every Shared Dev run (automatic and manual): the adapter-generated effective config (`.wrangler/deploy/config.json`), or if none was generated the source `env.` block, must have `name == dev_worker_name` and pass the resource policy below. -* **Same artifact.** `build.sh` records the path, SHA-256 and a content fingerprint (`artifact-digest`: config + Worker entry directory + assets directory) of the audited config. `publish.sh` refuses to run without that record, refuses a config that changed in between, always passes `--config ` (and `--env ` only for an un-flattened source config) and clears `CLOUDFLARE_ENV`. A caller-supplied `dev_deploy_cmd` is **publish-only**: it runs with `CF_AUDITED_CONFIG` exported and must `wrangler deploy --config "$CF_AUDITED_CONFIG"`; a changed config or deployable afterwards fails the run and the deployment is reported unverified. (A command that ignores the variable and deploys something else cannot be proven wrong by static means; keep `dev_deploy_cmd` empty to use the default.) +* **Same artifact.** `build.sh` records the path, SHA-256 and a content fingerprint (`artifact-digest`: config + Worker entry directory + assets directory) of the audited config. `publish.sh` refuses to run without that record, refuses a config that changed in between, always passes `--config ` (and `--env ` only for an un-flattened source config) and clears `CLOUDFLARE_ENV`. There is no caller-supplied publish command: the engine is the only publisher (`dev_deploy_cmd` is retired and fails the Plan when set). * **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. @@ -492,9 +492,9 @@ Rules: an unknown kind, a malformed entry, a duplicate entry, an entry that matc ### Manual vs automatic Dev contract -Dev contract (automatic Shared Dev push and manual dispatch alike): `dev_build_cmd` builds only (falls back to `build_cmd`; required with `d1_migrations`), `dev_deploy_cmd` (optional) publishes only, `dev_smoke_cmd` verifies only. Both triggers run CI → Plan → Migrate → Deploy → Smoke → conditional Revert; both require `dev_worker_name` and `app_url_dev`, and a manual dispatch additionally keeps the approved-ref gate and the protected `/dev` Environment. +Dev contract (automatic Shared Dev push and manual dispatch alike): `dev_build_cmd` builds only (falls back to `build_cmd`; required with `d1_migrations`), the engine alone publishes the audited Dev config (no caller publish command), `dev_smoke_cmd` verifies only. Both triggers run CI → Plan → Migrate → Deploy → Smoke → conditional Revert; both require `dev_worker_name` and `app_url_dev`, and a manual dispatch additionally keeps the approved-ref gate and the protected `/dev` Environment. -The former manual-only "legacy contract" (`dev_build_cmd` empty, `dev_deploy_cmd` builds and publishes in one command) no longer exists: a dispatch cannot select a different implementation than a push. Migration: set `dev_build_cmd`, make `dev_deploy_cmd` publish the audited config only (`wrangler deploy --config "$CF_AUDITED_CONFIG"`), and set `dev_worker_name` / `app_url_dev`. +The former "legacy contract" (`dev_deploy_cmd` builds and publishes in one command) no longer exists. `dev_deploy_cmd` is a retired input: a non-empty value fails the Plan before anything is built, migrated or published. Migration: build in `dev_build_cmd` (or `build_cmd`), delete `dev_deploy_cmd`, set `dev_worker_name` / `app_url_dev`, or move the app to Native Dev. ### Recovery limits diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md index 4afae6e..7fda1d4 100644 --- a/docs/cf-worker-v2-unified-delivery.md +++ b/docs/cf-worker-v2-unified-delivery.md @@ -376,11 +376,11 @@ identical to a feature-branch Shared Dev push. The delivery workflow has exactly Concurrency: the run-level group of a persistent-dev dispatch is the same as for the automatic Shared Dev push (`cfv2-nonprod-d1-` with `d1_migrations`, else `cfv2-shared-dev-`; never the Production group, even from `main`), so the Migrate job cannot race a feature-branch Dev push. Deploy and Revert keep the descriptor's job-level group `cfv2-dev--` (a different name, so no nested-group deadlock). -Behaviour changes versus the old manual path: it now runs CI, Plan and the dedicated Migrate job; a failed Smoke triggers the same conditional recovery to the last known-good Dev deployment (it previously had none); D1 is still never rolled back. Callers that relied on `dev_build_cmd` being empty (one `dev_deploy_cmd` that builds and publishes) must add `dev_build_cmd` and make `dev_deploy_cmd` publish only (see `docs/cf-worker-v2-refactor.md`). +Behaviour changes versus the old manual path: it now runs CI, Plan and the dedicated Migrate job; a failed Smoke triggers the same conditional recovery to the last known-good Dev deployment (it previously had none); D1 is still never rolled back. `dev_deploy_cmd` is retired (non-empty fails the Plan): the engine is the only publisher, callers build in `dev_build_cmd` (see `docs/cf-worker-v2-refactor.md`). -## Safety rules added in the PR #93 review round +## Final rules (PR #93) -- **CI environment follows the intended target** (`ci_target_env`, JS twin `ciTargetEnv`): push `main` → `prod`; push feature / PR → `preview`; `workflow_dispatch` with `dispatch_target=persistent-dev` (any ref, `main` included) → `dev` (`build_env_dev`, `VITE_APP_ENV=dev`); dispatch of `main` without a target → `prod`. Exactly one build env is imported; `ci_env` still applies afterwards. No extra job, no dependency on the Plan (which stays the authorization gate). -- **`dispatch_target` fails closed**: non-empty is valid only for `workflow_dispatch` and only as exactly `persistent-dev`; unapproved ref, tag, missing `persistent_dev_ref`, non-hybrid strategy or any other value fails the Plan. The Plan's `if:` also runs for an explicit target on a tag / PR / push / delete event so the run ends red, never green without deploying. -- **Non-Production recovery D1 guard** (`nonProdRecoveryBlock`, same D1 rule as Production via `d1SchemaBlock`): before the last known-good code of Native Branch / Native Dev / Persistent Dev is republished, the Migration job's result of the same pipeline must be present, for this target's D1, and either `schema_changed=false` or (`d1_rollback_compat=expand-contract` and scan `additive`). Otherwise recovery is blocked: nothing is redeployed, the deployment is marked UNVERIFIED, the summary names the reason and the manual recovery; D1 is never reverted. -- **`dev_deploy_cmd` is publish-only**: every Wrangler call it makes goes through `wrangler-guard.sh` (installed on `PATH` and over the project's `node_modules/.bin/wrangler`) which refuses non-publish subcommands, `--env`/`-e`, `--name`, any `--config` other than the audited one, and any call after the audited config/artifact changed — before the real Wrangler runs. An early static check (command + resolved package scripts) rejects legacy shapes (own `--env`, builds) before the build and migration. Post-run integrity checks remain; their failure is reported as "potentially published but UNVERIFIED". +- **One audited publisher.** The engine alone publishes: build (`dev_build_cmd` or `build_cmd`) -> audit the effective Wrangler config -> publish that config (`wrangler deploy --config `, plus `--env ` only for an audited *source* config) -> Smoke. There is no caller-supplied publish command: `dev_deploy_cmd` is a retired input and a non-empty value fails the Plan (explicit error instead of a workflow-syntax failure for stale callers). +- **CI environment follows the intended target** (`ci_target_env`, JS twin `ciTargetEnv`): `dev` when the run's only target is Persistent Dev (manual dispatch from any ref, `main` included, or an automatic push where Shared Dev is the only enabled channel); `prod` for `main`; `preview` otherwise (Native Branch, Native Dev, several kinds at once, PRs). With several kinds the shared validation keeps the Preview contract and each kind's own build and audit still validate its actual configuration. Exactly one build env is imported; `ci_env` applies afterwards; no Cloudflare credentials in CI. +- **`dispatch_target` fails closed**: valid only for `workflow_dispatch` and only as exactly `persistent-dev`; unapproved ref, tag, missing `persistent_dev_ref`, non-hybrid strategy or any other value fails the Plan, including for tag / PR / push / delete events (the Plan `if:` runs for an explicit target so the run is red, never green without deploying). +- **Non-Production recovery D1 guard** (`nonProdRecoveryBlock`, same rule as Production via `d1SchemaBlock`): last-known-good code is redeployed only when this pipeline's migration result is present, for this target's D1, and either unchanged or additive with `d1_rollback_compat: expand-contract`; otherwise recovery is blocked, the deployment is UNVERIFIED and the summary gives the reason and manual steps. D1 is never reverted. diff --git a/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md b/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md index 0f21ad5..f420cdd 100644 --- a/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md +++ b/openspec/changes/add-cloudflare-shared-dev-channel/proposal.md @@ -12,7 +12,7 @@ The first implementation added Shared Dev by duplicating the Preview/Production - **Thin workflow, one engine.** `_cf-worker-v2.yml` keeps events, gating, Environments, credentials, permissions, concurrency and Release (≈800 lines incl. the ~300-line public interface). All delivery logic is the composite action `actions/cf-deploy` (phases of `actions/cf-preview/engine.sh`, `build.sh`, `publish.sh`, `migrate.sh`, `smoke.sh`, `guard.sh`, `gh.sh`, `cli.mjs`). Production, Native Preview and Shared Dev are the same pipeline with `target = prod | preview | dev`; a manual persistent Dev dispatch is another trigger of the same pipeline (it only selects the persistent-dev kind; superseded the former `mode: manual`). Validation jobs share `actions/cf-ci-check`. - **Migration is a step, not a job.** Order: validate CI → build the deployable → audit the effective Wrangler config → validate the exact migration target (and that the deployed D1 is that D1) → stale check → apply pending D1 migrations → stale check → publish the audited artifact → smoke → mark verified / recover. No Prepare, Plan, Test-Gate or Migration job. -- New input `shared_dev_on_branch_push` (default `false`) next to the preserved `preview_on_branch_push`; four combinations. New inputs `app_url_dev`, `build_env_dev`, `dev_build_cmd`, `dev_wrangler_env`, `dev_worker_name`, `dev_binding_allow`, `dev_deny_resources`; `dev_deploy_cmd` becomes an optional publish-only command (default `wrangler deploy --config `). +- New input `shared_dev_on_branch_push` (default `false`) next to the preserved `preview_on_branch_push`; four combinations. New inputs `app_url_dev`, `build_env_dev`, `dev_build_cmd`, `dev_wrangler_env`, `dev_worker_name`, `dev_binding_allow`, `dev_deny_resources`; the engine publishes `wrangler deploy --config ` itself (`dev_deploy_cmd` was retired afterwards: a non-empty value fails the Plan). - Review fixes: protected deployment-control env names, complete Dev resource isolation, deployed-D1 == migrated-D1, publish of exactly the audited artifact, shared-D1 concurrency identity, stale re-check after the migration. - `config-guard` rejects `production-only` + Shared Dev. diff --git a/openspec/changes/add-cloudflare-shared-dev-channel/tasks.md b/openspec/changes/add-cloudflare-shared-dev-channel/tasks.md index 2b0b00e..bebfade 100644 --- a/openspec/changes/add-cloudflare-shared-dev-channel/tasks.md +++ b/openspec/changes/add-cloudflare-shared-dev-channel/tasks.md @@ -8,7 +8,7 @@ - [x] P1 `parse-env.mjs --reserved`: deployment-control names cannot be set by `build_env_*`, `smoke_env`, `PREVIEW_SECRETS`; target bound from the composite input; no `TARGET` job env - [x] P1 Dev isolation: policy covers D1, R2, KV, Vectorize, Analytics Engine, Hyperdrive, Secrets Store, Pipelines, Rate limits, Queues (producer / consumer / DLQ), Service bindings (default deny), routes, cross-Worker Durable Objects, unsupported kinds; negative tests with Production Queue / Service / D1 / KV - [x] P1 `d1-consistency`: deployed (effective) D1 `database_id` must equal the migration target before migration and publish -- [x] P1 same artifact: `build.sh` records config path + SHA + `artifact-digest`; `publish.sh` passes `--config` explicitly and rejects any change; `dev_deploy_cmd` is publish-only with `CF_AUDITED_CONFIG` +- [x] P1 same artifact: `build.sh` records config path + SHA + `artifact-digest`; `publish.sh` passes `--config` explicitly and rejects any change; the engine is the only publisher (`dev_deploy_cmd` later retired) - [x] P2 concurrency identity: `cfv2-prod-`, `cfv2-nonprod-d1-`, Dev job group keyed by the shared-D1 scope; repository-scope limit documented - [x] P2 stale re-check after the migration (and `inactive` ledger status for a superseded run); recovery skipped for a superseded run - [x] Tests: contract (DAG, gating, 4 combinations, credentials, composite structure, cross-repo references, concurrency), executed job tests through the composite engine, `build.sh`/`publish.sh` behaviour, unit tests diff --git a/scripts/cf-preview/delivery.test.mjs b/scripts/cf-preview/delivery.test.mjs index 2e98b73..b651260 100644 --- a/scripts/cf-preview/delivery.test.mjs +++ b/scripts/cf-preview/delivery.test.mjs @@ -4,7 +4,7 @@ import { DELIVERY_KINDS, resolveDeliveryTargets, resolveSmokeMode, parseSmokeOverrides, planMigrations, branchPreviewIdentity, ENGINE_TARGET, canonicalOrigin, previewUrlFromSuffix, resolveCanonicalUrl, checkBuildEnvOrigins, targetDescriptor, validateDescriptor, buildDescriptors, parseDescriptor, resolveMigrationUsed, - isContentLevel, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, wranglerPublishBlock, devPublishCmdBlock, ciTargetEnv, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, + isContentLevel, verificationOutcome, prodRollbackBlock, nonProdRecoveryBlock, ciTargetEnv, rollbackVerdict, restoreDescriptors, validateInit, runLockGroup, } from '../../actions/cf-preview/delivery.mjs'; const base = { event: 'push', ref: 'refs/heads/feat/x', strategy: 'production-preview', previewOnPush: true }; @@ -730,45 +730,17 @@ test('nonProdRecoveryBlock: non-Production recovery is fail-closed against the m assert.equal(rollbackVerdict({ target: 'dev', rbResult: 'blocked', goodSha: '' }).ok, false); }); -test('wranglerPublishBlock: the argument vector of every Wrangler call of dev_deploy_cmd is judged, not the command string', () => { - const A = '/w/audited.json'; - const b = (...args) => wranglerPublishBlock({ args, auditedConfig: A })?.code ?? null; - assert.equal(b('deploy', '--config', A), null); - assert.equal(b('deploy', `--config=${A}`, '--message', 'x'), null); - assert.equal(b('versions', 'upload', '-c', A), null); - assert.equal(b('deployments', 'list', '--json'), null, 'read-only subcommands are fine'); - assert.equal(b('--version'), null); - assert.equal(b('deploy', '--env', 'dev'), 'env_selection'); - assert.equal(b('deploy', '--config', A, '-e', 'dev'), 'env_selection'); - assert.equal(b('deploy', `--env=dev`, '--config', A), 'env_selection'); - assert.equal(b('deploy', '--config', A, '--name', 'other'), 'name_override'); - assert.equal(b('deploy'), 'config_not_audited'); - assert.equal(b('deploy', '--config', '/other.json'), 'config_not_audited'); - assert.equal(b('deploy', '--config', A, '--config', A), 'config_not_audited'); - for (const sub of [['d1', 'migrations', 'apply', 'DB'], ['d1', 'execute', 'DB'], ['delete'], ['rollback'], ['secret', 'put', 'X'], ['kv', 'key', 'put'], ['r2', 'bucket', 'delete', 'x']]) assert.equal(b(...sub), 'not_publish_only', sub.join(' ')); -}); -test('devPublishCmdBlock: early static rejection of legacy publish commands (package scripts resolved), supplementary to the runtime guard', () => { - const scripts = { 'deploy:dev': 'pnpm run build:frontend && pnpm exec wrangler deploy --env dev', 'build:frontend': 'vite build', ship: 'pnpm exec wrangler deploy --config "$CF_AUDITED_CONFIG"', hop: 'pnpm run ship' }; - assert.equal(devPublishCmdBlock('pnpm run deploy:dev', scripts)?.code, 'rebuilds'); - assert.equal(devPublishCmdBlock('pnpm exec wrangler deploy --env dev').code, 'env_selection'); - assert.equal(devPublishCmdBlock('wrangler deploy -e dev --config x').code, 'env_selection'); - assert.equal(devPublishCmdBlock('pnpm run build && wrangler deploy --config "$CF_AUDITED_CONFIG"').code, 'rebuilds'); - assert.equal(devPublishCmdBlock('npm run build:prod').code, 'rebuilds'); - assert.equal(devPublishCmdBlock('vite build').code, 'rebuilds'); - assert.equal(devPublishCmdBlock('pnpm exec wrangler d1 migrations apply DB').code, 'not_publish_only'); - assert.equal(devPublishCmdBlock('pnpm exec wrangler deploy --config "$CF_AUDITED_CONFIG"'), null); - assert.equal(devPublishCmdBlock('pnpm run hop', scripts), null, 'a script that only publishes with the audited config passes'); - assert.equal(devPublishCmdBlock('echo publish'), null); - assert.equal(devPublishCmdBlock('./opaque.sh'), null, 'an opaque command is NOT judged here: the runtime guard still sees every Wrangler call'); -}); -test('ciTargetEnv: the CI environment follows the intended target', () => { - const E = (event, ref, dispatchTarget = '') => ciTargetEnv({ event, ref, dispatchTarget }); - assert.equal(E('push', 'refs/heads/main'), 'prod'); - assert.equal(E('push', 'refs/heads/feat'), 'preview'); - assert.equal(E('workflow_dispatch', 'refs/heads/dev', 'persistent-dev'), 'dev'); - assert.equal(E('workflow_dispatch', 'refs/heads/main', 'persistent-dev'), 'dev'); - assert.equal(E('workflow_dispatch', 'refs/heads/main'), 'prod'); - assert.equal(E('pull_request', 'refs/pull/1/merge'), 'preview'); - assert.equal(E('push', 'refs/heads/main', 'persistent-dev'), 'prod', 'a push never becomes dev (the Plan rejects the conflicting target)'); - assert.equal(E('workflow_dispatch', 'refs/heads/main', 'Persistent-Dev'), 'prod', 'only the exact value selects dev (the Plan rejects the rest)'); +test('ciTargetEnv: the CI environment follows the intended target (automatic and manual Persistent Dev alike)', () => { + const E = (o) => ciTargetEnv({ strategy: 'hybrid', previewOnPush: true, ...o }); + assert.equal(E({ event: 'push', ref: 'refs/heads/main' }), 'prod'); + assert.equal(E({ event: 'push', ref: 'refs/heads/feat' }), 'preview', 'Native Branch'); + assert.equal(E({ event: 'push', ref: 'refs/heads/feat', previewOnPush: false, sharedDevNativeOnPush: true }), 'preview', 'Native Dev only: existing contract'); + assert.equal(E({ event: 'push', ref: 'refs/heads/feat', previewOnPush: false, sharedDevOnPush: true }), 'dev', 'automatic Persistent Dev only'); + assert.equal(E({ event: 'push', ref: 'refs/heads/feat', sharedDevOnPush: true }), 'preview', 'several kinds: shared Preview contract'); + assert.equal(E({ event: 'workflow_dispatch', ref: 'refs/heads/dev', dispatchTarget: 'persistent-dev' }), 'dev'); + assert.equal(E({ event: 'workflow_dispatch', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), 'dev'); + assert.equal(E({ event: 'workflow_dispatch', ref: 'refs/heads/main' }), 'prod'); + assert.equal(E({ event: 'pull_request', ref: 'refs/pull/1/merge', previewOnPush: false, sharedDevOnPush: true }), 'preview'); + assert.equal(E({ event: 'push', ref: 'refs/heads/main', dispatchTarget: 'persistent-dev' }), 'prod'); + assert.equal(E({ event: 'workflow_dispatch', ref: 'refs/heads/main', dispatchTarget: 'Persistent-Dev' }), 'prod'); }); diff --git a/scripts/cf-preview/ghexpr.py b/scripts/cf-preview/ghexpr.py index 28fffac..079e204 100644 --- a/scripts/cf-preview/ghexpr.py +++ b/scripts/cf-preview/ghexpr.py @@ -90,6 +90,7 @@ def ev(expr, ctx, failed=False): "F_failure": lambda: failed, "F_cancelled": lambda: False, "F_format": lambda f, *a: re.sub(r"\{\{|\}\}|\{(\d+)\}", lambda m: {"{{": "{", "}}": "}"}.get(m.group(0)) or str(a[int(m.group(1))]), f), + "F_startswith": lambda a, b: str(a).lower().startswith(str(b).lower()), "F_contains": lambda a, b: str(b) in (a if isinstance(a, (list, tuple)) else str(a)), "F_fromjson": lambda s: _wrap(json.loads(s)), "F_tojson": lambda v: json.dumps(v), diff --git a/scripts/cf-preview/jobrunner.py b/scripts/cf-preview/jobrunner.py index 5ff47a6..3db4006 100644 --- a/scripts/cf-preview/jobrunner.py +++ b/scripts/cf-preview/jobrunner.py @@ -78,6 +78,7 @@ def write_perf_report(): if [ -n "${STUB_PREVIEW_RAW+x}" ]; then printf '%s' "$STUB_PREVIEW_RAW"; else echo '{"preview":{"urls":["https://'"$5"'-app.example.workers.dev"]}}'; fi;; "deployments list"*) [ "${STUB_NO_PREV:-}" = 1 ] && { echo '[]'; exit 0; } echo '[{"created_on":"2024-01-01","versions":[{"version_id":"'"${STUB_ACTIVE:-prev-1}"'","percentage":100}]}]';; + "deploy "*) echo "PUBLISH $*" >> "$STUB_LOG"; [ "${STUB_DEPLOY_FAIL:-}" = 1 ] && exit 5;; "versions view"*) [ -n "${STUB_PREV_MESSAGE:-}" ] && echo '{"annotations":{"workers/message":"'"$STUB_PREV_MESSAGE"'"}}';; "versions upload"*) [ "${STUB_UPLOAD_FAIL:-}" = 1 ] && exit 6; echo "Worker Version ID: new-2";; "versions deploy prev-1@100") [ "${STUB_ROLLBACK_FAIL:-}" = 1 ] && exit 8;; diff --git a/scripts/cf-preview/run-job-groups.py b/scripts/cf-preview/run-job-groups.py index 28a21aa..666c05e 100755 --- a/scripts/cf-preview/run-job-groups.py +++ b/scripts/cf-preview/run-job-groups.py @@ -29,7 +29,7 @@ "deferred-verification": 50, "native-dev-smoke-modes": 50, "review-regressions": 36, - "ci-environment": 14, + "ci-environment": 18, } diff --git a/scripts/cf-preview/test-deploy.sh b/scripts/cf-preview/test-deploy.sh index d076291..490ff52 100755 --- a/scripts/cf-preview/test-deploy.sh +++ b/scripts/cf-preview/test-deploy.sh @@ -191,7 +191,7 @@ TARGET=prod ROLLBACK_TO=prev-1 "$P" >/dev/null 2>&1 && grep -Eq 'versions deploy TARGET=prod ROLLBACK_TO='x;rm -rf /' "$P" >/dev/null 2>&1 && bad "unsafe rollback id accepted" || ok "unsafe rollback version id rejected" TARGET=preview PREVIEW_NAME=feat-a ROLLBACK_TO=prev-1 "$P" >/dev/null 2>&1 && bad "ROLLBACK_TO accepted for preview" || ok "ROLLBACK_TO is Production only" -# ---- Shared Dev (target=dev): identity audit in build.sh; publish-only default; caller publish command is held to the contract ---- +# ---- Shared Dev (target=dev): identity audit in build.sh; the engine is the only publisher ---- cat > "$T/app/wrangler.toml" <<'TOML' name = "x" [[queues.producers]] @@ -213,39 +213,5 @@ grep -q 'deploy' "$T/log" && bad "published with a Production queue" || ok "dev: python3 - "$T/app/wrangler.toml" <<'PY' import sys; p = sys.argv[1]; s = open(p).read(); i = s.index("[env.dev]"); open(p, "w").write(s[:i] + s[i:].replace("prod-queue", "dev-queue")) PY -: > "$T/log"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD='echo "custom publish $CF_AUDITED_CONFIG" >> "$STUB_LOG"' bash -c "$(declare -f D); D" >/dev/null 2>&1 && grep -q 'custom publish .*wrangler.toml' "$T/log" && ok "dev: the caller's publish command runs with CF_AUDITED_CONFIG" || bad "dev custom publish" -: > "$T/log"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD='sed -i "s/x-dev/x-evil/" "$CF_AUDITED_CONFIG"' bash -c "$(declare -f D); D" >/dev/null 2>&1 && bad "publish command modifying the audited config accepted" || ok "dev: a publish command that modifies the audited config fails the run" -sed -i 's/x-evil/x-dev/' "$T/app/wrangler.toml" -mkdir -p "$T/app/dist"; echo orig > "$T/app/dist/index.js" -sed -i 's/^name = "x"$/name = "x"\nmain = "dist\/index.js"/' "$T/app/wrangler.toml" -: > "$T/log"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD='echo rebuilt > dist/index.js' bash -c "$(declare -f D); D" >/dev/null 2>&1 && bad "publish command replacing the artifact accepted" || ok "dev: a publish command that rebuilds/replaces the audited artifact fails the run" -# ---- publish-only contract of dev_deploy_cmd: every Wrangler call the command makes goes through the guard (BEFORE the real Wrangler) ---- -echo orig > "$T/app/dist/index.js" -mkdir -p "$T/app/node_modules/.bin" -cat > "$T/app/node_modules/.bin/wrangler" <<'REAL' -#!/usr/bin/env bash -echo "REAL $*" >> "$STUB_REAL_LOG" -REAL -chmod +x "$T/app/node_modules/.bin/wrangler"; ORIG_SUM="$(sha256sum "$T/app/node_modules/.bin/wrangler" | cut -d' ' -f1)" -export STUB_REAL_LOG="$T/real.log" -Dg() { : > "$T/log"; : > "$STUB_REAL_LOG"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD="$1" bash -c "$(declare -f D); D" 2>"$T/err"; } -OUT="$(Dg './node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"')" && grep -q '^REAL deploy --config' "$STUB_REAL_LOG" && ok "dev publish guard: wrangler deploy --config reaches the real Wrangler" || bad "guarded audited deploy should pass" -[ "$(sha256sum "$T/app/node_modules/.bin/wrangler" | cut -d' ' -f1)" = "$ORIG_SUM" ] && ok "dev publish guard: the project's node_modules/.bin/wrangler is restored after the command" || bad "project wrangler not restored" -for label in '--env dev' '--config "$CF_AUDITED_CONFIG" --env dev' '--config "$CF_AUDITED_CONFIG" -e dev' '--config "$CF_AUDITED_CONFIG" --name other' '' '--config other.toml'; do - OUT="$(Dg "./node_modules/.bin/wrangler deploy $label")" && bad "guard accepted: deploy $label" || { [ ! -s "$STUB_REAL_LOG" ] && printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: 'deploy $label' is refused BEFORE the real Wrangler runs (nothing changed, reported as blocked)" || bad "guard: deploy $label reached Wrangler or was not reported blocked"; } -done -OUT="$(Dg './node_modules/.bin/wrangler d1 migrations apply DB --remote')" && bad "d1 migrations accepted" || { [ ! -s "$STUB_REAL_LOG" ] && ok "dev publish guard: a D1 migration from the publish command is refused (the Migrate job owns migrations)" || bad "d1 reached wrangler"; } -OUT="$(Dg 'echo rebuilt > dist/index.js; ./node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"')" && bad "rebuild-then-deploy accepted" || { [ ! -s "$STUB_REAL_LOG" ] && printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: a command that REBUILT first is refused before the upload (artifact differs from the audited one) - not detected afterwards" || bad "rebuild then deploy reached wrangler"; } -echo orig > "$T/app/dist/index.js" -OUT="$(Dg './node_modules/.bin/wrangler deploy --config "$CF_AUDITED_CONFIG"; echo changed > dist/index.js')" && bad "post-publish artifact change accepted" || { grep -q '^REAL deploy' "$STUB_REAL_LOG" && printf '%s\n' "$OUT" | grep -qx 'publish_integrity_failed=true' && ! printf '%s\n' "$OUT" | grep -qx 'publish_blocked=true' && ok "dev publish guard: an artifact swapped AFTER the real publish is reported as integrity failure (potentially published, never 'unchanged')" || bad "post-publish integrity reporting"; } -echo orig > "$T/app/dist/index.js" -OUT="$(Dg 'exit 3')" && bad "failing publish command accepted" || { printf '%s\n' "$OUT" | grep -qx 'publish_started=true' && ! printf '%s\n' "$OUT" | grep -q 'publish_blocked\|publish_integrity_failed' && ok "dev publish guard: a plain failing command stays 'publish started, unverified' (not claimed unchanged, not claimed blocked)" || bad "plain failure reporting"; } -rm -rf "$T/app/node_modules" -cat > "$T/bin/wrangler" <<'REAL' -#!/usr/bin/env bash -echo "REAL $*" >> "$STUB_REAL_LOG" -REAL -chmod +x "$T/bin/wrangler" -OUT="$(Dg 'wrangler deploy --env dev')" && bad "bare wrangler --env accepted" || { [ ! -s "$STUB_REAL_LOG" ] && ok "dev publish guard: a bare wrangler (npx / global, no project bin) is guarded through PATH too" || bad "bare wrangler reached real"; } -rm -f "$T/bin/wrangler" +: > "$T/log"; env "${DEVV[@]}" BUILD_CMD=true PUBLISH_CMD='echo "evil custom publish" >> "$STUB_LOG"' bash -c "$(declare -f D); D" >/dev/null 2>&1 && ! grep -q "evil custom publish" "$T/log" && grep -Eq "deploy --config [^ ]+wrangler.toml --env dev" "$T/log" && ok "dev: the engine is the only publisher (a PUBLISH_CMD in the environment is never executed)" || bad "dev: engine-only publish" exit $fail diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index 32738c3..24f64ac 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -365,7 +365,6 @@ def good_dep(**over): "shared_dev_on_branch_push": True, "app_url_dev": "https://dev.example.com", "dev_worker_name": "app-dev", "app_version": SHA, "build_env_dev": f"PUBLIC_COMMIT_SHA={SHA}\nSITE=dev", "dev_wrangler_env": "dev", "dev_build_cmd": 'echo "BUILD ver=$VITE_APP_VERSION commit=$PUBLIC_COMMIT_SHA cfenv=$CLOUDFLARE_ENV" >> "$STUB_LOG"', - "dev_deploy_cmd": 'echo "PUBLISH cfg=$CF_AUDITED_CONFIG" >> "$STUB_LOG"', "dev_binding_allow": "r2_buckets:ASSETS=prod-assets services:AUTH=auth-dev", } @@ -802,8 +801,8 @@ def group_shared_dev(): r = dev(inputs=D1) check(r["result"] == "success" and "d1 migrations apply DB --remote" in r["log"] and "SHARED-ID" in mig_file(r, "dev") and "PROD-ID" not in mig_file(r, "dev"), "Shared Dev migrates the Dev D1 (env.dev), never the Production D1") check(r["log"].index("BUILD") < r["log"].index("migrations apply") < r["log"].index("PUBLISH"), "order in the executed Shared Dev job: build -> audit -> migration -> publish") - check("PUBLISH cfg=" in r["log"] and "wrangler.toml" in r["log"], "the caller's publish command receives the audited config (CF_AUDITED_CONFIG)") - r = dev(inputs={"dev_deploy_cmd": ""}) + check("PUBLISH exec wrangler deploy --config" in r["log"] and "wrangler.toml" in r["log"], "the engine publishes the audited config itself (the only publisher)") + r = dev() check(r["result"] == "success" and "wrangler deploy --config" in r["log"] and "--env dev" in r["log"], "default publish is `wrangler deploy --config --env ` (no reliance on CLOUDFLARE_ENV alone)") r = dev(inputs=dict(D1, dev_build_cmd="false")) @@ -841,23 +840,17 @@ def group_shared_dev(): r = dev(inputs={"dev_build_cmd": eff({"name": "app-dev", "d1_databases": [{"binding": "DB", "database_name": "shared-db", "database_id": "SHARED-ID"}], "r2_buckets": [{"binding": "ASSETS", "bucket_name": "prod-assets"}], "queues": {"producers": [{"binding": "Q", "queue": "dev-queue"}]}, "services": [{"binding": "AUTH", "service": "auth-dev"}]})}) - check(r["result"] == "success" and "PUBLISH cfg=" in r["log"] and "dist/wrangler.json" in r["log"], "a clean effective Dev config is audited AND is the exact config handed to the publish") + check(r["result"] == "success" and "PUBLISH exec wrangler deploy --config" in r["log"] and "dist/wrangler.json" in r["log"], "a clean effective Dev config is audited AND is the exact config handed to the publish") # P1: D1 consistency and same-artifact contract for Dev r = dev(inputs=dict(D1, dev_build_cmd=eff({"name": "app-dev", "d1_databases": [{"binding": "DB", "database_name": "elsewhere", "database_id": "ELSEWHERE-ID"}], "r2_buckets": [{"binding": "ASSETS", "bucket_name": "prod-assets"}], "queues": {"producers": [{"binding": "Q", "queue": "dev-queue"}]}, "services": [{"binding": "AUTH", "service": "auth-dev"}]}))) check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "Dev: deployed (effective) D1 differs from the migration target -> neither migrated nor published (no DB-A migration with a DB-B Worker)") - r = dev(inputs={"dev_deploy_cmd": 'echo PUBLISH >> "$STUB_LOG"; sed -i "s/app-dev/app-other/" "$CF_AUDITED_CONFIG"'}) - check(r["result"] == "failure" and ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false" and "unverified" in r["summary"].lower() and "state=success" not in r["log"], "a publish command that MODIFIES the audited config fails the run and is never verified") - r = dev(inputs={"dev_deploy_cmd": 'echo PUBLISH >> "$STUB_LOG"; mkdir -p dist; echo rebuilt > dist/index.js; sed -i "s/^name = \\"app-dev\\"/name = \\"app-dev\\"\\nmain = \\"dist\\/index.js\\"/" wrangler.toml'}) - check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "a publish command that rewrites the config to point at a replaced artifact is detected") # the audited ARTIFACT (Worker entry), not only the config file, is protected: a publish command that rebuilds it is caught MAIN_TOML = DEV_TOML.replace('name = "app"\n', 'name = "app"\nmain = "dist/index.js"\n', 1) r = dev(toml=MAIN_TOML, inputs={"dev_build_cmd": BUILD_ENTRY}) check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true", "Worker entry built by the build stage and published untouched: verified") - r = dev(toml=MAIN_TOML, inputs={"dev_build_cmd": BUILD_ENTRY, "dev_deploy_cmd": 'echo PUBLISH >> "$STUB_LOG"; echo replaced > dist/index.js'}) - check(r["result"] == "failure" and ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false" and "state=success" not in r["log"], "a publish command that replaces the audited Worker entry (config untouched) fails the run and is never verified") # P1: build env / smoke env cannot redirect Dev for line in ("TARGET=prod", "DEV_ENV=prod", "GH_TOKEN=x", "EXPECTED_SHA=fake"): r = dev(inputs=dict(D1, build_env_dev=line)) @@ -879,7 +872,7 @@ def group_shared_dev(): check(r["result"] == "success" and ran(r, "migrations apply") and not ran(r, "PUBLISH"), "branch advanced during the Dev migration: old code is not published") # (the old 'Shared-D1 guard' is superseded: Preview and Dev on one D1 now share ONE migration leg; see the multi-kind section) - r = dev(inputs={"dev_deploy_cmd": ""}) + r = dev() check(r["result"] == "success" and "wrangler deploy --config " in r["log"] and "wrangler.toml --env dev" in r["log"] and r["log"].index("BUILD") < r["log"].index("wrangler deploy"), "Shared Dev built-in publish (no dev_deploy_cmd): wrangler deploy gets the audited --config (and the named env for a source config), after the build") r = deploy(MAIN, inputs=BADCFG) check(r["jobs"]["plan"]["result"] == "failure" and r["jobs"]["migration"]["result"] == r["jobs"]["deploy"]["result"] == "skipped" and "BUILD" not in r["log"] and not published(r), "production-only + Shared Dev: the plan fails; no delivery job is scheduled (no build, migration, publish)") @@ -1003,7 +996,7 @@ def group_shared_dev(): r = manual(inputs=D1, env={"STUB_APPLY_FAIL": "1"}) check(r["result"] == "failure" and r["res"]["migration"]["result"] == "failure" and ran(r, "migrations apply") and not ran(r, "PUBLISH") and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and r["outputs"]["migration"] == "failed", "manual dispatch: the migrate step itself fails -> the Migrate job is red and Deploy / Smoke / Revert never run, nothing is published") - r = manual(inputs={"dev_deploy_cmd": 'echo "PUBLISH" >> "$STUB_LOG"; exit 1', "release_cmd": "pnpm release"}, env=dict(GD)) + r = manual(inputs={"release_cmd": "pnpm release"}, env=dict(GD, STUB_DEPLOY_FAIL="1")) dres = r["res"]["delivery_persistent_dev"] check(r["result"] == "failure" and inner_of(r)["deploy"] == "failure" and inner_of(r)["rollback"] == "skipped" and dres["outputs"]["smoke_status"] == "none" and r["log"].count("PUBLISH") == 1 and r["outputs"]["deployed_and_verified"] == "false" and not ran(r, "SMOKE") and "state=success" not in r["log"] and not release_runs(r, {"release_cmd": "pnpm release", "dispatch_target": "persistent-dev"}), @@ -1029,11 +1022,16 @@ def group_shared_dev(): check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "restored_verified" and r["log"].count("PUBLISH") == 2, f"{label}: nothing pending this run (schema unchanged) -> recovery allowed without attestation") r = manual(env=FAIL_GOOD) check(r["res"]["delivery_persistent_dev"]["outputs"]["rollback_result"] == "restored_verified" and r["log"].count("PUBLISH") == 2, "no D1 migrations configured -> recovery unchanged") - # ---- a legacy dev_deploy_cmd (builds / selects its own Wrangler environment) is rejected before ANYTHING is built, migrated or published - for label, cmd_ in (("selects its own Wrangler environment", "pnpm exec wrangler deploy --env dev"), ("builds before publishing", "pnpm run build && pnpm exec wrangler deploy --config \"$CF_AUDITED_CONFIG\"")): - r = manual(inputs=dict(D1, dev_deploy_cmd=cmd_)) - check(r["result"] == "failure" and not ran(r, "BUILD") and not ran(r, "PUBLISH") and "migrations apply" not in r["log"] and r["res"]["migration"]["result"] == "failure" and r["res"]["delivery_persistent_dev"]["result"] == "skipped", - f"manual dispatch with a legacy dev_deploy_cmd that {label}: rejected during the audit - nothing built, no D1 migration, nothing published") + # ---- the retired dev_deploy_cmd input: any non-empty value fails the Plan before anything is built, migrated or published + for cmd_ in ("pnpm run deploy:dev", "pnpm exec wrangler deploy --config \"$CF_AUDITED_CONFIG\""): + for runner in (manual, dev): + r = runner(inputs=dict(D1, dev_deploy_cmd=cmd_)) + check(r["result"] == "failure" and r["res"]["plan"]["result"] == "failure" and not ran(r, "BUILD") and not ran(r, "PUBLISH") and "migrations apply" not in r["log"] and r["res"]["migration"]["result"] == "skipped", + f"retired dev_deploy_cmd ({cmd_[:20]}...) via {runner.__name__}: the Plan fails it - nothing built, migrated or published") + # ---- regression: Dev build -> audited config -> engine-owned publish -> Smoke -> verified; and a failed publish is never verified + r = manual() + check(r["result"] == "success" and r["log"].index("BUILD") < r["log"].index("PUBLISH exec wrangler deploy --config") and r["log"].count("PUBLISH") == 1 and r["outputs"]["deployed_and_verified"] == "true" and r["outputs"]["verified_url"] == "https://dev.example.com", + "Dev build -> audited config -> engine-owned publish -> content Smoke -> verified deployment") # ---- other kinds keep their pipelines r = deploy(MAIN, inputs={"release_cmd": "pnpm release"}) check(r["result"] == "success" and r["res"]["delivery_persistent_dev"]["result"] == "skipped" and release_runs(r), "unchanged: a Production push still runs the Production delivery (and Release)") @@ -1500,6 +1498,12 @@ def ci_run(ref, event="push", dt="", job="lint", **extra): res, lines = ci_run(ref, event, dt) want = f"CI app_env={app_env} mark={mark} mode={ {"prod": "PRODCFG", "dev": "DEVCFG", "preview": "PREVCFG"}[mark] } ci_env=yes prodsecret=none" check(res == "success" and lines == [want], f"CI env: {event} {ref} target={dt or '-'} -> VITE_APP_ENV={app_env}, the {mark} build env only (got {lines})") + # automatic Shared Dev: the SAME Dev CI as the manual dispatch when it is the run's only target; several kinds keep the shared Preview contract + PDONLY = dict(preview_on_branch_push=False, shared_dev_on_branch_push=True, deployment_strategy="hybrid") + for label, extra, want in (("automatic Persistent Dev only", PDONLY, ("dev", "DEVCFG")), ("Persistent Dev + Native Branch", dict(PDONLY, preview_on_branch_push=True), ("preview", "PREVCFG")), + ("Native Dev only", dict(preview_on_branch_push=False, shared_dev_native_preview_on_branch_push=True), ("preview", "PREVCFG")), ("Native Branch only", {}, ("preview", "PREVCFG"))): + res, lines = ci_run(FEAT, "push", "", **extra) + check(lines == [f"CI app_env={want[0]} mark={want[0]} mode={want[1]} ci_env=yes prodsecret=none"], f"CI env: push to a feature branch, {label} -> {want[0]} (manual dispatch of the same Dev target sees the same values)") # every validation command receives the same environment for job in ("typecheck", "unit", "integration"): res, lines = ci_run(MAIN, "workflow_dispatch", "persistent-dev", job=job) diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index 08cf7e3..f9bdb87 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -767,16 +767,30 @@ def sup_run(handoff, kind="production", env_name="app/prod", sha="abc1234"): check(djobs["smoke"]["permissions"].get("deployments") == "write" and "secrets" not in yaml.dump(SUP), "Smoke: the step needs only deployments:write and uses no secrets") -sys.exit(fail) # ---------------------------------------------------------------- CI environment contract: expression == delivery.mjs ciTargetEnv, and the spec table CI_EXPR = jobs["ci"]["with"]["ci_target_env"] -ci_cases = [(e, r, dt) for e in ("push", "workflow_dispatch", "pull_request", "delete") for r in ("refs/heads/main", "refs/heads/feat/x", "refs/heads/dev", "refs/pull/1/merge", "refs/tags/v1") for dt in ("", "persistent-dev", "Persistent-Dev", "production")] -yaml_ci = [render(CI_EXPR, scenario(e, r, {"dispatch_target": dt})) for e, r, dt in ci_cases] -js_ci = json.loads(subprocess.run(["node", "-e", "import('./actions/cf-preview/delivery.mjs').then(async m=>{let t='';for await(const c of process.stdin)t+=c;console.log(JSON.stringify(JSON.parse(t).map(([e,r,d])=>m.ciTargetEnv({event:e,ref:r,dispatchTarget:d}))))})"], input=json.dumps(ci_cases), capture_output=True, text=True, check=True).stdout) -check(yaml_ci == js_ci and len(ci_cases) == 80, f"CI environment: the workflow expression and delivery.mjs ciTargetEnv agree on all {len(ci_cases)} event/ref/target scenarios") -ci_of = lambda e, r, dt="": render(CI_EXPR, scenario(e, r, {"dispatch_target": dt})) -check(ci_of("push", MAIN) == "prod" and ci_of("push", FEAT) == "preview" and ci_of("workflow_dispatch", "refs/heads/dev", "persistent-dev") == "dev" - and ci_of("workflow_dispatch", MAIN, "persistent-dev") == "dev" and ci_of("workflow_dispatch", MAIN) == "prod" and ci_of("pull_request", "refs/pull/1/merge") == "preview", - "CI environment table: push main -> prod, push feature -> preview, dispatch persistent-dev (approved feature OR main) -> dev, dispatch main without target -> prod, PR -> preview") +ci_cases = [] +for e in ("push", "workflow_dispatch", "pull_request", "delete"): + for r in ("refs/heads/main", "refs/heads/feat/x", "refs/heads/dev", "refs/pull/1/merge", "refs/tags/v1"): + for dt in ("", "persistent-dev", "Persistent-Dev"): + for strat in ("hybrid", "production-only"): + for pre, sd, nd in itertools.product((True, False), repeat=3): + ci_cases.append((e, r, dt, strat, pre, sd, nd)) +CIIN = lambda dt, strat, pre, sd, nd: {"dispatch_target": dt, "deployment_strategy": strat, "preview_on_branch_push": pre, "shared_dev_on_branch_push": sd, "shared_dev_native_preview_on_branch_push": nd, "persistent_dev_ref": "refs/heads/dev"} +yaml_ci = [render(CI_EXPR, scenario(e, r, CIIN(*rest))) for e, r, *rest in ci_cases] +JSCI = ("import('./actions/cf-preview/delivery.mjs').then(async m=>{let t='';for await(const c of process.stdin)t+=c;const c=JSON.parse(t);" + "console.log(JSON.stringify(c.map(([e,r,d,s,p,sd,nd])=>{const a=m.ciTargetEnv({event:e,ref:r,dispatchTarget:d,strategy:s,previewOnPush:p,sharedDevOnPush:sd,sharedDevNativeOnPush:nd});" + "let k;try{k=m.resolveDeliveryTargets({event:e,ref:r,strategy:s,previewOnPush:p,sharedDevOnPush:sd,sharedDevNativeOnPush:nd,dispatchTarget:d,devRef:'refs/heads/dev'})}catch{k=null}return [a,k]})))})") +js_ci = json.loads(subprocess.run(["node", "-e", JSCI], input=json.dumps(ci_cases), capture_output=True, text=True, check=True).stdout) +check(yaml_ci == [a for a, _ in js_ci] and len(ci_cases) > 900, f"CI environment: the workflow expression and delivery.mjs ciTargetEnv agree on all {len(ci_cases)} event/ref/target/strategy/channel scenarios") +check(all((a == "dev") == (k == ["persistent-dev"]) for a, k in js_ci if k is not None), "CI environment: `dev` exactly when the run's ONLY delivery target (resolveDeliveryTargets) is persistent-dev - automatic and manual alike") +check(all(a != "dev" for (a, k), c in zip(js_ci, ci_cases) if k is not None and len(k) > 1 or (k is not None and "persistent-dev" not in k)), "CI environment: several kinds at once, Native Branch / Native Dev only and Production never use the Dev env") +ci_of = lambda e, r, dt="", strat="hybrid", pre=True, sd=False, nd=False: render(CI_EXPR, scenario(e, r, CIIN(dt, strat, pre, sd, nd))) +check(ci_of("push", MAIN) == "prod" and ci_of("push", FEAT) == "preview" and ci_of("push", FEAT, pre=False, sd=True) == "dev" and ci_of("push", FEAT, pre=True, sd=True) == "preview" + and ci_of("push", FEAT, pre=False, nd=True) == "preview" and ci_of("workflow_dispatch", "refs/heads/dev", "persistent-dev") == "dev" and ci_of("workflow_dispatch", MAIN, "persistent-dev") == "dev" + and ci_of("workflow_dispatch", MAIN) == "prod" and ci_of("pull_request", "refs/pull/1/merge", pre=False, sd=True) == "preview", + "CI environment table: main -> prod; Native Branch / Native Dev -> preview; automatic Persistent-Dev-only push -> dev; manual dispatch (approved feature or main) -> dev; several kinds -> preview; PR -> preview") + +sys.exit(fail)