diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 43b1eee..3f6cd08 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,6 +1,14 @@ -# actionlint 1.7.12 predates `concurrency.queue` (GitHub changelog 2026-05-07) and reports it as an unknown key. -# Only that one message is ignored, only for the shared Worker workflow; everything else is still linted. +# actionlint 1.7.12 predates two GitHub features this pipeline uses; ONLY these messages are ignored, everything else is still linted. +# * `concurrency.queue` (GitHub changelog 2026-05-07), reported as an unknown key; only the shared Worker workflow uses it. +# * the self-repository reference `uses: $/...` (GitHub changelog 2026-07-30, runner >= 2.336.0): actionlint still demands +# `owner/repo@ref` for actions and `owner/repo/path.yml@ref` for reusable workflows. The contract test (test-workflow-contract.py) +# validates every `$/` reference itself: static, no @ref, the target exists in this repository. Drop these entries once actionlint +# learns `$/`. paths: .github/workflows/_cf-worker-v2.yml: ignore: - 'unexpected key "queue" for "concurrency" section' + .github/workflows/_cf-worker-*.yml: + ignore: + - 'specifying action "\$/[a-z/-]+" in invalid format because ref is missing' + - 'reusable workflow call "\$/\.github/workflows/_cf-worker-[a-z-]+\.yml" at "uses" is not following the format' diff --git a/.github/workflows/_cf-worker-ci.yml b/.github/workflows/_cf-worker-ci.yml new file mode 100644 index 0000000..30c091b --- /dev/null +++ b/.github/workflows/_cf-worker-ci.yml @@ -0,0 +1,263 @@ +name: CF Worker CI (validation) + +# --------------------------------------------------------------------------- +# INTERNAL — called ONLY by _cf-worker-v2.yml (one `uses:` per run). Not a public entrypoint. Contract: docs/cf-worker-v2-refactor.md +# +# Lint Typecheck Unit Integration Build Validation E2E six independent, PARALLEL jobs (no `needs` between them) +# | +# Validation Gate the ONE aggregation: every CONFIGURED job succeeded, every unconfigured one was skipped +# +# Bodies are actions/cf-ci-check (E2E has its own setup and own TEST build). None receives Cloudflare credentials and none uploads a +# build artifact: Build Validation builds for validation only, the delivery engine builds again for its real target. +# No workflow-level `concurrency` here (the public workflow owns the run-level group; reusing it would deadlock). +# --------------------------------------------------------------------------- + +on: + workflow_call: + inputs: + app_name: + type: string + required: true + app_path: + type: string + default: . + app_version: + type: string + default: '' + runner: + type: string + default: linux + lint_cmd: + type: string + default: '' + typecheck_cmd: + type: string + default: '' + test_unit_cmd: + type: string + default: '' + test_integration_cmd: + type: string + default: '' + build_cmd: + type: string + required: true + ci_env: + type: string + default: '' + use_mailpit: + type: boolean + default: false + build_env_prod: + type: string + default: '' + build_env_preview: + type: string + default: '' + test_e2e_cmd: + type: string + default: '' + test_e2e_setup_cmd: + type: string + default: '' + test_e2e_build_cmd: + type: string + default: '' + test_e2e_variants: + type: string + default: '[{"name":"","args":"","artifact_suffix":""}]' + test_e2e_env: + type: string + default: '' + outputs: + validation: + description: passed ONLY when every configured validation job succeeded (the Validation Gate); empty otherwise + value: ${{ jobs.gate.outputs.validation }} + +permissions: + contents: read + +defaults: + run: + shell: bash + +env: + PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-browsers + +jobs: + lint: + name: Lint + if: inputs.lint_cmd != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-ci-check + with: + title: Lint + cmd: ${{ inputs.lint_cmd }} + app_path: ${{ inputs.app_path }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_preview }} + ci_env: ${{ inputs.ci_env }} + app_version: ${{ inputs.app_version }} + typecheck: + name: Typecheck + if: inputs.typecheck_cmd != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-ci-check + with: + title: Typecheck + cmd: ${{ inputs.typecheck_cmd }} + app_path: ${{ inputs.app_path }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_preview }} + ci_env: ${{ inputs.ci_env }} + app_version: ${{ inputs.app_version }} + unit: + name: Unit Tests + if: inputs.test_unit_cmd != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-ci-check + with: + title: Unit Tests + cmd: ${{ inputs.test_unit_cmd }} + app_path: ${{ inputs.app_path }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_preview }} + ci_env: ${{ inputs.ci_env }} + app_version: ${{ inputs.app_version }} + use_mailpit: ${{ inputs.use_mailpit }} + integration: + name: Integration Tests + if: inputs.test_integration_cmd != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-ci-check + with: + title: Integration Tests + cmd: ${{ inputs.test_integration_cmd }} + app_path: ${{ inputs.app_path }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_preview }} + ci_env: ${{ inputs.ci_env }} + app_version: ${{ inputs.app_version }} + use_mailpit: ${{ inputs.use_mailpit }} + build: + name: Build Validation + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-ci-check + with: + title: Build Validation + cmd: ${{ inputs.build_cmd }} + app_path: ${{ inputs.app_path }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_preview }} + ci_env: ${{ inputs.ci_env }} + app_version: ${{ inputs.app_version }} + + # E2E — own setup and own TEST build (local URLs, mock services); never reused for a deployment. + e2e: + name: E2E + if: inputs.test_e2e_cmd != '' + strategy: + fail-fast: false + matrix: + e2e: ${{ fromJSON(inputs.test_e2e_variants) }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 45 + env: + E2E_ENV: ${{ inputs.test_e2e_env }} + steps: + - uses: actions/checkout@v6 + - uses: $/actions/cf-tools + - name: Import E2E env + run: node "$TOOLS_DIR/parse-env.mjs" E2E_ENV "$GITHUB_ENV" + - uses: $/actions/setup + with: + setup-node: 'false' + setup-pnpm: 'false' + - name: Start Mailpit + if: inputs.use_mailpit + uses: $/actions/mailpit + - name: E2E setup + if: inputs.test_e2e_setup_cmd != '' + working-directory: ${{ inputs.app_path }} + env: + CMD: ${{ inputs.test_e2e_setup_cmd }} + run: bash -c "$CMD" + - name: E2E test build + if: inputs.test_e2e_build_cmd != '' + working-directory: ${{ inputs.app_path }} + env: + CMD: ${{ inputs.test_e2e_build_cmd }} + run: bash -c "$CMD" + - name: E2E tests + working-directory: ${{ inputs.app_path }} + env: + CMD: ${{ inputs.test_e2e_cmd }} ${{ matrix.e2e.args }} + run: bash -c "$CMD" + # Failure-only evidence with short retention; never a delivery dependency. + - name: Upload E2E report (failure only) + uses: actions/upload-artifact@v7 + if: failure() + continue-on-error: true + with: + name: ${{ matrix.e2e.artifact_suffix != '' && format('e2e-report-{0}-{1}-{2}', inputs.app_name, matrix.e2e.artifact_suffix, github.run_id) || format('e2e-report-{0}-{1}', inputs.app_name, github.run_id) }} + path: | + ${{ inputs.app_path }}/playwright-report/ + ${{ inputs.app_path }}/test-results/ + if-no-files-found: ignore + retention-days: 3 + + # Validation Gate — fail closed. A configured job must be `success`; an unconfigured one must be `skipped`; anything else (failure, + # cancelled, an unexpected skip) fails the gate. `always()` makes the gate run (and fail) even when a job it needs failed, so the + # called workflow's conclusion is never a skipped-looking green. + gate: + name: Validation Gate + needs: [lint, typecheck, unit, integration, build, e2e] + if: always() + runs-on: ${{ inputs.runner }} + timeout-minutes: 5 + outputs: + validation: ${{ steps.gate.outputs.validation }} + steps: + - id: gate + name: Every configured validation job succeeded + env: + LINT: ${{ needs.lint.result }} + LINT_CONFIGURED: ${{ inputs.lint_cmd != '' }} + TYPECHECK: ${{ needs.typecheck.result }} + TYPECHECK_CONFIGURED: ${{ inputs.typecheck_cmd != '' }} + UNIT: ${{ needs.unit.result }} + UNIT_CONFIGURED: ${{ inputs.test_unit_cmd != '' }} + INTEGRATION: ${{ needs.integration.result }} + INTEGRATION_CONFIGURED: ${{ inputs.test_integration_cmd != '' }} + BUILD: ${{ needs.build.result }} + BUILD_CONFIGURED: 'true' + E2E: ${{ needs.e2e.result }} + E2E_CONFIGURED: ${{ inputs.test_e2e_cmd != '' }} + run: | + bad=0 + for j in LINT TYPECHECK UNIT INTEGRATION BUILD E2E; do + r="${j}"; c="${j}_CONFIGURED" + if [ "${!c}" = true ]; then + [ "${!r}" = success ] || { echo "::error::$j finished '${!r}' (configured: must succeed)"; bad=1; } + else + [ "${!r}" = skipped ] || { echo "::error::$j is not configured but finished '${!r}'"; bad=1; } + fi + done + [ "$bad" = 0 ] || exit 1 + echo "validation=passed" >> "$GITHUB_OUTPUT" + echo "Validation Gate passed." diff --git a/.github/workflows/_cf-worker-delivery.yml b/.github/workflows/_cf-worker-delivery.yml new file mode 100644 index 0000000..12cc2b6 --- /dev/null +++ b/.github/workflows/_cf-worker-delivery.yml @@ -0,0 +1,282 @@ +name: CF Worker delivery (one target) + +# --------------------------------------------------------------------------- +# INTERNAL — called ONLY by _cf-worker-v2.yml (one `uses:` per enabled delivery kind). Not a public entrypoint. +# OpenSpec: openspec/changes/unify-cf-worker-v2-delivery-targets. Contract: docs/cf-worker-v2-unified-delivery.md +# +# ONE implementation of target delivery, parameterised by the target descriptor (`kind` production | native-branch | native-dev | +# persistent-dev), reusing the audited engine (actions/cf-deploy -> actions/cf-preview/engine.sh). A call handles exactly ONE target, +# so every `needs..outputs.*` below is unambiguous: there is no matrix, no shared output key, no completion-order merge. +# +# 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) +# +# 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`). +# +# Reusable-workflow constraints honoured here: static `uses:` in the caller; the engine is `uses: $/actions/cf-deploy` (the +# self-repository syntax: this repository at the running commit, nothing checked out into the caller's workspace); permissions never exceed the calling job's; secrets are passed +# explicitly; the GitHub Environment is a job property of the called workflow; job-level concurrency only (the workflow-level +# group belongs to the public workflow, reusing it here would deadlock). +# --------------------------------------------------------------------------- + +on: + workflow_call: + inputs: + runner: + type: string + default: linux + inputs_json: + description: toJSON of the effective public inputs (the deploy call's inputs for a deferred verify). Data only. + type: string + required: true + call_inputs_json: + description: Deferred verify only - toJSON of the verify call's inputs (identity cross-check) + type: string + default: '' + phase: + description: pipeline_phase - all | deploy | verify + type: string + default: all + d1_migrations: + type: boolean + default: false + migration_result: + description: needs.migration.result of the single Migration job ('skipped' when d1_migrations is false or on a verify call) + type: string + default: '' + migration_json: + description: the Migration job's migration_json output (the one deterministic migration result of the run) + type: string + default: '' + handoff_json: + description: verify only - the deploy result of THIS kind from phase_state + type: string + default: '' + migration_json_in: + description: verify only - the migration result of THIS kind from phase_state + type: string + default: '' + upstream_gate: + description: verify only - needs..result of the job the cross-service smoke depends on + type: string + default: '' + descriptor: + description: toJSON of THIS kind's target descriptor (versioned, validated by the Delivery plan 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 + outputs: + handoff: + description: the validated deploy result of THIS kind (own publish in the deploy phase, the handed-over one in the verify phase) + value: ${{ jobs.deploy.outputs.handoff || jobs.smoke.outputs.handoff }} + migration_used: + description: the migration result this kind's deployment depended on (shared-D1 mapping resolved) + value: ${{ jobs.deploy.outputs.migration_used || jobs.smoke.outputs.migration_used }} + smoke_json: + description: engine outputs of this kind's Smoke + value: ${{ jobs.smoke.outputs.smoke_json }} + smoke_status: + description: success | failure | none (nothing published, so nothing was smoked) + value: ${{ jobs.smoke.outputs.smoke_status }} + rollback_result: + description: restored_verified | restored_unverified | restored_unhealthy | blocked | failed | no_known_good; empty when no rollback ran + value: ${{ jobs.rollback.outputs.rollback_result }} + secrets: + CLOUDFLARE_API_TOKEN: + required: false + CLOUDFLARE_ACCOUNT_ID: + required: false + PREVIEW_SECRETS: + required: false + SMOKE_USERNAME: + required: false + SMOKE_PASSWORD: + required: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + # ------------------------------------------------------------------------- + # Deploy — target-specific Build + Publish (stage publish) of THIS kind: its own URL (resolved before the build), its own build + # env, the audited artifact only. Depends on the run's single Migration result and on nothing + # of any other kind. A green Deploy means "published", not "verified". + # ------------------------------------------------------------------------- + deploy: + name: Deploy + if: inputs.phase != 'verify' + runs-on: ${{ inputs.runner }} + timeout-minutes: 45 + concurrency: + group: ${{ fromJSON(inputs.descriptor).deploy_group }} + cancel-in-progress: false + environment: + name: ${{ fromJSON(inputs.descriptor).environment }} + url: ${{ steps.engine.outputs.url }} + permissions: + contents: read + deployments: write + pull-requests: write + outputs: + handoff: ${{ toJSON(steps.engine.outputs) }} + migration_used: ${{ steps.engine.outputs.migration_used }} + 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: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - id: engine + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(inputs.descriptor).target }} + descriptor: ${{ inputs.descriptor }} + kind: ${{ fromJSON(inputs.descriptor).kind }} + stage: publish + migration_result: ${{ inputs.migration_result }} + migration_json: ${{ inputs.migration_json }} + inputs_json: ${{ inputs.inputs_json }} + d1_expected_id: ${{ fromJSON(inputs.descriptor).d1_expected_id }} + preview_secrets: ${{ fromJSON(inputs.descriptor).target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + + # ------------------------------------------------------------------------- + # Smoke — verifies THIS kind's deployment only (stage verify, no Cloudflare credentials): smoke_mode full | basic | skip. + # Starts the moment THIS kind's deploy is done. A kind that published nothing (stale / superseded / failed) has nothing to + # verify: smoke_status=none and no verification is claimed. + # ------------------------------------------------------------------------- + smoke: + name: Smoke Tests + needs: [deploy] + if: always() && inputs.phase != 'deploy' + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + permissions: + contents: read + deployments: write + pull-requests: write + outputs: + smoke_json: ${{ toJSON(steps.engine.outputs) }} + smoke_status: ${{ steps.res.outputs.status }} + handoff: ${{ inputs.phase == 'verify' && inputs.handoff_json || '' }} + migration_used: ${{ inputs.phase == 'verify' && inputs.migration_json_in || '' }} + env: + GH_TOKEN: ${{ github.token }} + HANDOFF: ${{ inputs.phase == 'verify' && inputs.handoff_json || needs.deploy.outputs.handoff }} + steps: + - id: go + name: Did this run publish ${{ fromJSON(inputs.descriptor).kind }}? + run: | + h="${HANDOFF:-}"; [ -n "$h" ] || h='{}' + echo "go=$(printf '%s' "$h" | jq -r '.published == "true"')" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6 + if: steps.go.outputs.go == 'true' + with: + ref: ${{ github.sha }} + persist-credentials: false + - id: engine + if: steps.go.outputs.go == 'true' + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(inputs.descriptor).target }} + descriptor: ${{ inputs.descriptor }} + kind: ${{ fromJSON(inputs.descriptor).kind }} + stage: verify + inputs_json: ${{ inputs.inputs_json }} + call_inputs_json: ${{ inputs.call_inputs_json }} + d1_expected_id: ${{ fromJSON(inputs.descriptor).d1_expected_id }} + preview_secrets: ${{ fromJSON(inputs.descriptor).target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + smoke_username: ${{ secrets.SMOKE_USERNAME }} + smoke_password: ${{ secrets.SMOKE_PASSWORD }} + deploy_outputs: ${{ env.HANDOFF }} + upstream_gate: ${{ inputs.phase == 'verify' && inputs.upstream_gate || '' }} + - id: res + if: always() + env: + JOB_STATUS: ${{ job.status }} + GO: ${{ steps.go.outputs.go }} + run: echo "status=$([ "$GO" = true ] && echo "$JOB_STATUS" || echo none)" >> "$GITHUB_OUTPUT" + # Failure-only evidence with short retention; never a delivery dependency. + - name: Upload smoke report (failure only) + uses: actions/upload-artifact@v7 + if: failure() + continue-on-error: true + with: + name: smoke-report-${{ fromJSON(inputs.descriptor).kind }}-${{ github.run_id }} + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 + + # ------------------------------------------------------------------------- + # Revert on Verify Failure — runs ONLY for THIS kind, ONLY when its Smoke FAILED on a deployment this run published (never + # after a blocked cross-service gate, never after smoke_mode=skip). Another kind's failure or success cannot reach it. + # production : policy `automatic` only; fail closed (recorded previous version, additive D1 change, failed version still live) + # other kinds: redeploy the last known-good deployment of THAT target, unless a newer deployment of it has started + # The job is green ONLY when the previous deployment was restored and content-verified. D1 is never rolled back. + # ------------------------------------------------------------------------- + rollback: + name: Revert on Verify Failure + needs: [deploy, smoke] + 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: + group: ${{ fromJSON(inputs.descriptor).rollback_group }} + cancel-in-progress: false + environment: + name: ${{ fromJSON(inputs.descriptor).environment }} + permissions: + contents: read + deployments: write + pull-requests: write + outputs: + rollback_result: ${{ steps.engine.outputs.rollback_result }} + env: + GH_TOKEN: ${{ github.token }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + HANDOFF: ${{ inputs.phase == 'verify' && inputs.handoff_json || needs.deploy.outputs.handoff }} + MIGJSON: ${{ inputs.phase == 'verify' && inputs.migration_json_in || needs.deploy.outputs.migration_used }} + SMOKEJSON: ${{ needs.smoke.outputs.smoke_json }} + steps: + - id: go + name: Did the Smoke of ${{ fromJSON(inputs.descriptor).kind }} fail on a deployment this run published? + env: + POLICY_OK: ${{ fromJSON(inputs.descriptor).kind != 'production' || fromJSON(inputs.inputs_json).production_rollback_policy == 'automatic' }} + run: | + h="${HANDOFF:-}"; [ -n "$h" ] || h='{}'; k="${SMOKEJSON:-}"; [ -n "$k" ] || k='{}' + pub="$(printf '%s' "$h" | jq -r '.published == "true"')" + blocked="$(printf '%s' "$k" | jq -r '.gate_blocked == "true"')" + if [ "$pub" = true ] && [ "$blocked" != true ] && [ "$POLICY_OK" = true ]; then echo "go=true" >> "$GITHUB_OUTPUT"; else echo "go=false" >> "$GITHUB_OUTPUT"; fi + - uses: actions/checkout@v6 + if: steps.go.outputs.go == 'true' + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - id: engine + if: steps.go.outputs.go == 'true' + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(inputs.descriptor).target }} + descriptor: ${{ inputs.descriptor }} + kind: ${{ fromJSON(inputs.descriptor).kind }} + stage: rollback + inputs_json: ${{ inputs.inputs_json }} + call_inputs_json: ${{ inputs.call_inputs_json }} + d1_expected_id: ${{ fromJSON(inputs.descriptor).d1_expected_id }} + preview_secrets: ${{ fromJSON(inputs.descriptor).target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + smoke_username: ${{ secrets.SMOKE_USERNAME }} + smoke_password: ${{ secrets.SMOKE_PASSWORD }} + deploy_outputs: ${{ env.HANDOFF }} + migration_outputs: ${{ env.MIGJSON }} + smoke_outputs: ${{ env.SMOKEJSON }} diff --git a/.github/workflows/_cf-worker-lifecycle.yml b/.github/workflows/_cf-worker-lifecycle.yml new file mode 100644 index 0000000..f2c859d --- /dev/null +++ b/.github/workflows/_cf-worker-lifecycle.yml @@ -0,0 +1,93 @@ +name: CF Worker Preview lifecycle + +# --------------------------------------------------------------------------- +# INTERNAL — called ONLY by _cf-worker-v2.yml, on the two events that are not a delivery. Never deploys, no Cloudflare credentials. +# same-repository PR : comment the branch's existing, VERIFIED Preview URL on the PR +# branch delete : retire the GitHub ledger only; the Cloudflare Native Preview is KEPT (owner policy) +# Cloudflare cannot remove only a Preview's stable URL (`urls` is read-only; `wrangler preview delete` removes the Preview with all +# its deployments), so deleting a branch never deletes its Preview. +# --------------------------------------------------------------------------- + +on: + workflow_call: + inputs: + app_name: + type: string + required: true + preview_worker_name: + description: Host Worker of the Native Previews (empty = app_name) + type: string + default: '' + reserve_native_dev: + description: shared_dev_native_preview_on_branch_push of the caller (the Preview name `dev` is reserved for Native Dev) + type: boolean + default: false + runner: + type: string + default: linux + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + lifecycle: + name: Preview lifecycle + runs-on: ${{ inputs.runner }} + timeout-minutes: 10 + concurrency: + group: cfv2-lifecycle-${{ github.repository }}-${{ inputs.app_name }}-${{ github.event.pull_request.head.ref || github.event.ref }} + cancel-in-progress: false + permissions: + contents: read + deployments: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + APP: ${{ inputs.app_name }} + BRANCH: ${{ github.event.pull_request.head.ref || github.event.ref }} + WORKER_NAME: ${{ inputs.preview_worker_name != '' && inputs.preview_worker_name || inputs.app_name }} + RESERVE_NATIVE_DEV: ${{ inputs.reserve_native_dev }} + steps: + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: $/actions/cf-tools + with: + install: 'true' + - name: Compute and validate Preview identity + run: | + [ "$BRANCH" != main ] || { echo "main has no Preview"; echo "SKIP=true" >> "$GITHUB_ENV"; exit 0; } + NAME="$(node "$TOOLS_DIR/cli.mjs" identity --repo "$GITHUB_REPOSITORY" --worker "$APP" --host-worker "$WORKER_NAME" --ref "refs/heads/$BRANCH" --reserve-native-dev "$RESERVE_NATIVE_DEV" | sed -n 's/^preview_name=//p')" + [[ "$NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || { echo "::error::unsafe name"; exit 1; } + { echo "PREVIEW_NAME=$NAME"; echo "ENV_NAME=$APP/preview/$NAME"; } >> "$GITHUB_ENV" + - name: Comment the existing VERIFIED Preview URL on the PR + if: env.SKIP != 'true' && github.event_name == 'pull_request' + run: | + # Host-bound known-good lookup: same repository, application, branch ref and HOST Worker, https URL and a + # content-level verified success. An old host (e.g. blog-preview), another branch or a liveness-only + # deployment is never advertised. + ROW="$("$TOOLS_DIR/gh.sh" last-good-preview "$ENV_NAME" "" "refs/heads/$BRANCH" "$WORKER_NAME" "$APP" || true)" + [ -n "$ROW" ] || { echo "No verified Preview for this branch on host Worker $WORKER_NAME; PR comment unchanged."; exit 0; } + "$TOOLS_DIR/gh.sh" comment "$BRANCH" "$APP" "${ROW#* }" "${ROW%% *}" + - name: Retire GitHub ledger (Preview is kept) + if: env.SKIP != 'true' && github.event_name == 'delete' + run: | + # Names are hashless: only touch the ledger if THIS branch owns the name, and not if the branch exists again. + OWNER="$("$TOOLS_DIR/gh.sh" owner "$ENV_NAME")" + if [ -n "$OWNER" ] && [ "$OWNER" != "refs/heads/$BRANCH" ]; then + echo "::notice::Name is owned by $OWNER, not this branch; leaving the ledger alone."; exit 0 + fi + if gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" >/dev/null 2>&1; then + echo "::notice::Branch exists again; leaving the ledger alone."; exit 0 + fi + "$TOOLS_DIR/gh.sh" retire "$ENV_NAME" + { + echo "### 📌 Branch \`$BRANCH\` deleted — Preview \`$PREVIEW_NAME\` is **kept**" + echo "- Cloudflare Preview and its stable URL were not touched; GitHub Deployments are marked inactive." + echo "- No automatic reclaim. To remove it manually:" + echo " \`wrangler preview delete --name $PREVIEW_NAME --worker-name $WORKER_NAME --skip-confirmation\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/_cf-worker-persistent-dev.yml b/.github/workflows/_cf-worker-persistent-dev.yml new file mode 100644 index 0000000..7946fdd --- /dev/null +++ b/.github/workflows/_cf-worker-persistent-dev.yml @@ -0,0 +1,109 @@ +name: CF Worker persistent Dev (Hybrid, manual) + +# --------------------------------------------------------------------------- +# INTERNAL — called ONLY by _cf-worker-v2.yml for an explicit workflow_dispatch of the approved ref (the caller's `if` checks +# dispatch_target, strategy hybrid and persistent_dev_ref). Never a side effect of a feature-branch push. The GitHub Environment +# `/dev` is a property of the job below, so its protection rules and approvals apply exactly as before. +# +# dev_build_cmd set : the SAME delivery engine as every other kind, in manual mode (build -> audit -> migration -> publish -> smoke -> +# ledger; no automatic recovery) - `uses: $/actions/cf-deploy` +# 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. +# --------------------------------------------------------------------------- + +on: + workflow_call: + inputs: + inputs_json: + description: toJSON(inputs) of the public workflow (data only; the engine reads its policy from it) + type: string + required: true + app_name: + type: string + required: true + app_path: + type: string + default: . + d1_lock_scope: + type: string + default: '' + dev_build_cmd: + type: string + default: '' + dev_deploy_cmd: + type: string + default: '' + dev_smoke_cmd: + type: string + default: '' + runner: + type: string + default: linux + secrets: + CLOUDFLARE_API_TOKEN: + required: false + CLOUDFLARE_ACCOUNT_ID: + required: false + SMOKE_USERNAME: + required: false + SMOKE_PASSWORD: + required: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + persistent-dev: + name: Persistent Dev (Hybrid) + runs-on: ${{ inputs.runner }} + timeout-minutes: 45 + environment: ${{ inputs.app_name }}/dev + concurrency: + group: cfv2-dev-${{ github.repository }}-${{ inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.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: inputs.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: inputs.dev_build_cmd == '' + with: + setup-node: 'false' + setup-pnpm: 'false' + - name: Deploy persistent Dev + if: inputs.dev_build_cmd == '' + working-directory: ${{ inputs.app_path }} + env: + CMD: ${{ inputs.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: inputs.dev_build_cmd == '' && inputs.dev_smoke_cmd != '' + working-directory: ${{ inputs.app_path }} + env: + CMD: ${{ inputs.dev_smoke_cmd }} + run: bash -c "$CMD" diff --git a/.github/workflows/_cf-worker-preview-gc.yml b/.github/workflows/_cf-worker-preview-gc.yml index c3bcb91..8bba2e6 100644 --- a/.github/workflows/_cf-worker-preview-gc.yml +++ b/.github/workflows/_cf-worker-preview-gc.yml @@ -39,9 +39,10 @@ on: type: string default: '' tools_ref: - description: 'Ref of firstsun-dev/.github providing actions/cf-preview-gc. MUST equal the ref the caller pins this workflow at.' + description: 'DEPRECATED, ignored (kept for existing callers): the GC tooling is referenced with `$/`, i.e. this repository at the commit of this workflow.' type: string - required: true + required: false + default: '' runner: type: string default: ubuntu-latest @@ -76,31 +77,21 @@ jobs: GC_ONLY_PREVIEWS: ${{ inputs.only_previews }} GC_REPORT_PATH: ${{ github.workspace }}/preview-gc-report.json steps: - - name: Fetch GC tooling - if: inputs.enabled - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-preview - actions/cf-preview-gc - sparse-checkout-cone-mode: false - persist-credentials: false - uses: actions/setup-node@v6 if: inputs.enabled with: node-version: 24 - - name: Install tooling deps + - name: Locate GC tooling and install its deps if: inputs.enabled - run: cd .cf-tools/actions/cf-preview && npm ci --ignore-scripts --no-audit --no-fund + uses: $/actions/cf-tools + with: + install: 'true' - name: Preview GC (${{ inputs.dry_run && 'dry run' || 'DELETE mode' }}) run: | if [ "$GC_ENABLED" != true ]; then echo "Preview GC is disabled (enabled: false)."; echo "### Preview GC disabled" >> "$GITHUB_STEP_SUMMARY"; exit 0 fi - node .cf-tools/actions/cf-preview-gc/cli.mjs + node "$TOOLS_DIR/../cf-preview-gc/cli.mjs" - name: Upload GC report if: always() && inputs.enabled # Best effort: the full report is already in the Job Summary; an artifact quota error must not fail the GC job. diff --git a/.github/workflows/_cf-worker-v2.yml b/.github/workflows/_cf-worker-v2.yml index 0dc1b7d..48c9306 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -5,39 +5,25 @@ name: CF Worker Pipeline v2 # OpenSpec: openspec/changes/refactor-cloudflare-worker-cicd-v2 (PR #57), openspec/changes/add-cloudflare-shared-dev-channel. # Interface / migration guide: docs/cf-worker-v2-refactor.md # -# Lint Typecheck Unit Tests Integration Build Validation E2E six independent, parallel jobs (no `needs` -# \ \ | | / / between them) -> actions/cf-ci-check -# +-------+----------+------------+------------+-----------+ -# | -# Migration (d1_migrations only: target D1, after the build + config audit) -# | -# Deploy publishes the audited artifact, nothing else -# | -# Smoke Tests probes THIS deployment (URL, commit SHA when available); marks it verified | unverified -# |\ -# | +-- failure --> Revert on Verify Failure (prod: fail-closed Worker rollback + re-smoke, policy -# | `automatic`; preview: last-known-good redeploy) -- the run stays FAILED -# Release optional, main only, only after a VERIFIED Production deploy -# Shared Dev target=dev (needs the Preview jobs for ORDERING only; still ONE job) +# CI (_cf-worker-ci.yml) Lint | Typecheck | Unit | Integration | Build Validation | E2E in parallel -> Validation Gate (fail closed) +# | +# Delivery plan enabled targets, ONE physical D1, the migration owner (pure data; runs only when CI passed) +# | +# Database Migration ONE job: read-only safety checks first (target build, effective-config audit, exact D1 binding), then +# | the migration is applied ONCE. No separate Preflight stage. +# Delivery x enabled kinds _cf-worker-delivery.yml, one call per kind: Deploy -> Smoke -> conditional Rollback, independent per kind +# | +# Release optional, main only, only after a VERIFIED Production delivery with no rollback # -# ONE delivery engine (actions/cf-deploy -> engine.sh) serves every job; `stage` selects the phases of each job: -# migrate : build -> config audit -> D1 target check -> stale check -> migrate -# publish : build -> config audit -> stale check -> publish the audited artifact -# verify : smoke -> mark verified | unverified (no Cloudflare credentials) -# rollback : prod: policy / previous version / D1 compatibility / still-our-deployment checks -> rollback -> re-smoke -# preview: redeploy the last known-good Preview -> re-smoke -# The jobs share only validated DATA (job outputs), never runner files. The run-level concurrency group below spans -# all of them, so no other run can touch the Worker between Publish and the end of Smoke / Rollback. -# Event handlers outside the DAG: `lifecycle` (PR comment / branch delete), `config-guard` (production-only + Shared -# Dev is rejected) and `persistent-dev` (Hybrid, protected manual dispatch; the same engine in one job, manual mode). +# Event handlers outside that DAG: `lifecycle` (_cf-worker-lifecycle.yml: PR comment / branch delete), `persistent-dev` +# (_cf-worker-persistent-dev.yml: Hybrid, protected manual dispatch, same engine in manual mode) and `config-guard`. +# 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. # -# An optional validation job (lint/typecheck/unit/integration/e2e) is skipped only when its command input is -# empty; Migration, Deploy and Shared Dev treat a CONFIGURED job that is skipped, failed or cancelled as a failure. -# -# Local composite actions: a reusable workflow called from another repository resolves `uses: ./...` against the -# CALLER's workspace and `uses:` cannot take an expression. Every job therefore checks out firstsun-dev/.github at -# `tools_ref` into `.cf-tools` and references the actions as the static path `./.cf-tools/actions/`. -# `tools_ref` MUST equal the ref the caller pins this workflow at. +# Shared actions / nested workflow: `uses: $/actions/` and `uses: $/.github/workflows/...` (GitHub self-repository syntax, +# needs runner >= 2.336.0; observed: org `linux` runners 2.338.0, GitHub-hosted 2.338.2) resolve to THIS repository at the exact commit of the running workflow, with no checkout into the caller's +# workspace (a plain `./` would resolve against the CALLER's). Version coupling is therefore exact by construction; `tools_ref` is +# a deprecated no-op. Scripts are located relative to their action (`$GITHUB_ACTION_PATH/../cf-preview`, or actions/cf-tools). # # Caller MUST trigger on: push (all branches), pull_request, delete, workflow_dispatch. # `delete` only fires from the DEFAULT branch's workflow file. @@ -48,56 +34,73 @@ on: outputs: deploy_url: description: URL that was published (Preview URL or app_url_prod); empty when nothing was deployed. May be published-but-unverified or rolled back - gate on deploy_verified_url - value: ${{ jobs.deploy.outputs.url || fromJSON(inputs.phase_state).deploy.url }} + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.handoff) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.handoff) || '{}').url }} deploy_verified_url: - description: Same URL, only when the Smoke job passed content-level verification; empty for Access-liveness-only, failed, rolled-back or skipped deployments - value: ${{ jobs.smoke.outputs.verified_url }} + description: Same URL, only when the Smoke passed content-level verification; empty for Access-liveness-only, failed, rolled-back, smoke_mode=skip or skipped deployments + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.smoke_json) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.smoke_json) || '{}').verified_url }} deploy_published_unverified: - description: true when the deployment is reachable but NOT content-verified (Access liveness only) - value: ${{ jobs.smoke.outputs.published_unverified }} + description: true when the deployment is reachable but NOT content-verified (Access liveness only, or smoke_mode=skip) + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.smoke_json) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.smoke_json) || '{}').published_unverified }} deploy_verified: - description: true ONLY when this run published its own SHA and its Smoke job passed at content level - value: ${{ jobs.smoke.outputs.deployed_and_verified }} + description: true ONLY when this run published its own SHA and its Smoke passed at content level + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.smoke_json) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.smoke_json) || '{}').deployed_and_verified }} deploy_smoke_level: - description: liveness | content | content-sha (verification level of the Smoke job; empty when it did not run) - value: ${{ jobs.smoke.outputs.smoke_level }} + description: skipped | liveness | content | content-sha (verification level of the Smoke; empty when it did not run) + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.smoke_json) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.smoke_json) || '{}').smoke_level }} deploy_target: - description: prod | preview - value: ${{ jobs.deploy.outputs.target || fromJSON(inputs.phase_state).deploy.target }} + description: prod | preview (the Production / per-branch Native Preview delivery) + value: ${{ jobs.plan.outputs.on_production == 'true' && 'prod' || jobs.plan.outputs.on_native_branch == 'true' && 'preview' || '' }} version_id: description: Production Worker version id (prod only) - value: ${{ jobs.deploy.outputs.version_id || fromJSON(inputs.phase_state).deploy.version_id }} + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.handoff) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.handoff) || '{}').version_id }} previous_version_id: description: Production Worker version that was live before this run's publish (prod only; the rollback target) - value: ${{ jobs.deploy.outputs.previous_version_id || fromJSON(inputs.phase_state).deploy.previous_version_id }} + value: ${{ fromJSON((jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.handoff) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.handoff) || '{}').previous_version_id }} migration_status: - description: applied | failed | none (Migration job; none when d1_migrations is false) - value: ${{ jobs.migration.outputs.migration || fromJSON(inputs.phase_state).migration.migration }} + description: applied | failed | none - the result of the single Migration job (none when d1_migrations is false, nothing needed migrating, or on a pipeline_phase verify call) + value: ${{ jobs.migration.outputs.migration_status || 'none' }} rollback_result: - description: restored_verified | restored_unverified | restored_unhealthy | blocked | failed | no_known_good; empty when no rollback ran. Anything but empty is a FAILED run - value: ${{ jobs.rollback.outputs.rollback_result || jobs.dev-rollback.outputs.rollback_result }} + description: restored_verified | restored_unverified | restored_unhealthy | blocked | failed | no_known_good; empty when no rollback ran. Anything but empty is a FAILED run (first kind of the run that rolled back) + value: ${{ (jobs.plan.outputs.on_production == 'true' && jobs.delivery_production.outputs.rollback_result) || (jobs.plan.outputs.on_native_branch == 'true' && jobs.delivery_native_branch.outputs.rollback_result) || (jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.rollback_result) || (jobs.plan.outputs.on_persistent_dev == 'true' && jobs.delivery_persistent_dev.outputs.rollback_result) || '' }} dev_url: - description: Shared Dev URL that was published and smoke-tested (app_url_dev); empty when Shared Dev did not deploy. May be published-but-unverified - gate on dev_verified_url - value: ${{ jobs.dev-verify.outputs.url || jobs.dev-deploy.outputs.url }} + description: persistent-dev (separate Dev Worker) URL that was published (app_url_dev); empty when it did not deploy. May be published-but-unverified - gate on dev_verified_url + value: ${{ fromJSON((jobs.plan.outputs.on_persistent_dev == 'true' && jobs.delivery_persistent_dev.outputs.handoff) || '{}').url }} dev_verified_url: - description: Shared Dev URL only when content-level verification passed - value: ${{ jobs.dev-verify.outputs.verified_url || jobs.dev-deploy.outputs.verified_url }} + description: persistent-dev URL only when content-level verification passed + value: ${{ fromJSON((jobs.plan.outputs.on_persistent_dev == 'true' && jobs.delivery_persistent_dev.outputs.smoke_json) || '{}').verified_url }} dev_published_unverified: - description: true when Shared Dev published a reachable but NOT content-verified deployment (Access liveness only) - value: ${{ jobs.dev-verify.outputs.published_unverified || jobs.dev-deploy.outputs.published_unverified }} + description: true when persistent-dev published a reachable but NOT content-verified deployment (Access liveness only, or smoke_mode=skip) + value: ${{ fromJSON((jobs.plan.outputs.on_persistent_dev == 'true' && jobs.delivery_persistent_dev.outputs.smoke_json) || '{}').published_unverified }} + native_dev_url: + description: Native Dev Preview URL that was published; empty when Native Dev did not deploy. May be published-but-unverified - gate on native_dev_verified_url + value: ${{ fromJSON((jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.handoff) || '{}').url }} + native_dev_verified_url: + description: Native Dev URL only when content-level verification passed + value: ${{ fromJSON((jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.smoke_json) || '{}').verified_url }} + native_dev_published_unverified: + description: true when Native Dev published a reachable but NOT content-verified deployment (Access liveness only, or smoke_mode=skip) + value: ${{ fromJSON((jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.smoke_json) || '{}').published_unverified }} + native_dev_verified: + description: true ONLY when this run published its own SHA to Native Dev and its Smoke passed at content level + value: ${{ fromJSON((jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.smoke_json) || '{}').deployed_and_verified }} + native_dev_smoke_level: + description: skipped | liveness | content | content-sha for Native Dev; empty when its Smoke did not run + value: ${{ fromJSON((jobs.plan.outputs.on_native_dev == 'true' && jobs.delivery_native_dev.outputs.smoke_json) || '{}').smoke_level }} deploy_gate_blocked: - description: true when the cross-service gate (upstream_gate) blocked the verification - the deployment is published but NOT verified and was not rolled back (pipeline_phase verify) - value: ${{ jobs.smoke.outputs.gate_blocked == 'true' || jobs.dev-verify.outputs.gate_blocked == 'true' }} + description: true when the cross-service gate (upstream_gate) blocked a verification - the deployment is published but NOT verified and was not rolled back (pipeline_phase verify) + value: ${{ fromJSON(jobs.delivery_production.outputs.smoke_json || '{}').gate_blocked == 'true' || fromJSON(jobs.delivery_native_branch.outputs.smoke_json || '{}').gate_blocked == 'true' || fromJSON(jobs.delivery_native_dev.outputs.smoke_json || '{}').gate_blocked == 'true' || fromJSON(jobs.delivery_persistent_dev.outputs.smoke_json || '{}').gate_blocked == 'true' }} phase_state: - description: pipeline_phase deploy - the validated hand-off (inputs, Deploy / Migration / Shared Dev outputs, concurrency group) for the verify call. Pass it unchanged as phase_state - value: ${{ format('{{"inputs":{0},"deploy":{1},"migration":{2},"dev":{3},"group":"{4}"}}', toJSON(inputs), toJSON(jobs.deploy.outputs), toJSON(jobs.migration.outputs), toJSON(jobs.dev-deploy.outputs), ((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.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 && 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)) }} + description: pipeline_phase deploy - the validated hand-off (inputs, enabled kinds, per-kind Deploy / Migration results, concurrency group, target descriptors) for the verify call. Pass it unchanged as phase_state + value: ${{ format('{{"inputs":{0},"kinds":{1},"deploy":{2},"migration":{3},"group":{4},"descriptors":{5}}}', toJSON(inputs), toJSON(fromJSON(jobs.plan.outputs.kinds || '[]')), format('{{"h_production":{0},"h_native_branch":{1},"h_native_dev":{2},"h_persistent_dev":{3}}}', toJSON(jobs.delivery_production.outputs.handoff), toJSON(jobs.delivery_native_branch.outputs.handoff), toJSON(jobs.delivery_native_dev.outputs.handoff), toJSON(jobs.delivery_persistent_dev.outputs.handoff)), format('{{"mj_production":{0},"mj_native_branch":{1},"mj_native_dev":{2},"mj_persistent_dev":{3}}}', toJSON(jobs.delivery_production.outputs.migration_used), toJSON(jobs.delivery_native_branch.outputs.migration_used), toJSON(jobs.delivery_native_dev.outputs.migration_used), toJSON(jobs.delivery_persistent_dev.outputs.migration_used)), toJSON(jobs.plan.outputs.group || format('cfv2-run-{0}', github.run_id)), toJSON(fromJSON(jobs.plan.outputs.descriptors || '{}'))) }} inputs: tools_ref: description: > - Ref of firstsun-dev/.github providing actions/cf-preview tooling. - MUST equal the ref the caller pins this workflow at (SHA or immutable v2.x.y). + DEPRECATED, ignored (kept so existing callers keep working). The shared actions and the delivery workflow are referenced + with the self-repository syntax `$/`, which resolves to the repository AND commit of this workflow, so the tooling can no + longer drift from the workflow ref the caller pins. Safe to delete from callers. type: string - required: true + required: false + default: '' app_name: description: Unique deployment/identity name (e.g. www, heaven-id). Part of the Preview identity (Worker scope). type: string @@ -122,6 +125,14 @@ on: verified deployment wins. Independent of preview_on_branch_push. Rejected with production-only. type: boolean default: false + shared_dev_native_preview_on_branch_push: + description: > + Native Dev — every non-main branch push updates the ONE stable, named Native Preview `dev` (host Worker = + preview_worker_name or app_name, `[previews]` bindings; NOT a Wrangler [env.dev] Worker). The last verified + deployment of ANY branch wins; pushes are serialized per Worker. Independent of preview_on_branch_push; conflicts + with shared_dev_on_branch_push (two automatic Dev channels). Rejected with production-only. + type: boolean + default: false preview_worker_name: description: > Host Native Previews under this existing Worker instead of the config's `name` @@ -189,6 +200,36 @@ on: description: Build-time env for the Preview build, one KEY=VALUE per line type: string default: '' + build_env_shared_dev_native_preview: + description: Build-time env for the Native Dev build, one KEY=VALUE per line (never falls back to build_env_preview / build_env_prod / build_env_dev; each target compiles its own URLs) + type: string + default: '' + app_url_shared_dev_native_preview: + description: > + Optional canonical URL of the Native Dev Preview, known before the build. When set it is exported to the build as + CFV2_TARGET_URL and the URL Cloudflare returns after the publish MUST equal it (otherwise the publish is rejected: the + build embeds the wrong URL). Also used as the smoke target. + type: string + default: '' + preview_url_suffix: + description: > + Optional host suffix of the Preview URLs (`-..workers.dev` for workers.dev, `.` for custom-domain + Preview hosting): the canonical Preview URL is `https://` (Cloudflare Previews documentation). Normally + empty: the suffix is read from Cloudflare (Workers subdomain API `preview_url_suffix`) BEFORE the build. Required for custom-domain + hosting (preview_host_check: false); when both exist they must agree. + type: string + default: '' + smoke_mode: + description: > + full (default: built-in HTTP probe + smoke_cmd, current behaviour) | basic (content-level HTTP/health verification + only, no smoke_cmd) | skip (no request at all: the deployment is Published/Unverified - never Verified, never + known-good, never released). Applies to every delivery kind unless smoke_mode_overrides names it. + type: string + default: full + smoke_mode_overrides: + description: 'Optional JSON object keyed by delivery kind (production | native-branch | native-dev | persistent-dev), e.g. {"native-dev":"basic"}; values full | basic | skip. Unknown keys / values fail the plan.' + type: string + default: '{}' build_env_dev: description: Build-time env for the Shared Dev build, one KEY=VALUE per line (never falls back to the Preview or Production env) type: string @@ -372,23 +413,26 @@ on: description: Token for release_cmd (exported as GH_TOKEN); defaults to nothing, GITHUB_TOKEN is NOT used for releases required: false -# One run-level concurrency group per piece of shared mutable state (GitHub `concurrency` cannot span two jobs, so -# the whole RUN is the unit; CI and E2E still run in parallel inside a run). `queue: max` keeps FIFO instead of -# cancelling an older pending run (beyond 100 GitHub cancels the extra run: fail-closed, it deploys nothing). -# The identity is the RESOURCE, not the app: `d1_lock_scope` replaces app_name so apps that share one physical D1 -# share one group, and Production and non-Production are always different groups: +# One run-level concurrency group per piece of shared mutable state (GitHub `concurrency` cannot span two jobs, so the whole RUN is the +# unit; CI and E2E still run in parallel inside a run). `queue: max` keeps FIFO instead of cancelling an older pending run (beyond 100 +# GitHub cancels the extra run: fail-closed, it deploys nothing). The identity is the RESOURCE, not the app: `d1_lock_scope` replaces +# app_name so apps that share one physical D1 share one group, and Production and non-Production are always different groups: # main -> cfv2-prod- # branch, d1_migrations, a non-main channel enabled (Preview and/or Shared Dev, strategy != production-only) # -> 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) -# The Shared Dev and persistent-Dev jobs additionally share the job-level group cfv2-dev--. -# 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 not known to actionlint 1.7.12: see .github/actionlint.yaml. +# 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. +# +# GitHub evaluates this BEFORE any job, so it cannot read a Plan output: this is the one calculation that must live in YAML. Its canonical +# twin is runLockGroup() in actions/cf-preview/delivery.mjs (the Plan emits it as `group`; test-workflow-contract.py renders this expression +# and compares both over a scenario matrix). A verify call (pipeline_phase=verify) schedules under the group its DEPLOY call recorded in +# 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 || (((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.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 && 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' && 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) }} queue: max permissions: @@ -402,303 +446,106 @@ defaults: env: NODE_VERSION: 24 PNPM_STORE_PATH: /cache/pnpm-store - TOOLS_DIR: .cf-tools/actions/cf-preview PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-browsers jobs: # ------------------------------------------------------------------------- - # Validation — six independent jobs, no `needs` between them: each has its own checkout / setup / logs / check - # conclusion and can be re-run alone. Bodies are actions/cf-ci-check. None receives Cloudflare credentials and - # none uploads a build artifact (Build Validation builds for validation only; the engine builds again for its - # real target). Same-repo PRs are covered by the push run of the same SHA; fork PRs run these credential-free. + # CI — ONE call of _cf-worker-ci.yml (six parallel checks + the fail-closed Validation Gate). Credential-free; fork PRs run it, same-repo + # PRs are covered by the push run of the same SHA. Its verdict is the only upstream condition of the Delivery plan. # ------------------------------------------------------------------------- - lint: - name: Lint - if: inputs.lint_cmd != '' && 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) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - name: Fetch CI action and env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-ci-check - actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: ./.cf-tools/actions/cf-ci-check - with: - title: Lint - cmd: ${{ inputs.lint_cmd }} - app_path: ${{ inputs.app_path }} - build_env_prod: ${{ inputs.build_env_prod }} - build_env_preview: ${{ inputs.build_env_preview }} - ci_env: ${{ inputs.ci_env }} - app_version: ${{ inputs.app_version }} - typecheck: - name: Typecheck - if: inputs.typecheck_cmd != '' && 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) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - name: Fetch CI action and env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-ci-check - actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: ./.cf-tools/actions/cf-ci-check - with: - title: Typecheck - cmd: ${{ inputs.typecheck_cmd }} - app_path: ${{ inputs.app_path }} - build_env_prod: ${{ inputs.build_env_prod }} - build_env_preview: ${{ inputs.build_env_preview }} - ci_env: ${{ inputs.ci_env }} - app_version: ${{ inputs.app_version }} - unit: - name: Unit Tests - if: inputs.test_unit_cmd != '' && 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) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - name: Fetch CI action and env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-ci-check - actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: ./.cf-tools/actions/cf-ci-check - with: - title: Unit Tests - cmd: ${{ inputs.test_unit_cmd }} - app_path: ${{ inputs.app_path }} - build_env_prod: ${{ inputs.build_env_prod }} - build_env_preview: ${{ inputs.build_env_preview }} - ci_env: ${{ inputs.ci_env }} - app_version: ${{ inputs.app_version }} - use_mailpit: ${{ inputs.use_mailpit }} - integration: - name: Integration Tests - if: inputs.test_integration_cmd != '' && 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) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - name: Fetch CI action and env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-ci-check - actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: ./.cf-tools/actions/cf-ci-check - with: - title: Integration Tests - cmd: ${{ inputs.test_integration_cmd }} - app_path: ${{ inputs.app_path }} - build_env_prod: ${{ inputs.build_env_prod }} - build_env_preview: ${{ inputs.build_env_preview }} - ci_env: ${{ inputs.ci_env }} - app_version: ${{ inputs.app_version }} - use_mailpit: ${{ inputs.use_mailpit }} - build: - name: Build Validation + # CI — ONE call of _cf-worker-ci.yml: Lint, Typecheck, Unit, Integration, Build Validation and E2E as six independent, parallel jobs, + # then the fail-closed Validation Gate. Credential-free; fork PRs run it, same-repo PRs are covered by the push run of the same SHA. + # Its result is the only upstream condition of the Delivery plan. + # ------------------------------------------------------------------------- + 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) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - name: Fetch CI action and env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-ci-check - actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: ./.cf-tools/actions/cf-ci-check - with: - title: Build Validation - cmd: ${{ inputs.build_cmd }} - app_path: ${{ inputs.app_path }} - build_env_prod: ${{ inputs.build_env_prod }} - build_env_preview: ${{ inputs.build_env_preview }} - ci_env: ${{ inputs.ci_env }} - app_version: ${{ inputs.app_version }} - - # E2E — own setup and own TEST build (local URLs, mock services); never reused for a deployment. - e2e: - name: E2E - if: inputs.test_e2e_cmd != '' && 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) - strategy: - fail-fast: false - matrix: - e2e: ${{ fromJSON(inputs.test_e2e_variants) }} - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - env: - E2E_ENV: ${{ inputs.test_e2e_env }} - steps: - - uses: actions/checkout@v6 - - name: Fetch env-import tool - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: actions/cf-preview/parse-env.mjs - sparse-checkout-cone-mode: false - - name: Import E2E env - run: | - node "$TOOLS_DIR/parse-env.mjs" E2E_ENV "$GITHUB_ENV" - rm -rf .cf-tools - - uses: firstsun-dev/.github/actions/setup@v1 - with: - setup-node: 'false' - setup-pnpm: 'false' - - name: Start Mailpit - if: inputs.use_mailpit - uses: firstsun-dev/.github/actions/mailpit@v1 - - name: E2E setup - if: inputs.test_e2e_setup_cmd != '' - working-directory: ${{ inputs.app_path }} - env: - CMD: ${{ inputs.test_e2e_setup_cmd }} - run: bash -c "$CMD" - - name: E2E test build - if: inputs.test_e2e_build_cmd != '' - working-directory: ${{ inputs.app_path }} - env: - CMD: ${{ inputs.test_e2e_build_cmd }} - run: bash -c "$CMD" - - name: E2E tests - working-directory: ${{ inputs.app_path }} - env: - CMD: ${{ inputs.test_e2e_cmd }} ${{ matrix.e2e.args }} - run: bash -c "$CMD" - # Failure-only evidence with short retention; never a delivery dependency. - - name: Upload E2E report (failure only) - uses: actions/upload-artifact@v7 - if: failure() - continue-on-error: true - with: - name: ${{ matrix.e2e.artifact_suffix != '' && format('e2e-report-{0}-{1}-{2}', inputs.app_name, matrix.e2e.artifact_suffix, github.run_id) || format('e2e-report-{0}-{1}', inputs.app_name, github.run_id) }} - path: | - ${{ inputs.app_path }}/playwright-report/ - ${{ inputs.app_path }}/test-results/ - if-no-files-found: ignore - retention-days: 3 + uses: $/.github/workflows/_cf-worker-ci.yml + with: + app_name: ${{ inputs.app_name }} + app_path: ${{ inputs.app_path }} + app_version: ${{ inputs.app_version }} + runner: ${{ inputs.runner }} + lint_cmd: ${{ inputs.lint_cmd }} + typecheck_cmd: ${{ inputs.typecheck_cmd }} + test_unit_cmd: ${{ inputs.test_unit_cmd }} + test_integration_cmd: ${{ inputs.test_integration_cmd }} + build_cmd: ${{ inputs.build_cmd }} + ci_env: ${{ inputs.ci_env }} + use_mailpit: ${{ inputs.use_mailpit }} + build_env_prod: ${{ inputs.build_env_prod }} + build_env_preview: ${{ inputs.build_env_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 }} + test_e2e_variants: ${{ inputs.test_e2e_variants }} + test_e2e_env: ${{ inputs.test_e2e_env }} # ------------------------------------------------------------------------- - # Migration — D1 only (d1_migrations). Same engine (stage migrate): build -> config audit -> exact D1 target - # check -> stale check -> apply pending migrations of THIS target's D1. Publishes nothing, so a failed migration - # leaves the live Worker untouched; Deploy and everything after it is skipped. Needs the Environment: it changes - # the Production D1 and is authorised like the deploy. A skipped Migration (d1_migrations: false) never blocks Deploy. + # Delivery plan — pure data, no Cloudflare credentials: the enabled delivery kinds of THIS event, the ONE physical D1 they share (distinct + # databases fail the plan) and its migration owner, the per-kind `on_` switches and target `descriptors`, the canonical lock `group`. + # Runs only after a passed CI, so every job below that needs the plan is implicitly gated by CI. A verify call plans from phase_state. # ------------------------------------------------------------------------- - migration: - name: Migration - needs: [lint, typecheck, unit, integration, build, e2e] - if: always() && inputs.pipeline_phase != 'verify' && inputs.d1_migrations && (needs.lint.result == 'success' || (needs.lint.result == 'skipped' && inputs.lint_cmd == '')) && (needs.typecheck.result == 'success' || (needs.typecheck.result == 'skipped' && inputs.typecheck_cmd == '')) && (needs.unit.result == 'success' || (needs.unit.result == 'skipped' && inputs.test_unit_cmd == '')) && (needs.integration.result == 'success' || (needs.integration.result == 'skipped' && inputs.test_integration_cmd == '')) && needs.build.result == 'success' && (needs.e2e.result == 'success' || (needs.e2e.result == 'skipped' && inputs.test_e2e_cmd == '')) && github.ref_type == 'branch' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dispatch_target != 'persistent-dev')) && !(inputs.deployment_strategy == 'production-only' && inputs.shared_dev_on_branch_push) && (github.ref == 'refs/heads/main' || (inputs.deployment_strategy != 'production-only' && inputs.preview_on_branch_push)) + plan: + name: Delivery 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'))) runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: - name: ${{ github.ref == 'refs/heads/main' && format('{0}/prod', inputs.app_name) || format('{0}/preview', inputs.app_name) }} - permissions: - contents: read - deployments: read # Preview identity guard reads the ledger owner on branch pushes (no ledger entry is written here) + timeout-minutes: 10 outputs: - migration: ${{ steps.engine.outputs.migration }} - schema_changed: ${{ steps.engine.outputs.schema_changed }} - d1_scan: ${{ steps.engine.outputs.d1_scan }} - env: - GH_TOKEN: ${{ github.token }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + kinds: ${{ steps.plan.outputs.kinds }} + descriptors: ${{ steps.plan.outputs.descriptors }} + has_targets: ${{ steps.plan.outputs.has_targets }} + has_migrations: ${{ steps.plan.outputs.has_migrations }} + migration: ${{ steps.plan.outputs.migration }} + on_production: ${{ steps.plan.outputs.on_production }} + on_native_branch: ${{ steps.plan.outputs.on_native_branch }} + on_native_dev: ${{ steps.plan.outputs.on_native_dev }} + on_persistent_dev: ${{ steps.plan.outputs.on_persistent_dev }} + group: ${{ steps.plan.outputs.group }} steps: - uses: actions/checkout@v6 + if: inputs.pipeline_phase != 'verify' with: ref: ${{ github.sha }} - fetch-depth: 0 persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy + node-version: 24 + - uses: $/actions/cf-tools with: - target: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} - stage: migrate - inputs_json: ${{ toJSON(inputs) }} - preview_secrets: ${{ github.ref != 'refs/heads/main' && secrets.PREVIEW_SECRETS || '' }} + install: 'true' + - id: plan + env: + INPUTS_JSON: ${{ toJSON(inputs) }} + PHASE_STATE: ${{ inputs.pipeline_phase == 'verify' && inputs.phase_state || '{}' }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PHASE: ${{ inputs.pipeline_phase }} + run: | + node "$TOOLS_DIR/cli.mjs" plan --event "$GITHUB_EVENT_NAME" --ref "$GITHUB_REF" --phase "$PHASE" --run-id "$GITHUB_RUN_ID" --app-dir "$GITHUB_WORKSPACE/${{ inputs.app_path }}" | tee -a "$GITHUB_OUTPUT" - # ------------------------------------------------------------------------- - # Deploy — publishes the audited artifact (stage publish): the ONE Production / Native Preview publish. target = - # prod (main) | preview (other branches). Builds again from the same SHA, repeats the config audit, the D1 target - # check and the stale-SHA guards, then publishes. It does NOT verify anything: a green Deploy means "published". - # `published` is the hand-off flag of Smoke and Rollback; the version ids, Worker identity and Preview ledger id are - # handed over as job outputs. Only the credentials of THIS target exist in the job env; there is no fallback. - # ------------------------------------------------------------------------- - deploy: - name: Deploy - needs: [lint, typecheck, unit, integration, build, e2e, migration] - # Fail closed: an invalid strategy/channel combination (see `config-guard`, which reports it) never reaches the - # Cloudflare publish or a Production version change. engine.sh `init` repeats the check. - # always(): skipped optional jobs (empty command) must not skip Deploy, but a failed / cancelled / unexpectedly - # skipped CONFIGURED one must block it, and so must a Migration that did not succeed. - if: always() && inputs.pipeline_phase != 'verify' && (needs.lint.result == 'success' || (needs.lint.result == 'skipped' && inputs.lint_cmd == '')) && (needs.typecheck.result == 'success' || (needs.typecheck.result == 'skipped' && inputs.typecheck_cmd == '')) && (needs.unit.result == 'success' || (needs.unit.result == 'skipped' && inputs.test_unit_cmd == '')) && (needs.integration.result == 'success' || (needs.integration.result == 'skipped' && inputs.test_integration_cmd == '')) && needs.build.result == 'success' && (needs.e2e.result == 'success' || (needs.e2e.result == 'skipped' && inputs.test_e2e_cmd == '')) && (needs.migration.result == 'success' || (needs.migration.result == 'skipped' && !inputs.d1_migrations)) && github.ref_type == 'branch' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dispatch_target != 'persistent-dev')) && !(inputs.deployment_strategy == 'production-only' && inputs.shared_dev_on_branch_push) && (github.ref == 'refs/heads/main' || (inputs.deployment_strategy != 'production-only' && inputs.preview_on_branch_push)) + # ========================================================================= + # Migration — the ONE job that mutates a database; there is no separate Preflight stage. Its steps run in this order and a failing step + # stops the job, so every read-only check precedes the single remote mutation: + # 1. each enabled target that is not the owner: target build, effective-config audit, exact D1 binding (stage preflight) + # 2. the owner: target build, audit, exact D1 binding, stale-SHA guard, then the migration (stage migrate) + # The plan picked the owner and its Environment (main -> /prod with its approvals; else the shared Preview/Dev D1). Skipped (Delivery + # proceeds) without d1_migrations; never on pull_request, delete or a verify call. The D1 schema is never rolled back automatically. + # ========================================================================= + migration: + name: Database Migration + needs: [plan] + if: always() && inputs.pipeline_phase != 'verify' && inputs.d1_migrations && needs.plan.result == 'success' && needs.plan.outputs.has_migrations == 'true' runs-on: ${{ inputs.runner }} timeout-minutes: 45 - # One Environment per app and target; Production authorization = its protection rules + the main-only credentials. environment: - name: ${{ github.ref == 'refs/heads/main' && format('{0}/prod', inputs.app_name) || format('{0}/preview', inputs.app_name) }} - url: ${{ steps.engine.outputs.url }} + name: ${{ fromJSON(needs.plan.outputs.migration || '{}').environment }} permissions: contents: read - deployments: write - pull-requests: write + deployments: read # Preview identity guard reads the ledger owner (no ledger entry is written here) outputs: - url: ${{ steps.engine.outputs.url }} - target: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} - published: ${{ steps.engine.outputs.published }} - published_sha: ${{ steps.engine.outputs.published_sha }} - version_id: ${{ steps.engine.outputs.version_id }} - previous_version_id: ${{ steps.engine.outputs.previous_version_id }} - worker_name: ${{ steps.engine.outputs.worker_name }} - ledger_id: ${{ steps.engine.outputs.ledger_id }} - preview_name: ${{ steps.engine.outputs.preview_name }} - migration: ${{ needs.migration.outputs.migration }} + migration_json: ${{ toJSON(steps.migrate.outputs) }} + migration_status: ${{ steps.result.outputs.status }} env: GH_TOKEN: ${{ github.token }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -709,341 +556,144 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} - stage: publish + - name: Verify native-dev build and D1 binding + if: needs.plan.outputs.on_native_dev == 'true' && fromJSON(needs.plan.outputs.migration).kind != 'native-dev' + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(needs.plan.outputs.descriptors)['native-dev'].target }} + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['native-dev']) }} + kind: native-dev + stage: preflight inputs_json: ${{ toJSON(inputs) }} - preview_secrets: ${{ github.ref != 'refs/heads/main' && secrets.PREVIEW_SECRETS || '' }} - - # ------------------------------------------------------------------------- - # Smoke Tests — verifies THIS deployment (stage verify): the published URL, the commit SHA when sha_path is set, and - # smoke_cmd (run from app_path, with TARGET_URL / EXPECTED_SHA / DEPLOY_TARGET). The verification level is explicit: - # content-sha (commit SHA proven) > content (application responded) > liveness (Cloudflare Access login reachable - # only: published but NOT verified). A failure marks the deployment UNVERIFIED (GitHub Environment deployment + - # Preview ledger), blocks Release and triggers Rollback. No Cloudflare credentials in this job. Runs only when - # Deploy actually published (a stale / superseded run publishes nothing, so there is nothing to smoke). - # ------------------------------------------------------------------------- - smoke: - name: Smoke Tests - needs: [deploy] - if: always() && inputs.pipeline_phase != 'deploy' && ((inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.published == 'true') || (inputs.pipeline_phase != 'verify' && needs.deploy.result == 'success' && needs.deploy.outputs.published == 'true')) - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - permissions: - contents: read - deployments: write - pull-requests: write - outputs: - verified_url: ${{ steps.engine.outputs.verified_url }} - deployed_and_verified: ${{ steps.engine.outputs.deployed_and_verified || 'false' }} - smoke_level: ${{ steps.engine.outputs.smoke_level }} - published_unverified: ${{ steps.engine.outputs.published_unverified || 'false' }} - gate_blocked: ${{ steps.engine.outputs.gate_blocked || 'false' }} - env: - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} - stage: verify - inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).inputs) || toJSON(inputs) }} - call_inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(inputs) || '' }} - preview_secrets: ${{ github.ref != 'refs/heads/main' && secrets.PREVIEW_SECRETS || '' }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - deploy_outputs: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).deploy) || toJSON(needs.deploy.outputs) }} - upstream_gate: ${{ inputs.pipeline_phase == 'verify' && inputs.upstream_gate || '' }} - # Failure-only evidence with short retention; never a delivery dependency. - - name: Upload smoke report (failure only) - uses: actions/upload-artifact@v7 - if: failure() - continue-on-error: true - with: - name: smoke-report-${{ inputs.app_name }}-${{ github.run_id }} - path: | - ${{ inputs.app_path }}/playwright-report/ - ${{ inputs.app_path }}/test-results/ - if-no-files-found: ignore - retention-days: 7 + d1_expected_id: ${{ fromJSON(needs.plan.outputs.descriptors)['native-dev'].d1_expected_id }} + preview_secrets: ${{ fromJSON(needs.plan.outputs.descriptors)['native-dev'].target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + - name: Verify persistent-dev build and D1 binding + if: needs.plan.outputs.on_persistent_dev == 'true' && fromJSON(needs.plan.outputs.migration).kind != 'persistent-dev' + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(needs.plan.outputs.descriptors)['persistent-dev'].target }} + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) }} + kind: persistent-dev + stage: preflight + inputs_json: ${{ toJSON(inputs) }} + d1_expected_id: ${{ fromJSON(needs.plan.outputs.descriptors)['persistent-dev'].d1_expected_id }} + preview_secrets: ${{ fromJSON(needs.plan.outputs.descriptors)['persistent-dev'].target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + - id: migrate + name: Apply pending D1 migrations (once) + uses: $/actions/cf-deploy + with: + target: ${{ fromJSON(needs.plan.outputs.descriptors)[fromJSON(needs.plan.outputs.migration).kind].target }} + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)[fromJSON(needs.plan.outputs.migration).kind]) }} + kind: ${{ fromJSON(needs.plan.outputs.migration).kind }} + stage: migrate + inputs_json: ${{ toJSON(inputs) }} + d1_expected_id: ${{ fromJSON(needs.plan.outputs.migration).d1_id }} + preview_secrets: ${{ fromJSON(needs.plan.outputs.descriptors)[fromJSON(needs.plan.outputs.migration).kind].target == 'preview' && secrets.PREVIEW_SECRETS || '' }} + # The one deterministic public status: applied | none (nothing was pending / superseded) when the job succeeded, failed otherwise + # (also when a validation step stopped the job before the migrate step ran). + - id: result + if: always() + env: + JOB_STATUS: ${{ job.status }} + STATE: ${{ steps.migrate.outputs.migration }} + run: echo "status=$([ "$JOB_STATUS" = success ] && echo "${STATE:-none}" || echo failed)" >> "$GITHUB_OUTPUT" - # ------------------------------------------------------------------------- - # Revert on Verify Failure — only after a FAILED Smoke Tests job of a deployment this run published (stage rollback). - # prod : policy `automatic` only (manual / disabled: the Smoke job already printed the manual commands). Fail - # closed: needs a recorded previous version, an attested/additive D1 change when this run migrated, and the - # failed version still being the live one; then re-smokes the restored Production. - # preview: redeploys the last known-good Preview of this branch and host Worker (never a Production version). - # The job is green ONLY when the previous deployment was restored and content-verified. A blocked or failed rollback - # keeps it red and prints the manual recovery commands. The Smoke job is red in every case, so a rolled-back deployment - # is never reported as successful and Release stays blocked. D1 is never rolled back. - # ------------------------------------------------------------------------- - rollback: - name: Revert on Verify Failure - needs: [migration, deploy, smoke] - if: always() && inputs.pipeline_phase != 'deploy' && needs.smoke.result == 'failure' && needs.smoke.outputs.gate_blocked != 'true' && ((inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.published == 'true') || (inputs.pipeline_phase != 'verify' && needs.deploy.result == 'success' && needs.deploy.outputs.published == 'true')) && (github.ref != 'refs/heads/main' || inputs.production_rollback_policy == 'automatic') - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: - name: ${{ github.ref == 'refs/heads/main' && format('{0}/prod', inputs.app_name) || format('{0}/preview', inputs.app_name) }} - permissions: + # ========================================================================= + # Delivery — ONE call per enabled kind of _cf-worker-delivery.yml (deploy -> smoke -> conditional rollback). A kind needs only the plan and + # the single Migration job (success, or skipped when nothing is migrated) and is never touched by another kind's Deploy / Smoke / Rollback. + # ========================================================================= + delivery_production: + name: Delivery (production) + needs: [plan, migration] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.on_production == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped') + uses: $/.github/workflows/_cf-worker-delivery.yml + permissions: &delivery_permissions contents: read deployments: write pull-requests: write - outputs: - rollback_result: ${{ steps.engine.outputs.rollback_result }} - env: - GH_TOKEN: ${{ github.token }} + with: + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['production']) }} + 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) || '' }} + phase: ${{ inputs.pipeline_phase }} + migration_result: ${{ needs.migration.result }} + migration_json: ${{ needs.migration.outputs.migration_json }} + handoff_json: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.h_production || '' }} + migration_json_in: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).migration.mj_production || '' }} + upstream_gate: ${{ inputs.pipeline_phase == 'verify' && inputs.upstream_gate || '' }} + secrets: &delivery_secrets CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: ${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }} - stage: rollback - inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).inputs) || toJSON(inputs) }} - call_inputs_json: ${{ inputs.pipeline_phase == 'verify' && toJSON(inputs) || '' }} - preview_secrets: ${{ github.ref != 'refs/heads/main' && secrets.PREVIEW_SECRETS || '' }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - deploy_outputs: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).deploy) || toJSON(needs.deploy.outputs) }} - migration_outputs: ${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).migration) || toJSON(needs.migration.outputs) }} - smoke_outputs: ${{ toJSON(needs.smoke.outputs) }} + PREVIEW_SECRETS: ${{ secrets.PREVIEW_SECRETS }} + SMOKE_USERNAME: ${{ secrets.SMOKE_USERNAME }} + SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} - # ------------------------------------------------------------------------- - # Shared Dev — optional, independent of Native Preview (own switch, Dev resources and Environment). The - # SAME engine with target = dev: every non-main branch push updates the ONE Dev Worker; the last verified - # deployment wins. `needs` on the Preview jobs is ORDERING ONLY (Preview and Dev may share one physical D1, so their - # migrations must never overlap); their results are ignored except by the engine's shared-D1 guard (Migration state). Branch - # deletion and disabling the switch never touch the Dev Worker. Not used by the manual persistent-dev path. - # ------------------------------------------------------------------------- - dev-deploy: - name: Shared Dev - needs: [lint, typecheck, unit, integration, build, e2e, migration, deploy, smoke, rollback] - if: always() && (needs.lint.result == 'success' || (needs.lint.result == 'skipped' && inputs.lint_cmd == '')) && (needs.typecheck.result == 'success' || (needs.typecheck.result == 'skipped' && inputs.typecheck_cmd == '')) && (needs.unit.result == 'success' || (needs.unit.result == 'skipped' && inputs.test_unit_cmd == '')) && (needs.integration.result == 'success' || (needs.integration.result == 'skipped' && inputs.test_integration_cmd == '')) && needs.build.result == 'success' && (needs.e2e.result == 'success' || (needs.e2e.result == 'skipped' && inputs.test_e2e_cmd == '')) && inputs.pipeline_phase != 'verify' && inputs.shared_dev_on_branch_push && inputs.deployment_strategy != 'production-only' && github.ref_type == 'branch' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dispatch_target != 'persistent-dev')) - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: - name: ${{ inputs.app_name }}/dev - url: ${{ steps.engine.outputs.url }} - # Same group as the manual persistent-dev job: automatic and manual Dev deployments never overlap. Keyed by the - # shared-D1 scope when set, distinct from the run-level groups, so caller and callee cannot deadlock. - concurrency: - group: cfv2-dev-${{ github.repository }}-${{ inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name }} - cancel-in-progress: false - permissions: - contents: read - deployments: write # Dev ledger (last known-good SHA for recovery) - outputs: - url: ${{ steps.engine.outputs.url }} - deployed_and_verified: ${{ steps.engine.outputs.deployed_and_verified || 'false' }} - smoke_level: ${{ steps.engine.outputs.smoke_level }} - published_url: ${{ steps.engine.outputs.published_url }} - verified_url: ${{ steps.engine.outputs.verified_url }} - published_unverified: ${{ steps.engine.outputs.published_unverified || 'false' }} - # Hand-off of the deferred verification (pipeline_phase deploy -> verify) - published: ${{ steps.engine.outputs.published }} - published_sha: ${{ steps.engine.outputs.published_sha }} - ledger_id: ${{ steps.engine.outputs.ledger_id }} - env: - GH_TOKEN: ${{ github.token }} - # Dev reuses the account token while keeping Dev target and binding checks. - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: dev - stage: ${{ inputs.pipeline_phase == 'deploy' && 'deploy' || 'all' }} - inputs_json: ${{ toJSON(inputs) }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - sibling_result: ${{ needs.migration.result }} - sibling_migration: ${{ needs.migration.outputs.migration }} + delivery_native_branch: + name: Delivery (native-branch) + needs: [plan, migration] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.on_native_branch == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped') + uses: $/.github/workflows/_cf-worker-delivery.yml + permissions: *delivery_permissions + with: + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['native-branch']) }} + 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) || '' }} + phase: ${{ inputs.pipeline_phase }} + migration_result: ${{ needs.migration.result }} + migration_json: ${{ needs.migration.outputs.migration_json }} + handoff_json: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.h_native_branch || '' }} + migration_json_in: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).migration.mj_native_branch || '' }} + upstream_gate: ${{ inputs.pipeline_phase == 'verify' && inputs.upstream_gate || '' }} + secrets: *delivery_secrets - # ------------------------------------------------------------------------- - # Shared Dev Smoke Tests / Shared Dev Revert — pipeline_phase verify only: the deferred second half of Shared Dev. - # The Shared Dev job (pipeline_phase deploy) migrated and published and stopped there. These two jobs run in the - # SECOND call, after the other Worker's deployment, and use the same engine stages as Production / Preview: - # verify : smoke the published Dev URL (cross-service smoke_cmd included); mark verified | unverified - # rollback : only after a FAILED smoke of THIS deployment (never after a blocked cross-service gate): redeploy the - # last known-good Dev deployment and re-smoke it. It first checks that no NEWER Dev deployment has started - # (the Dev group was released between the two calls) - it never replaces another run's deployment. - # ------------------------------------------------------------------------- - dev-verify: - name: Shared Dev Smoke Tests - if: always() && inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).dev.published == 'true' - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - permissions: - contents: read - deployments: write - outputs: - url: ${{ steps.engine.outputs.url }} - verified_url: ${{ steps.engine.outputs.verified_url }} - deployed_and_verified: ${{ steps.engine.outputs.deployed_and_verified || 'false' }} - smoke_level: ${{ steps.engine.outputs.smoke_level }} - published_unverified: ${{ steps.engine.outputs.published_unverified || 'false' }} - gate_blocked: ${{ steps.engine.outputs.gate_blocked || 'false' }} - env: - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: dev - stage: verify - inputs_json: ${{ toJSON(fromJSON(inputs.phase_state).inputs) }} - call_inputs_json: ${{ toJSON(inputs) }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - deploy_outputs: ${{ toJSON(fromJSON(inputs.phase_state).dev) }} - upstream_gate: ${{ inputs.upstream_gate }} - - name: Upload smoke report (failure only) - uses: actions/upload-artifact@v7 - if: failure() - continue-on-error: true - with: - name: smoke-report-dev-${{ inputs.app_name }}-${{ github.run_id }} - path: | - ${{ inputs.app_path }}/playwright-report/ - ${{ inputs.app_path }}/test-results/ - if-no-files-found: ignore - retention-days: 7 + delivery_native_dev: + name: Delivery (native-dev) + needs: [plan, migration] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.on_native_dev == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped') + uses: $/.github/workflows/_cf-worker-delivery.yml + permissions: *delivery_permissions + with: + descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['native-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) || '' }} + phase: ${{ inputs.pipeline_phase }} + migration_result: ${{ needs.migration.result }} + migration_json: ${{ needs.migration.outputs.migration_json }} + handoff_json: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.h_native_dev || '' }} + migration_json_in: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).migration.mj_native_dev || '' }} + upstream_gate: ${{ inputs.pipeline_phase == 'verify' && inputs.upstream_gate || '' }} + secrets: *delivery_secrets - dev-rollback: - name: Shared Dev Revert on Verify Failure - needs: [dev-verify] - if: always() && needs.dev-verify.result == 'failure' && needs.dev-verify.outputs.gate_blocked != 'true' - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: - name: ${{ inputs.app_name }}/dev - # Same group as the Shared Dev deploy job and the manual persistent-dev job. - concurrency: - group: cfv2-dev-${{ github.repository }}-${{ fromJSON(inputs.phase_state).inputs.d1_lock_scope != '' && fromJSON(inputs.phase_state).inputs.d1_lock_scope || inputs.app_name }} - cancel-in-progress: false - permissions: - contents: read - deployments: write - outputs: - rollback_result: ${{ steps.engine.outputs.rollback_result }} - 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: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - name: Fetch delivery tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - uses: ./.cf-tools/actions/cf-deploy - with: - target: dev - stage: rollback - inputs_json: ${{ toJSON(fromJSON(inputs.phase_state).inputs) }} - call_inputs_json: ${{ toJSON(inputs) }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} - deploy_outputs: ${{ toJSON(fromJSON(inputs.phase_state).dev) }} + delivery_persistent_dev: + name: Delivery (persistent-dev) + needs: [plan, migration] + 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: ${{ 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) || '' }} + phase: ${{ inputs.pipeline_phase }} + migration_result: ${{ needs.migration.result }} + migration_json: ${{ needs.migration.outputs.migration_json }} + handoff_json: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.h_persistent_dev || '' }} + migration_json_in: ${{ inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).migration.mj_persistent_dev || '' }} + upstream_gate: ${{ inputs.pipeline_phase == 'verify' && inputs.upstream_gate || '' }} + secrets: *delivery_secrets # ------------------------------------------------------------------------- - # Release — optional, main only, only after THIS run deployed its own SHA to Production, the Smoke job verified it at - # content level and no Rollback ran. + # Release — optional, main only, only after THIS run deployed its own SHA to Production, the Production Smoke verified it at + # content level (smoke_mode skip / liveness never qualify) and no Rollback ran. It reads ONLY the Production delivery call. # ------------------------------------------------------------------------- release: name: Release - needs: [deploy, smoke, rollback] - if: always() && inputs.release_cmd != '' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && inputs.dispatch_target != 'persistent-dev' && inputs.pipeline_phase != 'deploy' && ((inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).deploy.target == 'prod') || (inputs.pipeline_phase != 'verify' && needs.deploy.result == 'success' && needs.deploy.outputs.target == 'prod')) && needs.smoke.result == 'success' && needs.smoke.outputs.deployed_and_verified == 'true' && needs.rollback.result == 'skipped' + needs: [delivery_production] + if: always() && inputs.release_cmd != '' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && inputs.dispatch_target != 'persistent-dev' && inputs.pipeline_phase != 'deploy' && needs.delivery_production.result == 'success' && fromJSON(needs.delivery_production.outputs.smoke_json || '{}').deployed_and_verified == 'true' && needs.delivery_production.outputs.rollback_result == '' runs-on: ${{ inputs.runner }} timeout-minutes: 20 permissions: @@ -1054,7 +704,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: firstsun-dev/.github/actions/setup@v1 + - uses: $/actions/setup with: setup-node: 'false' setup-pnpm: 'false' @@ -1065,161 +715,55 @@ jobs: run: bash -c "$CMD" # ------------------------------------------------------------------------- - # Config guard — a production-only Worker must never expose a Dev deployment. Fails the run on ANY event so a - # misconfigured caller is visible, instead of silently skipping. (A production-only Worker keeps ignoring - # preview_on_branch_push, whose default is true, for backward compatibility.) + # Config guard — a production-only Worker must never expose a Dev deployment, and the two automatic Dev channels conflict. Fails the run + # on ANY event so a misconfigured caller is visible. (production-only keeps ignoring preview_on_branch_push=true, for compatibility.) # ------------------------------------------------------------------------- config-guard: name: Config guard - if: inputs.pipeline_phase != 'verify' && inputs.deployment_strategy == 'production-only' && inputs.shared_dev_on_branch_push + if: inputs.pipeline_phase != 'verify' && ((inputs.deployment_strategy == 'production-only' && (inputs.shared_dev_on_branch_push || inputs.shared_dev_native_preview_on_branch_push)) || (inputs.shared_dev_on_branch_push && inputs.shared_dev_native_preview_on_branch_push)) runs-on: ${{ inputs.runner }} timeout-minutes: 2 steps: - run: | - echo "::error::deployment_strategy: production-only cannot be combined with shared_dev_on_branch_push: true (a production-only Worker must not expose an unauthorized Dev deployment). Set shared_dev_on_branch_push: false or change the strategy." + echo "::error::Invalid Dev channel configuration: production-only cannot be combined with shared_dev_on_branch_push / shared_dev_native_preview_on_branch_push (a production-only Worker must not expose a Dev deployment), and the separate persistent Dev Worker and the Native Dev Preview are conflicting automatic Dev channels - enable only one." exit 1 # ------------------------------------------------------------------------- - # Lifecycle — event-specific handler outside the core DAG. Never deploys, no Cloudflare credentials. - # same-repo PR : comment the branch's existing Preview URL - # branch delete: retire the GitHub ledger only; the Native Preview is KEPT (owner policy) + # 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) and `persistent-dev` (Hybrid, explicit dispatch on the approved ref only). # ------------------------------------------------------------------------- lifecycle: name: Preview lifecycle if: inputs.pipeline_phase != 'verify' && inputs.deployment_strategy != 'production-only' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'delete' && github.event.ref_type == 'branch')) - runs-on: ${{ inputs.runner }} - timeout-minutes: 10 - concurrency: - group: cfv2-lifecycle-${{ github.repository }}-${{ inputs.app_name }}-${{ github.event.pull_request.head.ref || github.event.ref }} - cancel-in-progress: false + uses: $/.github/workflows/_cf-worker-lifecycle.yml permissions: contents: read deployments: write pull-requests: write - env: - GH_TOKEN: ${{ github.token }} - BRANCH: ${{ github.event.pull_request.head.ref || github.event.ref }} - WORKER_NAME: ${{ inputs.preview_worker_name != '' && inputs.preview_worker_name || inputs.app_name }} - steps: - - name: Fetch tooling - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: actions/cf-preview - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - - run: cd "$TOOLS_DIR" && npm ci --ignore-scripts --no-audit --no-fund - - name: Compute and validate Preview identity - env: - APP: ${{ inputs.app_name }} - run: | - [ "$BRANCH" != main ] || { echo "main has no Preview"; echo "SKIP=true" >> "$GITHUB_ENV"; exit 0; } - NAME="$(node "$TOOLS_DIR/cli.mjs" identity --repo "$GITHUB_REPOSITORY" --worker "$APP" --host-worker "$WORKER_NAME" --ref "refs/heads/$BRANCH" | sed -n 's/^preview_name=//p')" - [[ "$NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || { echo "::error::unsafe name"; exit 1; } - { echo "PREVIEW_NAME=$NAME"; echo "ENV_NAME=$APP/preview/$NAME"; } >> "$GITHUB_ENV" - - name: Comment the existing VERIFIED Preview URL on the PR - if: env.SKIP != 'true' && github.event_name == 'pull_request' - env: - APP: ${{ inputs.app_name }} - run: | - # Host-bound known-good lookup: same repository, application, branch ref and HOST Worker, https URL and a - # content-level verified success. An old host (e.g. blog-preview), another branch or a liveness-only - # deployment is never advertised. - ROW="$("$TOOLS_DIR/gh.sh" last-good-preview "$ENV_NAME" "" "refs/heads/$BRANCH" "$WORKER_NAME" "$APP" || true)" - [ -n "$ROW" ] || { echo "No verified Preview for this branch on host Worker $WORKER_NAME; PR comment unchanged."; exit 0; } - "$TOOLS_DIR/gh.sh" comment "$BRANCH" "$APP" "${ROW#* }" "${ROW%% *}" - # Policy (owner decision): deleting a branch does NOT delete its Native Preview. The Preview and its stable - # URL are kept (Cloudflare cannot remove only the stable URL: Preview `urls` is read-only and - # `wrangler preview delete` removes the Preview with all deployments). Only the GitHub ledger is retired. - - name: Retire GitHub ledger (Preview is kept) - if: env.SKIP != 'true' && github.event_name == 'delete' - run: | - # Names are hashless: only touch the ledger if THIS branch owns the name, and not if the branch exists again. - OWNER="$("$TOOLS_DIR/gh.sh" owner "$ENV_NAME")" - if [ -n "$OWNER" ] && [ "$OWNER" != "refs/heads/$BRANCH" ]; then - echo "::notice::Name is owned by $OWNER, not this branch; leaving the ledger alone."; exit 0 - fi - if gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BRANCH" >/dev/null 2>&1; then - echo "::notice::Branch exists again; leaving the ledger alone."; exit 0 - fi - "$TOOLS_DIR/gh.sh" retire "$ENV_NAME" - { - echo "### 📌 Branch \`$BRANCH\` deleted — Preview \`$PREVIEW_NAME\` is **kept**" - echo "- Cloudflare Preview and its stable URL were not touched; GitHub Deployments are marked inactive." - echo "- No automatic reclaim. To remove it manually:" - echo " \`wrangler preview delete --name $PREVIEW_NAME --worker-name $WORKER_NAME --skip-confirmation\`" - } >> "$GITHUB_STEP_SUMMARY" + with: + app_name: ${{ inputs.app_name }} + preview_worker_name: ${{ inputs.preview_worker_name }} + reserve_native_dev: ${{ inputs.shared_dev_native_preview_on_branch_push }} + runner: ${{ inputs.runner }} - # ------------------------------------------------------------------------- - # Protected persistent Dev (Hybrid) — explicit dispatch on the approved ref only. Never a side effect of a - # feature-branch push. With dev_build_cmd it is the SAME engine in manual mode (build -> audit -> migration -> - # publish -> smoke -> ledger; no automatic recovery). Without it, the legacy two-command contract is unchanged. - # ------------------------------------------------------------------------- persistent-dev: name: Persistent Dev (Hybrid) if: 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 - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - environment: ${{ inputs.app_name }}/dev - concurrency: - group: cfv2-dev-${{ github.repository }}-${{ inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name }} - cancel-in-progress: false + uses: $/.github/workflows/_cf-worker-persistent-dev.yml permissions: contents: read - deployments: write # engine mode only: Dev ledger entry, recorded verified ONLY after the smoke passes - env: - GH_TOKEN: ${{ github.token }} + deployments: write + with: + inputs_json: ${{ toJSON(inputs) }} + app_name: ${{ inputs.app_name }} + app_path: ${{ inputs.app_path }} + d1_lock_scope: ${{ inputs.d1_lock_scope }} + dev_build_cmd: ${{ inputs.dev_build_cmd }} + dev_deploy_cmd: ${{ inputs.dev_deploy_cmd }} + dev_smoke_cmd: ${{ inputs.dev_smoke_cmd }} + runner: ${{ inputs.runner }} + secrets: 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 - # ---- Legacy contract (dev_build_cmd empty): dev_deploy_cmd builds and publishes; never recorded in the Shared - # Dev last-good ledger, so automatic recovery can never treat it as a known-good release. - - uses: firstsun-dev/.github/actions/setup@v1 - if: inputs.dev_build_cmd == '' - with: - setup-node: 'false' - setup-pnpm: 'false' - - name: Deploy persistent Dev - if: inputs.dev_build_cmd == '' - working-directory: ${{ inputs.app_path }} - env: - CMD: ${{ inputs.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: inputs.dev_build_cmd == '' && inputs.dev_smoke_cmd != '' - working-directory: ${{ inputs.app_path }} - env: - CMD: ${{ inputs.dev_smoke_cmd }} - run: bash -c "$CMD" - # ---- Engine contract (dev_build_cmd set). - - name: Fetch delivery tooling - if: inputs.dev_build_cmd != '' - uses: actions/checkout@v6 - with: - repository: firstsun-dev/.github - ref: ${{ inputs.tools_ref }} - path: .cf-tools - sparse-checkout: | - actions/cf-deploy - actions/cf-preview - sparse-checkout-cone-mode: false - persist-credentials: false - - id: engine - if: inputs.dev_build_cmd != '' - uses: ./.cf-tools/actions/cf-deploy - with: - target: dev - mode: manual - inputs_json: ${{ toJSON(inputs) }} - smoke_username: ${{ secrets.SMOKE_USERNAME }} - smoke_password: ${{ secrets.SMOKE_PASSWORD }} + SMOKE_USERNAME: ${{ secrets.SMOKE_USERNAME }} + SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} diff --git a/.github/workflows/cf-worker-v2-ci.yml b/.github/workflows/cf-worker-v2-ci.yml index b71ce1c..61f963c 100644 --- a/.github/workflows/cf-worker-v2-ci.yml +++ b/.github/workflows/cf-worker-v2-ci.yml @@ -4,6 +4,11 @@ on: pull_request: paths: - '.github/workflows/_cf-worker-v2.yml' + - '.github/workflows/_cf-worker-ci.yml' + - '.github/workflows/_cf-worker-delivery.yml' + - '.github/workflows/_cf-worker-lifecycle.yml' + - '.github/workflows/_cf-worker-persistent-dev.yml' + - 'actions/cf-tools/**' - '.github/workflows/cf-worker-v2-ci.yml' - '.github/actionlint.yaml' - 'actions/cf-preview/**' @@ -16,6 +21,11 @@ on: branches: [main] paths: - '.github/workflows/_cf-worker-v2.yml' + - '.github/workflows/_cf-worker-ci.yml' + - '.github/workflows/_cf-worker-delivery.yml' + - '.github/workflows/_cf-worker-lifecycle.yml' + - '.github/workflows/_cf-worker-persistent-dev.yml' + - 'actions/cf-tools/**' - '.github/actionlint.yaml' - 'actions/cf-preview/**' - 'actions/cf-preview-gc/**' @@ -27,6 +37,12 @@ on: permissions: contents: read +# Validation only (read-only, no deployment or migration is ever started from here): a newer PR commit supersedes the +# older run. main pushes are never cancelled. Deployment/migration concurrency lives in the called workflows. +concurrency: + group: cf-v2-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: contract: runs-on: ubuntu-latest @@ -38,28 +54,12 @@ jobs: node-version: 24 - name: Install tooling deps run: cd actions/cf-preview && npm ci --ignore-scripts --no-audit --no-fund - - name: Workflow + composite action contract (DAG, gating matrix, credential isolation, concurrency, engine order, cross-repo references) - run: | - pip install --quiet pyyaml==6.0.1 - python3 scripts/cf-preview/test-workflow-contract.py - - name: Unit tests (identity, event/target matrix, preflight, Dev resource policy, migration target, D1 consistency, artifact digest, protected env) - run: node --test scripts/cf-preview/lib.test.mjs - - name: build.sh / publish.sh behaviour, all targets, same-artifact contract (stub wrangler) - run: scripts/cf-preview/test-deploy.sh - - name: migrate.sh behaviour (stub wrangler) - run: scripts/cf-preview/test-migrate.sh - - name: Smoke verification, fail-closed (stub curl) - run: scripts/cf-preview/test-smoke.sh - - name: Execute the real Migration -> Deploy -> Smoke -> Rollback chain (each job in a fresh workspace) plus Shared Dev / persistent-Dev with the composite engine and stubs (stale runs, fail-closed rollback, D1 compatibility, recovery, env-override protection, Release gate) - run: python3 scripts/cf-preview/test-jobs.py - - name: Env import is data, not code - run: scripts/cf-preview/test-parse-env.sh - - name: Preview probe classification (stub curl) - run: scripts/cf-preview/test-probe.sh - - name: GitHub ledger / PR comment idempotency (stub gh) - run: scripts/cf-preview/test-gh.sh - - name: Preview GC unit tests (mocked GitHub + Cloudflare; no remote calls) - run: node --test scripts/cf-preview-gc/gc.test.mjs + - name: Python deps + run: pip install --quiet pyyaml==6.0.1 + - name: FAST profile (contract, unit, stub shell tests) - fails early, before the expensive chain + run: LOGDIR="$RUNNER_TEMP/cf-v2-tests" scripts/cf-preview/test.sh fast + - name: FULL profile remainder - real Migration -> Deploy -> Smoke -> Rollback chains, Shared/persistent Dev, deferred verify (groups run in parallel) + run: LOGDIR="$RUNNER_TEMP/cf-v2-tests" scripts/cf-preview/test.sh chain - name: shellcheck (warnings and above) run: | shellcheck -x -S warning -P SCRIPTDIR actions/cf-preview/*.sh scripts/cf-preview/*.sh @@ -67,4 +67,4 @@ jobs: - name: actionlint run: | bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 - ./actionlint -color .github/workflows/_cf-worker-v2.yml .github/workflows/_cf-worker-preview-gc.yml .github/workflows/cf-worker-v2-ci.yml + ./actionlint -color .github/workflows/_cf-worker-v2.yml .github/workflows/_cf-worker-ci.yml .github/workflows/_cf-worker-delivery.yml .github/workflows/_cf-worker-lifecycle.yml .github/workflows/_cf-worker-persistent-dev.yml .github/workflows/_cf-worker-preview-gc.yml .github/workflows/cf-worker-v2-ci.yml diff --git a/actions/cf-ci-check/action.yml b/actions/cf-ci-check/action.yml index cc8313f..d1fe683 100644 --- a/actions/cf-ci-check/action.yml +++ b/actions/cf-ci-check/action.yml @@ -2,8 +2,8 @@ name: CF Worker CI check description: > One validation job body (Lint, Typecheck, Unit, Integration, Build Validation): import the target build env and ci_env as data, set up dependencies, run exactly ONE command, write a short summary. Never receives Cloudflare - credentials. The caller must have checked out the app and then this repository's `actions/cf-ci-check` and - `actions/cf-preview/parse-env.mjs` at `.cf-tools` (the checkout is removed again so linters never see it). + credentials. The caller only checks out the app: the action itself is referenced as `$/actions/cf-ci-check` (this repository at the + running workflow's commit), so nothing from this repository ever lands in the app's workspace. inputs: title: description: Check name for the summary @@ -40,15 +40,12 @@ runs: run: | node "$GITHUB_ACTION_PATH/../cf-preview/parse-env.mjs" TARGET_BUILD_ENV "$GITHUB_ENV" node "$GITHUB_ACTION_PATH/../cf-preview/parse-env.mjs" CI_ENV "$GITHUB_ENV" - # Keep the tool checkout out of lint/typecheck scope, but NEVER delete this action's own action.yml: the runner - # re-reads it for the post step of the nested `setup` action ("Can't find action.yml" fails the job otherwise). - find .cf-tools -type f ! -path '.cf-tools/.git/*' ! -path '.cf-tools/actions/cf-ci-check/action.yml' -delete - - uses: firstsun-dev/.github/actions/setup@v1 + - uses: $/actions/setup with: setup-node: 'false' setup-pnpm: 'false' - if: inputs.use_mailpit == 'true' - uses: firstsun-dev/.github/actions/mailpit@v1 + uses: $/actions/mailpit - name: ${{ inputs.title }} id: check shell: bash diff --git a/actions/cf-deploy/action.yml b/actions/cf-deploy/action.yml index 3daaa3a..f2b0444 100644 --- a/actions/cf-deploy/action.yml +++ b/actions/cf-deploy/action.yml @@ -6,21 +6,27 @@ description: > stale check -> publish -> smoke -> mark verified | last-known-good recovery deploy Shared Dev only, opt-in (pipeline_phase): the `all` job WITHOUT smoke and recovery. A later `verify` job smokes it and a `rollback` job recovers it, so a cross-service smoke can wait for another Worker's deployment. - migrate Migration job (prod | preview): build + audit + D1 target check + stale check + migrate. Publishes nothing. + preflight Pre-mutation validation of one target: URL resolution + build + effective-config audit + exact D1 binding check. + Non-publishing, mutates nothing remote. The single Migration job runs it for every enabled target that is not the + migration owner, then `migrate` for the owner. + migrate Migration job (prod | preview | dev): build + audit + D1 target check + stale check + migrate (ONCE per run). Publishes nothing. publish Deploy job (prod | preview): build + audit + stale check + publish the audited artifact. Runs no smoke. verify Smoke job (prod | preview | dev): smoke the published URL; mark verified | unverified. NO Cloudflare credentials. rollback Rollback job (prod | preview | dev): prod = fail-closed Worker rollback to the previous version + re-smoke; preview | dev = redeploy the last known-good deployment + re-smoke. Jobs share only validated DATA (deploy_outputs / migration_outputs / smoke_outputs), never runner files. The CALLER job owns the Environment, permissions, concurrency and which Cloudflare credentials exist (CLOUDFLARE_API_TOKEN / - CLOUDFLARE_ACCOUNT_ID in the job env). The caller must have checked out the app at the run SHA and then this - repository's `actions/` at `.cf-tools`. + CLOUDFLARE_ACCOUNT_ID in the job env). The caller must have checked out the app at the run SHA and references this action as + `$/actions/cf-deploy` (this repository at the running workflow's commit); the engine scripts are found next to it, in `../cf-preview`. inputs: target: - description: prod | preview | dev. Bound once by the engine; application env can never change it. + description: prod | preview | dev (config / credential class). Bound once by the engine; application env can never change it. required: true + kind: + description: production | native-branch | native-dev | persistent-dev. Empty = the kind implied by target (prod -> production, preview -> native-branch, dev -> persistent-dev). native-dev is target preview. + default: '' stage: - description: all | deploy | migrate | publish | verify | rollback (see description). dev supports all, deploy, verify, rollback; prod and preview support migrate, publish, verify, rollback. + 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) @@ -35,12 +41,6 @@ inputs: default: '' smoke_password: default: '' - sibling_result: - description: Result of the job this one was ordered after (Preview Migration), for the shared-D1 guard - default: '' - sibling_migration: - description: Migration state reported by that job (applied | failed | none) - default: '' deploy_outputs: description: toJSON(needs.deploy.outputs) - hand-off to the verify / rollback stages (validated as data) default: '{}' @@ -50,6 +50,18 @@ inputs: smoke_outputs: description: toJSON(needs.smoke.outputs) - hand-off to the rollback stage default: '{}' + d1_expected_id: + description: Physical D1 database id the plan resolved for this kind. Every build of this kind must bind exactly it (preflight, migrate, publish); empty = no D1. + default: '' + migration_result: + description: publish stage only - needs.migration.result of the single Migration job + default: '' + migration_json: + description: publish stage only - the Migration job's migration_json output + default: '' + descriptor: + description: toJSON of this kind's target descriptor (the Delivery plan's validated record). When set, the engine verifies it against target / kind / d1_expected_id and takes the smoke mode from it. + default: '' call_inputs_json: description: Deferred verification only - toJSON(inputs) of the VERIFY call, compared with the deploy call's identity inputs (inputs_json) default: '' @@ -57,6 +69,21 @@ inputs: description: Verify stage only - result of the job this verification must wait for (success | skipped = satisfied; anything else blocks, fail closed). Empty = no gate. default: '' outputs: + migration_used: + description: publish stage only - the validated migration result this kind's deployment depends on ({} without d1_migrations) + value: ${{ steps.migration_gate.outputs.migration_used }} + d1_id: + description: Physical D1 database id the built config of this kind binds (audited; empty without d1_migrations) + value: ${{ steps.report.outputs.d1_id }} + target_url: + description: Canonical URL this target was built for (resolved BEFORE the build); empty when none is declared (Production without app_url_prod) + value: ${{ steps.report.outputs.target_url }} + stale: + description: true when the run was superseded by a newer push and this stage did nothing + value: ${{ steps.report.outputs.stale || 'false' }} + kind: + description: Delivery kind this stage ran for + value: ${{ steps.report.outputs.kind }} gate_blocked: description: true when the verify stage refused to run because a required upstream job did not succeed (no smoke ran; not attributable to this Worker, so no rollback) value: ${{ steps.gate.outputs.blocked || 'false' }} @@ -97,7 +124,7 @@ outputs: description: true ONLY when this run published its own SHA and a CONTENT-level smoke passed (never for Access liveness) value: ${{ steps.verified.outputs.deployed_and_verified || 'false' }} smoke_level: - description: liveness (Access login reachable only) | content (application responded) | content-sha (content and commit SHA verified) + description: skipped (smoke_mode=skip) | liveness (Access login reachable only) | content (application responded) | content-sha (content and commit SHA verified) value: ${{ steps.verified.outputs.smoke_level || steps.smoke.outputs.smoke_level }} migration: description: applied | failed | none @@ -111,6 +138,9 @@ outputs: rollback_result: description: restored_verified | restored_unverified | restored_unhealthy | blocked | failed | no_known_good (rollback stage) value: ${{ steps.rbfinal.outputs.result }} + smoke_mode: + description: full | basic | skip - the smoke mode resolved for this delivery kind + value: ${{ steps.mode.outputs.smoke_mode }} rollback_ok: description: true only when the previous deployment was restored AND content-verified (rollback stage) value: ${{ steps.rbfinal.outputs.ok || 'false' }} @@ -120,18 +150,26 @@ runs: # Must run before `init`: engine.sh init calls cli.mjs, which imports `smol-toml` from this directory. - name: Install tooling deps shell: bash - run: cd "$GITHUB_ACTION_PATH/../cf-preview" && npm ci --ignore-scripts --no-audit --no-fund + # The Migration job calls this action once per validated target: install the tooling once per job. + run: cd "$GITHUB_ACTION_PATH/../cf-preview" && { [ -d node_modules/smol-toml ] || npm ci --ignore-scripts --no-audit --no-fund; } - name: Initialise (bind target and stage, validate credentials and env as data) id: init shell: bash env: 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 }} CALL_INPUTS_JSON: ${{ inputs.call_inputs_json }} PREVIEW_SECRETS: ${{ inputs.target == 'preview' && inputs.preview_secrets || '' }} run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" init' + - name: Resolved smoke mode + id: mode + shell: bash + run: 'echo "smoke_mode=$(cat "${STATE_DIR:-$RUNNER_TEMP/cfv2}/smoke_mode")" >> "$GITHUB_OUTPUT"' - name: Hand-off from the previous jobs (validated data) id: hydrate if: inputs.stage == 'verify' || inputs.stage == 'rollback' @@ -154,13 +192,22 @@ runs: run: | "$GITHUB_ACTION_PATH/../cf-preview/engine.sh" gate-blocked | tee -a "$GITHUB_OUTPUT" exit 1 + - name: Migration gate (fail closed before any build, migration or publish) + id: migration_gate + if: inputs.stage == 'publish' + shell: bash + env: + ENGINE_DESCRIPTOR: ${{ inputs.descriptor }} + MIGRATION_RESULT: ${{ inputs.migration_result }} + MIGRATION_JSON: ${{ inputs.migration_json }} + run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" migration-gate | tee -a "$GITHUB_OUTPUT"' - name: Stale-SHA guard (start) if: inputs.stage != 'verify' && inputs.stage != 'rollback' shell: bash run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" guard start' - name: Setup dependencies - if: env.STALE != 'true' - uses: firstsun-dev/.github/actions/setup@v1 + if: env.STALE != 'true' && env.CFV2_DEPS != 'done' + uses: $/actions/setup with: setup-node: 'false' setup-pnpm: 'false' @@ -168,7 +215,7 @@ runs: if: env.STALE != 'true' && inputs.stage != 'verify' && inputs.stage != 'rollback' shell: bash env: - PREPARE_LEDGER: ${{ inputs.stage != 'migrate' }} + PREPARE_LEDGER: ${{ inputs.stage != 'migrate' && inputs.stage != 'preflight' }} run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" prepare' - name: Build and audit the deployable (nothing remote is changed) if: env.STALE != 'true' && inputs.stage != 'verify' && inputs.stage != 'rollback' @@ -178,13 +225,6 @@ runs: if: env.STALE != 'true' && inputs.stage != 'verify' && inputs.stage != 'rollback' shell: bash run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" audit-d1' - - name: Shared-D1 guard (Dev after a failed Preview migration) - if: env.STALE != 'true' && inputs.target == 'dev' - shell: bash - env: - PREVIEW_MIGRATION: ${{ inputs.sibling_migration }} - PREVIEW_JOB_RESULT: ${{ inputs.sibling_result }} - run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" d1-guard' - name: Stale-SHA guard (before migration) if: env.STALE != 'true' && (inputs.stage == 'all' || inputs.stage == 'deploy' || inputs.stage == 'migrate') shell: bash @@ -340,6 +380,11 @@ runs: run: | echo "::error::Rollback result '${RESULT:-unknown}': the previous deployment was NOT restored and verified. See the job summary for the manual recovery commands." exit 1 + - name: Report stage state (D1 id, canonical URL, superseded) + id: report + if: always() && steps.init.outcome == 'success' + shell: bash + run: '"$GITHUB_ACTION_PATH/../cf-preview/engine.sh" report | tee -a "$GITHUB_OUTPUT"' - name: Remove staged env files if: always() shell: bash diff --git a/actions/cf-preview-gc/gc.mjs b/actions/cf-preview-gc/gc.mjs index 95a8514..62a5360 100644 --- a/actions/cf-preview-gc/gc.mjs +++ b/actions/cf-preview-gc/gc.mjs @@ -2,6 +2,7 @@ // Pure decision + orchestration logic; every remote effect goes through the injected `github` / `cloudflare` // clients so the whole policy is unit-testable with fakes. Fail closed: any uncertainty => SKIP, never delete. import { previewIdentity } from '../cf-preview/lib.mjs'; +import { RESERVED_PREVIEW_NAMES } from '../cf-preview/delivery.mjs'; /** Exact description written by `gh.sh retire` when a branch is deleted. */ export const TOMBSTONE_DESCRIPTION = 'Branch deleted; Preview kept'; @@ -44,6 +45,11 @@ export async function classify({ cfg, target, previewName, deployments, github, }); const { accountId, hostWorker, appName } = target; + // The stable Native Dev Preview owns the reserved name `dev` (OpenSpec unify-cf-worker-v2-delivery-targets): never collected, + // whatever an old branch ledger says (a legacy branch literally named `dev` would have used that name). + if (RESERVED_PREVIEW_NAMES.includes(previewName)) { + return mk('KEEP', 'protected', `'${previewName}' is the reserved name of the Native Dev Preview (never garbage-collected)`); + } if (cfg.protected.some((p) => sameProtected(p, accountId, hostWorker, previewName))) { return mk('KEEP', 'protected', 'in the protected_previews allowlist (account + host worker + preview name)'); } diff --git a/actions/cf-preview/build.sh b/actions/cf-preview/build.sh index a21d083..3291004 100755 --- a/actions/cf-preview/build.sh +++ b/actions/cf-preview/build.sh @@ -32,7 +32,10 @@ cd "$APP_DIR" # parse-env.mjs and is never written to GITHUB_ENV, so it cannot influence later stages. [ "$TARGET" != preview ] || load_env_file "${PREVIEW_ENV_FILE:-}" load_env_file "${BUILD_ENV_FILE:-}" -export VITE_APP_ENV="$TARGET" VITE_APP_VERSION="${APP_VERSION:-}" +# Engine-owned, informational: the kind and the canonical URL THIS build is for (never set by application env). +export CFV2_DELIVERY_KIND="${DELIVERY_KIND:-}" CFV2_TARGET_URL="${TARGET_URL:-}" +case "${DELIVERY_KIND:-}" in native-dev) export VITE_APP_ENV=dev;; *) export VITE_APP_ENV="$TARGET";; esac +export VITE_APP_VERSION="${APP_VERSION:-}" [ "$TARGET" = dev ] && export CLOUDFLARE_ENV="$DEV_ENV" || export CLOUDFLARE_ENV="" bash -eo pipefail -c "$BUILD_CMD" >&2 diff --git a/actions/cf-preview/cli.mjs b/actions/cf-preview/cli.mjs index 559d4ee..e075525 100644 --- a/actions/cf-preview/cli.mjs +++ b/actions/cf-preview/cli.mjs @@ -1,11 +1,16 @@ #!/usr/bin/env node -// CLI used by _cf-worker-v2.yml. Prints key=value lines (GITHUB_OUTPUT-safe) or exits non-zero. -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +// 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 { dirname, resolve } from 'node:path'; import { assertWranglerSupported, auditDevConfig, previewHostReadiness, auditPreviewConfig, auditProdConfig, classifyEvent, extractPreviewUrl, isStale, migrationPlan, workerName, classifyMigrationSql, previewIdentity, readConfig, rebindSha, artifactDigest, checkDeployedD1, resolveSharedDev, resolveTarget, sharedPreviewDevD1, } 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, +} from './delivery.mjs'; const [cmd, ...rest] = process.argv.slice(2); const opt = (n, d = '') => { @@ -18,7 +23,7 @@ const out = (o) => { for (const [k, v] of Object.entries(o)) console.log(`${k}=$ try { switch (cmd) { case 'identity': { - const id = previewIdentity({ repo: opt('repo'), worker: opt('worker'), ref: opt('ref'), hostWorker: opt('host-worker') }); + const id = branchPreviewIdentity({ repo: opt('repo'), worker: opt('worker'), ref: opt('ref'), hostWorker: opt('host-worker') }, { reserveNativeDev: opt('reserve-native-dev') === 'true' }); out({ preview_name: id.name, preview_slug: id.slug }); break; } @@ -34,7 +39,7 @@ try { accountId: process.env.CLOUDFLARE_ACCOUNT_ID, token: process.env.CLOUDFLARE_API_TOKEN, worker: opt('worker'), base: process.env.CF_API_BASE || undefined, }); - if (r.level === 'block') { console.log(`::error::${r.message}`); process.exit(1); } + if (r.level === 'block') { console.error(`::error::${r.message}`); process.exit(1); } console.log(r.level === 'ok' ? `::notice::${r.message}` : `::warning::${r.message}`); out({ preview_host: r.level }); break; @@ -52,8 +57,8 @@ try { const { errors, warnings } = auditPreviewConfig(readConfig(opt('config')), { allow: list(opt('allow')), denyResources: list(opt('deny')), }); - for (const w of warnings) console.log(`::warning::${w}`); - for (const e of errors) console.log(`::error::${e}`); + for (const w of warnings) console.error(`::warning::${w}`); + for (const e of errors) console.error(`::error::${e}`); if (errors.length) process.exit(1); out({ preflight: 'ok' }); break; @@ -106,10 +111,10 @@ try { else { effective = source.env?.[opt('dev-env', 'dev')]; if (!effective) throw new Error(`No effective config was generated and the source config has no env.${opt('dev-env', 'dev')}`); - console.log(`::notice::No adapter-generated config; auditing source env.${opt('dev-env', 'dev')}`); + console.error(`::notice::No adapter-generated config; auditing source env.${opt('dev-env', 'dev')}`); } const { errors } = auditDevConfig(effective, source, { worker: opt('worker'), denyResources: list(opt('deny')), allow: list(opt('allow')), prodUrl: opt('prod-url') }); - for (const e of errors) console.log(`::error::${e}`); + for (const e of errors) console.error(`::error::${e}`); if (errors.length) process.exit(1); out({ dev_config: 'ok' }); break; @@ -141,8 +146,8 @@ try { } case 'audit-prod': { const { errors, warnings } = auditProdConfig(readConfig(opt('effective')), readConfig(opt('source')), { allow: list(opt('allow')) }); - for (const w of warnings) console.log(`::warning::${w}`); - for (const e of errors) console.log(`::error::${e}`); + for (const w of warnings) console.error(`::warning::${w}`); + for (const e of errors) console.error(`::error::${e}`); if (errors.length) process.exit(1); out({ audit_prod: 'ok' }); break; @@ -161,14 +166,164 @@ try { out({ d1_scan: r.level, d1_scan_reason: r.reasons.slice(0, 3).join(' | ') || 'only additive statements' }); break; } + case 'resolve-kinds': { + // Enabled delivery kinds for the event (comma separated; empty = nothing to deliver). Throws on a forbidden combination. + const kinds = resolveDeliveryTargets({ + event: opt('event'), ref: opt('ref'), strategy: opt('strategy'), previewOnPush: opt('preview-on-push', 'true') === 'true', + sharedDevOnPush: opt('shared-dev-on-push', 'false') === 'true', sharedDevNativeOnPush: opt('shared-dev-native-on-push', 'false') === 'true', + dispatchTarget: opt('dispatch-target'), devRef: opt('dev-ref'), + }); + out({ kinds: kinds.join(',') }); + break; + } + case 'smoke-mode': + out({ smoke_mode: resolveSmokeMode(assertKind(opt('kind')), opt('mode', 'full'), opt('overrides')) }); + break; + case 'url-isolation': { + // --kind K --env-var VAR (this kind's build env text) --urls JSON {kind:url} + const bad = foreignUrlsInBuildEnv(assertKind(opt('kind')), process.env[opt('env-var')] ?? '', JSON.parse(opt('urls', '{}'))); + for (const b of bad) console.error(`::error::build environment of ${opt('kind')}: ${b}; each target must be built with its own URLs`); + if (bad.length) process.exit(1); + out({ url_isolation: 'ok' }); + break; + } + case 'resolve-url': { + // Canonical URL of ONE target, resolved BEFORE its build (fails closed). Preview kinds: derived from the real Preview + // identity + the host Worker's Cloudflare-reported preview_url_suffix (or the caller's preview_url_suffix), cross-checked + // with any declared URL. Production / persistent Dev: the declared URL (required for Dev). + const kind = assertKind(opt('kind')); + let url = ''; + let source = 'declared'; + if (kind === 'native-branch' || kind === 'native-dev') { + let apiSuffix = ''; + if (!opt('suffix') && opt('host-check', 'true') !== 'false') { + const r = await previewHostReadiness(fetch, { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, token: process.env.CLOUDFLARE_API_TOKEN, worker: opt('host-worker'), + base: process.env.CF_API_BASE || undefined, + }); + if (r.level === 'block') throw new Error(r.message); + apiSuffix = r.suffix ?? ''; + } + ({ url, source } = resolveCanonicalUrl({ previewName: opt('name'), explicit: opt('explicit'), suffix: opt('suffix'), apiSuffix })); + } else if (opt('explicit')) { + url = canonicalOrigin(opt('explicit'), `${kind} app URL`); + } else if (kind === 'persistent-dev') { + throw new Error('persistent-dev requires app_url_dev (the canonical Dev URL must be known before the build)'); + } + const envText = opt('build-env') && existsSync(opt('build-env')) ? readFileSync(opt('build-env'), 'utf8') : ''; + const bad = checkBuildEnvOrigins(envText, url); + for (const b of bad) console.error(`::error::build environment of ${kind}: ${b}`); + if (bad.length) process.exit(1); + out({ target_url: url, url_source: source }); + break; + } + case 'plan': { + // The delivery plan of a run, from the validated workflow inputs (INPUTS_JSON) - data only, no remote effects. + 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'; + 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') { + restored = restoreDescriptors(JSON.parse(process.env.PHASE_STATE ?? '{}'), { repo: process.env.GITHUB_REPOSITORY ?? '', runId: opt('run-id', '0'), event: opt('event'), ref: opt('ref') }); + kinds = Object.keys(restored); + } else { + kinds = resolveDeliveryTargets({ + event: opt('event'), ref: opt('ref'), strategy: str('deployment_strategy'), previewOnPush: bool('preview_on_branch_push'), + sharedDevOnPush: bool('shared_dev_on_branch_push'), sharedDevNativeOnPush: bool('shared_dev_native_preview_on_branch_push'), + dispatchTarget: str('dispatch_target'), devRef: str('persistent_dev_ref'), + }); + } + let migrations = []; + if (opt('phase') !== 'verify' && bool('d1_migrations') && kinds.length) { + const cfgPath = resolve(opt('app-dir', '.'), str('wrangler_config') || 'wrangler.toml'); + migrations = planMigrations(readConfig(cfgPath), kinds, { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? '', binding: str('d1_binding'), devEnv: str('dev_wrangler_env') || 'dev', + migrationsDirAbs: (d) => resolve(dirname(cfgPath), d), + }); + } + const flat = {}; + for (const k of DELIVERY_KINDS) flat[`on_${k.replace(/-/g, '_')}`] = String(kinds.includes(k)); + const descriptors = restored ?? buildDescriptors({ + kinds, app: str('app_name'), runId: opt('run-id', '0'), repo: process.env.GITHUB_REPOSITORY ?? '', lockScope: str('d1_lock_scope'), groups: migrations, + smokeMode: str('smoke_mode') || 'full', smokeOverrides: str('smoke_mode_overrides'), phase: opt('phase') || 'all', + }); + // THE migration of the run (at most one physical D1): the owner kind selects the Environment and the build the single Migration job uses. + const [mig] = migrations; + const migration = mig ? { kind: mig.owner, environment: descriptors[mig.owner].environment, d1_id: mig.databaseId, kinds: mig.members } : null; + if (mig) console.error(`::notice::D1 ${mig.databaseName}: ONE migration (owner ${mig.owner}) serves ${mig.members.join(', ')}`); + // The run-level lock identity (the canonical calculation; the workflow's scheduling-time `concurrency.group` expression is its tested + // YAML twin). A verify call already proved phase_state.group equals it inside restoreDescriptors. + const group = opt('phase') === 'verify' + ? String(JSON.parse(process.env.PHASE_STATE ?? '{}').group ?? '') + : runLockGroup({ event: opt('event'), ref: opt('ref'), inputs: i, runId: opt('run-id', '0') }); + out({ kinds: JSON.stringify(kinds), group, descriptors: JSON.stringify(descriptors), has_targets: String(kinds.length > 0), has_migrations: String(Boolean(mig)), migration: migration ? JSON.stringify(migration) : '', ...flat }); + break; + } + case 'descriptor-check': { // --kind K --target T --d1 ID (descriptor JSON in env DESCRIPTOR): the stage's own bindings must match the record + const d = parseDescriptor(process.env.DESCRIPTOR ?? '', opt('kind')); + if (d.target !== opt('target')) throw new Error(`target descriptor engine target '${d.target}' differs from the stage's '${opt('target')}'`); + if (d.d1_expected_id !== opt('d1')) throw new Error('target descriptor D1 id differs from the stage\'s expected D1'); + out({ descriptor: 'ok', smoke_mode: d.smoke_mode }); + break; + } + case 'migration-gate': { // publish stage; env: DESCRIPTOR, D1_MIGRATIONS, MIGRATION_RESULT / MIGRATION_JSON (needs.migration.result / outputs.migration_json) + const e = process.env; + const d = parseDescriptor(e.DESCRIPTOR ?? '', opt('kind')); + const m = resolveMigrationUsed({ + d1Migrations: e.D1_MIGRATIONS === 'true', kind: d.kind, ownerKind: d.d1_owner_kind, expectedId: d.d1_expected_id, + result: e.MIGRATION_RESULT ?? '', json: e.MIGRATION_JSON ?? '', + }); + out({ migration_used: JSON.stringify(m) }); + break; + } + case 'verification-outcome': { // --level L + const o = verificationOutcome(opt('level')); + out({ verified: String(o.verified), verification_kind: o.kind }); + break; + } + case 'rollback-guard': { // env: POLICY PREVIOUS CURRENT WORKER D1_PENDING D1_COMPAT D1_SCAN [LIVE set only for the live check] + const e = process.env; + const b = prodRollbackBlock({ + policy: e.POLICY ?? '', previous: e.PREVIOUS ?? '', current: e.CURRENT ?? '', worker: e.WORKER ?? '', d1Pending: e.D1_PENDING === 'true', + d1Compat: e.D1_COMPAT ?? '', d1Scan: e.D1_SCAN ?? '', live: 'LIVE' in e ? e.LIVE : undefined, + }); + 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({ + target: e.TARGET, rbResult: e.RB_RESULT ?? '', rbVerify: e.RB_VERIFY ?? '', rbLevel: e.RB_LEVEL ?? '', goodSha: e.GOOD_SHA ?? '', + recovered: e.RECOVERED ?? '', verified: e.VERIFIED ?? '', smokeLevel: e.SMOKE_LEVEL ?? '', + }); + out({ result: v.result, level: v.level, ok: String(v.ok) }); + break; + } + 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', + 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); + 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 }); + break; + } case 'stale': out({ stale: String(isStale({ runSha: opt('sha'), branchTipSha: opt('tip') })) }); break; default: - console.error('usage: cli.mjs identity|wrangler-check|extract-url|preview-host-check|classify|resolve|resolve-dev|rebind-env|artifact-digest|d1-consistency|d1-shared|dev-config-check|preflight|migration-config|effective-config|audit-prod|worker-name|migration-scan|stale'); + console.error('usage: cli.mjs identity|wrangler-check|extract-url|preview-host-check|classify|resolve|resolve-dev|rebind-env|artifact-digest|d1-consistency|d1-shared|dev-config-check|preflight|migration-config|effective-config|audit-prod|worker-name|migration-scan|resolve-kinds|smoke-mode|url-isolation|resolve-url|plan|stale'); process.exit(2); } } catch (e) { - console.log(`::error::${e.message}`); + console.error(`::error::${e.message}`); process.exit(1); } diff --git a/actions/cf-preview/delivery.mjs b/actions/cf-preview/delivery.mjs new file mode 100644 index 0000000..4dc972a --- /dev/null +++ b/actions/cf-preview/delivery.mjs @@ -0,0 +1,591 @@ +// Pure decision logic of the target-driven delivery architecture (OpenSpec unify-cf-worker-v2-delivery-targets). +// One normalised vocabulary for the four delivery kinds; no I/O. Imported by cli.mjs, unit-tested in lib.test.mjs. +import { classifyEvent, migrationPlan, previewIdentity, STRATEGIES } from './lib.mjs'; +import { parseEnvLines } from './parse-env.mjs'; + +export const DELIVERY_KINDS = ['production', 'native-branch', 'native-dev', 'persistent-dev']; +export const SMOKE_MODES = ['full', 'basic', 'skip']; +/** Fixed name of the stable Native Dev Preview. Reserved: no branch Preview may ever take it. */ +export const NATIVE_DEV_NAME = 'dev'; +export const RESERVED_PREVIEW_NAMES = [NATIVE_DEV_NAME]; + +/** Engine "target class" (config source / credentials / wrangler command family) of each kind. */ +export const ENGINE_TARGET = { production: 'prod', 'native-branch': 'preview', 'native-dev': 'preview', 'persistent-dev': 'dev' }; +/** Back-compat: the kind an engine target implies when the caller passes none. */ +export const DEFAULT_KIND = { prod: 'production', preview: 'native-branch', dev: 'persistent-dev' }; + +export function assertKind(kind) { + if (!DELIVERY_KINDS.includes(kind)) throw new Error(`Unknown delivery kind '${kind}' (expected ${DELIVERY_KINDS.join(' | ')})`); + return kind; +} + +/** smoke_mode_overrides: JSON object keyed by delivery kind; values full|basic|skip. Strict: unknown key/value rejects. */ +export function parseSmokeOverrides(text) { + const s = String(text ?? '').trim(); + if (!s) return {}; + let o; + try { o = JSON.parse(s); } catch (e) { throw new Error(`smoke_mode_overrides is not valid JSON: ${e.message}`); } + if (o === null || typeof o !== 'object' || Array.isArray(o)) throw new Error('smoke_mode_overrides must be a JSON object keyed by delivery kind'); + for (const [k, v] of Object.entries(o)) { + if (!DELIVERY_KINDS.includes(k)) throw new Error(`smoke_mode_overrides: unknown delivery kind '${k}' (expected ${DELIVERY_KINDS.join(' | ')})`); + if (!SMOKE_MODES.includes(v)) throw new Error(`smoke_mode_overrides.${k}: unknown smoke mode '${v}' (expected ${SMOKE_MODES.join(' | ')})`); + } + return o; +} + +export function resolveSmokeMode(kind, smokeMode = 'full', overridesText = '') { + assertKind(kind); + const base = smokeMode === '' || smokeMode == null ? 'full' : smokeMode; + if (!SMOKE_MODES.includes(base)) throw new Error(`Unknown smoke_mode '${base}' (expected ${SMOKE_MODES.join(' | ')})`); + return parseSmokeOverrides(overridesText)[kind] ?? base; +} + +/** + * 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. + */ +export function resolveDeliveryTargets({ + event, ref, strategy, previewOnPush = true, sharedDevOnPush = false, sharedDevNativeOnPush = false, dispatchTarget = '', devRef = '', +}) { + if (!STRATEGIES.includes(strategy)) throw new Error(`Unknown deployment_strategy: ${strategy}`); + if (strategy === 'production-only' && (sharedDevOnPush || sharedDevNativeOnPush)) { + throw new Error('deployment_strategy: production-only cannot be combined with shared_dev_on_branch_push / shared_dev_native_preview_on_branch_push (a production-only Worker must not expose a Dev deployment)'); + } + 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 (!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 []; + const kinds = []; + if (previewOnPush) kinds.push('native-branch'); + if (sharedDevNativeOnPush) kinds.push('native-dev'); + if (sharedDevOnPush) kinds.push('persistent-dev'); + return kinds; +} + +/** Native branch Preview identity that can never take a reserved name (the stable Native Dev Preview owns `dev`). */ +export function branchPreviewIdentity(args, { reserveNativeDev = false } = {}) { + const id = previewIdentity(args); + if (reserveNativeDev && RESERVED_PREVIEW_NAMES.includes(id.name)) return { ...id, name: `b-${id.name}` }; + return id; +} + +/** + * Migration plan across the enabled targets: at most ONE physical D1 (`account/database_id`) per pipeline. Kinds that name the same + * D1 (e.g. Native Branch + Native Dev) are deduplicated into one group; two DISTINCT databases fail closed before any mutation + * (documented compatibility restriction: docs/cf-worker-v2-unified-delivery.md). Returns [] without kinds, else [group]. + * `cfg` is the SOURCE Wrangler config; `kinds` the enabled kinds. Production D1 must never be reachable from a non-production + * kind (migrationPlan already refuses a D1 declared on both sides; this re-checks the final identities). + * Owner = the first member in OWNER_ORDER: the kind whose build + audit + Environment the single Migration job uses. + */ +const OWNER_ORDER = ['native-branch', 'native-dev', 'persistent-dev', 'production']; +export function planMigrations(cfg, kinds, { accountId = '', binding = '', devEnv = 'dev', migrationsDirAbs = (d) => d } = {}) { + const groups = new Map(); + for (const kind of kinds) { + assertKind(kind); + const p = migrationPlan(cfg, ENGINE_TARGET[kind], { binding, devEnv, migrationsDirAbs }); + const id = `${accountId}/${p.databaseId}`; + if (!groups.has(id)) groups.set(id, { id, databaseId: p.databaseId, databaseName: p.databaseName, binding: p.binding, members: [] }); + groups.get(id).members.push(kind); + } + if (groups.size > 1) { + const list = [...groups.values()].map((g) => `${g.databaseName} (${g.members.join(', ')})`).join(' vs '); + throw new Error(`d1_migrations: the enabled targets name ${groups.size} distinct physical D1 databases: ${list}. One pipeline migrates at most one database; point the targets at the same D1 or disable one channel (nothing was changed)`); + } + const out = [...groups.values()].map((g) => { + g.members.sort((a, b) => OWNER_ORDER.indexOf(a) - OWNER_ORDER.indexOf(b)); + return { ...g, owner: g.members[0] }; + }); + const prod = out.find((g) => g.members.includes('production')); + if (prod && prod.members.length > 1) { + throw new Error(`Production D1 ${prod.databaseName} is also mapped to ${prod.members.filter((m) => m !== 'production').join(', ')}; Preview/Dev must never migrate or bind Production D1`); + } + return out; +} + +/** GitHub Environment of each kind (protection rules / deployment history). Production and Preview names are unchanged. */ +export const ENVIRONMENT_SUFFIX = { production: 'prod', 'native-branch': 'preview', 'native-dev': 'native-dev', 'persistent-dev': 'dev' }; +export const environmentName = (app, kind) => `${app}/${ENVIRONMENT_SUFFIX[assertKind(kind)]}`; + +/** Ledger environment (GitHub Deployments) used for known-good lookup. Native Dev has ONE fixed ledger per app. */ +export function ledgerEnv(app, kind, previewName = '') { + switch (assertKind(kind)) { + case 'native-branch': return `${app}/preview/${previewName}`; + case 'native-dev': return `${app}/native-dev/${NATIVE_DEV_NAME}`; + case 'persistent-dev': return `${app}/dev/shared`; + default: return ''; + } +} + +/** + * Build-environment URL isolation: the build env of a target must not carry another target's public URL. + * `urls` = { kind: url } of every known target URL; `env` = the parsed KEY=VALUE text of THIS kind's build env. + * Returns the offending lines (empty = isolated). + */ +export function foreignUrlsInBuildEnv(kind, buildEnvText, urls) { + const origin = (u) => { try { return new URL(u).origin; } catch { return ''; } }; + const foreign = Object.entries(urls).filter(([k, u]) => k !== kind && u && origin(u)).map(([k, u]) => [k, origin(u)]); + const bad = []; + for (const line of String(buildEnvText ?? '').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const key = t.split('=')[0]; + for (const [k, o] of foreign) if (t.includes(o)) bad.push(`${key} contains the ${k} URL (${o})`); + } + return bad; +} + +/** Compare an asserted target URL with the published one (case/trailing-slash insensitive). */ +export const sameUrl = (a, b) => String(a).replace(/\/+$/, '').toLowerCase() === String(b).replace(/\/+$/, '').toLowerCase(); + +// --------------------------------------------------------------------------------------------------------------------- +// Canonical target URL, resolved BEFORE any build (Preview kinds). Cloudflare documents the Preview URL of a named Preview +// as `-..workers.dev` (workers.dev) or `.` (custom +// domain). The Workers subdomain API reports the host part as `preview_url_suffix` (leading "-" for workers.dev), so the URL +// is `https://`. Nothing is guessed: no suffix and no explicit URL means the build must not start. +// --------------------------------------------------------------------------------------------------------------------- +export const PREVIEW_SUFFIX_RE = /^[-.][a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; + +/** Normalise a canonical URL to its origin; only an https origin (no path/query/credentials) is a valid app URL. */ +export function canonicalOrigin(url, what = 'URL') { + let u; + try { u = new URL(String(url)); } catch { throw new Error(`${what} '${url}' is not a valid URL`); } + if (u.protocol !== 'https:') throw new Error(`${what} must be https: ${u.protocol}//${u.host}`); + if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== '/')) { + throw new Error(`${what} must be a bare origin (no path, query, fragment or credentials): ${url}`); + } + return u.origin; +} + +export function previewUrlFromSuffix(previewName, suffix) { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(previewName))) throw new Error(`unsafe Preview name '${previewName}'`); + if (!PREVIEW_SUFFIX_RE.test(String(suffix))) throw new Error(`Preview URL suffix '${suffix}' is invalid (expected e.g. -..workers.dev or .)`); + const host = `${previewName}${suffix}`; + const label = host.split('.')[0]; + if (label.length > 63) throw new Error(`Preview hostname label '${label}' is ${label.length} chars (> 63): the Preview name plus the Worker name is too long`); + return `https://${host}`; +} + +/** + * Resolve the canonical URL of a native-branch / native-dev target. `explicit` = caller-declared URL (may be empty), + * `suffix` = caller-declared preview_url_suffix, `apiSuffix` = Cloudflare-reported preview_url_suffix of the host Worker. + * Throws when nothing can be resolved, when the two suffix sources disagree, or when an explicit URL contradicts the derived one. + */ +export function resolveCanonicalUrl({ previewName, explicit = '', suffix = '', apiSuffix = '' }) { + if (suffix && apiSuffix && suffix !== apiSuffix) { + throw new Error(`preview_url_suffix '${suffix}' contradicts the suffix Cloudflare reports for the host Worker ('${apiSuffix}')`); + } + const useSuffix = suffix || apiSuffix; + const derived = useSuffix ? previewUrlFromSuffix(previewName, useSuffix) : ''; + const declared = explicit ? canonicalOrigin(explicit, 'declared app URL') : ''; + if (declared && derived && !sameUrl(declared, derived)) { + throw new Error(`declared app URL ${declared} differs from the URL Cloudflare will serve Preview '${previewName}' at (${derived}); the build would embed the wrong URL`); + } + const url = declared || derived; + if (!url) { + throw new Error(`cannot resolve the canonical URL of Preview '${previewName}' before the build: Cloudflare did not report a preview_url_suffix for the host Worker and neither an explicit app URL nor preview_url_suffix was given (set preview_url_suffix, or app_url_shared_dev_native_preview for Native Dev)`); + } + return { url, source: declared ? (derived ? 'declared+derived' : 'declared') : (suffix ? 'preview_url_suffix' : 'cloudflare') }; +} + +/** + * Build-env origin consistency where it is deterministic: variables that name THIS application's own callback / redirect / + * origin / site / app URL must be same-origin with the target URL. (An IdP issuer is another service and cannot be verified + * here; foreignUrlsInBuildEnv rejects it only when it is another target's URL of this application.) + */ +const SELF_URL_KEY = /(CALLBACK|REDIRECT_URI|REDIRECT_URL|APP_ORIGIN|APP_URL|SITE_URL|SELF_URL|PUBLIC_ORIGIN)/i; +export function checkBuildEnvOrigins(buildEnvText, targetUrl) { + if (!targetUrl) return []; + const origin = new URL(targetUrl).origin; + const bad = []; + for (const line of String(buildEnvText ?? '').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + const key = t.slice(0, eq); + const value = t.slice(eq + 1).trim(); + if (!SELF_URL_KEY.test(key) || !/^https?:\/\//i.test(value)) continue; + let o = ''; + try { o = new URL(value).origin; } catch { bad.push(`${key} is not a valid URL`); continue; } + if (o !== origin) bad.push(`${key}=${o} is not the origin of this target (${origin})`); + } + return bad; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Target descriptor: ONE versioned, validated record per enabled kind, produced by the Delivery plan job and consumed by the +// thin per-kind workflow calls (and by every engine stage). It replaces the per-field `with:` copies (engine target, GitHub +// Environment, concurrency groups, expected D1, migration owner, smoke policy). Pure data: never a secret, never a URL +// (URLs are resolved against Cloudflare before each build), validated at generation AND at every consumer. +// --------------------------------------------------------------------------------------------------------------------- +export const DESCRIPTOR_VERSION = 1; +export const PHASES = ['all', 'deploy', 'verify']; +const DESCRIPTOR_KEYS = ['v', 'kind', 'target', 'environment', 'deploy_group', 'rollback_group', 'd1_expected_id', 'd1_owner_kind', 'owns_migration', 'smoke_mode', 'phase']; + +/** + * 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. + */ +export function concurrencyGroups(kind, { runId, repo = '', app, lockScope = '' }) { + if (assertKind(kind) === 'persistent-dev') { + const g = `cfv2-dev-${repo}-${lockScope || app}`; + return { deploy_group: g, rollback_group: g }; + } + return { deploy_group: `cfv2-leg-${runId}-${kind}`, rollback_group: `cfv2-leg-${runId}-${kind}-rollback` }; +} + +export function targetDescriptor({ kind, app, runId, repo = '', lockScope = '', d1ExpectedId = '', ownerKind = '', smokeMode = 'full', phase = 'all' }) { + assertKind(kind); + return validateDescriptor({ + v: DESCRIPTOR_VERSION, kind, target: ENGINE_TARGET[kind], environment: environmentName(app, kind), + ...concurrencyGroups(kind, { runId, repo, app, lockScope }), + d1_expected_id: d1ExpectedId, d1_owner_kind: ownerKind, owns_migration: Boolean(d1ExpectedId && ownerKind === kind), + smoke_mode: smokeMode, phase, + }); +} + +/** Strict schema check (throws; returns the descriptor). Unknown/missing keys, cross-kind values and forged groups all reject. */ +export function validateDescriptor(d) { + if (d === null || typeof d !== 'object' || Array.isArray(d)) throw new Error('target descriptor must be an object'); + const keys = Object.keys(d); + const extra = keys.filter((k) => !DESCRIPTOR_KEYS.includes(k)); + const missing = DESCRIPTOR_KEYS.filter((k) => !keys.includes(k)); + if (extra.length || missing.length) throw new Error(`target descriptor keys: unexpected [${extra}] missing [${missing}]`); + if (d.v !== DESCRIPTOR_VERSION) throw new Error(`target descriptor version ${d.v} is not supported (expected ${DESCRIPTOR_VERSION})`); + assertKind(d.kind); + if (d.target !== ENGINE_TARGET[d.kind]) throw new Error(`target descriptor: kind ${d.kind} runs on engine target ${ENGINE_TARGET[d.kind]}, not ${d.target}`); + const app = String(d.environment).slice(0, String(d.environment).lastIndexOf('/')); + if (!app || /\s/.test(app) || d.environment !== environmentName(app, d.kind)) throw new Error(`target descriptor: environment '${d.environment}' does not belong to kind ${d.kind}`); + if (typeof d.deploy_group !== 'string' || typeof d.rollback_group !== 'string') throw new Error('target descriptor: concurrency groups must be strings'); + const legit = d.kind === 'persistent-dev' + ? /^cfv2-dev-\S+-\S+$/.test(d.deploy_group) && d.rollback_group === d.deploy_group + : new RegExp(`^cfv2-leg-\\d+-${d.kind}$`).test(d.deploy_group) && d.rollback_group === `${d.deploy_group}-rollback`; + if (!legit) throw new Error(`target descriptor: concurrency groups '${d.deploy_group}' / '${d.rollback_group}' do not belong to kind ${d.kind}`); + if (typeof d.d1_expected_id !== 'string' || !/^[\w-]*$/.test(d.d1_expected_id)) throw new Error('target descriptor: d1_expected_id is not a database id'); + if (d.d1_owner_kind !== '') assertKind(d.d1_owner_kind); + if (d.owns_migration !== Boolean(d.d1_expected_id && d.d1_owner_kind === d.kind)) throw new Error('target descriptor: owns_migration contradicts the migration owner'); + if (!SMOKE_MODES.includes(d.smoke_mode)) throw new Error(`target descriptor: unknown smoke mode '${d.smoke_mode}'`); + if (!PHASES.includes(d.phase)) throw new Error(`target descriptor: unknown phase '${d.phase}'`); + return d; +} + +/** + * All descriptors of a run, keyed by kind (enabled kinds only, stable order). `groups` = planMigrations() result of the run + * (owner/members per physical D1); `d1ByKind` = { kind: id } override for the deferred verify phase (comes from phase_state). + */ +export function buildDescriptors({ kinds, app, runId, repo = '', lockScope = '', groups = [], d1ByKind = null, smokeMode = 'full', smokeOverrides = '', phase = 'all' }) { + const out = {}; + for (const kind of DELIVERY_KINDS.filter((k) => kinds.includes(k))) { + const g = groups.find((m) => m.members.includes(kind)); + out[kind] = targetDescriptor({ + kind, app, runId, repo, lockScope, d1ExpectedId: d1ByKind ? String(d1ByKind[kind] ?? '') : (g ? g.databaseId : ''), ownerKind: g ? g.owner : '', + smokeMode: resolveSmokeMode(kind, smokeMode, smokeOverrides), phase, + }); + } + return out; +} + +/** Parse + validate a descriptor (or a map of them) arriving from a workflow output / phase_state - a trust boundary. */ +export function parseDescriptor(text, expectKind = '') { + let d; + try { d = JSON.parse(String(text)); } catch (e) { throw new Error(`target descriptor is not valid JSON: ${e.message}`); } + validateDescriptor(d); + if (expectKind && d.kind !== expectKind) throw new Error(`target descriptor is for ${d.kind}, not ${expectKind}`); + return d; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Migration gate (pure). Runs in the engine before any build or publish of a Delivery call and is unit-tested here. (The +// pre-mutation validation of every target lives in the single Migration job: its steps run in order, so a failed target +// check stops the job before the migrate step - no peer-result bookkeeping.) +// --------------------------------------------------------------------------------------------------------------------- +/** + * Publish stage: the result of THE Migration job this kind's deployment depends on. Fails closed unless that job succeeded and + * reports an APPLIED migration of the planned owner on exactly this kind's D1. A stale-skipped migration (`stale` true) is NEVER + * authorization: nothing was applied, and the branch may point at this commit again by the time the Delivery runs (A -> B -> A), so + * the later branch-tip check cannot prove the migration happened. Returns the validated migration result, or {} when the app has no + * D1 migrations. + */ +export function resolveMigrationUsed({ d1Migrations, kind, ownerKind = '', expectedId = '', result = '', json = '' }) { + if (!d1Migrations) return {}; + assertKind(kind); + if (!ownerKind) throw new Error(`no migration owner is planned for ${kind}; nothing is published`); + if (result !== 'success') throw new Error(`the D1 migration ${kind} depends on (owner ${ownerKind}) finished '${result || 'missing'}'; nothing is published`); + let m; + try { m = JSON.parse(json); } catch { throw new Error(`the migration result for ${kind} is missing or not valid JSON; nothing is published`); } + if (m === null || typeof m !== 'object' || Array.isArray(m)) throw new Error(`the migration result for ${kind} is malformed; nothing is published`); + if (m.stale === true || m.stale === 'true') throw new Error(`the migration for ${kind} was skipped because its run became stale; it does not authorize a publish (nothing is published)`); + if (m.migration !== 'applied') throw new Error(`the migration result for ${kind} reports no applied migration; nothing is published`); + if (m.kind !== ownerKind) throw new Error(`the migration result belongs to another kind than the planned owner ${ownerKind}; refusing`); + if (m.d1_id !== expectedId) throw new Error(`the migration result is for another D1 than ${kind}'s (${expectedId}); refusing`); + return m; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Verification and recovery decisions (pure). The engine only gathers facts (levels, versions, the live version) and +// performs the effects; WHAT those facts mean lives here, unit-tested. +// --------------------------------------------------------------------------------------------------------------------- +/** Only a content-level smoke verifies a deployment; Access liveness or a skipped smoke never does. */ +export const isContentLevel = (level) => level === 'content' || level === 'content-sha'; + +export function verificationOutcome(level) { + if (isContentLevel(level)) return { verified: true, kind: 'content' }; + return { verified: false, kind: level === 'skipped' ? 'skipped' : 'liveness' }; +} + +/** + * 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. + * A Worker rollback never reverts D1: after an applied migration the previous version must be PROVEN to cope. + */ +export function prodRollbackBlock({ policy, previous = '', current = '', worker = '', d1Pending = false, d1Compat = '', d1Scan = '', live }) { + if (policy !== 'automatic') return { code: 'policy', why: `production_rollback_policy is '${policy}'.` }; + 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.` }; + } + 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.` }; + } + return null; +} + +/** + * Final verdict of the Rollback job. restored_verified only when the restored deployment was re-smoked at CONTENT level. + * Production: facts = rolled back? post-rollback smoke? level. Preview/Dev: known-good found? recovered? re-verified? level. + */ +export function rollbackVerdict({ target, rbResult = '', rbVerify = '', rbLevel = '', goodSha = '', recovered = '', verified = '', smokeLevel = '' }) { + let result; + let level = ''; + if (target === 'prod') { + 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 (recovered === 'success' && verified === 'success') { result = isContentLevel(smokeLevel) ? 'restored_verified' : 'restored_unverified'; level = smokeLevel; } else result = 'failed'; + return { result, level, ok: result === 'restored_verified' }; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Run-level lock identity (the workflow's top-level `concurrency.group`). GitHub evaluates that expression BEFORE any job, so it can +// only use data available at scheduling time. This is its JavaScript twin (a contract test renders the YAML expression and compares +// both over a scenario matrix). For a verify call the YAML applies the same rule to the DEPLOY call's inputs (phase_state.inputs), +// never to an arbitrary caller-supplied group string. +// --------------------------------------------------------------------------------------------------------------------- +export function runLockGroup({ event, ref, inputs, runId }) { + const s = (k) => (inputs?.[k] == null ? '' : String(inputs[k])); + 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 (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'); + if (nonProdOnly && channels && on('d1_migrations')) return `cfv2-nonprod-d1-${scope}`; + if (nonProdOnly && (on('shared_dev_on_branch_push') || on('shared_dev_native_preview_on_branch_push'))) return `cfv2-shared-dev-${s('app_name')}`; + return `cfv2-${s('app_name')}-preview-${String(ref).slice('refs/heads/'.length)}`; + } + return `cfv2-run-${runId}`; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Deferred verification (pipeline_phase=verify): the descriptors of the DEPLOY call are the source of truth. The verify call +// receives only phase_state; it must never re-derive deployment-time policy (smoke mode, lock scope, owner, D1) from its own +// inputs. The only change allowed is the phase transition deploy -> verify. +// --------------------------------------------------------------------------------------------------------------------- +const keyOf = (k) => k.replace(/-/g, '_'); +const plainObject = (o) => o !== null && typeof o === 'object' && !Array.isArray(o); + +/** + * Descriptors for a verify call, from the validated phase_state of the deploy call. `repo` = this run's repository. + * - With `state.descriptors`: parsed, validated and cross-checked against the rest of the hand-off (kinds, app, repository, + * D1 ids, migration owner); phase must be `deploy`; the result is identical except phase = `verify`. + * - Legacy hand-off (no `descriptors`): reconstructed ONLY from the deploy call's own `state.inputs` (never the verify call's); + * anything ambiguous or missing fails closed. + */ +export function restoreDescriptors(state, { repo, runId = '0', event = '', ref = '' }) { + if (!plainObject(state)) throw new Error('phase_state is not an object'); + if (!Array.isArray(state.kinds) || !state.kinds.length) throw new Error('phase_state names no delivery kind'); + const kinds = DELIVERY_KINDS.filter((k) => state.kinds.includes(k)); + if (kinds.length !== state.kinds.length || new Set(state.kinds).size !== state.kinds.length) throw new Error('phase_state.kinds contains an unknown or duplicate delivery kind'); + if (!plainObject(state.inputs) || !state.inputs.app_name) throw new Error('phase_state.inputs.app_name is missing: cannot bind the hand-off to an application'); + const app = String(state.inputs.app_name); + // The handoff's run-level group must be exactly what the deploy call's own inputs imply here (same rule as the workflow's top-level + // concurrency expression). A `cfv2-run-` fallback group (no branch push) carries the deploy run's id, which only has to be numeric. + const expectedGroup = runLockGroup({ event, ref, inputs: state.inputs, runId }); + if (expectedGroup.startsWith('cfv2-run-') ? !/^cfv2-run-\d+$/.test(String(state.group ?? '')) : state.group !== expectedGroup) { + throw new Error(`phase_state.group '${state.group}' is not the lock identity the deploy inputs imply ('${expectedGroup}'); refusing to verify under another lock`); + } + const d1Of = (k) => (plainObject(state.d1) && state.d1[keyOf(k)] != null ? String(state.d1[keyOf(k)]) : null); + const ownerOf = (k) => { + const raw = plainObject(state.migration) ? state.migration[`mj_${keyOf(k)}`] : null; + if (!raw) return null; + const m = typeof raw === 'string' ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : raw; + return plainObject(m) && m.kind ? String(m.kind) : null; + }; + + if (state.descriptors === undefined) { + const s = state.inputs; + return buildDescriptors({ + kinds, app, runId, repo, lockScope: s.d1_lock_scope == null ? '' : String(s.d1_lock_scope), + d1ByKind: Object.fromEntries(kinds.map((k) => [k, d1Of(k) ?? ''])), groups: legacyMigrationGroups(state, kinds, d1Of, ownerOf), + smokeMode: s.smoke_mode ? String(s.smoke_mode) : 'full', smokeOverrides: s.smoke_mode_overrides == null ? '' : String(s.smoke_mode_overrides), phase: 'verify', + }); + } + + if (!plainObject(state.descriptors)) throw new Error('phase_state.descriptors is not an object'); + const have = Object.keys(state.descriptors).sort().join(','); + if (have !== [...kinds].sort().join(',')) throw new Error(`phase_state.descriptors [${have}] do not match phase_state.kinds [${kinds}]`); + const out = {}; + for (const k of kinds) { + const d = validateDescriptor(state.descriptors[k]); + if (d.kind !== k) throw new Error(`descriptor filed under ${k} is for ${d.kind}`); + if (d.phase !== 'deploy') throw new Error(`descriptor for ${k} is in phase '${d.phase}'; only a deploy-phase descriptor can be verified`); + if (d.environment !== environmentName(app, k)) throw new Error(`descriptor for ${k} belongs to another application (${d.environment}, expected ${environmentName(app, k)})`); + if (k === 'persistent-dev') { // the Dev lock is the legacy shared-D1 group of THIS repository and the deploy call's own scope / app + const want = concurrencyGroups(k, { runId, repo, app, lockScope: state.inputs.d1_lock_scope == null ? '' : String(state.inputs.d1_lock_scope) }); + if (d.deploy_group !== want.deploy_group || d.rollback_group !== want.rollback_group) { + throw new Error(`descriptor for persistent-dev carries lock '${d.deploy_group}' / '${d.rollback_group}', expected '${want.deploy_group}' for repository ${repo}, application ${app} and the deploy call's d1_lock_scope`); + } + } + const d1 = d1Of(k); + if (d1 !== null && d1 !== d.d1_expected_id) throw new Error(`descriptor for ${k} binds D1 '${d.d1_expected_id}' but the hand-off recorded '${d1}'`); + const owner = ownerOf(k); + if (owner !== null && owner !== d.d1_owner_kind) throw new Error(`descriptor for ${k} names migration owner '${d.d1_owner_kind}' but the migration result belongs to '${owner}'`); + out[k] = validateDescriptor({ ...d, phase: 'verify' }); + } + return out; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Engine initialisation (pure): every check that used to be a separate Node/jq call in `engine.sh init`, evaluated once over the +// validated inputs. Throws (fail closed, BEFORE any mutation) with the message the operator sees. Credentials stay a shell check. +// --------------------------------------------------------------------------------------------------------------------- +const KIND_OF_TARGET = { prod: 'production', preview: 'native-branch', dev: 'persistent-dev' }; +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 = '' }) { + 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'; + if (!['prod', 'preview', 'dev'].includes(target)) throw new Error(`bad engine target: ${target}`); + const K = kind || KIND_OF_TARGET[target]; + if (ENGINE_TARGET[K] !== target) throw new Error(`delivery kind '${K}' does not belong to engine target '${target}'`); + let smokeMode; + if (descriptor) { + const d = parseDescriptor(descriptor, K); + if (d.target !== target) throw new Error(`target descriptor engine target '${d.target}' differs from the stage's '${target}'`); + if (d.d1_expected_id !== d1Expect) throw new Error("target descriptor D1 id differs from the stage's expected D1"); + smokeMode = d.smoke_mode; + } else smokeMode = resolveSmokeMode(K, s('smoke_mode'), s('smoke_mode_overrides')); + if (!STAGES.includes(stage)) throw new Error(`bad engine stage: ${stage}`); + if (target === 'dev') { /* every stage is allowed for persistent Dev */ } else if (!['preflight', 'migrate', 'publish', 'verify', 'rollback'].includes(stage)) { + 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; } + if (c === null || typeof c !== 'object' || Array.isArray(c)) throw new Error('call inputs are not an object'); + for (const k of ['app_name', 'app_path', 'production_rollback_policy']) { + if ((c[k] == null ? '' : String(c[k])) !== s(k)) throw new Error(`verify call input '${k}' differs from the deploy call that produced the hand-off; refusing to verify another deployment`); + } + } + const strategy = s('deployment_strategy'); + if (!STRATEGIES.includes(strategy)) throw new Error('bad deployment_strategy'); + if (!['automatic', 'manual', 'disabled'].includes(s('production_rollback_policy'))) throw new Error('bad production_rollback_policy'); + if (strategy === 'production-only' && (on('shared_dev_on_branch_push') || on('shared_dev_native_preview_on_branch_push'))) { + 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 }) }; +} + +/** 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). */ +function validateTargetConfig({ inputs, kind, target, previewSecrets }) { + const s = (k) => (inputs[k] == null ? '' : String(inputs[k])); + const on = (k) => s(k) === 'true'; + if (kind === 'native-dev' && !on('shared_dev_native_preview_on_branch_push')) throw new Error('native-dev requires shared_dev_native_preview_on_branch_push: true'); + if (target === 'dev') { + if (!s('dev_worker_name')) throw new Error('Shared Dev requires dev_worker_name (the Dev Worker identity is always verified)'); + if (!s('app_url_dev')) throw new Error('Shared Dev requires app_url_dev (a Dev deployment without a probe is not verified)'); + } + const buildCmd = target === 'dev' ? (s('dev_build_cmd') || s('build_cmd')) : s('build_cmd'); + if (!buildCmd) throw new Error(`no build command for target ${target}`); + if (on('d1_migrations') && target === 'dev' && !s('dev_build_cmd')) throw new Error('Shared Dev with d1_migrations requires dev_build_cmd: the Dev build must succeed BEFORE the Dev D1 is migrated'); + const buildEnvText = s(BUILD_ENV_INPUT[kind]); + const bad = foreignUrlsInBuildEnv(kind, buildEnvText, { production: s('app_url_prod'), 'persistent-dev': s('app_url_dev'), 'native-dev': s('app_url_shared_dev_native_preview') }); + if (bad.length) throw new Error(`build environment of ${kind}: ${bad.join('; ')}; each target must be built with its own URLs`); + const parse = (name, text) => { + const r = parseEnvLines(text, { reserved: true }); + if (r.errors.length) throw new Error(`${name}: ${r.errors.join('; ')}`); + return r.pairs; + }; + return { + buildEnv: parse('CFV2_BUILD_ENV', buildEnvText), smokeEnv: parse('CFV2_SMOKE_ENV', s('smoke_env')), + previewEnv: target === 'preview' && previewSecrets ? parse('CFV2_PS', previewSecrets) : null, + }; +} + +/** + * Legacy hand-offs (no `descriptors`): the physical-D1 ownership the deploy call used, proven from the hand-off itself. Per D1 id, every + * member kind's migration result must name the SAME owner, the owner must be one of the members, the owner's own result must name itself, + * and each result must carry that D1 id. D1 migrations disabled -> no ownership is needed. Anything missing or conflicting fails closed: + * ownership is never invented and never silently left empty for a D1-enabled deployment. + */ +function legacyMigrationGroups(state, kinds, d1Of, ownerOf) { + const enabled = state.inputs.d1_migrations === true || state.inputs.d1_migrations === 'true'; + if (!enabled) return []; + const byD1 = new Map(); + for (const k of kinds) { + const id = d1Of(k); + if (!id) continue; + if (!byD1.has(id)) byD1.set(id, []); + byD1.get(id).push(k); + } + const resultOf = (k) => { + const raw = plainObject(state.migration) ? state.migration[`mj_${keyOf(k)}`] : null; + if (!raw) return null; + const m = typeof raw === 'string' ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : raw; + return plainObject(m) ? m : null; + }; + const groups = []; + for (const [id, members] of byD1) { + const owners = new Set(); + for (const k of members) { + const m = resultOf(k); + if (!m || !m.kind) throw new Error(`legacy phase_state: no migration-owner evidence for ${k} (D1 ${id}) although d1_migrations was enabled; refusing to guess the owner`); + if (m.d1_id !== id) throw new Error(`legacy phase_state: the migration result of ${k} is for D1 '${m.d1_id}', not '${id}'`); + owners.add(String(m.kind)); + } + if (owners.size !== 1) throw new Error(`legacy phase_state: conflicting migration owners [${[...owners]}] for D1 ${id}`); + const [owner] = owners; + if (!members.includes(owner)) throw new Error(`legacy phase_state: migration owner '${owner}' is not among the kinds [${members}] that use D1 ${id}`); + if (resultOf(owner)?.kind !== owner) throw new Error(`legacy phase_state: the owner's own migration result does not name itself (${owner})`); + groups.push({ databaseId: id, members, owner }); + } + return groups; +} diff --git a/actions/cf-preview/engine.sh b/actions/cf-preview/engine.sh index 52d3234..a6fdd73 100755 --- a/actions/cf-preview/engine.sh +++ b/actions/cf-preview/engine.sh @@ -5,6 +5,11 @@ # 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. # +# Delivery KIND (ENGINE_KIND, bound once in `init`): production | native-branch | native-dev | persistent-dev. TARGET stays the +# config/credential class (prod | preview | dev): native-branch and native-dev are BOTH `preview` (wrangler preview, [previews] +# bindings); native-dev is the fixed, protected Preview name `dev`. Everything kind-specific (build env, URL, ledger, +# Environment, smoke mode) is resolved from KIND, never from application env. +# # Stages (ENGINE_STAGE, set once in `init`): `all` = every phase in ONE job (Shared Dev, persistent Dev); the Production / # Preview path is split across jobs that each re-run `init` (same inputs, same target binding) and exchange only # validated DATA through `hydrate` - never runner files: migrate (Migration job), publish (Deploy job), verify (Smoke @@ -26,7 +31,16 @@ export TOOLS_DIR S="${STATE_DIR:-${RUNNER_TEMP:?RUNNER_TEMP or STATE_DIR required}/cfv2}" IN="$S/inputs.json" die() { echo "::error::$*" >&2; exit 1; } -inp() { jq -r --arg k "$1" '.[$k] | if . == null then "" else tostring end' "$IN"; } +# One jq process per engine invocation: inputs.json is read into INP once (NUL-delimited, so values may hold newlines). +# `inp` runs inside $(...) subshells, so the cache must be filled in the parent shell (load_inputs) before it is used. +declare -A INP=() +load_inputs() { + INP=(); local k v + [ -f "$IN" ] || return 0 + while IFS= read -r -d '' k && IFS= read -r -d '' v; do INP[$k]="$v"; done \ + < <(jq -j 'if type == "object" then to_entries[] | (.key, "\u0000", (.value | if . == null then "" else tostring end), "\u0000") else empty end' "$IN" 2>/dev/null || true) +} +inp() { printf '%s' "${INP[$1]-}"; } summary() { printf '%s\n' "$*" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"; } setenv() { printf '%s=%s\n' "$1" "$2" >> "${GITHUB_ENV:-/dev/null}"; } rd() { [ -f "$S/$1" ] && cat "$S/$1" || true; } @@ -34,7 +48,8 @@ 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)" + TARGET="$(rd target)"; MODE="$(rd mode)"; 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}" BRANCH_REF="${GITHUB_REF:?}"; BRANCH="${GITHUB_REF#refs/heads/}"; RUN_SHA="${GITHUB_SHA:?}" @@ -47,13 +62,50 @@ ctx() { *) HOST_WORKER="$APP_NAME";; esac PREVIEW_NAME="$(rd preview_name)"; ENV_NAME="$(rd ledger_env)"; LEDGER_ID="$(rd ledger_id)" - export 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" + [ "$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" } # Which commands/env belong to the target. Never falls back across targets (except dev_build_cmd -> build_cmd). target_build_cmd() { case "$TARGET" in dev) local c; c="$(inp dev_build_cmd)"; echo "${c:-$(inp build_cmd)}";; *) inp build_cmd;; esac; } target_smoke_cmd() { case "$TARGET" in dev) local c; c="$(inp dev_smoke_cmd)"; echo "${c:-$(inp smoke_cmd)}";; *) inp smoke_cmd;; esac; } -target_url() { case "$TARGET" in prod) inp app_url_prod;; dev) inp app_url_dev;; *) echo "";; esac; } +# Declared (caller-supplied) canonical URL of the kind; Preview kinds additionally derive theirs from Cloudflare (resolve_target_url). +declared_url() { + case "$KIND" in production) inp app_url_prod;; persistent-dev) inp app_url_dev;; native-dev) inp app_url_shared_dev_native_preview;; *) echo "";; esac +} +# The canonical URL THIS target is built for: resolved ONCE before the build (state file), else the declared one. +target_url() { if [ -s "$S/target_url" ]; then rd target_url; else declared_url; fi; } +# Resolve the canonical URL BEFORE any build of this target (fails closed): Preview kinds derive it from the real Preview +# identity and the host Worker's Cloudflare-reported preview_url_suffix (or preview_url_suffix), cross-checked with the declared +# URL; the build env is checked for self-origin consistency (callback / redirect / app origin). Writes $S/target_url. +resolve_target_url() { # + local R + R="$(node "$TOOLS_DIR/cli.mjs" resolve-url --kind "$KIND" --name "$PREVIEW_NAME" --host-worker "$HOST_WORKER" --explicit "$(declared_url)" \ + --suffix "$(inp preview_url_suffix)" --host-check "$(inp preview_host_check)" --build-env "$1")" || die "cannot resolve the canonical URL of $KIND before the build; nothing was built" + printf '%s\n' "$R" | grep -v '^target_url=\|^url_source=' >&2 || true + R_URL="$(printf '%s\n' "$R" | sed -n 's/^target_url=//p')" + if [ -n "$R_URL" ]; then printf '%s' "$R_URL" > "$S/target_url"; else rm -f "$S/target_url"; fi + echo "::notice::canonical URL of $KIND resolved before the build: ${R_URL:-} ($(printf '%s\n' "$R" | sed -n 's/^url_source=//p'))" >&2 +} +# GitHub Environment (deployment history, protection rules) of the kind. Production / Preview / Dev names are unchanged. +gh_env() { case "$KIND" in production) echo "$APP_NAME/prod";; native-branch) echo "$APP_NAME/preview";; native-dev) echo "$APP_NAME/native-dev";; persistent-dev) echo "$APP_NAME/dev";; esac; } +# Smoke commands/modes are per kind: `basic` runs the HTTP/health verification without the application smoke command. +smoke_mode() { rd smoke_mode; } +kind_smoke_cmd() { if [ "$(smoke_mode)" = basic ]; then echo ""; else target_smoke_cmd; fi; } + +# ONE audited build and ONE publish primitive, used by the normal run AND the recovery rebuild. The caller supplies only what +# differs: the state dir, the (validated) build env file, the app version, whether the Preview host is re-checked, the commit message. +run_build() { # + STATE_DIR="$1" BUILD_CMD="$(target_build_cmd)" BUILD_ENV_FILE="$2" APP_VERSION="$3" PREVIEW_ENV_FILE="$S/preview.env" \ + PREVIEW_NAME="$PREVIEW_NAME" PREVIEW_HOST_CHECK="$4" PREVIEW_ALLOW="$(inp preview_binding_allow)" PREVIEW_DENY="$(inp preview_deny_resources)" \ + DEV_WORKER="$(inp dev_worker_name)" DEV_ALLOW="$(inp dev_binding_allow)" DEV_DENY="$(inp dev_deny_resources)" PROD_URL="$(inp app_url_prod)" \ + DELIVERY_KIND="$KIND" TARGET_URL="$(target_url)" "$TOOLS_DIR/build.sh" +} +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)" \ + PREVIEW_NAME="$PREVIEW_NAME" EXPECTED_URL="$([ "$TARGET" = preview ] && target_url || true)" "$TOOLS_DIR/publish.sh" +} stale_check() { # sets STALE for the rest of the job when the branch moved on; FAILS when the tip cannot be resolved ctx @@ -67,96 +119,59 @@ stale_check() { # sets STALE for the rest of the job when the branch moved on; F fi } +load_inputs case "$PHASE" in guard) stale_check "${2:-guard}" || true;; init) + # One job may run several engine invocations (the Migration job validates every target, then migrates): each starts from a clean state. + # Only the engine's own default state dir (/cfv2) is ever wiped - never a path an earlier step could have redirected. + [ "$S" != "${RUNNER_TEMP:-}/cfv2" ] || rm -rf -- "$S" mkdir -p "$S"; chmod 700 "$S"; umask 077 - printf '%s' "${INPUTS_JSON:?}" > "$IN"; jq -e 'type == "object"' "$IN" >/dev/null || die "inputs_json is not an object" - T="${ENGINE_TARGET:?}"; M="${ENGINE_MODE:-auto}"; STAGE="${ENGINE_STAGE:-all}" - case "$T" in prod|preview|dev) ;; *) die "bad engine target: $T";; esac - case "$STAGE" in all|deploy|migrate|publish|verify|rollback) ;; *) die "bad engine stage: $STAGE";; esac - # Production / Preview are always split. Shared Dev and persistent Dev default to the single-job engine (all); Shared Dev - # alone may split into deploy (migrate + publish, no smoke, no recovery) -> verify -> rollback (pipeline_phase). - if [ "$T" = dev ]; then - case "$STAGE" in all|deploy|verify|rollback) ;; *) die "Shared Dev stage must be all, deploy, verify or rollback, got '$STAGE'";; esac - else - case "$STAGE" in migrate|publish|verify|rollback) ;; *) die "$T must run as split stages (migrate/publish/verify/rollback), got '$STAGE'";; esac - fi - printf '%s' "$STAGE" > "$S/stage" - case "$M" in auto|manual) ;; *) die "bad engine mode: $M";; esac - [ "$M" = auto ] || [ "$T" = dev ] || die "manual mode is only for the persistent Dev target" - [ "$M" = auto ] || [ "$STAGE" = all ] || die "manual Dev runs as one job (stage all)" - # Deferred verification (pipeline_phase verify): the engine inputs are the DEPLOY call's inputs, handed over as data. - # The verify call passes its own copy of the identity inputs; they must be identical or nothing is acted on. - if [ -n "${CALL_INPUTS_JSON:-}" ]; then - printf '%s' "$CALL_INPUTS_JSON" | jq -e 'type == "object"' >/dev/null || die "call inputs are not an object" - for k in app_name app_path production_rollback_policy tools_ref; do - [ "$(printf '%s' "$CALL_INPUTS_JSON" | jq -r --arg k "$k" '.[$k] | if . == null then "" else tostring end')" = "$(inp "$k")" ] \ - || die "verify call input '$k' differs from the deploy call that produced the hand-off; refusing to verify another deployment" - done - fi - printf '%s' "$T" > "$S/target"; printf '%s' "$M" > "$S/mode" - ctx - STRATEGY="$(inp deployment_strategy)"; POLICY="$(inp production_rollback_policy)" - case "$STRATEGY" in production-preview|hybrid|production-only) ;; *) die "bad deployment_strategy";; esac - case "$POLICY" in automatic|manual|disabled) ;; *) die "bad production_rollback_policy";; esac - if [ "$STRATEGY" = production-only ] && [ "$(inp shared_dev_on_branch_push)" = true ]; then - die "deployment_strategy: production-only cannot be combined with shared_dev_on_branch_push: true; refusing to publish or migrate anything" - fi - EVENT="${GITHUB_EVENT_NAME:?}" - if [ "$M" = manual ]; then - ACTION="$(node "$TOOLS_DIR/cli.mjs" classify --event "$EVENT" --ref "$BRANCH_REF" --strategy "$STRATEGY" --dev-ref "$(inp persistent_dev_ref)" --dispatch-target "$(inp dispatch_target)" | sed -n 's/^action=//p')" - [ "$ACTION" = persistent-dev ] || die "manual Dev is only allowed from the approved persistent_dev_ref via dispatch (classified '$ACTION')" - elif [ "$T" = dev ]; then - R="$(node "$TOOLS_DIR/cli.mjs" resolve-dev --event "$EVENT" --ref "$BRANCH_REF" --strategy "$STRATEGY" --shared-dev-on-push "$(inp shared_dev_on_branch_push)" --dispatch-target "$(inp dispatch_target)" | sed -n 's/^target=//p')" - [ "$R" = dev ] || die "target mismatch (resolved '$R', engine 'dev'); refusing to deploy" - else - R="$(node "$TOOLS_DIR/cli.mjs" resolve --event "$EVENT" --ref "$BRANCH_REF" --strategy "$STRATEGY" --preview-on-push "$(inp preview_on_branch_push)" --dispatch-target "$(inp dispatch_target)" | sed -n 's/^target=//p')" - [ "$R" = "$T" ] || die "target mismatch (resolved '$R', engine '$T'); refusing to deploy" - fi + printf '%s' "${INPUTS_JSON:?}" > "$IN" + # ONE Node process validates everything pure (target/kind/stage/mode binding, descriptor, strategy and policy enums, enabled kind of + # this event, per-target requirements, URL isolation, env data) and writes the validated state files. Nothing is mutated if it fails. + node "$TOOLS_DIR/cli.mjs" init-checks --state "$S" --inputs "$IN" | { grep -v '^kind=\|^smoke_mode=' || true; } + load_inputs # Prod, Preview and Dev all use the one account credential pair (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID). # The Smoke job (stage verify) never receives Cloudflare credentials: it only probes the published URL. - if [ "$STAGE" != verify ]; then - [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "$T credentials not provided (set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)" + if [ "$(rd stage)" != verify ]; then + [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "$(rd target) credentials not provided (set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)" elif [ -n "${CLOUDFLARE_API_TOKEN:-}${CLOUDFLARE_ACCOUNT_ID:-}" ]; then die "the Smoke stage must not receive Cloudflare credentials" - fi - if [ "$T" = dev ]; then - [ -n "$(inp dev_worker_name)" ] || die "Shared Dev requires dev_worker_name (the Dev Worker identity is always verified)" - [ -n "$(inp app_url_dev)" ] || die "Shared Dev requires app_url_dev (a Dev deployment without a probe is not verified)" - fi - [ -n "$(target_build_cmd)" ] || die "no build command for target $T" - if [ "$(inp d1_migrations)" = true ] && [ "$T" = dev ] && [ -z "$(inp dev_build_cmd)" ]; then - die "Shared Dev with d1_migrations requires dev_build_cmd: the Dev build must succeed BEFORE the Dev D1 is migrated" - fi - # Caller env text -> validated data files. Reserved control names are rejected (parse-env.mjs RESERVED). - case "$T" in prod) BE="$(inp build_env_prod)";; preview) BE="$(inp build_env_preview)";; dev) BE="$(inp build_env_dev)";; esac - CFV2_BUILD_ENV="$BE" node "$TOOLS_DIR/parse-env.mjs" CFV2_BUILD_ENV "$S/build.env" --reserved >/dev/null - CFV2_SMOKE_ENV="$(inp smoke_env)" node "$TOOLS_DIR/parse-env.mjs" CFV2_SMOKE_ENV "$S/smoke.env" --reserved >/dev/null - if [ "$T" = preview ] && [ -n "${PREVIEW_SECRETS:-}" ]; then - CFV2_PS="$PREVIEW_SECRETS" node "$TOOLS_DIR/parse-env.mjs" CFV2_PS "$S/preview.env" --mask --reserved >/dev/null fi;; + migration-gate) # publish stage, before anything is built or published: the result of THE Migration job this kind depends on + ctx + D1_MIGRATIONS="$(inp d1_migrations)" DESCRIPTOR="${ENGINE_DESCRIPTOR:?a target descriptor is required}" \ + node "$TOOLS_DIR/cli.mjs" migration-gate --kind "$KIND";; + prepare) ctx + setenv CFV2_DEPS "done" # the dependencies are installed (Setup step precedes this phase): a later engine call of the same job skips the install if [ "$TARGET" != prod ]; then VER="$(cd "$APP_DIR" && pnpm exec wrangler --version | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" node "$TOOLS_DIR/cli.mjs" wrangler-check --version "$VER" fi # Preview: stable branch identity + ledger ownership (a collision fails BEFORE anything remote is touched), then # the SOURCE binding/auth-isolation preflight. Dev: single shared ledger. Prod: no ledger. - if [ "$TARGET" = preview ]; then - OUT="$("$TOOLS_DIR/guard.sh" identity)" + if [ "$KIND" = native-dev ]; then + # The stable named Preview `dev`: fixed identity (no branch ownership), one ledger per app, shared by all branches. + printf '%s' dev > "$S/preview_name"; printf '%s' "$APP_NAME/native-dev/dev" > "$S/ledger_env" + (cd "$APP_DIR" && node "$TOOLS_DIR/cli.mjs" preflight --config "$WRANGLER_CONFIG" --allow "$(inp preview_binding_allow)" --deny "$(inp preview_deny_resources)") + PAYLOAD="$(jq -nc --arg ref "$BRANCH_REF" --arg w "$APP_NAME" --arg h "$HOST_WORKER" --arg r "$GITHUB_REPOSITORY" '{kind:"native-dev",branch_ref:$ref,preview_name:"dev",worker:$w,host_worker:$h,repo:$r}')" + DESC="Native Dev Preview" + elif [ "$TARGET" = preview ]; then + OUT="$(RESERVE_NATIVE_DEV="$(inp shared_dev_native_preview_on_branch_push)" "$TOOLS_DIR/guard.sh" identity)" printf '%s' "$(printf '%s\n' "$OUT" | sed -n 's/^PREVIEW_NAME=//p')" > "$S/preview_name" printf '%s' "$(printf '%s\n' "$OUT" | sed -n 's/^ENV_NAME=//p')" > "$S/ledger_env" (cd "$APP_DIR" && node "$TOOLS_DIR/cli.mjs" preflight --config "$WRANGLER_CONFIG" --allow "$(inp preview_binding_allow)" --deny "$(inp preview_deny_resources)") - PAYLOAD="$(jq -nc --arg ref "$BRANCH_REF" --arg n "$(rd preview_name)" --arg w "$APP_NAME" --arg h "$HOST_WORKER" --arg r "$GITHUB_REPOSITORY" '{branch_ref:$ref,preview_name:$n,worker:$w,host_worker:$h,repo:$r}')" + PAYLOAD="$(jq -nc --arg ref "$BRANCH_REF" --arg n "$(rd preview_name)" --arg w "$APP_NAME" --arg h "$HOST_WORKER" --arg r "$GITHUB_REPOSITORY" '{kind:"native-branch",branch_ref:$ref,preview_name:$n,worker:$w,host_worker:$h,repo:$r}')" 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" '{branch_ref:$ref,worker:$w,dev_worker:$d,repo:$r,mode:$m}')" + 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)" fi # The Migration job runs the identity / preflight checks but opens no ledger entry (the Deploy job owns it). @@ -168,29 +183,25 @@ case "$PHASE" in build) ctx - BUILD_CMD="$(target_build_cmd)" BUILD_ENV_FILE="$S/build.env" APP_VERSION="$(inp app_version)" PREVIEW_ENV_FILE="$S/preview.env" \ - PREVIEW_NAME="$PREVIEW_NAME" PREVIEW_HOST_CHECK="$(inp preview_host_check)" PREVIEW_ALLOW="$(inp preview_binding_allow)" PREVIEW_DENY="$(inp preview_deny_resources)" \ - DEV_WORKER="$(inp dev_worker_name)" DEV_ALLOW="$(inp dev_binding_allow)" DEV_DENY="$(inp dev_deny_resources)" PROD_URL="$(inp app_url_prod)" \ - "$TOOLS_DIR/build.sh";; + resolve_target_url "$S/build.env" + run_build "$S" "$S/build.env" "$(inp app_version)" "$(inp preview_host_check)";; audit-d1) ctx [ "$(inp d1_migrations)" = true ] || exit 0 # The D1 the DEPLOYED config binds must be the exact physical database the migration targets, and the migration # directory must be real: checked read-only before any remote command. - node "$TOOLS_DIR/cli.mjs" d1-consistency --config "$(rd config.path)" --source "$APP_DIR/$WRANGLER_CONFIG" --target "$TARGET" --binding "$D1_BINDING" --dev-env "$DEV_ENV" --mode "$(rd config.mode)" + D1R="$(node "$TOOLS_DIR/cli.mjs" d1-consistency --config "$(rd config.path)" --source "$APP_DIR/$WRANGLER_CONFIG" --target "$TARGET" --binding "$D1_BINDING" --dev-env "$DEV_ENV" --mode "$(rd config.mode)")" || die "the built $KIND config does not bind the audited D1" + printf '%s\n' "$D1R" >&2 + D1_ID="$(printf '%s\n' "$D1R" | sed -n 's/^d1_id=//p')" + [ -n "$D1_ID" ] || die "the built $KIND config binds no D1 database id" + # Pre-mutation / pre-publish proof: the DEPLOYED binding is the physical D1 the plan migrates (never another database). + if [ -n "$(rd d1_expect)" ] && [ "$D1_ID" != "$(rd d1_expect)" ]; then + die "the built $KIND config binds D1 '$D1_ID' but the plan migrates '$(rd d1_expect)'; refusing (the artifact would run against an unmigrated or foreign database)" + fi + printf '%s' "$D1_ID" > "$S/d1_id" (cd "$APP_DIR" && node "$TOOLS_DIR/cli.mjs" migration-config --config "$WRANGLER_CONFIG" --target "$TARGET" --binding "$D1_BINDING" --dev-env "$DEV_ENV" --out "$S/migrate-check.json" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}");; - d1-guard) - ctx - [ "$TARGET" = dev ] && [ "$(inp d1_migrations)" = true ] || exit 0 - SHARED="$(cd "$APP_DIR" && node "$TOOLS_DIR/cli.mjs" d1-shared --config "$WRANGLER_CONFIG" --binding "$D1_BINDING" --dev-env "$DEV_ENV" | sed -n 's/^shared=//p')" - echo "Preview and Dev share one physical D1: $SHARED (Preview migration: ${PREVIEW_MIGRATION:-unknown}, Preview job: ${PREVIEW_JOB_RESULT:-unknown})" - if [ "$SHARED" = true ] && { [ "${PREVIEW_MIGRATION:-}" = failed ] || [ "${PREVIEW_JOB_RESULT:-}" = cancelled ]; }; then - summary "### ❌ Shared Dev blocked — the Preview migration failed or was cancelled on the shared D1 (state unknown); Dev and its D1 unchanged." - die "Preview migration '${PREVIEW_MIGRATION:-?}' (job ${PREVIEW_JOB_RESULT:-?}) on the D1 that Shared Dev also uses; refusing to touch Dev. Fix the migration and push again." - fi;; - migrate) ctx [ "$(inp d1_migrations)" = true ] || exit 0 @@ -201,8 +212,13 @@ case "$PHASE" in PENDING="$(printf '%s\n' "$OUT" | sed -n 's/^pending=//p')" printf '%s' "$PENDING" > "$S/migration" echo "schema_changed=$PENDING" + echo "kind=$KIND"; echo "d1_id=$(rd d1_id)" echo "migration=applied";; + report) # final, always-run state report of a stage (data only) + ctx + echo "d1_id=$(rd d1_id)"; echo "target_url=$(rd target_url)"; echo "stale=${STALE:-false}"; echo "kind=$KIND";; + fail-migrate) # the Migration job failed: nothing was published; the remote D1 state is whatever wrangler left ctx summary "### ❌ $TARGET D1 migration failed or was cancelled — nothing was published and the Worker is unchanged. The D1 may be partially migrated: inspect it before pushing again (migrations are never reverted automatically)." @@ -216,12 +232,10 @@ case "$PHASE" in AUDITED_NAME="" if [ "$TARGET" = prod ]; then AUDITED_NAME="$(node "$TOOLS_DIR/cli.mjs" worker-name --config "$(rd config.path)" | sed -n 's/^worker_name=//p')"; [ -n "$AUDITED_NAME" ] || die "no safe Worker name in the audited config; refusing to publish"; fi COMMIT_MSG=""; [ -z "${GITHUB_EVENT_PATH:-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || COMMIT_MSG="$(jq -r '.head_commit.message // ""' "$GITHUB_EVENT_PATH")" - APP_URL="$(target_url)" OUT_FILE="$S/preview.json" APP_VERSION="$(inp app_version)" COMMIT_MSG="$COMMIT_MSG" \ - WORKER_NAME="$(inp preview_worker_name)" PUBLISH_CMD="$([ "$TARGET" = dev ] && inp dev_deploy_cmd || true)" \ - "$TOOLS_DIR/publish.sh" + run_publish "$S" "$S/preview.json" "$(inp app_version)" "$COMMIT_MSG" # Reached only when publish.sh succeeded: the hand-off of this deployment to the Smoke / Rollback jobs. echo "published=true"; echo "published_sha=$RUN_SHA"; echo "ledger_id=$LEDGER_ID"; echo "preview_name=$PREVIEW_NAME" - echo "worker_name=$AUDITED_NAME";; + echo "worker_name=$AUDITED_NAME"; echo "kind=$KIND"; echo "target_url=$(target_url)"; echo "d1_id=$(rd d1_id)";; hydrate) # Smoke / Rollback jobs: validated hand-off from the Migration / Deploy / Smoke jobs (data, never runner files) ctx @@ -241,16 +255,26 @@ case "$PHASE" in H_PNAME="$(val "$D" preview_name '^([a-z0-9]+(-[a-z0-9]+)*)?$')" H_PUB="$(val "$D" published '^(true|false)?$')" H_PUBSHA="$(val "$D" published_sha '^([0-9a-f]{7,40})?$')" + H_KIND="$(val "$D" kind '^(production|native-branch|native-dev|persistent-dev)?$')" + H_TURL="$(val "$D" target_url '^(https://[^[:space:]]+)?$')" + M_D1="$(val "$M" d1_id '^[A-Za-z0-9_-]*$')" H_PENDING="$(val "$M" schema_changed '^(true|false)?$')" H_SCAN="$(val "$M" d1_scan '^(none|additive|non-additive|unknown)?$')" H_MIG="$(val "$M" migration '^(applied|failed|none)?$')" H_LEVEL="$(val "$K" smoke_level '^(liveness|content|content-sha)?$')" [ "$H_PUB" = true ] || die "the Deploy job did not report a published deployment; nothing to verify or roll back" + # Each kind receives ONLY its own results: a hand-off of another kind (or without a kind) is refused, fail closed. + [ "$H_KIND" = "$KIND" ] || die "hand-off belongs to kind '${H_KIND:-}', this stage is '$KIND'; refusing to act on another target's deployment" + [ -z "$H_TURL" ] || [ "$H_URL" = "$H_TURL" ] || die "hand-off published $H_URL but the build was for $H_TURL; refusing" + if [ -n "${HANDOFF_MIGRATION:-}" ] && [ "$HANDOFF_MIGRATION" != '{}' ]; then + [ -z "$M_D1" ] || [ -z "$(rd d1_expect)" ] || [ "$M_D1" = "$(rd d1_expect)" ] || die "migration hand-off is for D1 '$M_D1', this kind's D1 is '$(rd d1_expect)'; refusing" + 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" printf '%s' "$H_LEDGER" > "$S/ledger_id"; printf '%s' "$H_PNAME" > "$S/preview_name" - case "$TARGET" in preview) printf '%s' "$APP_NAME/preview/$H_PNAME" > "$S/ledger_env";; dev) printf '%s' "$APP_NAME/dev/shared" > "$S/ledger_env";; esac + 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" + [ "$KIND" != native-dev ] || [ "$H_PNAME" = dev ] || die "native-dev hand-off names Preview '$H_PNAME', not 'dev'; refusing to act on another Preview" setenv PUBLISHED_URL "$H_URL"; setenv VERSION_ID "$H_VER"; setenv PREVIOUS_VERSION_ID "$H_PREV" setenv AUDITED_WORKER "$H_WORKER"; setenv D1_PENDING "$H_PENDING"; setenv D1_SCAN "$H_SCAN"; setenv HANDOFF_SMOKE_LEVEL "$H_LEVEL" echo "hydrated: target=$TARGET url=${H_URL:-} version=${H_VER:-?} previous=${H_PREV:-?} migration=${H_MIG:-?}/schema_changed=${H_PENDING:-?}/${H_SCAN:-?}" >&2 @@ -268,13 +292,19 @@ case "$PHASE" in [ -z "$LEDGER_ID" ] || "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" failure "$URL" "Cross-service smoke blocked (upstream ${UPSTREAM_GATE:-?}) — $TARGET UNVERIFIED" || true summary "- No automatic recovery: the failure is not attributable to this Worker. Fix or re-run the upstream job." fi - "$TOOLS_DIR/gh.sh" mark-env-deployment "$APP_NAME/$TARGET" "$RUN_SHA" failure "Cross-service smoke blocked — UNVERIFIED (served $RUN_SHA)" || echo "::warning::could not annotate the Environment deployment as unverified" >&2 + "$TOOLS_DIR/gh.sh" mark-env-deployment "$(gh_env)" "$RUN_SHA" failure "Cross-service smoke blocked — UNVERIFIED (served $RUN_SHA)" || echo "::warning::could not annotate the Environment deployment as unverified" >&2 echo "blocked=true";; smoke) ctx + # smoke_mode=skip: NO request of any kind. The deployment stays Published/Unverified (the `verified` phase maps the + # `skipped` level to unverified: no verified_url, no known-good ledger record, no Release). + if [ "$(smoke_mode)" = skip ]; then + echo "::notice::smoke_mode=skip for $KIND: no post-publish verification was performed; the deployment is Published/Unverified" >&2 + echo "smoke_level=skipped"; exit 0 + fi URL="$(target_url)"; [ "$TARGET" != preview ] || URL="${PUBLISHED_URL:-}" - TARGET_URL="$URL" DEPLOY_TARGET="$TARGET" TEST_ENV="$TARGET" EXPECTED_SHA="$RUN_SHA" SMOKE_CMD="$(target_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ + TARGET_URL="$URL" DEPLOY_TARGET="$TARGET" TEST_ENV="$TARGET" EXPECTED_SHA="$RUN_SHA" SMOKE_CMD="$(kind_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ PROBE_PATH="$(inp probe_path)" PROBE_EXPECT_STATUS="$(inp probe_expect_status)" \ ACCESS_TARGETS="$(inp access_protected_targets)" SMOKE_ENV_FILE="$S/smoke.env" PREVIEW_ENV_FILE="$S/preview.env" NO_PROXY='*' \ "$TOOLS_DIR/smoke.sh";; @@ -283,28 +313,35 @@ case "$PHASE" in ctx URL="${PUBLISHED_URL:-$(target_url)}" LEVEL="${SMOKE_LEVEL:-}" - case "$LEVEL" in - content|content-sha) V=true; NOTE="verification: $LEVEL"; LEDGER_STATE=success; LEDGER_DESC="Serving $RUN_SHA ($LEVEL)";; + VO="$(node "$TOOLS_DIR/cli.mjs" verification-outcome --level "$LEVEL")" # the ONE rule: only a content-level smoke verifies + case "$(printf '%s\n' "$VO" | sed -n 's/^verification_kind=//p')" in + content) V=true; NOTE="verification: $LEVEL"; LEDGER_STATE=success; LEDGER_DESC="Serving $RUN_SHA ($LEVEL)";; + skipped) V=false; NOTE="PUBLISHED, NOT VERIFIED — smoke_mode=skip; no verification was performed" + LEDGER_STATE=inactive; LEDGER_DESC="Published $RUN_SHA; unverified (smoke_mode=skip); not a known-good deployment";; *) V=false; NOTE="PUBLISHED, NOT VERIFIED — Cloudflare Access liveness only; content and commit NOT verified" LEDGER_STATE=inactive; LEDGER_DESC="Published $RUN_SHA; unverified (${LEVEL:-no smoke level}); not a known-good deployment";; esac echo "deployed_and_verified=$V"; echo "smoke_level=$LEVEL"; echo "url=$URL"; echo "published_url=$URL" if [ "$V" = true ]; then echo "verified_url=$URL"; echo "published_unverified=false" else echo "verified_url="; echo "published_unverified=true" - echo "::warning::$TARGET $URL is published but NOT content-verified (smoke_level='$LEVEL'); no Release, no known-good record. Provide an authenticated probe (smoke_env PROBE_HEADERS) for content verification." >&2 + echo "::warning::$KIND $URL is published but NOT content-verified (smoke_level='$LEVEL'); no Release, no known-good record. Provide an authenticated probe (smoke_env PROBE_HEADERS) for content verification." >&2 fi - case "$TARGET" in - preview) + case "$KIND" in + native-dev) + "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" "$LEDGER_STATE" "$URL" "$LEDGER_DESC from $BRANCH" + summary "### $([ "$V" = true ] && echo ✅ || echo ⚠️) Native Dev Preview — \`$APP_NAME\` ($NOTE)" + summary "- URL: $URL (stable Preview \`dev\`; the last verified deployment of ANY branch wins) · branch \`$BRANCH\` · commit \`$RUN_SHA\`";; + native-branch) "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" "$LEDGER_STATE" "$URL" "$LEDGER_DESC" "$TOOLS_DIR/gh.sh" comment "$BRANCH" "$APP_NAME" "$URL" "$RUN_SHA" "$([ "$V" = true ] || echo 'UNVERIFIED: Access liveness only; content and commit not verified')" >&2 || echo "::warning::PR comment skipped" >&2 echo "::notice title=Preview URL ($APP_NAME)::$URL" >&2 summary "### $([ "$V" = true ] && echo ✅ || echo ⚠️) Preview — \`$APP_NAME\` ($NOTE)" summary "- URL: $URL · branch \`$BRANCH\` · identity \`$PREVIEW_NAME\` · commit \`$RUN_SHA\`";; - dev) + persistent-dev) "$TOOLS_DIR/gh.sh" status "$LEDGER_ID" "$LEDGER_STATE" "$URL" "$LEDGER_DESC from $BRANCH" summary "### $([ "$V" = true ] && echo ✅ || echo ⚠️) Shared Dev — \`$APP_NAME\` ($NOTE)" summary "- URL: $URL (one Dev for ALL branches; the last verified deployment wins) · branch \`$BRANCH\` · commit \`$RUN_SHA\`";; - prod) + production) summary "### $([ "$V" = true ] && echo ✅ || echo ⚠️) Production — \`$APP_NAME\` ($NOTE)" summary "- Version: \`${VERSION_ID:-}\` · previous: \`${PREVIOUS_VERSION_ID:-}\` · rollback policy \`$(inp production_rollback_policy)\`. A Worker rollback does NOT revert D1 migrations or external side effects." [ "$V" = true ] || summary "- Release is **blocked**: Production was not content-verified.";; @@ -349,19 +386,20 @@ case "$PHASE" in fi fi # The Environment deployment GitHub created for the Deploy job is "success" (it published). Record that it is NOT verified. - "$TOOLS_DIR/gh.sh" mark-env-deployment "$APP_NAME/$TARGET" "$RUN_SHA" failure "Smoke failed — UNVERIFIED (served $RUN_SHA)" || echo "::warning::could not annotate the Environment deployment as unverified" >&2;; + "$TOOLS_DIR/gh.sh" mark-env-deployment "$(gh_env)" "$RUN_SHA" failure "Smoke failed — UNVERIFIED (served $RUN_SHA)" || echo "::warning::could not annotate the Environment deployment as unverified" >&2;; recover-find) ctx ROW="" - case "$TARGET" in - preview) ROW="$("$TOOLS_DIR/gh.sh" last-good-preview "$(rd ledger_env)" "$LEDGER_ID" "$BRANCH_REF" "$HOST_WORKER" "$APP_NAME" || true)";; - dev) ROW="$("$TOOLS_DIR/gh.sh" last-good-dev "$(rd ledger_env)" "$LEDGER_ID" "$HOST_WORKER" "$APP_NAME" || true)";; + 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)";; + persistent-dev) ROW="$("$TOOLS_DIR/gh.sh" last-good-dev "$(rd ledger_env)" "$LEDGER_ID" "$HOST_WORKER" "$APP_NAME" || true)";; esac # Deferred Shared Dev (stage rollback): the Dev group was released between deploy and rollback, so ANOTHER branch may # have started a newer Dev deployment meanwhile. Recovery must never replace it. - if [ -n "$ROW" ] && [ "$TARGET" = dev ] && [ "$(rd stage)" = rollback ] && [ "$("$TOOLS_DIR/gh.sh" newest-id "$(rd ledger_env)")" != "$LEDGER_ID" ]; then - summary "### ⚠️ Recovery skipped — a newer Shared Dev deployment started after this one; it owns the Dev Worker." + if [ -n "$ROW" ] && { [ "$KIND" = persistent-dev ] || [ "$KIND" = native-dev ]; } && [ "$(rd stage)" = rollback ] && [ "$("$TOOLS_DIR/gh.sh" newest-id "$(rd ledger_env)")" != "$LEDGER_ID" ]; then + summary "### ⚠️ Recovery skipped — a newer $KIND deployment started after this one; it owns the target and is never replaced by an older run." ROW="" fi # Never act on a superseded run: a newer push of this branch will redeploy, and recovery must not replace it. @@ -393,11 +431,9 @@ case "$PHASE" in VERSION="$(APP_VERSION_IN="$(inp app_version)" node "$TOOLS_DIR/cli.mjs" rebind-env --var APP_VERSION_IN --from "$RUN_SHA" --to "$GOOD_SHA")" [ -z "$(inp app_version)" ] || [ "$VERSION" != "$(inp app_version)" ] || echo "::warning::app_version is not derived from the commit SHA; recovery keeps it as configured" >&2 (cd "$APP_DIR" && pnpm install --frozen-lockfile) - STATE_DIR="$R" BUILD_CMD="$(target_build_cmd)" BUILD_ENV_FILE="$R/build.env" APP_VERSION="$VERSION" PREVIEW_ENV_FILE="$S/preview.env" PREVIEW_NAME="$PREVIEW_NAME" \ - PREVIEW_HOST_CHECK=false PREVIEW_ALLOW="$(inp preview_binding_allow)" PREVIEW_DENY="$(inp preview_deny_resources)" \ - DEV_WORKER="$(inp dev_worker_name)" DEV_ALLOW="$(inp dev_binding_allow)" DEV_DENY="$(inp dev_deny_resources)" PROD_URL="$(inp app_url_prod)" "$TOOLS_DIR/build.sh" >&2 - OUT="$(STATE_DIR="$R" APP_URL="$(target_url)" OUT_FILE="$R/preview.json" APP_VERSION="$VERSION" WORKER_NAME="$(inp preview_worker_name)" \ - PUBLISH_CMD="$([ "$TARGET" = dev ] && inp dev_deploy_cmd || true)" "$TOOLS_DIR/publish.sh")" + resolve_target_url "$R/build.env" + run_build "$R" "$R/build.env" "$VERSION" false >&2 + OUT="$(run_publish "$R" "$R/preview.json" "$VERSION" "")" URL="$(printf '%s\n' "$OUT" | sed -n 's/^url=//p')" [ -n "$URL" ] || die "recovery produced no URL" [ "$URL" = "$GOOD_URL" ] || die "recovered URL $URL differs from the recorded last-good URL $GOOD_URL; refusing to verify a different deployment" @@ -405,7 +441,7 @@ case "$PHASE" in recover-verify) ctx - TARGET_URL="${RECOVERED_URL:?}" DEPLOY_TARGET="$TARGET" TEST_ENV="$TARGET" EXPECTED_SHA="${GOOD_SHA:?}" SMOKE_CMD="$(target_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ + TARGET_URL="${RECOVERED_URL:?}" DEPLOY_TARGET="$TARGET" TEST_ENV="$TARGET" EXPECTED_SHA="${GOOD_SHA:?}" SMOKE_CMD="$(kind_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ PROBE_PATH="$(inp probe_path)" PROBE_EXPECT_STATUS="$(inp probe_expect_status)" \ ACCESS_TARGETS="$(inp access_protected_targets)" SMOKE_ENV_FILE="$S/smoke.env" PREVIEW_ENV_FILE="$S/preview.env" NO_PROXY='*' "$TOOLS_DIR/smoke.sh";; @@ -437,20 +473,20 @@ case "$PHASE" in summary "- Manual recovery, after confirming the previous version runs on the current D1 schema: \`$MANUAL\`" exit 0 } - [ "$POLICY" = automatic ] || blocked policy "production_rollback_policy is '$POLICY'." - [ -n "$PREV" ] || blocked no_previous_version "No previous Worker version id was recorded (first deployment, or the deployment list was unreadable); there is nothing safe to roll back to." - [ -n "$NEW" ] && [ -n "$W" ] || blocked no_identity "The deployed version id or Worker name was not handed over." - [ "$PREV" != "$NEW" ] || blocked same_version "The previous and the new version id are identical." - if [ "${D1_PENDING:-}" = true ]; then - # A Worker rollback never reverts D1. This run changed the schema: the previous version must be PROVEN to cope. - [ "$(inp d1_rollback_compat)" = expand-contract ] || blocked d1_unattested "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." - [ "${D1_SCAN:-}" = additive ] || blocked d1_not_additive "This run applied D1 migrations that are not provably additive (scan: ${D1_SCAN:-unknown}); the previous Worker version may not work on the new schema." - fi + # The decision (policy, identity, no D1 schema rollback, never over a newer/foreign deployment) is delivery.mjs prodRollbackBlock. + guard() { # [VAR=value ...] -> refuses (exit 0 with rollback_result=blocked) when the guard says so + local G c + G="$(env POLICY="$POLICY" PREVIOUS="$PREV" CURRENT="$NEW" WORKER="$W" D1_PENDING="${D1_PENDING:-}" D1_COMPAT="$(inp d1_rollback_compat)" D1_SCAN="${D1_SCAN:-}" "$@" \ + node "$TOOLS_DIR/cli.mjs" rollback-guard)" + c="$(printf '%s\n' "$G" | sed -n 's/^block_code=//p')" + [ -z "$c" ] || blocked "$c" "$(printf '%s\n' "$G" | sed -n 's/^block_why=//p')" + } + guard RB="$S/rollback"; mkdir -p "$RB"; node -e 'const [n,f]=process.argv.slice(1);require("fs").writeFileSync(f,JSON.stringify({name:n,compatibility_date:"2025-01-01"}))' "$W" "$RB/wrangler.json" LIVE="$(cd "$APP_DIR" && pnpm exec wrangler deployments list --config "$RB/wrangler.json" --json 2>/dev/null \ | jq -r 'sort_by(.created_on) | reverse | .[0].versions | max_by(.percentage // 0) | .version_id // empty' 2>/dev/null || echo "")" # Never roll back a deployment that is no longer ours (someone deployed out of band): fail closed. - [ "$LIVE" = "$NEW" ] || blocked live_version_changed "The live version is '${LIVE:-unknown}', not the version \`$NEW\` this run deployed; refusing to replace someone else's deployment." + guard LIVE="$LIVE" if (cd "$APP_DIR" && pnpm exec wrangler versions deploy "$PREV@100" --config "$RB/wrangler.json" --message "Revert: smoke failed on $RUN_SHA" --yes) >&2; then echo "rollback_result=rolled_back"; echo "rollback_reason=rolled back to $PREV" else @@ -465,37 +501,23 @@ case "$PHASE" in PREV_SHA="$(printf '%s' "$VIEW" | jq -r '.annotations["workers/message"] // .message // empty' 2>/dev/null | grep -oE '^\[[0-9a-f]{7}\]' | tr -d '[]' | head -1 || true)" [ -z "$PREV_SHA" ] || PREV_SHA="$(cd "$APP_DIR" && git rev-parse --verify --quiet "$PREV_SHA^{commit}" 2>/dev/null || echo "$PREV_SHA")" echo "previous_commit=$PREV_SHA" - TARGET_URL="$(target_url)" DEPLOY_TARGET=prod TEST_ENV=prod EXPECTED_SHA="$PREV_SHA" ABSENT_SHA="$RUN_SHA" SMOKE_CMD="$(target_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ + TARGET_URL="$(target_url)" DEPLOY_TARGET=prod TEST_ENV=prod EXPECTED_SHA="$PREV_SHA" ABSENT_SHA="$RUN_SHA" SMOKE_CMD="$(kind_smoke_cmd)" SMOKE_CWD="$APP_DIR" SHA_PATH="$(inp sha_path)" \ PROBE_PATH="$(inp probe_path)" PROBE_EXPECT_STATUS="$(inp probe_expect_status)" ACCESS_TARGETS="$(inp access_protected_targets)" \ SMOKE_ENV_FILE="$S/smoke.env" PREVIEW_ENV_FILE="$S/preview.env" NO_PROXY='*' "$TOOLS_DIR/smoke.sh";; rollback-finish) # final, target-independent verdict of the Rollback job (ok=true only when restored AND content-verified) ctx - R=""; LEVEL="" + RV="$(env TARGET="$TARGET" RB_RESULT="${RB_RESULT:-}" RB_VERIFY="${RB_VERIFY:-}" RB_LEVEL="${RB_LEVEL:-}" GOOD_SHA="${GOOD_SHA:-}" RECOVERED="${RECOVERED:-}" VERIFIED="${VERIFIED:-}" \ + SMOKE_LEVEL="${SMOKE_LEVEL:-}" node "$TOOLS_DIR/cli.mjs" rollback-verdict)" # delivery.mjs rollbackVerdict + R="$(printf '%s\n' "$RV" | sed -n 's/^result=//p')"; LEVEL="$(printf '%s\n' "$RV" | sed -n 's/^level=//p')" if [ "$TARGET" = prod ]; then - case "${RB_RESULT:-}" in - rolled_back) - if [ "${RB_VERIFY:-}" = success ]; then - case "${RB_LEVEL:-}" in - content|content-sha) R=restored_verified; LEVEL="$RB_LEVEL";; - *) R=restored_unverified; LEVEL="${RB_LEVEL:-}";; - esac - else R=restored_unhealthy; fi;; - blocked) R=blocked;; - *) R=failed;; - esac case "$R" in restored_verified) summary "### ♻️ Production rolled back to \`${PREVIOUS_VERSION_ID:-?}\` and verified ($LEVEL). The run stays **FAILED** for $RUN_SHA and Release is blocked. D1 migrations (if any) were NOT reverted." - "$TOOLS_DIR/gh.sh" mark-env-deployment "$APP_NAME/$TARGET" "$RUN_SHA" inactive "Rolled back to ${PREVIOUS_VERSION_ID:-previous} after a failed smoke" || true;; + "$TOOLS_DIR/gh.sh" mark-env-deployment "$(gh_env)" "$RUN_SHA" inactive "Rolled back to ${PREVIOUS_VERSION_ID:-previous} after a failed smoke" || true;; restored_unverified) summary "### ⚠️ Production rolled back to \`${PREVIOUS_VERSION_ID:-?}\` but only liveness could be observed (${LEVEL:-no level}): the restored deployment is NOT content-verified. Run FAILED.";; restored_unhealthy) summary "### 🚨 Production was rolled back to \`${PREVIOUS_VERSION_ID:-?}\` but the post-rollback smoke FAILED — Production may be unhealthy. Investigate now. Run FAILED.";; esac - else - if [ -z "${GOOD_SHA:-}" ]; then R=no_known_good - elif [ "${RECOVERED:-}" = success ] && [ "${VERIFIED:-}" = success ]; then - case "${SMOKE_LEVEL:-}" in content|content-sha) R=restored_verified; LEVEL="$SMOKE_LEVEL";; *) R=restored_unverified; LEVEL="${SMOKE_LEVEL:-}";; esac - else R=failed; fi fi echo "result=$R"; echo "level=$LEVEL"; echo "ok=$([ "$R" = restored_verified ] && echo true || echo false)";; diff --git a/actions/cf-preview/gh.sh b/actions/cf-preview/gh.sh index e5f4e64..768dc76 100755 --- a/actions/cf-preview/gh.sh +++ b/actions/cf-preview/gh.sh @@ -63,6 +63,11 @@ JSON scan_good "$1" "${2:-}" --arg repo "$R" --arg dev "$3" --arg app "$4" -- \ 'select(.payload.repo == $repo and .payload.dev_worker == $dev and .payload.worker == $app)' ;; + last-good-native-dev) # -> " " of the newest verified Native Dev deployment + # (any branch; Native Dev is ONE stable Preview `dev`) recorded for THIS host Worker, app and repository. + scan_good "$1" "${2:-}" --arg repo "$R" --arg host "$3" --arg app "$4" -- \ + 'select(.payload.repo == $repo and .payload.kind == "native-dev" and .payload.preview_name == "dev" and .payload.host_worker == $host and .payload.worker == $app)' + ;; comment) # [note] : only same-repo PRs from this exact head branch owner="${R%%/*}" marker="" diff --git a/actions/cf-preview/guard.sh b/actions/cf-preview/guard.sh index 3475ac4..138d0bb 100755 --- a/actions/cf-preview/guard.sh +++ b/actions/cf-preview/guard.sh @@ -9,7 +9,7 @@ set -euo pipefail case "${1:?}" in identity) : "${BRANCH_REF:?}" "${APP_NAME:?}" - out="$(node "${TOOLS_DIR:?}/cli.mjs" identity --repo "$GITHUB_REPOSITORY" --worker "$APP_NAME" --host-worker "${HOST_WORKER:-$APP_NAME}" --ref "$BRANCH_REF")" + out="$(node "${TOOLS_DIR:?}/cli.mjs" identity --repo "$GITHUB_REPOSITORY" --worker "$APP_NAME" --host-worker "${HOST_WORKER:-$APP_NAME}" --ref "$BRANCH_REF" --reserve-native-dev "${RESERVE_NATIVE_DEV:-false}")" name="$(printf '%s\n' "$out" | sed -n 's/^preview_name=//p')" [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || { echo "::error::unsafe Preview name: $name" >&2; exit 1; } env_name="$APP_NAME/preview/$name" diff --git a/actions/cf-preview/lib.mjs b/actions/cf-preview/lib.mjs index 203a7ad..dac7335 100644 --- a/actions/cf-preview/lib.mjs +++ b/actions/cf-preview/lib.mjs @@ -122,7 +122,7 @@ export async function previewHostReadiness(fetchFn, { accountId, token, worker, let body; try { body = await res.json(); } catch { return { ok: false, level: 'unknown', message: 'Preview host check returned an unreadable body' }; } const r = body?.result ?? {}; - if (r.previews_enabled === true) return { ok: true, level: 'ok', message: `Worker ${worker} has Preview URLs enabled` }; + if (r.previews_enabled === true) return { ok: true, level: 'ok', message: `Worker ${worker} has Preview URLs enabled`, suffix: typeof r.preview_url_suffix === 'string' ? r.preview_url_suffix : '' }; if (r.previews_enabled === false) { return { ok: false, level: 'block', message: `Worker "${worker}" has previews_enabled=false: no Preview URL host exists. Enable Preview URLs for that Worker (Settings > Domains & Routes > Preview URLs, or deploy it with \`preview_urls = true\`) — this pipeline never changes the Worker automatically. For custom-domain Preview hosting set preview_host_check: false.` }; } diff --git a/actions/cf-preview/parse-env.mjs b/actions/cf-preview/parse-env.mjs index 70c247e..42b5dd6 100644 --- a/actions/cf-preview/parse-env.mjs +++ b/actions/cf-preview/parse-env.mjs @@ -16,7 +16,7 @@ export const RESERVED = new Set([ 'LEDGER_ENV', 'PREVIEW_NAME', 'ENV_NAME', 'OUT_FILE', 'PREVIEW_ENV_FILE', 'SMOKE_ENV_FILE', 'BUILD_ENV_FILE', 'D1_BINDING', 'WRANGLER_CONFIG', 'TARGET_URL', 'DEPLOY_TARGET', 'TEST_ENV', 'ACCESS_TARGETS', 'SHA_PATH', 'SMOKE_CMD', 'BUILD_CMD', 'PUBLISH_CMD', 'ROLLBACK_TO', 'INPUTS_JSON', 'BUILD_ENV', 'SMOKE_ENV', 'CI_ENV', 'TARGET_BUILD_ENV', - 'PREVIEW_SECRETS', 'VITE_APP_ENV', 'VITE_APP_VERSION', 'CF_AUDITED_CONFIG', 'PREVIEW_HOST_CHECK', + 'PREVIEW_SECRETS', 'KIND', 'DELIVERY_KIND', 'CFV2_DELIVERY_KIND', 'CFV2_TARGET_URL', 'CFV2_DEPS', 'EXPECTED_URL', 'RESERVE_NATIVE_DEV', 'VITE_APP_ENV', 'VITE_APP_VERSION', 'CF_AUDITED_CONFIG', 'PREVIEW_HOST_CHECK', ]); /** `reserved: true` (the delivery engine: build_env_*, smoke_env, Preview secrets) also rejects RESERVED control names. */ @@ -42,7 +42,10 @@ export function parseEnvLines(text, { reserved = false } = {}) { * `--emit0 [--reserved]` re-validate a file written by the first form and print NUL-separated NAME=VALUE pairs. * `--reserved` is what the delivery engine always passes: application env may not set deployment-control names. */ // Compare real paths: a symlinked checkout must not turn the importer into a silent no-op. -if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { +const isMain = () => { + try { return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } // e.g. `node -e`: argv[1] is not a file +}; +if (isMain()) { const args = process.argv.slice(2); const mask = args.includes('--mask'); const reserved = args.includes('--reserved'); diff --git a/actions/cf-preview/publish.sh b/actions/cf-preview/publish.sh index 83166c3..52923a8 100755 --- a/actions/cf-preview/publish.sh +++ b/actions/cf-preview/publish.sh @@ -56,6 +56,11 @@ if [ "$TARGET" = preview ]; then fi PREVIEW_URL="$(printf '%s\n' "$EXTRACTED" | sed -n 's/^preview_url=//p')" [[ "$PREVIEW_URL" =~ ^https://[^[:space:]]+$ ]] || { echo "::error::Preview URL missing or not https after extraction" >&2; exit 1; } + # Pre-resolved URL (Native Dev): the published URL must be exactly the one the artifact was BUILT for. + if [ -n "${EXPECTED_URL:-}" ] && ! node -e 'const n=(u)=>u.replace(/\/+$/,"").toLowerCase();process.exit(n(process.argv[1])===n(process.argv[2])?0:1)' "$PREVIEW_URL" "$EXPECTED_URL"; then + echo "::error::published Preview URL $PREVIEW_URL differs from the URL this artifact was built for ($EXPECTED_URL); the build embeds the wrong URL" >&2 + exit 1 + fi echo "url=$PREVIEW_URL" exit 0 fi diff --git a/actions/cf-tools/action.yml b/actions/cf-tools/action.yml new file mode 100644 index 0000000..7769aa9 --- /dev/null +++ b/actions/cf-tools/action.yml @@ -0,0 +1,26 @@ +name: CF tools +description: > + Locate this repository's shared Cloudflare tooling (actions/cf-preview) for a job that runs its scripts directly (the delivery + plan, the Preview lifecycle, the E2E env import). Called as `uses: $/actions/cf-tools`: `$/` resolves to the repository AND the exact + commit of the running workflow, so the tooling always matches the workflow that is executing and nothing is checked out into the + caller's workspace. Exports TOOLS_DIR (absolute) and, with `install`, installs the tooling's locked dependencies (needs Node). +inputs: + install: + description: Run `npm ci` for the tooling (the delivery plan and the lifecycle use cli.mjs; the E2E env import does not need it) + default: 'false' +outputs: + tools_dir: + description: Absolute path of actions/cf-preview + value: ${{ steps.locate.outputs.tools_dir }} +runs: + using: composite + steps: + - id: locate + shell: bash + env: + INSTALL: ${{ inputs.install }} + run: | + dir="$(cd "$GITHUB_ACTION_PATH/../cf-preview" && pwd)" + { echo "tools_dir=$dir"; } >> "$GITHUB_OUTPUT" + { echo "TOOLS_DIR=$dir"; } >> "$GITHUB_ENV" + if [ "$INSTALL" = true ]; then (cd "$dir" && npm ci --ignore-scripts --no-audit --no-fund); fi diff --git a/docs/cf-worker-v2-refactor.md b/docs/cf-worker-v2-refactor.md index 387d395..6126167 100644 --- a/docs/cf-worker-v2-refactor.md +++ b/docs/cf-worker-v2-refactor.md @@ -2,6 +2,237 @@ `.github/workflows/_cf-worker-v2.yml` was refactored **in place** (breaking, no `v3`). Spec: `openspec/changes/refactor-cloudflare-worker-cicd-v2` (PR #57). Shipped v2.x behavior stays documented by `docs/cf-worker-v2.md` / PR #56 as the historical record; **the interface below replaces it**. The floating `@v2` tag has not moved and no consumer has been changed. +> **Topology superseded:** the job set below (Migration → Deploy → Smoke → Rollback + Shared Dev trio) was replaced by the matrix topology in [cf-worker-v2-unified-delivery.md](cf-worker-v2-unified-delivery.md). Engine, security and recovery rules here still apply. + +## Phase 1 simplification (PR #89 refactor) — current structure + +Goal: less orchestration, no duplicated configuration, no needless set-up, **same behaviour, same public interface, same security boundaries**. +The full-regression and real-runner evidence is at the end of this section. + +### Before / after + +``` +BEFORE _cf-worker-v2.yml (1,081 lines, 17 jobs) AFTER _cf-worker-v2.yml (769 lines, 11 jobs) + lint typecheck unit integration build e2e (6 jobs, each ci ──► _cf-worker-ci.yml: lint ‖ typecheck ‖ unit ‖ integration ‖ + its own .cf-tools checkout, 5 near-identical bodies) build ‖ e2e ─► Validation Gate (output validation=passed) + gate │ + plan (checkout .cf-tools + npm ci) plan (uses: $/actions/cf-tools, install) needs: [ci] + migration (checkout .cf-tools) needs: gate, plan migration (ONE job, unchanged) needs: [plan] + delivery_production | _native_branch | _native_dev | _persistent_dev delivery_* ×4 (thin calls) needs: [plan, migration] + (uses ./.github/workflows/_cf-worker-delivery.yml, (uses $/.github/workflows/_cf-worker-delivery.yml) + tools_ref forwarded, needs: gate, plan, migration) release (Production only) + release config-guard config-guard + lifecycle (inline, 5 steps) lifecycle ─► _cf-worker-lifecycle.yml + persistent-dev (inline, two contracts) persistent-dev ─► _cf-worker-persistent-dev.yml +``` + +| File | Before | After | +| --- | ---: | ---: | +| `_cf-worker-v2.yml` (public workflow) | 1,081 | 769 | +| `_cf-worker-delivery.yml` | 320 | 282 | +| `_cf-worker-ci.yml` (new) | – | 263 | +| `_cf-worker-lifecycle.yml` (new) | – | 93 | +| `_cf-worker-persistent-dev.yml` (new) | – | 109 | +| `actions/cf-tools` (new) | – | 26 | +| `_cf-worker-preview-gc.yml` | 113 | 104 | +| **pipeline workflows (v2 + delivery + ci + lifecycle + persistent-dev)** | 1,401 | 1,516 | + +Total lines grew by 8 % (each extracted workflow repeats its own `on:`/`permissions:`/`inputs:` header); what shrank is the **public** workflow (−29 %), the +number of places where behaviour is defined once instead of five times, and the set-up: **0** checkouts of this repository remain (before: 6 definitions, up to +11 executed in one feature-branch push: 5 CI checks + E2E + plan + Migration + Deploy + Smoke + Rollback). Defined jobs: 20 → 23 (the three new ones are the +extracted event handlers and the CI call; jobs *executed* per event are unchanged). **No runtime improvement is claimed**: the real-runner pairs (same scenario before and after, +n = 1 each) differ by less than runner-queue noise (plan 16 s → 18 s, Migration 15 s → 16 s, Build Validation 16 s → 17 s); a checkout of a sparse tree takes about a second. +`cf-deploy` keeps its 391 lines (only its `setup` reference and header comment changed). + +### Duplicated sections removed + +* Six copies of the tool checkout + `cf-ci-check` call (one anchor + five aliases) → one reusable CI workflow; the `Validation Gate` script moved with it (and now also emits the verdict). +* The 3.5 KB top-level `concurrency.group` expression existed **three times** (deploy call, verify call, `phase_state.group`) → once; see "Concurrency and phase_state". +* `phase_state.d1` (a per-kind D1 map the descriptors already carry) is gone; `d1_migrations` is no longer forwarded to Delivery (it was unused there). +* Four `Fetch delivery tooling` steps in Delivery/Migration/persistent-dev, the Preview lifecycle's and the plan's checkout + `npm ci` + `TOOLS_DIR` env → `uses: $/…`. +* `tools_ref` plumbing (a required input forwarded to every call and compared in the verify identity check) → a deprecated, ignored input. + +### `$/` (self-repository reference) coverage + +GitHub changelog 2026-07-30: `uses: $/path` is *this workflow's repository at the exact commit that is running*, with no checkout (needs runner ≥ 2.336.0; not on GHES; no `@ref` suffix; docs: +"always resolves against the repository of the file it appears in, not the repository that called it" — which is exactly what a cross-repository reusable workflow needs). + +| Was | Now | +| --- | --- | +| `actions/checkout` of `firstsun-dev/.github` @ `tools_ref` into `.cf-tools` + `uses: ./.cf-tools/actions/cf-deploy` (Delivery ×3, Migration ×3, persistent Dev) | `uses: $/actions/cf-deploy` | +| same + `./.cf-tools/actions/cf-ci-check` (CI ×5) | `uses: $/actions/cf-ci-check` (inside `_cf-worker-ci.yml`) | +| sparse checkout + `parse-env.mjs` (E2E), checkout + `npm ci` + `TOOLS_DIR` (plan, lifecycle, GC) | `uses: $/actions/cf-tools` (new, 25 lines: exports `TOOLS_DIR`, optional `npm ci`) | +| `uses: ./.github/workflows/_cf-worker-delivery.yml` | `uses: $/.github/workflows/_cf-worker-delivery.yml` (also `-ci`, `-lifecycle`, `-persistent-dev`) | +| `firstsun-dev/.github/actions/{setup,mailpit}@v1` inside the pipeline | `$/actions/{setup,mailpit}` (byte-identical to `v1`, so no behaviour change; exact-version coupling instead of a floating pin) | + +Sibling files are **not** assumed to land in `$GITHUB_WORKSPACE`. The runner downloads this repository at the running SHA; each action finds its scripts relative to itself +(`$GITHUB_ACTION_PATH/../cf-preview`, already how `cf-deploy` and `cf-ci-check` worked), and `cf-tools` exports that path for the jobs that run the scripts directly. The application checkout +is unchanged; nothing from this repository is ever in the application's workspace (the old `find .cf-tools … -delete` workaround in `cf-ci-check` is gone). +`tools_ref` is still accepted (required → optional, default empty) and ignored; a caller passing a non-existent ref keeps working (scenario r6 below). +**New environmental requirement:** every runner that executes a pipeline job (the `runner` input) must be ≥ 2.336.0; the org `linux` runners are 2.338.0 and GitHub-hosted images 2.338.2. A caller on an older self-hosted runner fails at "Prepare all required actions". +`actionlint` 1.7.12 does not know `$/`; `.github/actionlint.yaml` ignores exactly those two message formats and the contract test validates every `$/` reference itself (static, no `@ref`, target exists). + +### CI validation workflow + +`_cf-worker-ci.yml` runs the six checks as independent jobs without `needs` between them (asserted by the contract test; in the real run r7 below all seven jobs — E2E counts twice for two variants — started within about one second, the last one after waiting for a free runner), reusing `actions/cf-ci-check` +(E2E keeps its own set-up and test build, the variants matrix and the optional Mailpit). The **Validation Gate** (`if: always()`, so it fails instead of being skipped) accepts only +`success` for a configured job and `skipped` for an unconfigured one; only then it writes `validation=passed`, which the workflow exposes. The public workflow calls it **once**, with the +skip rules in one place (not on `delete`, not on the persistent-dev dispatch, not in a verify call, and not on a same-repo `pull_request` — the push run of the same SHA covers it; fork PRs run it credential-free, so CI is never duplicated). +The Delivery plan runs only when `needs.ci.result == 'success' && needs.ci.outputs.validation == 'passed'` (a skipped, failed or cancelled call, or a green call without the verdict, blocks it). +Migration and the four Delivery calls depend on the plan only: a plan can only succeed after a passed CI (or in a verify call), so they no longer repeat the CI condition. +Build Validation stays separate from the target build of Deploy. **Check names change:** the checks now appear as ` / CI / Lint` … ` / CI / Validation Gate` (was ` / Lint`). Callers with *required status checks* must update them; for the two consumer repositories that could be +checked read-only (`heaven-monorepo`, `innovation-apps`) this cannot matter — GitHub rejects the branch-protection and ruleset API on their plan, so they have no required checks; the other consumers were not inspected. + +### Migration (unchanged guarantees) + +Exactly one remote-D1 job per pipeline, same order, same Environment protection: `Validation → Plan → Database Migration (resolve D1 · validate bindings · audit effective config · stale-SHA guard · apply once) → Delivery`. +It stays inline in the public workflow: extracting it would add a workflow-call layer and re-plumb the descriptors, the inputs blob and the credentials without removing a stage or +weakening anything, which the brief rules out. Preview/Dev still can never reach the Production D1 (plan + engine audit); the shared-D1 group still serialises Workers sharing one D1; distinct physical D1s are still rejected by the plan. + +### Delivery engine and the matrix question + +One engine (`cf-deploy` → `engine.sh`) and one delivery workflow serve all four kinds, driven by the target descriptor. **The four calls are not a matrix, on purpose:** the public outputs +(`deploy_*`, `native_dev_*`, `dev_*`, `rollback_result`, `phase_state`), the per-kind hand-off of a deferred verify, Migration attribution and the Production Release gate each read *one* kind's outputs; a matrix call exposes only +one unspecified leg's output per key, so keeping them apart would need a results-collector job (a new stage and a new JSON layer). The four 12-line calls share `permissions:` and `secrets:` through YAML anchors (accepted by the GitHub parser in every real run). + +### 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. + +### Concurrency and `phase_state` + +* GitHub evaluates workflow-level `concurrency.group` before any job, so it cannot read a Plan output. The scheduling-time expression therefore stays in YAML, **once** (previously three copies). Its tested twin is `runLockGroup()` in `delivery.mjs`; the Plan now *emits* that + canonical value (`group` output), `phase_state.group` is taken from it, and `test-workflow-contract.py` renders the YAML expression against `runLockGroup()` over 512 event/ref/input scenarios. +* **One deliberate semantic note.** A verify call used to schedule under a group *recomputed* from `phase_state.inputs` (a second copy of the expression). It now schedules under `phase_state.group`, the value its own deploy call recorded. A forged or wrong value is rejected by the Plan job (`restoreDescriptors` compares it with `runLockGroup()` of the deploy inputs) + **before any job that can mutate**, so the worst outcome is a failed verify run that queued in the wrong group; it can never mutate under another lock. Tests: `test-jobs.py` (forged / foreign-family / missing group → Plan fails, nothing smoked or released), contract test (rejection executed against the real validator). +* `phase_state` keeps its shape minus the redundant `d1` map: `{inputs, kinds, deploy, migration, group, descriptors}`. It is produced and consumed inside one run by the same ref, and hand-built legacy blobs still verify (scenario r5). + +### Public interface + +All 60 inputs and 20 outputs remain with their names, defaults and semantics (diffed against the PR base: **no input or output removed, renamed or re-typed**). Two values changed, both internal: `tools_ref` is now optional and ignored, and the *content* of the opaque `phase_state` output lost its redundant `d1` key (callers pass it through unchanged). Semantics unchanged: `deploy_url`, `deploy_verified_url`, `deploy_verified`, `dev_url`, `native_dev_url`, `migration_status`, `rollback_result`, `phase_state` (opaque), all others. +Recorded for a later phase (not done, would be breaking or out of scope): drop `tools_ref`; collapse the `build_env_*`/`app_url_*` per-channel input families into one `targets` object; give Release its own verified-Production output so callers do not re-derive it; a results collector plus matrix once GitHub aggregates reusable-workflow matrix outputs; make `phase_state` keys kind-indexed so the Delivery workflow can read its hand-off itself. + +### Verification + +* **Static + contract** (`scripts/cf-preview/test-workflow-contract.py`, 546 checks): YAML parse, job set and `needs` (no serial dependency between the six checks), permission ceilings per called workflow, every `$/` reference static/no-`@ref`/existing, + call `with:` keys vs called inputs, secrets isolation, event matrix for every job `if`, Validation Gate **executed** over success/failure/cancelled/skipped for each check, Release gate, concurrency expression vs `runLockGroup()` (512 scenarios), forged-group rejection, interface (inputs/outputs/defaults). +* **Behavioural chain** (`scripts/cf-preview/test-jobs.py`, 413 checks, 8 groups, real `run:` steps and the composite engine against stubbed `gh`/`pnpm`/`curl`, each job in a fresh workspace): main push → Production; non-main → Branch Preview; Native Dev; persistent Dev; production-only; Hybrid dispatch; D1 on/off and shared D1; distinct D1s rejected; + failed Migration blocks every Delivery; each failing validation job blocks Migration and Delivery; smoke success / failure / skip / Access liveness; Production rollback; Preview/Dev recovery; Release only after a verified Production delivery; `pipeline_phase` deploy/verify and the cross-service `upstream_gate`; PR comment and branch-delete lifecycle. +* **CI on the PR** (`cf-worker-v2-ci.yml`, run 41589279882 @ `3bda5fb`): fast profile, chain profile, `shellcheck`, `shellcheck` of the composite scripts, `actionlint` — green. +* **Cross-repository, real runner** — disposable private `firstsun-dev/cf-v2-scratch-caller` calling `firstsun-dev/.github/.github/workflows/_cf-worker-v2.yml@3bda5fb40ddde8091c320702f58f6efe40d6faae`, org `linux` runners (2.338.0), **no Cloudflare credentials, no live deployment**, so every failure below is the designed fail-closed one. + The runner log shows `Download action repository 'firstsun-dev/.github@3bda5fb…'` for the nested `$/` actions and workflows: they resolved to the called workflow's repository and SHA, not to the caller. + +| Scenario (run) | Observed (jobs that ran) | +| --- | --- | +| r7 six checks + 2 E2E variants (41589291655, `feat/x`) | Lint, Typecheck, Unit, Integration, Build, E2E(a), E2E(b) all `success` and started together → Validation Gate → plan → `Deploy` failed closed (`preview credentials not provided`) → Smoke `none`; `d1_migrations: false` ⇒ Migration skipped | +| r8 `lint_cmd: exit 3` (41589295764) | Lint `failure`, the other five still ran and reported independently → Validation Gate `failure` (`LINT finished 'failure'`) → **plan, Migration, every Delivery and Release skipped** | +| r1 Branch Preview + Native Dev + D1 (41589300476, `feat/x`; 41589322177, `main`) | Gate → plan → the single `Database Migration` (Environment of the owner) failed closed on credentials before any remote call; Deliveries skipped; `migration_status=failed`. `main`: `prod credentials not provided` | +| r2 `d1_migrations: false` (41589304686) | Migration skipped, **both Deliveries started** (Deploy failed on credentials, Smoke `none`) | +| r3 two distinct D1s (41589308923) | Plan failed: `… 2 distinct physical D1 databases: scratch-preview-db (native-branch) vs scratch-dev-db (persistent-dev) … (nothing was changed)`; Migration and all Deliveries skipped | +| r6 caller still passing `tools_ref: this-ref-does-not-exist` (41589313763) | identical to r1 — the deprecated input is ignored | +| r4 `pipeline_phase` deploy → verify (41589317795) | deploy-phase plan + Delivery; the verify call's plan accepted the real `phase_state` (group + descriptors) and its Smoke ran; no Migration in the verify call | +| r5 hand-built legacy `phase_state` (41589326325) | success: plan + Production Smoke from the hand-off | +| r9 persistent Dev, legacy contract (41589330341, `val/pd`) | success: `dev_deploy_cmd` / `dev_smoke_cmd` ran (`$/actions/setup` in the nested workflow) | +| r10 persistent Dev, engine contract (41589334806) | `$/actions/cf-deploy` ran; failed closed on `dev credentials not provided` | +| r11 same-repo PR (41589372547), push to `evt/a` (41589358522), branch delete (41589501121) | PR: only `Preview lifecycle` ran (CI/plan/Delivery skipped), success, comment skipped as no verified Preview exists; push: CI → plan → Deploy failed on credentials; delete (default-branch workflow): lifecycle success | + +**Not demonstrated, not claimed:** a live D1 migration, publish, smoke against a live Worker, rollback, Environment approval gates (scratch Environments are unprotected), Mailpit, a runner older than 2.336.0, `$/` on GHES, and any runtime improvement. + +* **P1 follow-up (stale migration):** fixed after review — see "P1 fix" in [cf-worker-v2-unified-delivery.md](cf-worker-v2-unified-delivery.md). A stale-skipped migration no longer authorizes a publish; a superseded run's Delivery now fails at the Migration Gate instead of ending green. + +### Final-SHA merge-readiness validation (implementation `8ad4d4029b42f173dec5b6c39ddc8738fc6bf0a8`) + +Everything below was run against exactly `8ad4d40`; no implementation file was changed afterwards (this section is docs only). Nothing was merged, `@v2` was not moved, no consumer or Cloudflare resource was changed. + +**Regression (Phase 1).** PR CI [41595263650](https://github.com/firstsun-dev/.github/actions/runs/41595263650) passed at this SHA. Independently re-run locally on the detached checkout of `8ad4d40`: `scripts/cf-preview/test.sh fast` and `full` → `PASSED` (561 contract checks, 420 chain checks in 8 groups), `shellcheck 0.11.0 -x -S warning` over `actions/cf-preview/*.sh scripts/cf-preview/*.sh` + `shellcheck-actions.py` clean, `actionlint 1.7.12` over the seven workflows clean. Required assertions and the test that proves each: + +| Assertion | Evidence | +| --- | --- | +| `stale=true` / `stale="true"` never authorizes publishing | contract: `Migration Gate (executed): stale=true / stale='true' / stale=true with otherwise valid metadata blocks the publish` | +| Missing or malformed result fails closed | contract: empty result, invalid JSON, `migration none`, owner mismatch, D1 mismatch, failed / cancelled / skipped-while-required job all block | +| Valid Migration + matching owner and physical D1 succeeds | contract: `a succeeded Migration job with an applied migration of the planned owner on the planned D1 authorizes the publish` | +| `d1_migrations=false` does not block Delivery | contract: `Migration Gate (executed): d1_migrations=false needs no migration result`; chain: `d1_migrations=false … Delivery is NOT blocked` | +| A → B → A cannot bypass | chain group `migration-d1`: `A->B->A: the Delivery sees a FRESH tip but the Migration Gate rejects the stale result BEFORE the build; nothing is built or published` (+ nothing smoked, no rollback, no Release) | +| Failed Migration cannot start publishing | chain: `Migration failure -> the dependent Delivery never starts …, nothing published, Release blocked` | +| Deferred verification never repeats Migration | contract: `Migration: never in a verify call`; real runner r4 below | +| Smoke / Rollback target-isolated | chain: forged / cross-target / tampered `phase_state` descriptors fail the plan closed; Native Dev recovery never touches a Production version | +| Production Release requires verified success | chain group `production-release`; contract: `Release reads ONLY the Production delivery call` | + +**Stale Migration gate on a real runner (r12).** [41598319442](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598319442) checks out `8ad4d40` of this repository and runs the actual engine entrypoint (`actions/cf-preview/cli.mjs migration-gate`, the command `engine.sh migration-gate` runs in the publish stage) with a descriptor built by `delivery.mjs` and synthetic handoffs, on the org `linux` runner (`heavenweb-runner-3`) **and** `ubuntu-latest`. No Cloudflare secret exists in the repository, the environment is `env -i`, and `wrangler gh curl pnpm npx` are PATH shims that record any call. Result on both runners: `stale=true`, `stale="true"`, stale + otherwise valid metadata, empty, malformed JSON, `none`, owner mismatch, D1 mismatch and a failed Migration job all exit `1` (the stale refusal prints `the migration for native-branch was skipped because its run became stale; it does not authorize a publish (nothing is published)`); the valid and `d1_migrations=false` controls exit `0`; the shim log is empty (no publish-side tool was invoked). The first dispatch ([41598162595](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598162595)) failed because of a bug in the test script itself (`bash -e` aborted on the first expected non-zero exit) and is not an implementation result. This is a single-gate smoke test: the full A → B → A sequence needs a force-push race between a Migration and its Delivery against a live Cloudflare account and is **not** reproduced on GitHub Actions; its evidence remains the chain regression above. + +**Scratch caller pinned to `8ad4d40` (Phase 2).** `firstsun-dev/cf-v2-scratch-caller`, org `linux` runners (all `2.338.0`), no Cloudflare credentials. `referenced_workflows` of the run lists `_cf-worker-v2/ci/delivery/lifecycle/persistent-dev.yml` all at `@8ad4d40…`, and the nested `$/actions/cf-deploy`, `cf-tools`, `cf-ci-check` steps log `@8ad4d4029b42f173dec5b6c39ddc8738fc6bf0a8`, i.e. the public workflow and every `$/` reference resolved to the same revision. Overall `failure` below is the designed fail-closed result (no credentials), not an implementation regression. + +| Scenario | Run | Observed | Expected? | +| --- | --- | --- | --- | +| Parallel CI (6 checks, 2 E2E variants) | [41598183911](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598183911) | Lint, Typecheck, Unit, Integration, Build, E2E(a), E2E(b) `success`; Validation Gate, Plan `success`; Migration skipped (`d1_migrations: false`); `Deploy` failed `preview credentials not provided`; Smoke ran with status `none` | yes (conclusion `failure` from missing credentials) | +| Validation failure | [41598188577](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598188577) | Lint `failure`, the other checks still ran; Gate `failure` (`LINT finished 'failure'`); Plan, Migration, every Delivery, Release **skipped** | yes | +| Single D1 Migration | [41598193186](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598193186) (`feat/final`), [41598197646](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598197646) (`main`) | exactly one `Database Migration` job; failed closed on `preview` / `prod credentials not provided` before any remote call; every Delivery skipped | yes | +| Migration disabled | [41598201733](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598201733) | Migration skipped; Native Branch **and** Native Dev Deliveries started (failed on credentials) | yes | +| Multiple distinct D1s | [41598206295](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598206295) | Plan failed `… 2 distinct physical D1 databases: scratch-preview-db (native-branch) vs scratch-dev-db (persistent-dev) … (nothing was changed)`; Migration and Deliveries skipped | yes | +| Stale Migration | r12 above (real gate on runner); chain tests for the full path | gate exits nonzero before any publish-side call | yes | +| Deferred verification | [41598211512](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598211512), [41598215678](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598215678) | deploy-phase Plan + Delivery (failed on credentials); the verify call's Plan accepted the handoff, **Database Migration skipped** in both calls, Smoke ran from the original handoff (status `none`: nothing was published); hand-built legacy `phase_state` → success | yes | +| Deprecated `tools_ref` | [41598219951](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598219951) | `tools_ref: this-ref-does-not-exist` ignored; same fail-closed result as r1 | yes | +| Same-repo PR | [41598279417](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598279417) | only `Preview lifecycle` ran (no CI, Plan or Delivery); `success`; PR comment step success without a verified Preview | yes | +| Branch push / Branch Delete | [41598264159](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598264159) / [41598501202](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598501202) | push: CI → Plan → Deploy failed on credentials; delete (default-branch workflow): `Retire GitHub ledger (Preview is kept)` success; the lifecycle job's only mutating step is the ledger retire (the `wrangler preview delete` command appears only as a manual hint in the summary) | yes | +| Manual Persistent Dev | [41598224024](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598224024) (legacy contract), [41598228322](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598228322) (engine contract) | legacy `dev_deploy_cmd` / `dev_smoke_cmd` ran → success; engine path failed closed on `dev credentials not provided` | yes | + +The scratch Environments are unprotected and hold fake D1 ids, so Environment approval and every remote Cloudflare effect remain undemonstrated. The pre-existing `call.yml`/`v_*` workflows of the scratch repository are pinned to older SHAs and also fire on pushes; their runs are not evidence for `8ad4d40`. + +**Consumer compatibility (Phase 3, read from each default branch on 2026-11-03).** Callers: blog, heaven-monorepo (3 apps), innovation-apps (2 apps), anas-mcp, heaven-www — an org-wide code search for `_cf-worker-v2.yml` found no others (the `cf-v2-scratch-caller` is the disposable test repository). All call `_cf-worker-v2.yml@v2` and pass `tools_ref: v2` (deprecated, ignored). Every `with:` key and every output they read exists in the 60-input / 20-output interface. The **real planner** (`cli.mjs plan`, data only) was run over every call's actual `wrangler` config for push `main`, push `feat/x`, dispatch `main` (default and `persistent-dev`), dispatch `feat/x`, `pull_request` and `delete`: + +| Repository / App | Runner compatible | Required Checks compatible | D1 compatible | Deployment compatible | Action | +| --- | --- | --- | --- | --- | --- | +| blog | yes — `runner: linux` (ten org runners, all `2.338.0` ≥ 2.336.0); `[self-hosted, linux]` jobs run in the same pool | **UNVERIFIED — ADMIN ACCESS REQUIRED** (see below) | yes — main: prod `45031ada…`; branch push / dispatch: preview `feb45b04…`; one D1 per invocation (Shared Dev off) | yes (Native Branch + Production) | **fix `secrets:` (see rollout blocker 1)**; update Required Checks | +| heaven-monorepo / heaven-id | yes | UNVERIFIED | yes — main: prod `193440aa…`; branch push / `persistent-dev` dispatch: dev `ca755ecb…` | yes (hybrid + Shared Dev) | update Required Checks | +| heaven-monorepo / heaven-checkin | yes | UNVERIFIED | yes — prod `41fa4337…`; branch push: dev `8e3f8323…` | yes | update Required Checks | +| heaven-monorepo / test-client | yes | UNVERIFIED | yes — no D1, `d1_migrations: false` | yes | update Required Checks | +| innovation-apps / diet-manager | yes | UNVERIFIED | yes — prod `e12e2d9e…`; branch push: preview `b9421085…` | yes (Native Branch) | update Required Checks | +| innovation-apps / notification-hub | yes | UNVERIFIED | yes — prod `311ab3c8…`; branch push / dispatch: dev `9a69f8e9…` | yes (hybrid, Native Preview off) | update Required Checks | +| anas-mcp | yes — default `runner: linux` | **yes — verified: public repo, `main` not protected, no rulesets** | yes — no D1 | yes (`production-only`) | none required for checks; context names change but nothing requires them | +| heaven-www | yes | UNVERIFIED | yes — no D1 | yes (`production-preview`) | **fix `secrets:` (rollout blocker 1)**; update Required Checks | + +No call enables two distinct physical D1s in any modeled event, so the single-D1 rule does not reject any supported configuration. Not modeled: the post-build effective-config audit of Astro/adapter-generated configs (blog Shared Dev is off; it runs only in a live Migration/Deploy). + +**Required Checks.** Branch protection and rulesets could not be read for the four private repositories: the API answers `403 Upgrade to GitHub Pro or make this repository public` although the token has repository admin rights (organization plan: `free`; organization rulesets: `Upgrade to GitHub Team`). This is a plan limit, not a permissions gap, and it makes enforcement unlikely, but it is not proof — a rule configured under an earlier plan would be invisible here. Treat them as **UNVERIFIED — ADMIN ACCESS REQUIRED** and have an administrator confirm in the repository settings that none of the following contexts is required (`` is the caller's job name: `Pipeline` for blog, innovation-apps and heaven-www; `Heaven ID`, `Heaven Checkin`, `Heaven ID Test Client` for heaven-monorepo; `cf` for anas-mcp). Required-check names as seen on the consumers' recent default-branch runs ("today") and in the `8ad4d40` scratch runs ("after"): + +| Today (consumer runs) | After `8ad4d40` | +| --- | --- | +| ` / Lint` | ` / CI / Lint` | +| ` / Typecheck` | ` / CI / Typecheck` | +| ` / Unit Tests` | ` / CI / Unit Tests` | +| ` / Integration Tests` | ` / CI / Integration Tests` | +| ` / Build Validation` | ` / CI / Build Validation` | +| ` / E2E` and ` / E2E (, , )` (blog: four variants) | ` / CI / E2E` and ` / CI / E2E (, , )` | +| — | ` / CI / Validation Gate` (new aggregate) | +| ` / Migration` | ` / Database Migration` | +| ` / Deploy`, `Smoke Tests`, `Revert on Verify Failure`, `Shared Dev`, `Shared Dev Smoke Tests`, `Shared Dev Revert on Verify Failure` | ` / Delivery (production\|native-branch\|native-dev\|persistent-dev) / Deploy\|Smoke Tests\|Revert on Verify Failure` | +| ` / Release`, `Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | unchanged; new ` / Delivery 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. + +**Runner findings.** All ten org runners (`heavenweb-runner-1…10`, labels `self-hosted, Linux, X64, 32gb-ram, e2e, docker, playwright`) report `2.338.0` through the API (confirmed, not assumed); the label match `linux`/`Linux` is case-insensitive and the scratch jobs ran on `heavenweb-runner-3`. No repository-level runners exist. Hosted `ubuntu-latest` accepted `$/` as well (r12) but lacks `pnpm`, so full pipelines still need the org pool. The runner binary auto-updates, so the floor must be re-checked if a runner is rebuilt from an older image. Setup expectations are unchanged from `main` (same `actions/setup` with `setup-node`/`setup-pnpm: 'false'`, same `PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers`, same optional Mailpit action). No consumer uses `pipeline_phase`/`phase_state`/`upstream_gate`. + +**Environments.** No consumer Environment (`*/prod`, `*/dev`, `*/dev/shared`, `*/preview*`, in all five repositories) has required reviewers, a wait timer or a deployment-branch policy. The Migration job now runs in the owner's Environment, so the `/prod` approval is only as strong as these (currently absent) protections; approval behaviour was **not** exercised anywhere. + +**Non-production deployment evidence (Phase 4): BLOCKED.** The only Cloudflare credentials reachable from this organization are the Production-scoped `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` org secrets; the scratch repository has none, and no disposable, explicitly approved Cloudflare account/zone/D1 or protected scratch Environment exists or is documented. Missing prerequisites: a scratch Cloudflare account (or an API token scoped to disposable Workers, a throw-away D1 with a `migrations_dir`, and a Worker name prefix), repository secrets in `cf-v2-scratch-caller`, and a protected Environment with a reviewer (needs a paid plan for private repositories, or a public scratch repository). Nothing in this section is live deployment evidence. + +#### Rollout blockers found (not caused by this PR, must be resolved before `@v2` moves) + +1. **Undeclared secrets → `startup_failure`.** `blog/.github/workflows/cicd.yml` and `heaven-www/.github/workflows/cicd.yml` pass `CLOUDFLARE_PREVIEW_API_TOKEN` / `CLOUDFLARE_PREVIEW_ACCOUNT_ID`, and `blog/.github/workflows/preview-gc.yml` passes them to `_cf-worker-preview-gc.yml`. Neither the current `@v2` (`def3679`) nor `8ad4d40` declares them (commit `5def01d`, already on `main`: Native Preview uses the shared `CLOUDFLARE_API_TOKEN`/`ACCOUNT_ID`). GitHub rejects such a caller before any job starts — reproduced with [41599151750](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41599151750) at `8ad4d40` and [41600536212](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41600536212) at `fd6ab85` (`startup_failure`, zero jobs). The run page states: `Invalid secret, CLOUDFLARE_PREVIEW_API_TOKEN is not defined in the referenced workflow.` (and the same for `CLOUDFLARE_PREVIEW_ACCOUNT_ID`) — the exact message is only visible in the UI, not through the API. The consumers' own history is consistent with this (runs that ended `startup_failure` with zero jobs; the cause was inferred from the matching interface, not read from a log): heaven-www `CI/CD` runs [41017574023](https://github.com/firstsun-dev/heaven-www/actions/runs/41017574023) / [41017577275](https://github.com/firstsun-dev/heaven-www/actions/runs/41017577275) (2026-10-30) and blog `Preview GC` ([41559816233](https://github.com/firstsun-dev/blog/actions/runs/41559816233) and earlier nightly runs). Minimum remediation: remove the two `CLOUDFLARE_PREVIEW_*` lines from those three callers (the shared token must be allowed to manage Previews), or re-declare them as optional secrets in the called workflows. **Fix prepared (not merged):** [blog#461](https://github.com/firstsun-dev/blog/pull/461) and [heaven-www#56](https://github.com/firstsun-dev/heaven-www/pull/56) remove the lines (blog Preview GC maps the shared `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` instead). Replicas of the fixed callers start against both the v2 tag and the candidate. Rollout procedure, rollback and required-check guidance: [cf-worker-v2-rollout.md](cf-worker-v2-rollout.md). +2. Required Checks audit and update as tabulated above. +3. Live non-production evidence (Phase 4) and a staged rollout pinned to an immutable SHA. + +### Remaining risks and limitations + +* **Runner floor 2.336.0** for every runner passed as `runner` (see above); GHES is unsupported by `$/`. Confirmed `2.338.0` on all ten org runners on 2026-11-03. +* **Check names changed** (` / CI / Lint`, ` / Database Migration`, ` / Delivery () / …`): required status checks in other consumers must be updated when they adopt this ref; they are unreadable on four of the five consumers (Free plan), see the table above. +* `actionlint` cannot lint `$/` yet (two message formats ignored, guarded by the contract test). +* A verify call queues under the recorded group before the Plan proves it (see "Concurrency and phase_state"). +* The public workflow is 769 lines, above the 600–750 target: the remaining bulk is the 60-input / 20-output public interface (≈ 300 lines), which Phase 1 may not change. +* Total pipeline lines grew (see table); the gain is separation of concerns and the removal of duplication, not volume. + ## Job DAG and architecture (thin workflow, one delivery engine) ``` @@ -93,9 +324,9 @@ detect ─┬─ A (deploy call): CI ‖ E2E ─► Migration ─► Deploy * **Hand-off = data.** `phase_state` (workflow output of the deploy call) is `{inputs, deploy, migration, dev, group}`: the deploy call's inputs, its job outputs and its concurrency group. The verify call passes it unchanged; the engine takes - its inputs from `phase_state.inputs`, so the verify call only repeats `app_name`, `app_path`, `tools_ref`, `runner`, + its inputs from `phase_state.inputs`, so the verify call only repeats `app_name`, `app_path`, `runner`, `production_rollback_policy` (+ `release_cmd`, the secrets). `engine.sh init` compares `app_name`, `app_path`, - `production_rollback_policy` and `tools_ref` of the verify call with the blob and **refuses a mismatch**. `hydrate` still + and `production_rollback_policy` of the verify call with the blob and **refuses a mismatch**. `hydrate` still validates every field by regex and refuses a hand-off published from another SHA. * **Cross-service gate (`upstream_gate`).** The caller passes `needs..result`. `success` or `skipped` (the other Worker is unchanged: its existing verified environment is used) satisfy it. Anything else (failed, cancelled, unknown) makes the @@ -139,7 +370,7 @@ detect ─┬─ A (deploy call): CI ‖ E2E ─► Migration ─► Deploy ### Local composite actions in a cross-repository reusable workflow -A reusable workflow called from another repository resolves `uses: ./...` against the **caller's** workspace, and `uses:` cannot take an expression, so `uses: firstsun-dev/.github/actions/x@${{ inputs.tools_ref }}` is impossible and a pinned `@v2` would not contain the new actions until the tag moves. Each job therefore (1) checks out the app, (2) checks out `firstsun-dev/.github` at `inputs.tools_ref` into `.cf-tools` (sparse: only the action it uses plus `actions/cf-preview`), and (3) references the action by the **static** path `./.cf-tools/actions/cf-deploy` / `cf-ci-check`. `tools_ref` must equal the ref the caller pins the workflow at (required input, unchanged). `test-workflow-contract.py` enforces: static `./.cf-tools/actions/` paths only, the tooling checkout precedes every such reference and includes the action directory, the action exists in this repository. Only the pre-existing `setup` / `mailpit` `@v1` shared actions are referenced remotely. +**Superseded by `$/` (see "Phase 1 simplification").** The old pattern — check out `firstsun-dev/.github` at `tools_ref` into `.cf-tools` and reference `./.cf-tools/actions/` — existed because `./` resolves against the *caller's* workspace and `uses:` cannot take an expression. Actions and nested workflows are now `uses: $/actions/` / `uses: $/.github/workflows/`, which resolve to this repository at the running commit. **Revision (issue #61):** the former single `CI` job (Lint → Typecheck → Unit → Integration → Build as steps) is split into independent jobs for failure isolation, per-check duration/conclusion and single-job reruns. Same v2 name and public inputs; no v3. @@ -164,7 +395,7 @@ A reusable workflow called from another repository resolves `uses: ./...` agains | Group | Inputs | |---|---| -| Identity | `tools_ref` (must equal the ref the caller pins), `app_name`, `app_path`, `app_version`, `runner`, `wrangler_config` | +| Identity | `tools_ref` (deprecated, ignored), `app_name`, `app_path`, `app_version`, `runner`, `wrangler_config` | | Strategy | `deployment_strategy` (`production-preview` \| `hybrid` \| `production-only`), `preview_on_branch_push`, `shared_dev_on_branch_push` (default `false`), `preview_worker_name` | | Validation | `lint_cmd`, `typecheck_cmd`, `test_unit_cmd`, `test_integration_cmd`, `build_cmd` (required), `ci_env`, `use_mailpit` | | E2E | `test_e2e_cmd`, `test_e2e_setup_cmd`, `test_e2e_build_cmd`, `test_e2e_env`, `test_e2e_variants` | diff --git a/docs/cf-worker-v2-rollout.md b/docs/cf-worker-v2-rollout.md new file mode 100644 index 0000000..9e2d9b9 --- /dev/null +++ b/docs/cf-worker-v2-rollout.md @@ -0,0 +1,102 @@ +# CF Worker v2 — `v2` tag promotion: preconditions, procedure, rollback + +Status at the time of writing (2026-11-03): **PR #89 is ready to merge; the floating `v2` tag is NOT ready to be promoted.** This document does not move any tag. Promotion needs a separate, explicit authorization. + +| Item | Value | +| --- | --- | +| Current `v2` tag (lightweight, commit) | `def36792bf2fa79bb171abc7101f63320680e06b` | +| `v1` tag (still used by `actions/setup@v1`) | `cf678d19…` — untouched by PR #89 | +| Candidate (PR #89 head, implementation unchanged since `8ad4d40`) | `f86062d6110723d8b58622a9dfbdf05361bfa2f9` — a fast-forward of the `v2` tag (`ahead 36, behind 0`) | +| Final merge SHA | **to be recorded after merge** (a squash/merge commit gets a new SHA; promote that SHA, not the PR head) | + +## Compatibility facts (verified against `f86062d`) + +* Interface v2 tag → candidate: no input or output removed; 6 inputs and 5 outputs added; `tools_ref` becomes optional and ignored; the 6 declared secrets are unchanged (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `PREVIEW_SECRETS`, `SMOKE_USERNAME`, `SMOKE_PASSWORD`, `RELEASE_TOKEN`). +* Every `with:` key, secret and read output of all eight consumer calls (blog, heaven-www, heaven-id, heaven-checkin, heaven-id-test-client, diet-manager, notification-hub, anas-mcp) exists in the candidate; caller permissions cover the maximum the called jobs request. The only defect found was blog / heaven-www passing `CLOUDFLARE_PREVIEW_*` (below). +* Other shared references used by consumers (`_detect-changes.yml@v2`, `actions/setup@v1`, `_zap-scan-template.yml@v1`, `_lighthouse-template.yml@v2`, `_speedinsights-template.yml@v2`) are not changed by PR #89. `_cf-worker-preview-gc.yml` is changed but keeps its inputs, outputs and secrets. +* The real planner never plans two distinct physical D1s for any consumer call × (push main, push feature, dispatch main default / persistent-dev, dispatch feature, pull_request, delete). +* Runners: every online org runner reports `2.338.0` (floor `2.336.0`). On 2026-11-03 nine of the ten `heavenweb-runner-*` were listed online; `heavenweb-runner-10` was absent/offline (version unknown). + +## Preconditions (all required before moving the tag) + +1. **PR #89 reviewed and merged.** Record the merge commit SHA (`M`). +2. **Consumer compatibility PRs reviewed and merged** — [blog#461](https://github.com/firstsun-dev/blog/pull/461) (`cicd.yml`, `preview-gc.yml`) and [heaven-www#56](https://github.com/firstsun-dev/heaven-www/pull/56) (`cicd.yml`). They are valid against the current tag *and* the candidate, so merge them first; they are the only fix for the `startup_failure` that GitHub raises (`Invalid secret, CLOUDFLARE_PREVIEW_API_TOKEN is not defined in the referenced workflow.`). + * **Merging runs the real pipelines.** A merge to `main` of either repository runs the Production pipeline through the *current* `v2` tag (deploy, and for blog the D1 migration on the Production D1). heaven-www's `CI/CD` has ended in `startup_failure` since 2026-10-30, and blog's `cicd.yml` has not run since 2026-10-30 (its last runs succeeded), so a merge can publish everything that has accumulated on `main` since then. Merge deliberately, when a Production deploy is acceptable, and watch the run. + * The shared `CLOUDFLARE_API_TOKEN` must be allowed to manage Previews (v2 no longer has dedicated Preview credentials). +3. **No `startup_failure` from workflow-call contracts.** Evidence at `f86062d`: scratch replicas of the three fixed callers, built from the PR branches, started their jobs (1 job for Preview GC, 17–19 for the Pipeline calls) against both the v2 tag and the candidate and failed closed only on missing credentials — see "Validation evidence". +4. **Required Checks audited, or the limitation explicitly accepted.** Branch protection/rulesets of blog, heaven-www, heaven-monorepo and innovation-apps cannot be read (Free plan, `403 Upgrade to GitHub Pro`); anas-mcp has none (verified). An administrator must inspect the contexts below. **Recommendation: require only ` / CI / Validation Gate`.** Delivery jobs are skipped as whole jobs on PRs, so a required ` / Delivery (…) / Deploy` would wait forever for a status that is never reported. innovation-apps has two workflows that both name their job `Pipeline`, so a requirement on that name is ambiguous. +5. **Runner compatibility** re-checked immediately before promotion (`gh api orgs/firstsun-dev/actions/runners --paginate`), all online runners ≥ `2.336.0`; decide what to do about `heavenweb-runner-10`. +6. **Single-D1 assumptions** re-run if a consumer's `wrangler` config or switches changed (the planner harness used for this audit is described in `docs/cf-worker-v2-refactor.md`). +7. **Non-production deployment validation** completed, or explicitly accepted as a release risk. Today it is **not done**: no approved disposable Cloudflare account exists (only Production-scoped org secrets), so live migrate / publish / smoke / recovery and Environment approval are unproven (see "Not demonstrated"). +8. Scratch-caller validation repeated on `M` (below). + +### Required-check contexts to inspect + +`` is the caller's job display name: `Pipeline` (blog, heaven-www, innovation-apps), `Heaven ID` / `Heaven Checkin` / `Heaven ID Test Client` (heaven-monorepo), `cf` (anas-mcp). + +| Old | New | +| --- | --- | +| ` / Lint`, `Typecheck`, `Unit Tests`, `Integration Tests`, `Build Validation`, `E2E[ (variant)]` | ` / CI / Lint`, `Typecheck`, `Unit Tests`, `Integration Tests`, `Build Validation`, `E2E[ (variant)]` | +| — | ` / CI / Validation Gate`, ` / Delivery plan` | +| ` / Migration` | ` / Database Migration` | +| ` / Deploy`, `Smoke Tests`, `Revert on Verify Failure`, `Shared Dev`, `Shared Dev Smoke Tests`, `Shared Dev Revert on Verify Failure` | ` / Delivery (production\|native-branch\|native-dev\|persistent-dev) / Deploy\|Smoke Tests\|Revert on Verify Failure` | +| `Release`, `Config guard`, `Preview lifecycle`, `Persistent Dev (Hybrid)` | unchanged | + +## Procedure + +```text +PR #89 merged ──► record M + │ + ▼ +consumer PRs (blog#461, heaven-www#56) merged ──► confirm the resulting runs start (no startup_failure) + │ + ▼ +scratch caller on M (parallel CI, gate, one Migration, disabled, distinct-D1, stale gate, deferred, persistent Dev, PR / delete) + │ + ▼ +authorized non-production deployment (separate approval) + │ + ▼ +Go / No-Go + │ + ▼ +move v2 ──► monitor +``` + +1. Merge PR #89 (human decision); `M=$(git rev-parse origin/main)`. +2. Merge the consumer PRs one at a time; confirm each resulting run starts (jobs exist) and that Production deploys as intended. +3. Re-run the scratch scenarios pinned to `M` (`uses: …/_cf-worker-v2.yml@M`) and compare with the evidence below. +4. Run the authorized non-production exercise when an approved environment exists. +5. Go/No-Go, then move the tag (explicitly authorized only): + + ```bash + # record the old value first + OLD=$(gh api repos/firstsun-dev/.github/git/ref/tags/v2 --jq .object.sha) # expected def36792bf2fa79bb171abc7101f63320680e06b + gh api -X PATCH repos/firstsun-dev/.github/git/refs/tags/v2 -f sha="$M" -F force=true + ``` + +6. Monitor the first run of every consumer pipeline: blog, heaven-www, heaven-monorepo (3 apps), innovation-apps (2 apps), anas-mcp, plus the nightly blog Preview GC. Watch for `startup_failure`, the Migration Gate, Environment approvals, Smoke and Release. + +## Rollback + +* **Restore the tag** (the action that restores workflow behaviour for every consumer at once): + + ```bash + gh api -X PATCH repos/firstsun-dev/.github/git/refs/tags/v2 -f sha=def36792bf2fa79bb171abc7101f63320680e06b -F force=true + ``` + + The consumer-side fixes (no `CLOUDFLARE_PREVIEW_*`) are valid against both refs, so they do not need reverting. +* **Pin one consumer** to a known-good immutable SHA instead (per-repository, no tag change): change `uses: firstsun-dev/.github/.github/workflows/_cf-worker-v2.yml@v2` to `@<40-character SHA>`. `tools_ref` is deprecated and ignored, so it needs no change. Pin to `def3679…` (old behaviour) or to `M` (new behaviour). +* **Not reverted by restoring the tag:** Workers and Preview deployments that were already published (restore with the pipeline's own Production rollback, or redeploy a known-good commit; Production rollback is `manual` or `automatic` per caller via `production_rollback_policy`); **D1 migrations** that were applied (there is no automatic schema rollback; additive-only is assumed by the `automatic` policy, which blocks otherwise); Cloudflare Previews created; GitHub Deployment ledger entries and Environments; semantic-release tags / releases; Smoke / Release side effects. A tag restore only changes what the *next* run executes. A run already in flight keeps the revision it resolved. + +## Validation evidence (candidate `f86062d`) + +* PR CI `CF Worker v2 contract tests` green at `8ad4d40`, `fd6ab85` and `f86062d` (the implementation is byte-identical across the three). Local `test.sh fast|full` PASSED, `actionlint 1.7.12` and `shellcheck 0.11.0` clean at `8ad4d40`. +* Scratch caller (`firstsun-dev/cf-v2-scratch-caller`, **no Cloudflare credentials**, org `linux` runners, workflows pinned to `f86062d6110723d8b58622a9dfbdf05361bfa2f9`): real stale-migration gate on `linux` and `ubuntu-latest` ([41603813297](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603813297)); r7 parallel CI [41603819671](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603819671); r8 Validation Gate [41603825658](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603825658); r1 single Migration [41603830812](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603830812) / [41603836186](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603836186); r2 Migration disabled [41603841545](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603841545); r3 distinct D1s [41603847106](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603847106); r4 / r5 deferred verification [41603852919](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603852919) / [41603857824](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603857824); r6 deprecated `tools_ref` [41603863062](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603863062); r9 / r10 manual Persistent Dev [41603868914](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603868914) / [41603875248](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603875248); same-repo PR [41604617855](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41604617855), push [41604599527](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41604599527) and branch delete [41604768826](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41604768826); undeclared-secret contract probe [41603880413](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41603880413) (`startup_failure` by design). + Observed results are identical to the earlier `8ad4d40` set: every `failure` is the designed fail-closed result (`preview|prod|dev credentials not provided`, `LINT finished 'failure'`, `… 2 distinct physical D1 databases …`). +* **Startup vs fail-closed:** replicas of the *fixed* callers built from the PR branches (`c_blog_*`, `c_blogGc_*`, `c_www_*` in the scratch repository; keys kept, shell commands neutralized) against the v2 tag and against `f86062d`: all six started jobs (Preview GC 1; Pipeline calls 17–19) and failed only on `CLOUDFLARE_ACCOUNT_ID is required` / `prod credentials not provided`. A caller that still passes `CLOUDFLARE_PREVIEW_*` ends `startup_failure` with zero jobs (r13). +* Consumer audit: no defect in heaven-monorepo, innovation-apps or anas-mcp; `dev-config-check` / `preflight` policy checks passed on their source configs. + +## Not demonstrated + +Live Cloudflare migration, publish, smoke, rollback; GitHub Environment approval (no consumer Environment has required reviewers or a branch policy today); the post-build effective-config audit of adapter-generated configs; the blog Preview GC dry run with real credentials; the full A → B → A race (chain regression only); GHES; runners older than 2.336.0; Required Checks of the four private repositories. diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md new file mode 100644 index 0000000..7a00e1a --- /dev/null +++ b/docs/cf-worker-v2-unified-delivery.md @@ -0,0 +1,358 @@ +# CF Worker v2 — unified target-driven delivery (shared-engine contract) + +Spec: `openspec/changes/unify-cf-worker-v2-delivery-targets`. This document is the **frozen Phase 1 contract** consumers build against. +It supersedes the job topology in `cf-worker-v2-refactor.md` ("Job DAG", "Shared Dev job order"); the engine, security rules, ledger +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 and manual persistent Dev in their own workflows, 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; +> the sections "Topology", "Behaviour" and "Single Migration job" are current. + +## Topology (final DAG) + +``` +CI call (_cf-worker-ci.yml): Lint ‖ Typecheck ‖ Unit ‖ Integration ‖ Build Validation ‖ E2E (independent, parallel) + │ + Validation Gate every CONFIGURED job succeeds, every unconfigured one is skipped, else fail closed; output validation=passed + │ + Delivery plan enabled kinds + smoke modes + ONE physical D1 (pure data, no credentials) + │ outputs: on_, descriptors, migration {owner kind, Environment, d1_id, kinds} + │ + Database Migration ONE job, Environment of the owner (/prod on main) + │ 1. each enabled target that is not the owner: build + effective-config audit + exact D1 binding (nothing remote) + │ 2. the owner: build + audit + D1 binding + stale-SHA guard, then the ONE migration + │ skipped when d1_migrations is false / verify phase; never rolls schema back + delivery_production | delivery_native_branch | delivery_native_dev | delivery_persistent_dev one CALL per enabled kind, mutually independent + └─ deploy ──► smoke ──► (conditional) rollback jobs of the SAME internal workflow, scoped to that one target + │ + Release (Production, main only; reads ONLY delivery_production) +``` + +Kinds: `production`, `native-branch` (per-branch Native Preview), `native-dev` (the stable Native Preview named `dev`), +`persistent-dev` (the separate `[env.dev]` Dev Worker; Notification Hub). Only enabled kinds exist as calls (a disabled call is skipped by the +plan flag `on_`), so no phantom delivery paths exist. **A delivery call waits only for the plan (which itself requires a passed CI) and the +single Migration job (success, or skipped when there is nothing to migrate); it never waits for, and is never affected by, another kind's Deploy / Smoke / Rollback.** There is no +matrix anywhere in the delivery graph: every `needs..outputs.*` belongs to exactly one target. + +| Event | Kinds | +| --- | --- | +| 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 existing manual `persistent-dev` job (unchanged) | +| 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 +`shared_dev_native_preview_on_branch_push`; unknown `smoke_mode` / `smoke_mode_overrides`; an unknown kind in a `phase_state` hand-off; +a Preview/Dev kind whose D1 is the Production D1; enabled kinds that name **two or more distinct physical D1 databases** while `d1_migrations` is on (single-DB rule). + +### P1 fix: a stale-skipped migration never authorizes a publish (PR #89 review of `f3325f5`) + +**Root cause.** `resolveMigrationUsed()` (the Migration Gate every Delivery runs before anything is built or published) skipped the result, owner and physical-D1 checks when the migration result carried `stale=true`. +A Migration job superseded by a newer push succeeds *as a skip* (nothing applied), so with `needs.migration.result == success` the gate accepted it. The Delivery's own branch-tip check then decided alone: if the branch was +force-pushed back to the original commit in between (A → B → A) the tip looked fresh and the Publish proceeded **without the required migration**. Found by code review; a potential race, not a confirmed incident. + +**Fix** (`actions/cf-preview/delivery.mjs`, no other production file): the gate now fails closed, in this order — `d1_migrations=false` → no migration needed; no planned owner; Migration job result ≠ `success` (failed, cancelled, skipped, missing); result missing / invalid JSON / not an object; +**`stale` is `true` or `"true"` → rejected, whatever else the result contains**; `migration` ≠ `applied`; owner kind ≠ planned owner; D1 id ≠ planned physical D1. The branch-tip check stays as an additional guard but is no longer relied on. + +**Authorization invariant.** *No completed migration (applied, by the planned owner, on exactly this call's physical D1) = no Publish authorization.* Nothing infers "none" from a skip, nothing re-runs a stale migration, and the gate (engine step `Migration gate`) runs before the stale guard, set-up, prepare, build and publish of the publish stage. + +**Behaviour change to know.** A genuinely superseded run (branch moved and stayed moved) used to end green with nothing published; with `d1_migrations: true` its Delivery now **fails at the Migration Gate** (red, nothing built, published, smoked or rolled back). `migration_status` is unchanged (`none` for a stale-skipped migration). `d1_migrations: false` and `pipeline_phase: verify` (which never runs the gate and never migrates) are unaffected. + +**Tests.** `delivery.test.mjs` (the old "stale is accepted" test is replaced by rejection of `stale: true` / `"true"`, with and without valid metadata or owner/D1, plus missing / invalid / non-object JSON, `none`, wrong owner, wrong D1, failed / cancelled / skipped job, migration disabled); +`test-workflow-contract.py` (the real `cli.mjs migration-gate` executed for each case; gate precedes every other publish-stage step); `test-jobs.py` chain (stale branch and "superseded after the build" now fail at the gate; **A → B → A**: Migration superseded, Delivery sees a fresh tip, gate rejects before the build, nothing published, smoked, rolled back or released; deferred verification unchanged). + +### Reusable-workflow limits honoured + +| Constraint | How | +| --- | --- | +| Nesting depth | caller → `_cf-worker-v2.yml` → `_cf-worker-delivery.yml` / `-ci` / `-lifecycle` / `-persistent-dev` = 3 levels (limit 10); none of them calls a further workflow | +| Permission ceiling | each `uses:` job declares the permissions the called jobs need (`contents: read`; `deployments: read` for the Migration job, `deployments: write` + `pull-requests: write` for Delivery); the caller of the public workflow already had to grant these | +| Static `uses:` | `$/.github/workflows/_cf-worker-delivery.yml` per call (no expression); actions are `uses: $/actions/` (this repository at the running commit) | +| `tools_ref` | deprecated and ignored: `$/` makes the engine always come from the commit of the running workflow | +| Secrets | passed explicitly per call (smoke credentials only to Delivery); never `secrets: inherit` | +| Environments | job property inside the called workflow (`/prod`, `/preview`, `/native-dev`, `/dev`); the Migration job runs in the owner's Environment; Smoke has none | +| Concurrency | the run-level group stays on the public workflow; the delivery workflow has **only job-level** groups (`cfv2-leg--[-rollback]`, persistent Dev keeps `cfv2-dev--`) so nothing deadlocks | + +## Inputs + +All pre-existing inputs are unchanged (names, types, defaults). New, all optional and default-off: + +| Input | Default | Meaning | +| --- | --- | --- | +| `shared_dev_native_preview_on_branch_push` | `false` | Native Dev: every eligible branch push updates Native Preview `dev` (host = `preview_worker_name` or `app_name`, `[previews]` bindings, **not** `[env.dev]`). | +| `build_env_shared_dev_native_preview` | `""` | Build-time `KEY=VALUE` env of the Native Dev build. Never falls back to another kind's env. | +| `app_url_shared_dev_native_preview` | `""` | Optional declared Native Dev URL. Must equal the URL derived from Cloudflare (below) or the run fails **before** the build. | +| `preview_url_suffix` | `""` | Optional host suffix of Preview URLs (`-..workers.dev` or `.`). Normally empty: Cloudflare reports it. Required for custom-domain Preview hosting (`preview_host_check: false`). | +| `smoke_mode` | `full` | `full` = built-in probe + `smoke_cmd` (current behaviour); `basic` = content-level HTTP/health only (no `smoke_cmd`); `skip` = no request. | +| `smoke_mode_overrides` | `{}` | JSON keyed by kind, e.g. `{"native-dev":"basic"}`. | + +Engine-owned build variables (reserved, cannot be set from caller env): `CFV2_DELIVERY_KIND`, `CFV2_TARGET_URL`; `VITE_APP_ENV` is +`prod` / `preview` / `dev` (`dev` for **both** `native-dev` and `persistent-dev`). + +## Outputs (public contract) + +Pre-existing outputs keep their meaning: `deploy_*`, `version_id`, `previous_version_id`, `migration_status`, `rollback_result` +describe the Production / per-branch Preview delivery; `dev_*` describe `persistent-dev`; `deploy_gate_blocked`; `phase_state`. +Added: `native_dev_url`, `native_dev_verified_url`, `native_dev_published_unverified`, `native_dev_verified`, `native_dev_smoke_level`. +`deploy_smoke_level` / `native_dev_smoke_level` may now be `skipped`. `phase_state` is opaque: pass it unchanged to the verify call. + +**Output determinism.** Every per-kind output SELECTS its source call from the plan (`jobs.plan.outputs.on_ == 'true'`) and then reads that call's own result - never "whichever string is non-empty". `migration_status` is the single Migration job's always-run status step: `applied`, `none` (nothing pending, superseded, `d1_migrations` false or a verify call) or `failed`. `deploy_*` read Production, else +Native Branch (mutually exclusive by event: `main` vs. other branches); `dev_*` read `persistent-dev`; `native_dev_*` read `native-dev`; +`rollback_result` is the first rolled-back kind in the order production, native-branch, native-dev, persistent-dev. + +## Behaviour + +* **Per-target builds and URL pre-resolution (P1-B).** Every Deploy / Preflight checks out the validated SHA, imports its own build env, resolves + **its canonical URL before building**, builds, audits its generated Wrangler config and publishes that artifact. + * Native Preview URL (Cloudflare Workers "Previews" documentation): `workers.dev` → `-..workers.dev`; + custom domain → `.`. The Workers subdomain API (`GET /accounts/{id}/workers/scripts/{worker}/subdomain`, the same + read-only call as `preview-host-check`) reports `previews_enabled` and `preview_url_suffix`; the URL is `https://`. + The identity is the real one: `` (Native Branch, via the collision-guarded identity) or the reserved name `dev`. + * Nothing is guessed: if neither Cloudflare nor `preview_url_suffix` provides a suffix and no URL is declared the run fails before the build; + a declared URL (`app_url_shared_dev_native_preview`) that contradicts the derived one fails before the build; two suffix sources must agree. + * The URL is exported to the build as `CFV2_TARGET_URL`; the published URL must equal it (also in recovery rebuilds), else the publish is rejected. + * Build-env checks before the build: another target's public URL is rejected; this application's own callback / redirect / app-origin / site + variables (`*CALLBACK*`, `*REDIRECT_URI/URL*`, `*APP_ORIGIN*`, `*APP_URL*`, `*SITE_URL*`) must be **same-origin with the target URL**. + An IdP issuer / `IDP_URL` names another service and is not verifiable, so it is only checked against this app's other targets. +* **Single Migration job (replaces Preflight → Migration, P2-B).** The plan groups the enabled kinds by `account_id/database_id` of the source config and admits + **one** group; identical D1s are deduplicated (Native Branch + Native Dev on one preview D1), distinct ones fail the plan before any build. The `migration` + job then validates every enabled target that is not the owner (engine stage `preflight`: non-publishing build, effective-config audit, built config binds + **exactly** the plan's D1) and finally runs the owner's `migrate` stage (the same checks for the owner, a stale-SHA guard, then the one `d1 migrations apply`). + Step order is the guarantee: no mutation can precede a failed validation. A Preview/Dev kind mapped to the Production D1 fails the plan. Each Delivery call's + publish stage re-audits the built config against the migrated D1 id and validates that the migration result has the planned owner kind and D1 id. A failed + migration starts no Delivery. Stale-SHA checks run before migration and again before publish; D1 is never rolled back. Same-repository serialization is + the workflow-level concurrency group (`cfv2-nonprod-d1-` / `cfv2-shared-dev-` also cover Native Dev); a D1 shared with **another + repository** is not locked (GitHub limitation, see limits). +* **Result routing (P1-A).** Each Delivery call receives only its own hand-off and THE migration result (validated by the planned owner kind and the call's D1 id inside the call). The Smoke and Rollback jobs refuse a hand-off whose `kind`, `published_sha` or URL belong + to anything else (fail closed on missing / ambiguous / stale / mismatched data). +* **Native Dev.** Fixed name `dev`, own ledger environment `/native-dev/dev`, own GitHub Environment `/native-dev`, never + advertised on PRs. The name `dev` is reserved: with Native Dev on, a branch named `dev` gets Preview `b-dev`; Preview GC never collects `dev`. +* **Smoke / recovery.** `skip`: no request, `deployed_and_verified=false`, `verified_url` empty, `smoke_level=skipped`, ledger `inactive` + (never known-good), no rollback, no Release. `basic`: Access-redirect = liveness only = Published/Unverified. Recovery is target-owned and runs + only in the failing kind's call: `native-dev` / `persistent-dev` redeploy the last known-good deployment of the same ledger unless a newer one + exists; Production keeps its fail-closed policy; a blocked cross-service gate never rolls back. +* **Release** requires the Production Smoke to be content-verified and no Rollback to have run; it reads only `delivery_production`. +* **`pipeline_phase`.** `deploy` runs the Migration job and each call's Deploy only; the `phase_state` output carries each kind's own Deploy + result, its migration result and D1 id, the kinds, the inputs and the concurrency group. `verify` (second call) runs only each kind's Smoke and + Rollback from that data (`handoff_json`, `migration_json_in`, `d1_expected_id` are that kind's own entries) and `upstream_gate`; Heaven's + ID ↔ Test Client ↔ Check-in dependencies are expressed by the caller's `needs` between the deploy and verify calls, unchanged. + +## Verification evidence + +Local (mocked Cloudflare/GitHub) — suites: `test-workflow-contract.py` (job graph, no matrix / no cross-kind `needs`, gating, credential isolation, +permission ceiling, concurrency, interface compatibility, `pipeline_phase`), `test-jobs.py` (executes the real jobs and composite engine per call +against stubs: all four kinds, simultaneous Branch + Native Dev, reversed completion order, one-target failure isolation, shared-D1 dedupe, URL +pre-resolution, smoke full/basic/skip, rollback isolation and stale-deployment protection, Release gate, deferred `pipeline_phase`), +`delivery.test.mjs`, `lib.test.mjs`, `test-*.sh`, `gc.test.mjs`, shellcheck, actionlint 1.7.12. Local gate at `e72ae91`+: contract 623, test-jobs 379, node (lib+delivery+gc) 114, test-deploy 84, migrate 26, smoke 49, parse-env 27, probe 20, gh 24 — all +passing; actionlint 1.7.12 and shellcheck clean. + +**Real-runner evidence** — disposable private caller `firstsun-dev/cf-v2-scratch-caller`, `runner: ubuntu-latest`, **no Cloudflare secrets**, calling +`firstsun-dev/.github/.github/workflows/_cf-worker-v2.yml` at the pinned SHA `88d9777` (`tools_ref` = same SHA): + +| Run | What it proves | Observed | +| --- | --- | --- | +| [41035153116](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41035153116) / [41035205869](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41035205869) | **Defect found by the real runner**: `startup_failure` at `c6423d0`. | GitHub validates the permission ceiling for **every** job of a called workflow, including jobs skipped by `if:`. The Preflight / Migration calls granted only `deployments: read` while the same workflow's Deploy/Smoke/Rollback jobs request `deployments: write` + `pull-requests: write`. Fixed in `88d9777` (all per-kind calls grant the full ceiling; each called job still requests only what it needs); the contract test now checks the ceiling against every called job. Bisected by copying the workflow into the scratch repo (`pf1`/`mg1` startup_failure, `dl1`/`nocalls` start). | +| [41035901102](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41035901102) | Cross-repo call of the public workflow at `88d9777` **starts**; the nested `uses: ./.github/workflows/_cf-worker-delivery.yml` resolves; all 20 public outputs are readable by a read-back job. | `call` = failure by design at *Build Validation*: `pnpm: command not found` (see limitations). `readback` = success: `CALL_RESULT=failure`, `DEPLOY_GATE_BLOCKED=false`, every other output empty, `phase_state` rendered as JSON. 22 downstream jobs skipped (fail closed behind the red Gate). | +| [41036120312](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41036120312) | `pipeline_phase: verify` with a hand-built `phase_state` (kinds native-branch + native-dev): plan from the hand-off, **one independent delivery call per kind**. | success: `Delivery plan`, `Delivery (native-branch) / Smoke Tests`, `Delivery (native-dev) / Smoke Tests` ran (nothing was published, so nothing is verified); 29 other jobs skipped; read-back: `DEPLOY_TARGET=preview` (selected by the plan flag), `DEPLOY_URL`, `DEPLOY_VERIFIED`, `NATIVE_DEV_URL`, `NATIVE_DEV_VERIFIED`, `MIGRATION_STATUS`, `ROLLBACK_RESULT` empty, `DEPLOY_GATE_BLOCKED=false`. | +| [41036125410](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41036125410) (ref `main`) | Engine event/kind guard on a real runner. | Preflight(native-dev) failed closed: `target mismatch (enabled kinds 'production', engine 'native-dev'); refusing to deploy`; Deploy / Migration / Smoke / Rollback skipped. | +| [41036258062](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41036258062) (ref `feat/x`) | Preflight engine stage on a real runner, branch event. | Passed init and the kind guard, then failed closed: `preview credentials not provided (set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)`; the other four jobs skipped. | + +Not demonstrable on GitHub-hosted runners without Cloudflare credentials (and so **not** proven by a real run): URL resolution against the Workers +subdomain API, the build, effective-config audit and D1 binding checks, migration, publish, smoke against a live Worker, rollback. Those are proven +only by the local suites above. + +## Test profiles and measured performance (PR #89 refactor) + +`scripts/cf-preview/test.sh fast|chain|full` is the single entrypoint for CI and agents (logs go to `$LOGDIR`, only summaries print). +`fast` = static workflow contract, unit tests (`lib.test.mjs`, `delivery.test.mjs`), stub shell tests, GC unit tests (~10 s). +`chain` = the real-shell Migration → Deploy → Smoke → Rollback scenarios of `test-jobs.py`, which is now shared fixtures plus **eight explicit +`group_*` functions** (`test-jobs.py --list | --group ID`). `run-job-groups.py` is a thin launcher (bounded parallelism: `CF_V2_TEST_JOBS` or +min(4, CPUs)) that checks a manifest of expected groups with minimum check counts: a missing, unexpected, empty, shrunken or skipped group, or a +missing perf report, fails the run. `full` = both; it is the only profile behind the required `contract` check. `JOBRUNNER_PERF=` writes a JSON +report (subprocesses by command/engine phase, workspaces, jobs). + +| Metric (404 chain checks; 379 before + 25 added in this work, none removed) | Before (3842a45 / a9fd174) | After (8ad9f5f) | +| --- | --- | --- | +| Chain wall time, local (bounded parallelism 4) | ~6 min serial equivalent | ~101 s (`run-job-groups.py`) | +| Fast profile, local | not separated | ~11.5 s | +| Full (Fast then Chain, local) | not separated | 1m53s | +| Whole CI job (GitHub-hosted: setup, tests, shellcheck, actionlint) | 2m29s – 4m13s | 2m19s – 3m17s | +| Engine `init` average cost | 213 ms (697 calls, 36 % of subprocess CPU) | 58 ms (702 calls, 40.9 s CPU) | +| Recorded step-subprocess CPU | ~398 s | ~306 s | +| Node processes in one Production chain (system-wide `execve`) | 17 | 9 | +| All `execve` in one Production chain | 567 | 549 (-3 %; the rest is the harness's own bash stubs) | +| Step-shell subprocesses counted by the harness | 7180 | 7640 (+6 %: new `migration-gate`/`init-checks` steps; not a reduction) | +| Workspaces created | 1408 | 1437 (new tests) | + +Honest reading: wall time improved through parallel groups, a lighter `init` and the Fast/Full split. The harness's step-shell count did **not** fall; the +work removed is *inside* the steps (Node/`jq` processes per engine call). Replacing repeated full-chain scenarios with focused tests was **not** done in +this round: every end-to-end scenario is still run, new focused unit tests were added alongside them. Remaining top costs: `engine:build` 14 %, +`prepare` 7 %, `hydrate` 5 %. + +### Structure (static, before → after) + +| | 3842a45 | a9fd174 | 8ad9f5f | +| --- | --- | --- | --- | +| `_cf-worker-v2.yml` lines | 1318 | 1259 | 1249 (+2: the scheduling-lock expression now covers verify) | +| `_cf-worker-delivery.yml` lines | 494 | 450 | 446 | +| `engine.sh` lines | 593 | 606 | 524 | +| `with:` field lines across the 12 per-kind calls | 196 | 144 | 132 | +| Inline `jq` policy steps in the delivery workflow | 10 | 3 | 3 | +| Node spawn sites inside `engine.sh init` | 24 | 17 | 3 | +| Build / publish implementation paths in the engine | 2 / 2 | 1 / 1 | 1 / 1 | +| Independent implementations of "which kinds / D1 / owner / smoke mode" | plan YAML + engine shell + workflow expressions | descriptor (JS) | descriptor (JS), engine re-validates it | + +Per call, `target`, `environment`, `d1_expected_id`, `d1_owner_kind`, `deploy_group`, `rollback_group` and `kind` are one `descriptor`. Unavoidable +GitHub Actions duplication: the 12 static `uses:` calls (one job per kind keeps outputs unambiguous), each call's `permissions` ceiling and `secrets` +block, and the two-step tooling checkout in every delivery job — `uses:` cannot take an expression, so a composite that fetches the tools at +`tools_ref` cannot itself be referenced from `tools_ref`; each job runs on its own runner anyway. + +Pure decisions now unit-tested in `delivery.mjs` instead of inline shell/jq: target descriptor (incl. deferred-verification restore), migration peer gate, +migration-used resolution, engine initialisation (`validateInit`), verification outcome, Production rollback guard, rollback verdict. + +### Deferred verification keeps the deploy call's decisions + +`phase_state` carries the deploy call's validated descriptor map (`descriptors`). The verify call's plan restores it (`restoreDescriptors`), checks it +against the rest of the hand-off (kinds, application, repository, D1 ids, migration owner; phase must be `deploy`) and only moves `phase` to `verify`. +A legacy hand-off without `descriptors` is rebuilt from the deploy call's own `phase_state.inputs`, never from the verify call's inputs; missing identity +fails closed. For leg kinds the legacy rebuild uses the verify run's id in the (uncontended) rollback group name — the only documented difference. + +### Lock identity across Deploy → Verify → Rollback + +* **Job-level locks.** Persistent Dev's Deploy and Rollback groups are `cfv2-dev--`. `restoreDescriptors` requires the handed-over + descriptor to carry exactly that, rebuilt from the current repository, the deploy call's application and the deploy call's `d1_lock_scope` — a modified + deploy/rollback group, another repository, another application or a changed scope is rejected. A verify call has no input that can pick another Dev lock. +* **Run-level (top-level `concurrency`) lock.** GitHub evaluates it *before any job*, so no Plan check can repair it. For a verify call the expression is therefore the + deploy expression applied to `phase_state.inputs` (never `phase_state.group`); `delivery.mjs runLockGroup` is its tested twin (512 scenarios, deploy and verify + mode, rendered from the real YAML). The Plan job then requires `phase_state.group` to equal that value (a `cfv2-run-` fallback group only has to be numeric). +* **Trust boundary (explicit).** `phase_state.inputs` is caller-supplied data. It is the *source* of the scheduling-time lock, so a caller that edits it chooses a lock + exactly as it could by calling the workflow with a different `d1_lock_scope` directly; GitHub concurrency is cooperative serialisation, not authorisation. What is + guaranteed: a stale, malformed or inconsistent hand-off (group, descriptor, D1, owner, repository, application) cannot silently join or create a different lock and + fails the Plan closed, and verifying from another ref/event than the deploy ran on is refused (its derived lock would differ from `phase_state.group`). + A group string that merely *claims* another lock is ignored for scheduling (see the queue evidence below). + +### CLI contract + +`cli.mjs` writes workflow annotations (`::error::`, `::warning::`, `::notice::`, `::add-mask::`) to **stderr**; **stdout carries only `key=value` records** and is what the +workflow pipes into `$GITHUB_OUTPUT`. A rejected input therefore exits non-zero with the original message and writes nothing to the output file (previously GitHub added +`Unable to process file command 'output'`). The job harness now enforces GitHub's output-file format, so any regression fails the chain tests. `parse-env.mjs` keeps its +documented stdout contract (its stdout never feeds an output file). Legacy `phase_state` (no `descriptors`): migration ownership is reconstructed per physical D1 only +when every member kind's migration result proves the same owner on the same D1; missing, conflicting or foreign evidence fails closed; hand-offs without D1 migrations +stay valid. `test.sh full` does not start Chain when Fast failed. + +### Real-runner evidence pinned to `8ad9f5f7b2ab83ea40200c7a4ccf625bbcb03d9b` (final implementation SHA; later commits are docs only) + +Disposable `firstsun-dev/cf-v2-scratch-caller`; public workflow and `tools_ref` both the SHA above; jobs on the org's provisioned `linux` runners (`heavenweb-runner-*`, pnpm +baked in). No Cloudflare credentials existed, so nothing remote was touched. + +| Run | What it proves | Observed | +| --- | --- | --- | +| [41062254408](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41062254408) | Valid deferred verify handoff; nested reusable workflows; public outputs; deploy `smoke_mode=skip` kept although the verify call defaults to `full` | success; `deploy_smoke_level=skipped`, `deploy_verified=false`, `deploy_published_unverified=true`, `deploy_url` present | +| [41062261540](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41062261540) | Forged `phase_state.group` | Plan fails with `phase_state.group 'attacker-group' is not the lock identity the deploy inputs imply ('cfv2-prod-scratch')`; no `Unable to process file command`; delivery skipped | +| [41062268866](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41062268866) | Tampered D1 metadata | Plan fails with `descriptor for production binds D1 'FOREIGN' but the hand-off recorded 'REAL-DB'`; no output-file error; delivery skipped | +| the three runs above, dispatched back-to-back | Scheduling-time lock | They ran strictly one after another (valid run first; the forged-group run stayed `pending`/`queued` until it finished, then failed; then the tampered run): the forged group string did **not** create a separate lock — all three shared the lock derived from `phase_state.inputs` (`cfv2-prod-scratch`) | +| [41062278092](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41062278092) | Real `deploy` → `verify` chain, persistent Dev, `d1_lock_scope: shared-db` | Build Validation, Gate, Plan succeed; Deploy fails closed (`dev credentials not provided`); the real `phase_state` has `group: cfv2-shared-dev-scratch` and persistent Dev `deploy_group = rollback_group = cfv2-dev-firstsun-dev/cf-v2-scratch-caller-shared-db`; the verify call (no scope input) restores it; its Plan and Smoke succeed | + +Local suites on the same code: 404 chain checks, 131 Node tests, 626 workflow-contract checks (`test.sh full` = 1m53s). CI on the SHA: [run 41061646039](https://github.com/firstsun-dev/.github/actions/runs/41061646039) +(3m17s; actionlint and shellcheck green). + +**Not demonstrable here and therefore NOT claimed:** live D1 migration, URL resolution against the Workers subdomain API, publish, smoke against a live Worker, rollback and +recovery — they need approved non-production Cloudflare credentials and are covered only by the local suites. The queue timeline shows GitHub's behaviour for the scheduling +lock on `workflow_dispatch` callers; it was not exercised for every event type. + +## Single Migration job (PR #89 simplification) - protections, removed complexity, measurements + +**Decision.** No separate Preflight stage. One Migration job with integrated safety validation. Reusable target-driven Delivery. (The earlier requirement of a +Preflight matrix is superseded.) No new reusable-workflow layer, no matrix, no generated YAML. + +**Protections that remain** (all enforced by the engine / `delivery.mjs`, none re-implemented in YAML): physical D1 identity from the plan; effective (adapter-generated) +Wrangler config audit per target; exact D1 binding per enabled target before any mutation; Preview/Dev never on the Production D1; stale-SHA guard before the +migration and before publish; Environment protection (`/prod`) on the Migration job; one migration result validated by owner kind + D1 id in every Delivery call; +Production/non-production isolation; never an automatic schema rollback; per-target Smoke and Rollback; deferred verification never migrates. + +**Complexity removed.** 8 reusable-workflow calls (4 Preflight, 4 Migration) and their `peers`/`kinds` strings, the per-kind `owns_` plan outputs, `own_`/`owner_` +migration inputs and the Native-Branch-as-owner special case, `checkPeerPreflights`, the `stage` input and the `preflight`/`migrate` jobs of the delivery workflow. + +**Engine changes for several calls in one job:** `init` starts from a clean state directory; the tooling `npm ci` and the application `Setup dependencies` run once per job +(`CFV2_DEPS`, reserved name); the migration gate only guards `publish`. + +Measured locally on the original PR head (`aa19cf6`) and this branch, same machine, same suites (mocked Cloudflare/GitHub; this is **not** a live-run measurement): + +| Metric | Before (`aa19cf6`) | After | +| --- | --- | --- | +| `_cf-worker-v2.yml` lines | 1249 | 1081 | +| `_cf-worker-delivery.yml` lines | 446 | 320 | +| Preflight calls | 4 | 0 | +| Migration job declarations | 4 calls | 1 job | +| Migration executions per run (`d1 migrations apply`) | 1 (deduplicated by owner) | 1 (at most 1; distinct D1s rejected) | +| Runner jobs after the gate - main push + D1 | 6 | 5 | +| Runner jobs - feature push + D1 (Native Branch) | 6 | 5 | +| Runner jobs - Native Branch + Native Dev on one D1 | 9 | 7 | +| Runner jobs - no D1 | 4 | 4 | +| Application builds - main + D1 / feature + D1 / Branch + Dev on one D1 / no D1 | 3 / 3 / 5 / 1 | 2 / 2 / 4 / 1 | +| Engine `init` calls (same four scenarios) | 4 / 4 / 7 / 2 | 3 / 3 / 6 / 2 | +| Fast profile | ~12.1 s | ~11.7 s | +| Chain profile (parallelism 4) | 103 s | 92 s | +| Full profile | 1m54s | 1m43s | +| Chain checks | 404 | 413 | +| Workflow-contract checks | 626 | 583 | +| Node tests (lib + delivery + cli) | 131 | 130 | +| Harness workspaces / step subprocesses | 1456 / 7668 | 1285 / 6878 | + +Contract checks fell by 43 because the per-kind loops over eight removed call jobs (needs, stage literals, descriptors, secrets, permission ceilings) no longer exist; each +protection they covered has a counterpart on the single job (needs, Environment expression, permissions, secrets, engine-step order, status step). One Node test (the peer +gate) was replaced by the migration-result test. Chain manifest minimums were only raised (`migration-d1` 55 → 62, `review-regressions` 34 → 36). Saved wall time comes from fewer +runner jobs and builds; fewer YAML lines alone are not claimed as a performance gain, and no live-runner timing exists. + +**Required scenarios** (all in `test-jobs.py` / `test-workflow-contract.py` / `delivery.test.mjs`): 1 main migrates Production once; 2 feature push migrates Preview once; 3 Branch + Native +Dev on one D1 migrate once; 4/5 D1-less or disabled migration deploys and never blocks; 6 Preview mapped to Production D1 fails before mutation; 7 distinct D1s fail the plan; +8 a failed Migration starts no Delivery; 9 Smoke/Rollback isolated per target; 10 Release needs a verified Production; 11 deferred verification never repeats the Migration; 12 +cross-service ordering unchanged; 13 public outputs unchanged (`migration_status` now always `applied` / `none` / `failed`); 14 the Migration job runs in `/prod` on main. + +### Real-runner evidence for the single Migration job (pinned to `e69365e4087fb3af0aa50629b79f368a8b4738e2`; later commits are docs only) + +Disposable private `firstsun-dev/cf-v2-scratch-caller`; public workflow and `tools_ref` both the SHA above; org `linux` runners; **no Cloudflare credentials and unprotected scratch +Environments**, so nothing remote was touched and approval protection was not exercised. Fake D1 ids in the scratch `wrangler.toml`. + +| Run | What it proves | Observed | +| --- | --- | --- | +| [41575194834](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575194834) (ref `feat/x`, Native Branch + Native Dev, `d1_migrations`) | Nested reusable-workflow resolution; the single `Database Migration` job runs in the plan's **dynamic Environment** (`scratch/preview`); validation of the non-owner target happens first | Plan success; Migration failed closed at the first validation step (`preview credentials not provided`); the always-run status step ran; **all Delivery calls skipped**; public `migration_status=failed` readable by the caller | +| [41575205777](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575205777) (ref `main`) | Production path: Environment `scratch/prod`, owner = production | Migration failed closed (`prod credentials not provided`), Deliveries skipped, `migration_status=failed` | +| [41575198199](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575198199) (`d1_migrations: false`) | **Skipped Migration does not block Delivery** | `Database Migration` skipped; Delivery (native-branch) and (native-dev) both started (Deploy failed closed on credentials, Smoke reported `none`); `migration_status=none` | +| [41575202262](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575202262) (`wrangler.two.toml`, Native Branch + persistent Dev on distinct D1s) | Single-DB rule on a real runner | Plan failed: `d1_migrations: the enabled targets name 2 distinct physical D1 databases: scratch-preview-db (native-branch) vs scratch-dev-db (persistent-dev) ... (nothing was changed)`; Migration and all Delivery calls skipped | +| [41575755238](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575755238) (`pipeline_phase: deploy` then `verify`) | `pipeline_phase` compatibility; the verify call never migrates | Deploy-phase plan, Migration skipped (no D1), Deploy failed closed on credentials; the verify call planned from the real `phase_state`, `Database Migration` skipped, Smoke ran | +| [41575759173](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41575759173) (hand-built v1 `phase_state`) | Backward-compatible hand-off | success; Migration skipped, Production Smoke ran from the hand-off | + +YAML anchors (`&delivery_permissions`, `&delivery_secrets`, `&fetch_ci_tools`) were accepted by the GitHub parser in all runs; the permission ceilings did not produce a `startup_failure`. +**Not demonstrated and not claimed:** a live D1 migration, publish, smoke against a live Worker, rollback, and Environment approval gates (no credentials / unprotected scratch Environments). + +### Final-SHA verification (implementation `8ad4d4029b42f173dec5b6c39ddc8738fc6bf0a8`) + +* Fast + Full profiles, ShellCheck and `actionlint 1.7.12` pass at this SHA (PR CI [41595263650](https://github.com/firstsun-dev/.github/actions/runs/41595263650); local re-run: 561 contract + 420 chain checks). +* **Stale-migration gate on real runners:** [41598319442](https://github.com/firstsun-dev/cf-v2-scratch-caller/actions/runs/41598319442) runs the real `cli.mjs migration-gate` entrypoint from `8ad4d40` on the org `linux` runner and `ubuntu-latest` with a validated descriptor and synthetic handoffs. `stale=true`, `stale="true"`, stale + valid metadata, empty, malformed, `none`, owner mismatch, D1 mismatch and a failed Migration job all exit `1` with no call to `wrangler`/`gh`/`curl`/`pnpm`/`npx` (PATH shims record none); the valid and `d1_migrations=false` controls exit `0`. The full A → B → A race is covered by the chain regression only, not reproduced on Actions. +* Cross-repository scratch scenarios, every nested `$/` reference and the public workflow resolved to `8ad4d40`, per-scenario run links and expected-failure classification: [cf-worker-v2-refactor.md](cf-worker-v2-refactor.md#final-sha-merge-readiness-validation-implementation-8ad4d4029b42f173dec5b6c39ddc8738fc6bf0a8). +* Single-D1 rule against the real consumers: the planner was run over all eight consumer calls × seven event/ref scenarios; none plans two distinct physical D1s. +* Not demonstrated: a live migration/publish/smoke/recovery, Environment approval, GHES, a runner older than 2.336.0. + +## Known limitations + +* **Hosted `ubuntu-latest` runners lack `pnpm`** (resolved for validation by using the org's provisioned `linux` runners; see the evidence above). `actions/setup` with `setup-pnpm: 'false'` (used by every validation job and the engine) assumes a runner image with pnpm + baked in (the org's `linux` runners). On `ubuntu-latest` the Validation jobs fail with `pnpm: command not found`, so a full real chain + (Gate → Plan → Migration → …) cannot be exercised on a hosted runner. This is pre-existing behaviour and unchanged by this fix. +* A caller of the public workflow must grant at least `contents: write` (Release job), `deployments: write` and `pull-requests: write` + (ceiling of the called jobs), as before. + +* Locks are repository-scoped; a D1 shared across repositories must be serialized by the owner (documented on `d1_lock_scope`). +* The compiled-asset scan ("an artifact embeds another target's callback/issuer/origin") is **not** implemented; the build-env checks above and the + post-publish URL assertion are. Consumers must keep `PUBLIC_*` / `VITE_*` / `IDP_URL` in `build_env_*`. +* `wrangler preview --name dev` custom-domain attachment is unverified and not assumed; custom-domain Preview hosting needs `preview_url_suffix`. +* Preview URL derivation depends on the Workers subdomain API reporting `preview_url_suffix`. If the API is unreadable and no suffix / declared URL + is supplied, Native Branch / Native Dev fail before the build (by design) rather than build for a guessed URL. +* **Single-DB rule (compatibility restriction).** One pipeline migrates at most one physical D1. A caller that enables targets with distinct D1s in one + invocation (typically Native Branch on `[previews]` plus `shared_dev_on_branch_push` on a different `[env.dev]` D1, with `d1_migrations: true`) now fails the plan + 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. diff --git a/docs/cf-worker-v2.md b/docs/cf-worker-v2.md index 890a0b9..f535673 100644 --- a/docs/cf-worker-v2.md +++ b/docs/cf-worker-v2.md @@ -21,7 +21,7 @@ Spec: `openspec/changes/redesign-cloudflare-worker-cicd-v2`; tracking issue #43. | `@v2` | floating major tag (same convention as `@v1`); always the latest compatible 2.x | | `@v2.x.y` | immutable release | -`uses:` and the `tools_ref:` input **must be the same ref** (the workflow checks out the tooling at `tools_ref`). +The shared actions and nested workflows are referenced with the self-repository syntax `$/`, i.e. at the **same commit as the workflow you pin in `uses:`** - nothing to keep in sync. `tools_ref:` is accepted for compatibility but **ignored**; you can delete it. The runner must be **>= 2.336.0** (the org `linux` runners are 2.338.0). | Release | Change | |---|---| @@ -81,7 +81,6 @@ jobs: cf: uses: firstsun-dev/.github/.github/workflows/_cf-worker-v2.yml@v2 with: - tools_ref: v2 app_name: www build_cmd: pnpm run build deployment_strategy: production-preview diff --git a/openspec/changes/unify-cf-worker-v2-delivery-targets/design.md b/openspec/changes/unify-cf-worker-v2-delivery-targets/design.md index ac94547..5d53fcb 100644 --- a/openspec/changes/unify-cf-worker-v2-delivery-targets/design.md +++ b/openspec/changes/unify-cf-worker-v2-delivery-targets/design.md @@ -6,7 +6,8 @@ Detect (consumer-specific, optional) -> Lint || Typecheck || Unit || Integration || Build Validation || E2E -> Validation Gate - -> Migration Stage [target preflight; unique physical D1; resource lock] + -> Delivery plan [enabled targets; ONE physical D1; migration owner] + -> Database Migration (ONE job) [validate every target's effective config + D1 binding -> migrate once; resource lock] -> Target Delivery [one independent build + publish per enabled target] -> Smoke [full | basic | skip] pass -> Verified ---------> Production Release (main only) @@ -62,6 +63,9 @@ For Native Dev, protect `dev` from Preview GC and branch deletion. Use the same Validation checks stay parallel and separately visible. The Gate succeeds only when every configured check succeeds; intentionally omitted optional checks may be skipped. Fail/cancel/unexpected skip is fail-closed. +> **Superseded (PR #89 simplification).** There is no separate Preflight stage and no per-target Migration call. One `migration` job holds the read-only safety +> checks as its first steps and the single remote mutation as its last; the plan admits at most one physical D1 per pipeline. See "Single Migration job (revised decision)" at the end. + The independent **Migration Stage** is downstream of the Gate and upstream of every relevant target's publish. Plan migrations by validated physical D1 identity (`account_id/database_id`), with target-specific effective-config preflight in the Migration Stage **before executing any remote schema change**. Where adapters materialize Wrangler configuration at build time, preflight may run a nonpublishing target-specific config-generation/build command; do not confuse this with the later target-specific deploy build. Before publish, verify the independently rebuilt artifact still binds the exact audited D1. If identities disagree, fail without publishing. - Build the unique-D1 migration plan across enabled targets; two Native Preview URLs sharing one D1 yield **one** migration execution for that D1, not two. @@ -112,3 +116,26 @@ Phase 1 complete on .github/main + all tests + contract freeze After G1, create independent sub-sessions in isolated clean worktrees: one for `blog`, one for `innovation-apps`, one for `heaven-monorepo`, one for `infra-config`, and one read-only compatibility session covering `anas-mcp`/`heaven-www`. The main session reviews integration and changes to the frozen contract; consumer sessions do not edit `.github`. Do not move floating `@v2` before all compatibility tests and coordinated rollout are complete. Retain legacy Dev Workers, custom domains, Access rules and stateful resources until their replacements are verified and a separate cleanup is approved. + +## Single Migration job (revised decision, PR #89 simplification) + +**No separate Preflight stage. One Migration job with integrated safety validation. Reusable target-driven Delivery.** The earlier requirement of a Preflight +matrix / four Preflight calls and four Migration calls is superseded. + +```text +Gate -> Delivery plan -> Database Migration (ONE job) -> Delivery(kind)* -> Release + 1. non-owner targets: build + effective-config audit + exact D1 binding (engine stage preflight; nothing remote) + 2. owner: build + effective-config audit + exact D1 binding + stale-SHA guard + migrate ONCE (engine stage migrate) +``` + +* **Plan.** Resolves every enabled kind's physical D1 (`account_id/database_id`), deduplicates identical ones and **fails closed on two distinct databases** + (single-DB rule) or on a Preview/Dev kind that maps to the Production D1 - before any mutation. `migration` = `{kind (owner), environment, d1_id, kinds}`. +* **Job.** Environment = the owner's (`/prod` on main: the approval gate is unchanged and not optional). Steps run in order, so a failing validation + stops the job before the migrate step - no peer-result mapping. The engine (not YAML) implements every check; the owner is validated inside its own + migrate stage, so a shared-D1 run builds each target once for validation (N targets = N builds, previously N + 1). +* **Handoff.** `migration_json` (the `migrate` step outputs) + `migration_result` go to every Delivery call; its publish gate requires `success`, the planned + owner kind and exactly the call's D1 id (a stale-superseded migration is **rejected** since the P1 fix: it authorizes nothing, see the unified-delivery doc). `d1_migrations=false` skips the job; Delivery proceeds. +* **Deferred verification** never migrates: the job is skipped in a `verify` call and each kind uses its own `migration_json_in` from `phase_state`. +* **Compatibility restriction.** A pipeline that enables targets with distinct D1s in one invocation (e.g. Native Branch on the `[previews]` D1 together with + `shared_dev_on_branch_push` on a different `[env.dev]` D1) is rejected by the plan. None of the planned consumers (blog, innovation-apps Notification Hub, + heaven-monorepo, anas-mcp, heaven-www) is specified to need this; each caller must be confirmed before the rollout, and the `@v2` move stays blocked until then. diff --git a/openspec/changes/unify-cf-worker-v2-delivery-targets/proposal.md b/openspec/changes/unify-cf-worker-v2-delivery-targets/proposal.md index d2e0535..602f571 100644 --- a/openspec/changes/unify-cf-worker-v2-delivery-targets/proposal.md +++ b/openspec/changes/unify-cf-worker-v2-delivery-targets/proposal.md @@ -15,7 +15,7 @@ Consumers also need a stable **named Native Dev Preview** alongside optional per - **One shared delivery implementation, many explicit targets.** Production (`production`), per-branch Native Preview (`native-branch`), stable named `dev` Native Preview (`native-dev`), and legacy separate Dev Worker (`persistent-dev`) use the same target-parameterized delivery engine. Target-specific build environments, Wrangler commands/config, URLs, bindings, secrets, smoke and recovery policies remain isolated. - **Canonical DAG:** changed-app detection (when applicable) → independent Lint/Typecheck/Unit/Integration/Build Validation/E2E → Validation Gate → **independent D1 Migration Stage** → target-specific Build + Publish → optional Smoke → Verified or conditional Recovery → optional Production Release. Migration belongs **after Validation and before Publish**, never inside an opaque `Shared Dev` deploy job. - **Target-independent gate.** The enabled targets have equivalent validation/migration prerequisites. No target unconditionally waits for a different target's Deploy, Smoke or Rollback. Cross-service Smoke and shared physical resources are explicit exceptions, not copied pipelines. -- **D1 identity, not target URL.** Migrate once per distinct physical D1 for the eligible run; shared nonproduction D1 targets use one migration execution. Preflight validates all target bindings/effective Wrangler configurations before DB mutation; re-audit the actual deploy configuration before publish. Serialize migration by physical resource identity. Never migrate Production D1 from Dev/Preview, and never auto-rollback schema. +- **D1 identity, not target URL.** Migrate once per distinct physical D1 for the eligible run; shared nonproduction D1 targets use one migration execution. The single Migration job validates all target bindings/effective Wrangler configurations (read-only steps) before its one DB mutation; there is no separate Preflight stage and at most one physical D1 per pipeline (distinct D1s fail the plan); re-audit the actual deploy configuration before publish. Serialize migration by physical resource identity. Never migrate Production D1 from Dev/Preview, and never auto-rollback schema. - **Target-specific builds.** Resolve the canonical target URL before each build, then compile each enabled target independently. E2E/local builds remain separate from deployment builds; no shared compiled artifact or mandatory GitHub artifact handoff. - **Optional Smoke, explicit status.** `full` preserves existing HTTP plus app-level smoke; `basic` performs meaningful content/health verification; `skip` produces Published/Unverified, not Verified. Production Release requires a verified deployment. Rollback is conditional on an eligible failed Smoke and restricted to the same target/version. - **Opt-in Native Dev.** Preserve existing `preview_on_branch_push` and `shared_dev_on_branch_push` inputs; introduce `shared_dev_native_preview_on_branch_push` (default false), `build_env_shared_dev_native_preview`, and an opt-in target-aware `smoke_mode` interface. Native Dev uses a protected named Native Preview `dev`; it is **not** a Wrangler `[env.dev]` Worker. diff --git a/openspec/changes/unify-cf-worker-v2-delivery-targets/specs/cloudflare-worker-delivery/spec.md b/openspec/changes/unify-cf-worker-v2-delivery-targets/specs/cloudflare-worker-delivery/spec.md index e03ae8b..cbedc3b 100644 --- a/openspec/changes/unify-cf-worker-v2-delivery-targets/specs/cloudflare-worker-delivery/spec.md +++ b/openspec/changes/unify-cf-worker-v2-delivery-targets/specs/cloudflare-worker-delivery/spec.md @@ -112,3 +112,27 @@ Where Smoke is enabled, it SHALL operate against the exact deployed target URL a #### Scenario: App config requires full OIDC smoke - **WHEN** Heaven ID's full smoke is enabled - **THEN** it SHALL wait for the correct test-client deployment and required ID readiness without forcing unrelated build/deploy jobs to wait. + +## MODIFIED Requirements (PR #89 simplification) + +### Requirement: Migration SHALL be ONE job that validates every target before its single mutation + +The public pipeline SHALL declare exactly one Migration job and no Preflight stage. Its read-only steps SHALL validate, for every enabled target, the effective Wrangler +configuration and the exact D1 binding before the job applies a migration, and SHALL reject any Preview/Dev target bound to the Production D1. The Delivery plan SHALL +admit at most one distinct physical D1 per pipeline and SHALL fail closed on two or more before any mutation. The job SHALL run in the GitHub Environment of the +migration owner (Production approvals apply on main), SHALL be skipped when `d1_migrations` is false or in a `pipeline_phase=verify` call, and SHALL never roll back schema. + +#### Scenario: Main push +- **WHEN** a push to main enables Production with D1 migrations +- **THEN** the Production D1 SHALL be migrated exactly once by the single Migration job after the Environment approval, and Delivery SHALL wait for it. + +#### Scenario: Native Branch and Native Dev share one D1 +- **WHEN** both map to the same nonproduction D1 +- **THEN** each target SHALL be validated (build, effective-config audit, D1 binding), the D1 SHALL be migrated once, and both deliveries SHALL consume that one result. + +#### Scenario: Distinct D1s in one invocation +- **WHEN** two enabled targets name different physical D1 databases +- **THEN** the plan SHALL fail before any build or mutation with a message naming both databases. + +#### Scenario: Migration disabled or failed +- **WHEN** `d1_migrations` is false **THEN** Delivery SHALL proceed with `migration_status=none`. **WHEN** the Migration job fails **THEN** no Delivery call SHALL start and nothing SHALL be published. 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 7d172da..3c8a09b 100644 --- a/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md +++ b/openspec/changes/unify-cf-worker-v2-delivery-targets/tasks.md @@ -9,17 +9,35 @@ ## Phase 1 — .github shared engine ONLY (MAIN SESSION; no sub-sessions) -- [ ] Implement shared Validation Gate with independent parallel lint/typecheck/unit/integration/build-validation/E2E. -- [ ] Implement standalone audited, target-bound Migration Stage after Validation, grouped by physical D1, including no-D1 bypass and DB locking. -- [ ] Refactor target-parameterized Delivery (Production / Native Branch / Native Dev / Persistent Dev) around one engine, with no unconditional cross-target `needs`. -- [ ] Add opt-in Native Dev named Preview `dev`, protected identity, separate ledger/recovery and concurrent-branch serialization. -- [ ] Add target-specific URL pre-resolution/build-env, build/artifact audit, no shared compiled bundle, and post-publish URL assertion. -- [ ] Add `smoke_mode=full|basic|skip` and target overrides, explicit Published/Unverified semantics and conditional target-owned Recovery. -- [ ] Preserve `pipeline_phase`/`phase_state`, public caller inputs/outputs, release gates, v1/v1.5 and the currently deployed `@v2` behavior. -- [ ] Add/extend contract, shell/unit, DAG, smoke, skip, migration, DB lock, stale SHA, rollback, Access, preview cleanup and cross-service handoff tests. -- [ ] Run actionlint and relevant OpenSpec checks; prove reusable workflow calls from a separate scratch repository/runnable caller; record evidence. +- [x] Implement shared Validation Gate with independent parallel lint/typecheck/unit/integration/build-validation/E2E. +- [x] Implement standalone audited, target-bound Migration Stage after Validation, grouped by physical D1, including no-D1 bypass and DB locking. +- [x] Refactor target-parameterized Delivery (Production / Native Branch / Native Dev / Persistent Dev) around one engine, with no unconditional cross-target `needs`. +- [x] Add opt-in Native Dev named Preview `dev`, protected identity, separate ledger/recovery and concurrent-branch serialization. +- [x] Add target-specific URL pre-resolution/build-env, build/artifact audit, no shared compiled bundle, and post-publish URL assertion. +- [x] Add `smoke_mode=full|basic|skip` and target overrides, explicit Published/Unverified semantics and conditional target-owned Recovery. +- [x] Preserve `pipeline_phase`/`phase_state`, public caller inputs/outputs, release gates, v1/v1.5 and the currently deployed `@v2` behavior. (Contract + 404 chain checks; deferred verification now keeps the deploy call's descriptors; real-runner evidence in docs.) +- [x] Add/extend contract, shell/unit, DAG, smoke, skip, migration, DB lock, stale SHA, rollback, Access, preview cleanup and cross-service handoff tests. +- [x] Run actionlint and relevant OpenSpec checks; prove reusable workflow calls from a separate scratch repository/runnable caller; record evidence. (actionlint+shellcheck green in CI on 8ad9f5f; scratch-caller runs 41062254408, 41062261540, 41062268866, 41062278092. Live Cloudflare behaviour is NOT proven — no credentials.) +- [x] Review finding 1 (P1): deferred verification restores the deploy call's validated descriptors; verify-call defaults never replace deployment-time policy. +- [x] Review finding 2 (P2): structural refactor — descriptor replaces seven per-call fields, inline migration/rollback/verification policy moved to `delivery.mjs`, one build and one publish primitive, `engine.sh init` is one validated Node process. +- [x] Review finding 3 (P2): AST-based test grouping replaced by explicit `group_*` functions + manifest-checked launcher. +- [x] Review finding 4 (P2): measured subprocess/CPU reduction inside the engine (init 213 ms -> 58 ms; Node processes per chain 17 -> 9); the harness step-shell count is NOT reduced (documented). +- [x] Review finding 5 (P1): final-SHA scratch-caller validation on org runners; credential-less scope stated. +- [x] Final review fixes: lock-identity integrity (scheduling-time lock derived from `phase_state.inputs`, group/descriptor/owner/D1 cross-checks, documented trust boundary), CLI annotations on stderr, legacy D1 ownership proof, fail-fast `test.sh full`. - [ ] Merge the fully tested shared implementation to **.github/main**; record frozen immutable SHA, exact interface, compatibility proof and explicit G1 pass/fail. Do not move `@v2` yet. +## Phase 1b — PR #89 simplification (supersedes the Preflight matrix / per-target Migration calls) + +- [x] Remove the four Preflight calls and the four Migration calls; add ONE `migration` job (validation steps first, one migration last). No matrix, no new reusable layer. +- [x] Plan: one physical D1 per pipeline (dedupe identical, reject distinct), `migration` output replaces `owns_`; Production D1 never reachable from Preview/Dev. +- [x] Delivery workflow: only deploy / smoke / rollback; `migration_result` + `migration_json` replace the stage, peer and owner inputs; a failed Migration skips every Delivery. +- [x] Engine: state is fresh per `init`; tooling and application dependencies install once per job; migration gate only for publish. +- [x] Tests updated in the same slices (contract, job chain, `delivery.mjs`, `cli.mjs`); required scenarios 1-14 covered; manifest minimums only raised. +- [x] Measured before/after (docs/cf-worker-v2-unified-delivery.md, "Single Migration job"). +- [x] Real-runner scratch-caller evidence for the final SHA `8ad4d40` (docs/cf-worker-v2-refactor.md, "Final-SHA merge-readiness validation"), including the real Migration Gate entrypoint with stale handoffs on the `linux` and `ubuntu-latest` runners; live Cloudflare migration/deploy remain unproven (no approved non-production credentials — BLOCKED). +- [x] Confirm that no supported caller needs distinct D1s in one invocation: the real planner over blog, heaven-monorepo (3 apps), innovation-apps (2 apps), anas-mcp and heaven-www × push/dispatch/PR/delete never plans two distinct physical D1s. Re-confirm if a caller's `wrangler` config or switches change before the `@v2` move. +- [ ] **Before the `@v2` move** (procedure and rollback: docs/cf-worker-v2-rollout.md; fix PRs blog#461 and heaven-www#56 prepared, not merged): remove the undeclared `CLOUDFLARE_PREVIEW_API_TOKEN` / `CLOUDFLARE_PREVIEW_ACCOUNT_ID` secrets from blog `cicd.yml`, heaven-www `cicd.yml` and blog `preview-gc.yml` (they cause `startup_failure`), have an administrator confirm/update Required Checks for the renamed contexts in blog, heaven-monorepo, innovation-apps and heaven-www (unreadable on the Free plan), and run a live non-production deployment/smoke/recovery exercise. + ## G1 — MANDATORY HARD GATE (all conditions required) - [ ] Shared .github implementation is complete and merged to main (not merely proposed). @@ -28,6 +46,16 @@ - [ ] No unresolved critical blockers (resource isolation, D1, Access, stale deploy/rollback, Release). - [ ] Main Session records **G1 = PASS**. No consumer implementation or sub-sessions may start before this line is checked. +## Phase 1 simplification (PR #89 refactor, no behaviour change) + +- [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] 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`. + ## Phase 2 — AFTER G1 ONLY: consumer sub-sessions (parallel when independent) - [ ] **Sub-session A, blog:** Native Branch ON, Native Dev ON, Persistent Dev OFF; distinct Astro URL builds, shared Preview D1 audit, Preview GC protection; retain content-only CI optimization. diff --git a/scripts/cf-preview-gc/gc.test.mjs b/scripts/cf-preview-gc/gc.test.mjs index 135e671..0dc61f6 100644 --- a/scripts/cf-preview-gc/gc.test.mjs +++ b/scripts/cf-preview-gc/gc.test.mjs @@ -78,10 +78,17 @@ test('fixed Dev preview in the allowlist is never deleted, even without a branch assert.equal(r.summary.protected, 2); }); +test('the reserved Native Dev name `dev` is never collected, with NO allowlist entry (a legacy `dev` branch ledger cannot delete it)', async () => { + const w = world({ deployments: [dep('dev', [tomb(400)])], cfPreviews: [cfKey('dev')] }); + const r = await run(w); + assert.equal(deletes(w).length, 0); + assert.equal(r.summary.protected, 1); +}); + test('allowlist matches the full account + worker + name tuple', async () => { - const w = world({ deployments: [dep('dev', [tomb(40)])], cfPreviews: [cfKey('dev')] }); - const r = await run(w, cfg({ protected: [{ accountId: 'other', hostWorker: 'blog-preview', previewName: 'dev' }] })); - assert.equal(byPreview(r, 'dev').status, 'DELETE'); // a different account's allowlist entry does not protect this one + const w = world({ deployments: [dep('stable', [tomb(40)])], cfPreviews: [cfKey('stable')] }); + const r = await run(w, cfg({ protected: [{ accountId: 'other', hostWorker: 'blog-preview', previewName: 'stable' }] })); + assert.equal(byPreview(r, 'stable').status, 'DELETE'); // a different account's allowlist entry does not protect this one }); test('branch deleted then recreated (before or after a new push): not deleted', async () => { diff --git a/scripts/cf-preview/cli.test.mjs b/scripts/cf-preview/cli.test.mjs new file mode 100644 index 0000000..b4ff621 --- /dev/null +++ b/scripts/cf-preview/cli.test.mjs @@ -0,0 +1,93 @@ +// CLI contract: stdout = ONLY key=value records (it is piped into $GITHUB_OUTPUT), diagnostics/annotations = stderr, failure = non-zero. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { targetDescriptor, buildDescriptors } from '../../actions/cf-preview/delivery.mjs'; + +const CLI = new URL('../../actions/cf-preview/cli.mjs', import.meta.url).pathname; +const run = (args, env = {}) => spawnSync('node', [CLI, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }); +const RECORD = /^[A-Za-z_][\w.-]*=.*$/; +const records = (out) => out.split('\n').filter(Boolean); + +test('invalid descriptor: non-zero exit, annotation on stderr, stdout empty (cannot corrupt $GITHUB_OUTPUT)', () => { + const r = run(['descriptor-check', '--kind', 'production', '--target', 'prod', '--d1', ''], { DESCRIPTOR: '{"v":1,"kind":"production"}' }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /^::error::target descriptor keys/m); + assert.equal(r.stdout, ''); +}); + +test('valid descriptor: unchanged output records on stdout, nothing on stderr', () => { + const d = targetDescriptor({ kind: 'production', app: 'app', runId: 1, smokeMode: 'skip' }); + const r = run(['descriptor-check', '--kind', 'production', '--target', 'prod', '--d1', ''], { DESCRIPTOR: JSON.stringify(d) }); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'descriptor=ok\nsmoke_mode=skip\n'); + assert.equal(r.stderr, ''); +}); + +test('plan: a rejected phase_state fails with the ORIGINAL validation error and leaves a valid output file (piped like the workflow does)', () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-')); + const out = join(dir, 'output'); + writeFileSync(out, ''); + const st = { inputs: { app_name: 'app' }, kinds: ['production'], d1: { production: 'REAL' }, migration: {}, group: 'cfv2-prod-app', + descriptors: { ...buildDescriptors({ kinds: ['production'], app: 'app', runId: 1, phase: 'deploy' }) } }; + st.descriptors.production.d1_expected_id = 'FOREIGN'; + const r = spawnSync('bash', ['-c', `set -eo pipefail; node "${CLI}" plan --event push --ref refs/heads/main --phase verify --run-id 9 | tee -a "${out}"`], { + encoding: 'utf8', env: { ...process.env, INPUTS_JSON: '{"app_name":"app"}', PHASE_STATE: JSON.stringify(st), GITHUB_REPOSITORY: 'o/r' }, + }); + assert.notEqual(r.status, 0, 'fail closed'); + assert.match(r.stderr, /::error::descriptor for production binds D1 'FOREIGN' but the hand-off recorded 'REAL'/); + assert.equal(readFileSync(out, 'utf8'), '', 'nothing written to $GITHUB_OUTPUT, so GitHub cannot add an "Invalid format" error'); +}); + +test('plan: a valid verify hand-off still produces only well-formed records', () => { + const inputs = { app_name: 'app', deployment_strategy: 'production-preview', preview_on_branch_push: true }; + const st = { inputs, kinds: ['production'], d1: {}, migration: {}, group: 'cfv2-prod-app', descriptors: buildDescriptors({ kinds: ['production'], app: 'app', runId: 1, phase: 'deploy' }) }; + const r = run(['plan', '--event', 'push', '--ref', 'refs/heads/main', '--phase', 'verify', '--run-id', '9'], { INPUTS_JSON: JSON.stringify(inputs), PHASE_STATE: JSON.stringify(st), GITHUB_REPOSITORY: 'o/r' }); + assert.equal(r.status, 0, r.stderr); + for (const line of records(r.stdout)) assert.match(line, RECORD); + assert.ok(records(r.stdout).includes('has_targets=true')); + assert.ok(records(r.stdout).some((l) => l.startsWith('descriptors={'))); +}); + +test('every failing command keeps stdout record-clean: unknown command, bad kind, bad inputs, migration gate', () => { + const cases = [ + [['nope'], {}], + [['smoke-mode', '--kind', 'bogus', '--mode', 'full'], {}], + [['verification-outcome'], {}], + [['migration-gate', '--kind', 'production'], { DESCRIPTOR: 'not json' }], + [['init-checks', '--state', tmpdir(), '--inputs', '/nonexistent'], { ENGINE_TARGET: 'prod' }], + ]; + for (const [args, env] of cases) { + const r = run(args, env); + for (const line of records(r.stdout)) assert.match(line, RECORD, `${args[0]}: stdout line '${line}' would corrupt $GITHUB_OUTPUT`); + if (r.status !== 0) assert.equal(r.stdout.split('\n').filter((l) => l.startsWith('::')).length, 0, `${args[0]}: annotations must be on stderr`); + } + assert.notEqual(run(['nope']).status, 0); + assert.notEqual(run(['smoke-mode', '--kind', 'bogus', '--mode', 'full']).status, 0); +}); + +test('migration gate: a failed Migration job is annotated on stderr with a non-zero exit and clean stdout', () => { + const d = targetDescriptor({ kind: 'production', app: 'app', runId: 1, d1ExpectedId: 'DB1', ownerKind: 'production' }); + const env = { DESCRIPTOR: JSON.stringify(d), D1_MIGRATIONS: 'true' }; + const r = run(['migration-gate', '--kind', 'production'], { ...env, MIGRATION_RESULT: 'failure', MIGRATION_JSON: '{}' }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /finished 'failure'/); + assert.equal(r.stdout, ''); + const ok = run(['migration-gate', '--kind', 'production'], { ...env, MIGRATION_RESULT: 'success', MIGRATION_JSON: JSON.stringify({ migration: 'applied', kind: 'production', d1_id: 'DB1' }) }); + assert.equal(ok.status, 0, ok.stderr); + assert.match(ok.stdout, /^migration_used=\{.*\}$/m); +}); + +test('add-mask for Preview secrets goes to stderr, not into any output file', () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-')); + const inputsFile = join(dir, 'inputs.json'); + writeFileSync(inputsFile, JSON.stringify({ app_name: 'app', deployment_strategy: 'production-preview', production_rollback_policy: 'manual', build_cmd: 'true', preview_on_branch_push: true })); + const r = run(['init-checks', '--state', dir, '--inputs', inputsFile], { ENGINE_TARGET: 'preview', ENGINE_STAGE: 'migrate', GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/feat/x', PREVIEW_SECRETS: 'API_KEY=s3cret' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /::add-mask::s3cret/); + for (const line of records(r.stdout)) assert.match(line, RECORD); + assert.doesNotMatch(r.stdout, /s3cret/); +}); diff --git a/scripts/cf-preview/delivery.test.mjs b/scripts/cf-preview/delivery.test.mjs new file mode 100644 index 0000000..ce0e958 --- /dev/null +++ b/scripts/cf-preview/delivery.test.mjs @@ -0,0 +1,666 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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, +} from '../../actions/cf-preview/delivery.mjs'; + +const base = { event: 'push', ref: 'refs/heads/feat/x', strategy: 'production-preview', previewOnPush: true }; +const T = (o) => resolveDeliveryTargets({ ...base, ...o }); + +test('deployment target matrix: exactly the enabled targets run, nothing else', () => { + assert.deepEqual(T({ ref: 'refs/heads/main' }), ['production']); + assert.deepEqual(T({ ref: 'refs/heads/main', sharedDevNativeOnPush: true, sharedDevOnPush: false }), ['production']); + assert.deepEqual(T({}), ['native-branch']); + assert.deepEqual(T({ previewOnPush: false }), []); + assert.deepEqual(T({ previewOnPush: false, sharedDevNativeOnPush: true }), ['native-dev']); + assert.deepEqual(T({ sharedDevNativeOnPush: true }), ['native-branch', 'native-dev']); + assert.deepEqual(T({ previewOnPush: false, sharedDevOnPush: true }), ['persistent-dev']); + assert.deepEqual(T({ sharedDevOnPush: true }), ['native-branch', 'persistent-dev']); +}); +test('non-deploy events and refs never produce a target', () => { + assert.deepEqual(T({ event: 'pull_request', sharedDevNativeOnPush: true }), []); + assert.deepEqual(T({ event: 'delete', sharedDevNativeOnPush: true }), []); + 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', () => { + 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' }), []); +}); +test('production-only rejects every Dev channel; conflicting automatic Dev channels are rejected', () => { + assert.throws(() => T({ strategy: 'production-only', sharedDevNativeOnPush: true }), /production-only/); + assert.throws(() => T({ strategy: 'production-only', sharedDevOnPush: true }), /production-only/); + assert.throws(() => T({ sharedDevOnPush: true, sharedDevNativeOnPush: true }), /conflicting automatic Dev channels/); + assert.deepEqual(T({ strategy: 'production-only', ref: 'refs/heads/main' }), ['production']); // existing: ignores preview default + assert.deepEqual(T({ strategy: 'production-only' }), []); +}); +test('unknown strategy rejects', () => assert.throws(() => T({ strategy: 'x' }), /Unknown deployment_strategy/)); +test('every kind maps to an engine target class', () => { + for (const k of DELIVERY_KINDS) assert.ok(ENGINE_TARGET[k], k); + assert.equal(ENGINE_TARGET['native-dev'], 'preview'); // Native Dev is a Preview, never a [env.dev] Worker +}); + +test('smoke_mode: default full, per-target override wins, strict validation', () => { + assert.equal(resolveSmokeMode('production'), 'full'); + assert.equal(resolveSmokeMode('native-dev', 'basic'), 'basic'); + assert.equal(resolveSmokeMode('native-dev', 'basic', '{"native-dev":"skip"}'), 'skip'); + assert.equal(resolveSmokeMode('production', 'basic', '{"native-dev":"skip"}'), 'basic'); + assert.equal(resolveSmokeMode('production', '', ''), 'full'); + assert.throws(() => resolveSmokeMode('production', 'quick'), /Unknown smoke_mode/); + assert.throws(() => resolveSmokeMode('prod'), /Unknown delivery kind/); + assert.throws(() => parseSmokeOverrides('{"prod":"skip"}'), /unknown delivery kind/); + assert.throws(() => parseSmokeOverrides('{"production":"off"}'), /unknown smoke mode/); + assert.throws(() => parseSmokeOverrides('[1]'), /JSON object/); + assert.throws(() => parseSmokeOverrides('{bad'), /not valid JSON/); + assert.deepEqual(parseSmokeOverrides(' '), {}); +}); + +test('Native Dev owns the preview name "dev": a branch called dev gets b-dev when Native Dev is on', () => { + const a = { repo: 'o/r', worker: 'blog', ref: 'refs/heads/dev' }; + assert.equal(branchPreviewIdentity(a).name, 'dev'); // legacy behaviour unchanged while Native Dev is off + assert.equal(branchPreviewIdentity(a, { reserveNativeDev: true }).name, 'b-dev'); + assert.equal(branchPreviewIdentity({ ...a, ref: 'refs/heads/feat/x' }, { reserveNativeDev: true }).name, 'feat-x'); +}); + +const cfg = (prev, dev, prod = 'PROD') => ({ + name: 'app', d1_databases: [{ binding: 'DB', database_name: 'prod-db', database_id: prod }], + previews: { d1_databases: [{ binding: 'DB', database_name: 'prev-db', database_id: prev }] }, + env: { dev: { d1_databases: [{ binding: 'DB', database_name: 'dev-db', database_id: dev }] } }, +}); +test('migration plan: Branch and Native Dev sharing one preview D1 migrate ONCE', () => { + const g = planMigrations(cfg('PREV', 'DEV'), ['native-branch', 'native-dev'], { accountId: 'acc' }); + assert.equal(g.length, 1); + assert.equal(g[0].id, 'acc/PREV'); + assert.deepEqual(g[0].members, ['native-branch', 'native-dev']); + assert.equal(g[0].owner, 'native-branch'); +}); +test('migration plan: single-DB rule - distinct physical D1s fail closed before any mutation', () => { + assert.throws(() => planMigrations(cfg('PREV', 'DEV'), ['native-branch', 'persistent-dev'], { accountId: 'acc' }), /2 distinct physical D1 databases: prev-db \(native-branch\) vs dev-db \(persistent-dev\).*nothing was changed/); + assert.throws(() => planMigrations(cfg('PREV', 'DEV'), ['native-branch', 'native-dev', 'persistent-dev'], { accountId: 'acc' }), /distinct physical D1/); +}); +test('migration plan: Preview and persistent Dev on the SAME physical D1 dedupe (owner = first in order)', () => { + const g = planMigrations(cfg('SHARED', 'SHARED'), ['persistent-dev', 'native-dev'], { accountId: 'acc' }); + assert.equal(g.length, 1); + assert.equal(g[0].owner, 'native-dev'); +}); +test('migration plan: production D1 can never be reached from Preview/Dev', () => { + assert.throws(() => planMigrations(cfg('PROD', 'DEV', 'PROD'), ['native-dev'], { accountId: 'acc' }), /shared across trust boundaries/); + assert.throws(() => planMigrations(cfg('PREV', 'PROD', 'PROD'), ['persistent-dev'], { accountId: 'acc' }), /shared across trust boundaries/); + assert.equal(planMigrations(cfg('PREV', 'DEV'), ['production'], { accountId: 'acc' })[0].id, 'acc/PROD'); +}); +test('migration plan: a target without a D1 binding fails closed; no kinds = empty plan', () => { + assert.throws(() => planMigrations({ name: 'a' }, ['native-dev'], { accountId: 'acc' }), /No D1 binding/); + assert.deepEqual(planMigrations(cfg('P', 'D'), [], { accountId: 'acc' }), []); +}); +test('same database_id under a different account is a different physical D1', () => { + const a = planMigrations(cfg('X', 'D'), ['native-dev'], { accountId: 'a1' })[0].id; + const b = planMigrations(cfg('X', 'D'), ['native-dev'], { accountId: 'a2' })[0].id; + assert.notEqual(a, b); +}); + +import { environmentName, ledgerEnv, foreignUrlsInBuildEnv, sameUrl } from '../../actions/cf-preview/delivery.mjs'; +test('environment and ledger names: legacy names unchanged, Native Dev gets its own', () => { + assert.equal(environmentName('blog', 'production'), 'blog/prod'); + assert.equal(environmentName('blog', 'native-branch'), 'blog/preview'); + assert.equal(environmentName('blog', 'persistent-dev'), 'blog/dev'); + assert.equal(environmentName('blog', 'native-dev'), 'blog/native-dev'); + assert.equal(ledgerEnv('blog', 'native-dev'), 'blog/native-dev/dev'); + assert.equal(ledgerEnv('blog', 'persistent-dev'), 'blog/dev/shared'); + assert.equal(ledgerEnv('blog', 'native-branch', 'feat-x'), 'blog/preview/feat-x'); + assert.throws(() => environmentName('blog', 'prod'), /Unknown delivery kind/); +}); +test('build env URL isolation: another target URL in this build env is reported', () => { + const urls = { production: 'https://app.example.com', 'native-dev': 'https://dev-app.x.workers.dev' }; + assert.deepEqual(foreignUrlsInBuildEnv('native-dev', 'PUBLIC_URL=https://dev-app.x.workers.dev\nIDP=https://id.example.org', urls), []); + assert.deepEqual(foreignUrlsInBuildEnv('native-dev', 'PUBLIC_URL=https://app.example.com/', urls).length, 1); + assert.deepEqual(foreignUrlsInBuildEnv('production', 'X=https://dev-app.x.workers.dev', urls).length, 1); + assert.deepEqual(foreignUrlsInBuildEnv('production', '# c\n\nX=1', urls), []); +}); +test('sameUrl ignores case and trailing slashes only', () => { + assert.ok(sameUrl('https://A.example.com/', 'https://a.example.com')); + assert.ok(!sameUrl('https://a.example.com/x', 'https://a.example.com')); +}); + +// --------------------------------------------------------------------------------------------------------------------- +// P1-B: the canonical URL is resolved BEFORE the build from the real Preview identity (Cloudflare documents +// `-..workers.dev`; the Workers subdomain API reports the host part as preview_url_suffix). +// --------------------------------------------------------------------------------------------------------------------- +test('previewUrlFromSuffix: https://, never a guessed format', () => { + assert.equal(previewUrlFromSuffix('feat-x', '-blog.acme.workers.dev'), 'https://feat-x-blog.acme.workers.dev'); + assert.equal(previewUrlFromSuffix('dev', '-blog.acme.workers.dev'), 'https://dev-blog.acme.workers.dev'); + assert.equal(previewUrlFromSuffix('dev', '.app.example.com'), 'https://dev.app.example.com'); + assert.throws(() => previewUrlFromSuffix('Feat_X', '-w.s.workers.dev'), /unsafe Preview name/); + for (const bad of ['', 'blog.acme.workers.dev', '-', '-a/b', '-a b.workers.dev', 'https://x.dev']) assert.throws(() => previewUrlFromSuffix('x', bad), /suffix/); + assert.throws(() => previewUrlFromSuffix('a'.repeat(40), '-' + 'w'.repeat(30) + '.s.workers.dev'), /63/); +}); +test('canonicalOrigin: only a bare https origin is a valid app URL', () => { + assert.equal(canonicalOrigin('https://dev-blog.acme.workers.dev/'), 'https://dev-blog.acme.workers.dev'); + for (const bad of ['http://x.dev', 'https://x.dev/path', 'https://x.dev?a=1', 'https://u:p@x.dev', 'nope']) assert.throws(() => canonicalOrigin(bad)); +}); +test('resolveCanonicalUrl: derived from Cloudflare, cross-checked with the declared URL, fails closed when unresolvable', () => { + const api = '-blog.acme.workers.dev'; + assert.deepEqual(resolveCanonicalUrl({ previewName: 'feat-x', apiSuffix: api }), { url: 'https://feat-x-blog.acme.workers.dev', source: 'cloudflare' }); + assert.equal(resolveCanonicalUrl({ previewName: 'dev', apiSuffix: api, explicit: 'https://dev-blog.acme.workers.dev' }).source, 'declared+derived'); + assert.equal(resolveCanonicalUrl({ previewName: 'dev', suffix: '.dev.example.com' }).url, 'https://dev.dev.example.com'); + assert.equal(resolveCanonicalUrl({ previewName: 'dev', explicit: 'https://dev.example.com' }).url, 'https://dev.example.com'); + // incorrect URL: the declared one contradicts what Cloudflare will serve -> the build must not start + assert.throws(() => resolveCanonicalUrl({ previewName: 'dev', apiSuffix: api, explicit: 'https://dev-other.acme.workers.dev' }), /differs from the URL Cloudflare will serve/); + assert.throws(() => resolveCanonicalUrl({ previewName: 'dev', apiSuffix: api, explicit: 'https://app.example.com' }), /differs/); + // two suffix sources must agree + assert.throws(() => resolveCanonicalUrl({ previewName: 'dev', suffix: '-a.b.workers.dev', apiSuffix: api }), /contradicts/); + // missing URL: nothing declared and Cloudflare reported no suffix -> unresolved (never guessed) + assert.throws(() => resolveCanonicalUrl({ previewName: 'feat-x' }), /cannot resolve the canonical URL/); + assert.throws(() => resolveCanonicalUrl({ previewName: 'feat-x', apiSuffix: '' , explicit: '' }), /before the build/); + assert.throws(() => resolveCanonicalUrl({ previewName: 'dev', explicit: 'http://dev.example.com' }), /https/); +}); +test('checkBuildEnvOrigins: this application\'s own callback / redirect / origin variables must be same-origin with the target', () => { + const url = 'https://dev-id.acme.workers.dev'; + const ok = 'PUBLIC_APP_URL=https://dev-id.acme.workers.dev\nOAUTH_CALLBACK_URL=https://dev-id.acme.workers.dev/auth/callback\nIDP_URL=https://idp.example.com\nPUBLIC_API=https://api.example.com'; + assert.deepEqual(checkBuildEnvOrigins(ok, url), []); + const bad = checkBuildEnvOrigins('OAUTH_CALLBACK_URL=https://id.example.com/cb\nPUBLIC_APP_ORIGIN=https://feat-x-id.acme.workers.dev\nOIDC_REDIRECT_URI=https://dev-id.acme.workers.dev/cb', url); + assert.equal(bad.length, 2); + assert.match(bad[0], /OAUTH_CALLBACK_URL=https:\/\/id\.example\.com/); + assert.match(bad[1], /PUBLIC_APP_ORIGIN/); + assert.deepEqual(checkBuildEnvOrigins('A=1\n# c\n\nSITE_URL=notaurl', url), []); + assert.deepEqual(checkBuildEnvOrigins('OAUTH_CALLBACK_URL=https://x.example.com', ''), []); +}); +test('Branch and Native Dev never share a build URL: each resolves its own Preview name', () => { + const api = '-blog.acme.workers.dev'; + const a = resolveCanonicalUrl({ previewName: 'feat-x', apiSuffix: api }).url; + const b = resolveCanonicalUrl({ previewName: 'dev', apiSuffix: api }).url; + assert.notEqual(a, b); + assert.deepEqual(checkBuildEnvOrigins('PUBLIC_APP_URL=' + a, b).length, 1); +}); + +// --------------------------------------------------------------------------------------------------------------------- +// P2-B: migration plan - one execution per physical D1; the owner is the first member; Production D1 is never shared. +// --------------------------------------------------------------------------------------------------------------------- +test('planMigrations: Branch + Native Dev on one preview D1 -> ONE group (owner native-branch); distinct D1s are refused', () => { + const cfg = { name: 'app', d1_databases: [{ binding: 'DB', database_name: 'prod', database_id: 'PROD' }], + previews: { d1_databases: [{ binding: 'DB', database_name: 'prev', database_id: 'PREV' }] }, + env: { dev: { name: 'app-dev', d1_databases: [{ binding: 'DB', database_name: 'devdb', database_id: 'DEV' }] } } }; + const g = planMigrations(cfg, ['native-branch', 'native-dev'], { accountId: 'A' }); + assert.equal(g.length, 1); + assert.deepEqual(g[0].members, ['native-branch', 'native-dev']); + assert.equal(g[0].owner, 'native-branch'); + assert.equal(g[0].databaseId, 'PREV'); + assert.throws(() => planMigrations(cfg, ['native-branch', 'persistent-dev'], { accountId: 'A' }), /distinct physical D1/); + assert.deepEqual(planMigrations(cfg, ['production'], { accountId: 'A' })[0].members, ['production']); +}); +test('planMigrations: a Production D1 exposed to Preview/Dev is refused', () => { + const cfg = { name: 'app', d1_databases: [{ binding: 'DB', database_name: 'prod', database_id: 'PROD' }], + previews: { d1_databases: [{ binding: 'DB', database_name: 'prod', database_id: 'PROD' }] } }; + assert.throws(() => planMigrations(cfg, ['native-branch'], { accountId: 'A' }), /Production D1|production/i); +}); + +// ---- target descriptor: the single versioned record the thin per-kind calls consume ------------------------------------- +const D = { kind: 'native-dev', app: 'app', runId: 77, d1ExpectedId: 'DB1', ownerKind: 'native-branch' }; + +test('descriptor: derived once from the kind - engine target, environment, concurrency groups', () => { + const expect = { production: ['prod', 'app/prod'], 'native-branch': ['preview', 'app/preview'], 'native-dev': ['preview', 'app/native-dev'], 'persistent-dev': ['dev', 'app/dev'] }; + for (const k of DELIVERY_KINDS) { + const d = targetDescriptor({ kind: k, app: 'app', runId: 5, repo: 'o/r' }); + assert.deepEqual([d.target, d.environment], expect[k]); + if (k !== 'persistent-dev') { + assert.equal(d.deploy_group, `cfv2-leg-5-${k}`); + assert.equal(d.rollback_group, `cfv2-leg-5-${k}-rollback`); + } + assert.equal(d.v, 1); + } +}); + +test('descriptor: migration ownership - only the owner of the physical D1 owns the migration', () => { + assert.equal(targetDescriptor(D).owns_migration, false); + assert.equal(targetDescriptor({ ...D, kind: 'native-branch' }).owns_migration, true); + assert.equal(targetDescriptor({ ...D, d1ExpectedId: '', ownerKind: '' }).owns_migration, false); +}); + +test('descriptor: buildDescriptors maps the D1 plan (shared D1 -> one owner) and keeps enabled kinds only, in stable order', () => { + const groups = [{ databaseId: 'SHARED', members: ['native-branch', 'native-dev'], owner: 'native-branch' }, { databaseId: 'PROD', members: ['production'], owner: 'production' }]; + const m = buildDescriptors({ kinds: ['native-dev', 'production', 'native-branch'], app: 'app', runId: 9, groups, smokeOverrides: '{"native-dev":"skip"}' }); + assert.deepEqual(Object.keys(m), ['production', 'native-branch', 'native-dev']); + assert.equal(m['native-dev'].d1_expected_id, 'SHARED'); + assert.equal(m['native-dev'].d1_owner_kind, 'native-branch'); + assert.equal(m['native-dev'].owns_migration, false); + assert.equal(m['native-branch'].owns_migration, true); + assert.equal(m.production.d1_expected_id, 'PROD'); + assert.equal(m['native-dev'].smoke_mode, 'skip'); + assert.equal(m['native-branch'].smoke_mode, 'full'); +}); + +test('descriptor: deferred verify takes the D1 ids from the hand-off, not from a re-plan', () => { + const m = buildDescriptors({ kinds: ['production'], app: 'app', runId: 9, d1ByKind: { production: 'HANDED' }, phase: 'verify' }); + assert.equal(m.production.d1_expected_id, 'HANDED'); + assert.equal(m.production.phase, 'verify'); +}); + +test('descriptor: validation rejects forged, cross-kind, unknown-key and wrong-version records (trust boundary)', () => { + const ok = targetDescriptor(D); + assert.doesNotThrow(() => validateDescriptor({ ...ok })); + const bad = (patch, re) => assert.throws(() => validateDescriptor({ ...ok, ...patch }), re); + bad({ v: 2 }, /version/); + bad({ target: 'prod' }, /engine target/); + bad({ environment: 'app/prod' }, /environment/); + bad({ environment: 'a b/native-dev' }, /environment/); + bad({ deploy_group: 'cfv2-leg-77-production' }, /concurrency groups/); + bad({ rollback_group: 'cfv2-leg-77-native-dev' }, /concurrency groups/); + bad({ deploy_group: 'other' }, /concurrency groups/); + assert.throws(() => validateDescriptor({ ...targetDescriptor({ kind: 'persistent-dev', app: 'app', runId: 1, repo: 'o/r' }), rollback_group: 'cfv2-dev-o/r-other' }), /concurrency groups/); + assert.throws(() => validateDescriptor({ ...targetDescriptor({ kind: 'persistent-dev', app: 'app', runId: 1, repo: 'o/r' }), deploy_group: 'cfv2-leg-1-persistent-dev', rollback_group: 'cfv2-leg-1-persistent-dev-rollback' }), /concurrency groups/); + bad({ d1_expected_id: 'a b' }, /database id/); + bad({ d1_owner_kind: 'bogus' }, /Unknown delivery kind/); + bad({ owns_migration: true }, /owns_migration/); + bad({ smoke_mode: 'maybe' }, /smoke mode/); + bad({ phase: 'later' }, /phase/); + bad({ CLOUDFLARE_API_TOKEN: 'x' }, /unexpected/); + assert.throws(() => validateDescriptor({ ...ok, kind: undefined }), /./); + const { phase, ...noPhase } = ok; + assert.throws(() => validateDescriptor(noPhase), /missing \[phase\]/); + assert.throws(() => validateDescriptor(null), /object/); + assert.throws(() => parseDescriptor('{'), /not valid JSON/); + assert.throws(() => parseDescriptor(JSON.stringify(ok), 'production'), /not production/); + assert.equal(parseDescriptor(JSON.stringify(ok), 'native-dev').kind, 'native-dev'); +}); + +test('descriptor: carries no secret and no URL (secrets stay in job env, URLs are resolved per build)', () => { + const s = JSON.stringify(targetDescriptor(D)); + assert.doesNotMatch(s, /https?:|token|secret|password/i); +}); + +test('descriptor: persistent-dev keeps the legacy Shared Dev group (Deploy AND Rollback serialise per repo + shared-D1 scope)', () => { + const g = (o) => targetDescriptor({ kind: 'persistent-dev', app: 'blog', runId: 9, repo: 'o/r', ...o }); + assert.equal(g({ lockScope: 'shared-db' }).deploy_group, 'cfv2-dev-o/r-shared-db'); + assert.equal(g({}).deploy_group, 'cfv2-dev-o/r-blog'); + assert.equal(g({}).rollback_group, g({}).deploy_group); + assert.notEqual(g({}).deploy_group, g({ app: 'wiki' }).deploy_group); + assert.notEqual(g({ lockScope: 'a' }).deploy_group, g({ lockScope: 'b' }).deploy_group); + // other kinds never share a group across runs' legs or kinds + assert.notEqual(targetDescriptor({ kind: 'native-dev', app: 'a', runId: 1 }).deploy_group, targetDescriptor({ kind: 'native-branch', app: 'a', runId: 1 }).deploy_group); +}); + +// ---- migration gates (were inline bash/jq in the delivery workflow) ----------------------------------------------------- +const MJ = (o = {}) => JSON.stringify({ migration: 'applied', kind: 'native-branch', d1_id: 'DB1', ...o }); +const U = (o) => resolveMigrationUsed({ d1Migrations: true, kind: 'native-branch', ownerKind: 'native-branch', expectedId: 'DB1', result: 'success', json: MJ(), ...o }); + +test('migration used: without d1_migrations nothing is required', () => { + assert.deepEqual(resolveMigrationUsed({ d1Migrations: false, kind: 'production' }), {}); +}); + +test('migration used: every kind (owner or sharing sibling) validates the ONE migration result against the planned owner and its own D1', () => { + assert.equal(U({}).d1_id, 'DB1'); + const shared = resolveMigrationUsed({ d1Migrations: true, kind: 'native-dev', ownerKind: 'native-branch', expectedId: 'DB1', result: 'success', json: MJ() }); + assert.equal(shared.kind, 'native-branch'); + const dev = resolveMigrationUsed({ d1Migrations: true, kind: 'persistent-dev', ownerKind: 'persistent-dev', expectedId: 'DB1', result: 'success', json: MJ({ kind: 'persistent-dev' }) }); + assert.equal(dev.kind, 'persistent-dev'); +}); + +test('migration used: fails closed on a missing, failed, foreign or malformed migration result', () => { + assert.throws(() => U({ ownerKind: '' }), /no migration owner is planned/); + assert.throws(() => U({ ownerKind: 'persistent-dev' }), /another kind/); + assert.throws(() => U({ result: 'failure' }), /finished 'failure'/); + assert.throws(() => U({ result: 'cancelled' }), /finished 'cancelled'/); + assert.throws(() => U({ result: '' }), /finished 'missing'/); + assert.throws(() => U({ result: 'skipped' }), /finished 'skipped'/); + assert.throws(() => U({ json: MJ({ migration: 'none' }) }), /no applied migration/); + assert.throws(() => U({ json: '' }), /missing or not valid JSON/); + assert.throws(() => U({ json: '{not json' }), /missing or not valid JSON/); + assert.throws(() => U({ json: '[1]' }), /malformed/); + assert.throws(() => U({ json: 'null' }), /malformed/); + assert.throws(() => U({ json: '"applied"' }), /malformed/); + assert.throws(() => U({ json: '{}' }), /no applied migration/); + assert.throws(() => U({ json: MJ({ kind: 'persistent-dev' }) }), /another kind/); + assert.throws(() => U({ json: MJ({ d1_id: 'FOREIGN' }) }), /another D1/); + assert.throws(() => U({ expectedId: 'OTHER' }), /another D1/); +}); + +test('migration used: a stale-skipped migration NEVER authorizes a publish (P1: the branch can point at this commit again, A -> B -> A)', () => { + // The Migration job succeeds when it was superseded (nothing applied); its result then carries stale=true. Every shape of it is refused. + for (const stale of [true, 'true']) { + assert.throws(() => U({ json: JSON.stringify({ stale }) }), /became stale/, `stale=${JSON.stringify(stale)} without owner/D1 metadata`); + assert.throws(() => U({ json: MJ({ stale }) }), /became stale/, `stale=${JSON.stringify(stale)} with otherwise valid applied/owner/D1 metadata`); + assert.throws(() => U({ json: JSON.stringify({ stale, migration: 'none', kind: 'native-branch', d1_id: 'DB1' }) }), /became stale/); + } + // stale is checked before the owner / D1 comparison, but those stay mandatory for everything else + assert.throws(() => U({ json: MJ({ stale: 'true', kind: 'persistent-dev', d1_id: 'FOREIGN' }) }), /became stale/); + // only an explicit non-stale flag value passes through + assert.equal(U({ json: MJ({ stale: 'false' }) }).d1_id, 'DB1'); + assert.equal(U({ json: MJ({ stale: false }) }).kind, 'native-branch'); + // 'none' (nothing pending is still reported as applied by the engine; a bare none/absent migration is not an authorization) + assert.throws(() => U({ json: MJ({ migration: 'none' }) }), /no applied migration/); + assert.throws(() => U({ json: JSON.stringify({ stale: 'false', kind: 'native-branch', d1_id: 'DB1' }) }), /no applied migration/); + // d1_migrations=false stays migration-free, even with a stale-looking or garbage result lying around + assert.deepEqual(resolveMigrationUsed({ d1Migrations: false, kind: 'production', result: 'skipped', json: JSON.stringify({ stale: 'true' }) }), {}); +}); + + +// ---- verification and recovery decisions --------------------------------------------------------------------------------- +test('verification: ONLY a content-level smoke verifies a deployment', () => { + for (const l of ['content', 'content-sha']) assert.deepEqual(verificationOutcome(l), { verified: true, kind: 'content' }); + for (const l of ['liveness', 'access', '', undefined, 'CONTENT', 'content ']) assert.equal(verificationOutcome(l).verified, false); + assert.equal(verificationOutcome('skipped').kind, 'skipped'); + assert.equal(verificationOutcome('liveness').kind, 'liveness'); + assert.equal(isContentLevel('content-sha'), true); +}); + +const RB = { policy: 'automatic', previous: 'v1', current: 'v2', worker: 'app', d1Pending: false }; + +test('production rollback guard: every refusal reason, in order, fail closed', () => { + assert.equal(prodRollbackBlock({ ...RB, live: 'v2' }), null); + assert.equal(prodRollbackBlock(RB), null, 'without an observed live version only the static checks run'); + for (const p of ['manual', 'never', '']) assert.equal(prodRollbackBlock({ ...RB, policy: p }).code, 'policy'); + assert.equal(prodRollbackBlock({ ...RB, previous: '' }).code, 'no_previous_version'); + assert.equal(prodRollbackBlock({ ...RB, current: '' }).code, 'no_identity'); + assert.equal(prodRollbackBlock({ ...RB, worker: '' }).code, 'no_identity'); + assert.equal(prodRollbackBlock({ ...RB, previous: 'v2' }).code, 'same_version'); + assert.equal(prodRollbackBlock({ ...RB, policy: 'manual', previous: '' }).code, 'policy', 'policy is checked first'); +}); + +test('production rollback guard: no D1 schema rollback - applied migrations need attested expand-contract AND an additive scan', () => { + const D = { ...RB, d1Pending: true }; + assert.equal(prodRollbackBlock({ ...D, d1Compat: '' }).code, 'd1_unattested'); + assert.equal(prodRollbackBlock({ ...D, d1Compat: 'none', d1Scan: 'additive' }).code, 'd1_unattested'); + for (const scan of ['unknown', 'destructive', '', 'none']) assert.equal(prodRollbackBlock({ ...D, d1Compat: 'expand-contract', d1Scan: scan }).code, 'd1_not_additive'); + assert.match(prodRollbackBlock({ ...D, d1Compat: 'expand-contract', d1Scan: '' }).why, /scan: unknown/); + assert.equal(prodRollbackBlock({ ...D, d1Compat: 'expand-contract', d1Scan: 'additive', live: 'v2' }), null); + assert.equal(prodRollbackBlock({ ...RB, d1Pending: false, d1Compat: '', d1Scan: 'destructive' }), null, 'no applied migration: D1 is irrelevant'); +}); + +test('production rollback guard: never replaces a deployment that is no longer ours (stale / out-of-band)', () => { + assert.equal(prodRollbackBlock({ ...RB, live: 'someone-elses' }).code, 'live_version_changed'); + assert.equal(prodRollbackBlock({ ...RB, live: '' }).code, 'live_version_changed'); + assert.match(prodRollbackBlock({ ...RB, live: '' }).why, /'unknown'/); +}); + +test('rollback verdict (production): restored_verified only after a content-level re-smoke of the rolled-back version', () => { + const P = (o) => rollbackVerdict({ target: 'prod', ...o }); + assert.deepEqual(P({ rbResult: 'rolled_back', rbVerify: 'success', rbLevel: 'content-sha' }), { result: 'restored_verified', level: 'content-sha', ok: true }); + assert.equal(P({ rbResult: 'rolled_back', rbVerify: 'success', rbLevel: 'liveness' }).result, 'restored_unverified'); + assert.equal(P({ rbResult: 'rolled_back', rbVerify: 'success', rbLevel: '' }).ok, false); + assert.equal(P({ rbResult: 'rolled_back', rbVerify: 'failure', rbLevel: 'content' }).result, 'restored_unhealthy'); + assert.equal(P({ rbResult: 'rolled_back', rbVerify: '' }).result, 'restored_unhealthy'); + assert.equal(P({ rbResult: 'blocked' }).result, 'blocked'); + for (const r of ['failed', '', 'weird']) assert.deepEqual(P({ rbResult: r, rbVerify: 'success', rbLevel: 'content' }), { result: 'failed', level: '', ok: false }); +}); + +test('rollback verdict (Preview / Dev): known-good recovery must be rebuilt AND re-verified at content level', () => { + const R = (o) => rollbackVerdict({ target: 'preview', goodSha: 'abc1234', recovered: 'success', verified: 'success', smokeLevel: 'content', ...o }); + assert.deepEqual(R({}), { result: 'restored_verified', level: 'content', ok: true }); + assert.equal(R({ smokeLevel: 'liveness' }).result, 'restored_unverified'); + assert.equal(R({ goodSha: '' }).result, 'no_known_good'); + assert.equal(R({ goodSha: '', recovered: 'success' }).ok, false); + assert.equal(R({ recovered: 'failure' }).result, 'failed'); + assert.equal(R({ verified: 'failure' }).result, 'failed'); + assert.equal(R({ recovered: '' }).result, 'failed'); + assert.equal(rollbackVerdict({ target: 'dev', goodSha: 'x', recovered: 'success', verified: 'success', smokeLevel: 'content-sha' }).ok, true); +}); + +// ---- deferred verification: the deploy call's descriptors are the source of truth ------------------------------------------ +const REPO = 'o/r'; +const deployState = (over = {}, kinds = ['native-branch', 'native-dev'], dOver = {}) => { + const descriptors = buildDescriptors({ + kinds, app: 'app', runId: 11, repo: REPO, lockScope: 'shared-db', smokeMode: 'skip', phase: 'deploy', + groups: [{ databaseId: 'DB1', members: kinds, owner: kinds[0] }], ...dOver, + }); + const inputs = { app_name: 'app', d1_lock_scope: 'shared-db', smoke_mode: 'skip', deployment_strategy: 'hybrid', preview_on_branch_push: true, d1_migrations: true }; + return { + inputs, group: runLockGroup({ ...EV, inputs, runId: 11 }), kinds, d1: Object.fromEntries(kinds.map((k) => [k.replace(/-/g, '_'), 'DB1'])), + migration: Object.fromEntries(kinds.map((k) => [`mj_${k.replace(/-/g, '_')}`, JSON.stringify({ kind: kinds[0], d1_id: 'DB1' })])), descriptors, ...over, + }; +}; +const EV = { event: 'push', ref: 'refs/heads/feat/x' }; +const R = (state, o = {}) => restoreDescriptors(state, { repo: REPO, runId: 99, ...EV, ...o }); + +test('deferred verify: deployment-time policy survives (smoke skip, lock scope, owner, D1, groups); only the phase changes', () => { + const st = deployState(); + const r = R(st); + for (const k of ['native-branch', 'native-dev']) assert.deepEqual({ ...r[k], phase: 'deploy' }, st.descriptors[k]); + assert.equal(r['native-dev'].smoke_mode, 'skip'); + assert.equal(r['native-dev'].phase, 'verify'); + assert.equal(r['native-dev'].d1_owner_kind, 'native-branch'); + const pd = R(deployState({}, ['persistent-dev'])); + assert.equal(pd['persistent-dev'].deploy_group, 'cfv2-dev-o/r-shared-db', 'persistent Dev keeps the deploy-time lock identity, not a verify-call default'); + assert.equal(pd['persistent-dev'].rollback_group, pd['persistent-dev'].deploy_group); +}); + +test('deferred verify: every kind restores (Native Branch, Native Dev, Persistent Dev, Production)', () => { + for (const k of DELIVERY_KINDS) assert.equal(R(deployState({}, [k]))[k].phase, 'verify'); +}); + +test('deferred verify: tampering is rejected (kind set, cross-target injection, D1, owner, app, repo, phase, schema)', () => { + const bad = (st, re) => assert.throws(() => R(st), re); + const st = deployState(); + bad({ ...st, descriptors: { 'native-branch': st.descriptors['native-branch'] } }, /do not match phase_state.kinds/); + bad({ ...st, descriptors: { ...st.descriptors, production: targetDescriptor({ kind: 'production', app: 'app', runId: 1, phase: 'deploy' }) } }, /do not match/); + bad({ ...st, descriptors: { 'native-branch': st.descriptors['native-dev'], 'native-dev': st.descriptors['native-branch'] } }, /filed under/); + bad({ ...st, d1: { native_branch: 'FOREIGN', native_dev: 'DB1' } }, /hand-off recorded 'FOREIGN'/); + bad({ ...st, descriptors: { ...st.descriptors, 'native-dev': { ...st.descriptors['native-dev'], d1_expected_id: 'FOREIGN' } } }, /binds D1 'FOREIGN'/); + bad({ ...st, descriptors: { ...st.descriptors, 'native-dev': { ...st.descriptors['native-dev'], d1_owner_kind: 'native-dev', owns_migration: true } } }, /migration owner/); + bad({ ...st, inputs: { ...st.inputs, app_name: 'other' } }, /another application/); +}); + +test('deferred verify: wrong repository, wrong phase, schema violations and missing identity fail closed', () => { + const st = deployState({}, ['persistent-dev']); + assert.throws(() => R(st, { repo: 'evil/r' }), /carries lock/); + const allPhase = deployState({}, ['native-branch'], { phase: 'all' }); + assert.throws(() => R(allPhase), /only a deploy-phase descriptor/); + const already = deployState({}, ['native-branch'], { phase: 'verify' }); + assert.throws(() => R(already), /only a deploy-phase descriptor/); + const forged = deployState({}, ['native-branch']); + forged.descriptors['native-branch'] = { ...forged.descriptors['native-branch'], smoke_mode: 'maybe' }; + assert.throws(() => R(forged), /smoke mode/); + assert.throws(() => R({ ...deployState(), kinds: [] }), /no delivery kind/); + assert.throws(() => R({ ...deployState(), kinds: ['native-dev', 'native-dev'] }), /duplicate/); + assert.throws(() => R({ ...deployState(), kinds: ['bogus'] }), /unknown/); + assert.throws(() => R({ ...deployState(), inputs: {} }), /app_name is missing/); + assert.throws(() => R(null), /not an object/); + assert.throws(() => R({ ...deployState(), descriptors: [] }), /not an object/); +}); + +test('deferred verify: a legacy hand-off (no descriptors) is rebuilt ONLY from the deploy call\'s own inputs, else fails closed', () => { + const { descriptors, ...legacy } = deployState(); + const r = R(legacy); + assert.equal(r['native-dev'].smoke_mode, 'skip', 'from phase_state.inputs (the deploy call), not from the verify call'); + assert.equal(r['native-dev'].d1_expected_id, 'DB1'); + assert.equal(r['native-dev'].phase, 'verify'); + const pd = R({ ...deployState({}, ['persistent-dev']), descriptors: undefined }); + assert.equal(pd['persistent-dev'].deploy_group, 'cfv2-dev-o/r-shared-db'); + const badIn = { ...legacy.inputs, smoke_mode: 'maybe' }; + assert.throws(() => R({ ...legacy, inputs: badIn, group: runLockGroup({ ...EV, inputs: badIn, runId: 11 }) }), /smoke_mode/); + assert.throws(() => R({ ...legacy, inputs: { smoke_mode: 'skip' } }), /app_name is missing/); +}); + +// ---- engine initialisation: one pure validation (was ~6 Node processes + jq in engine.sh init) -------------------------------- +const IN0 = { + app_name: 'app', deployment_strategy: 'production-preview', production_rollback_policy: 'automatic', build_cmd: 'pnpm build', preview_on_branch_push: true, + app_url_prod: 'https://app.example.com', app_url_dev: 'https://dev.example.com', dev_worker_name: 'app-dev', smoke_env: '', build_env_prod: '', build_env_preview: '', +}; +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', () => { + const r = INIT(); + assert.deepEqual([r.kind, r.target, r.stage, r.mode, r.smokeMode], ['native-branch', 'preview', 'migrate', 'auto', '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', () => { + 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/); +}); + +test('init: strategy, rollback policy and Dev channel combinations fail closed before anything runs', () => { + initFails(/bad deployment_strategy/, {}, { deployment_strategy: 'x' }); + initFails(/bad production_rollback_policy/, {}, { production_rollback_policy: 'maybe' }); + initFails(/production-only cannot be combined/, { ref: 'refs/heads/main' }, { deployment_strategy: 'production-only', shared_dev_on_branch_push: true }); + initFails(/conflicting automatic Dev channels/, {}, { shared_dev_on_branch_push: true, shared_dev_native_preview_on_branch_push: true }); +}); + +test('init: the engine kind must be among the kinds this event enables (no phantom delivery)', () => { + initFails(/target mismatch/, { target: 'prod', stage: 'publish' }); // branch push never enables production + initFails(/target mismatch/, {}, { preview_on_branch_push: false }); + initFails(/target mismatch/, { ref: 'refs/heads/main' }); // main push enables production only + 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 }); + 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/); +}); + +test('init: per-target requirements (Native Dev flag, Dev identity/URL/build, build command)', () => { + initFails(/target mismatch/, { kind: 'native-dev' }); // without the flag Native Dev is not an enabled kind at all + const D = (i = {}) => validateInit({ inputs: { ...IN0, deployment_strategy: 'hybrid', shared_dev_on_branch_push: true, dev_build_cmd: 'x', ...i }, target: 'dev', stage: 'all', event: 'push', ref: 'refs/heads/feat/x' }); + assert.equal(D().kind, 'persistent-dev'); + assert.throws(() => D({ dev_worker_name: '' }), /dev_worker_name/); + assert.throws(() => D({ app_url_dev: '' }), /app_url_dev/); + assert.throws(() => D({ dev_build_cmd: '', build_cmd: '' }), /no build command for target dev/); + assert.throws(() => D({ dev_build_cmd: '', d1_migrations: true }), /requires dev_build_cmd/); + assert.equal(D({ dev_build_cmd: '' }).kind, 'persistent-dev', 'dev_build_cmd falls back to build_cmd without D1 migrations'); + initFails(/no build command for target preview/, {}, { build_cmd: '' }); +}); + +test('init: env data - reserved/forbidden names rejected, foreign target URLs rejected, each kind uses ONLY its own build env', () => { + initFails(/CFV2_BUILD_ENV: .*TARGET may not be set/, {}, { build_env_preview: 'TARGET=prod' }); + initFails(/CFV2_SMOKE_ENV/, {}, { smoke_env: 'GITHUB_TOKEN=x' }); + initFails(/CFV2_PS/, { previewSecrets: 'CLOUDFLARE_API_TOKEN=x' }); + initFails(/contains the production URL/, {}, { build_env_preview: 'PUBLIC_URL=https://app.example.com' }); + const r = INIT({ previewSecrets: 'API_KEY=s3cret' }, { build_env_preview: 'A=1\nB=2', build_env_prod: 'PROD_ONLY=1', smoke_env: 'S=1' }); + assert.deepEqual(r.buildEnv, [['A', '1'], ['B', '2']], 'a Preview build never sees the Production build env'); + assert.deepEqual(r.smokeEnv, [['S', '1']]); + assert.deepEqual(r.previewEnv, [['API_KEY', 's3cret']]); + assert.equal(INIT({ target: 'prod', stage: 'publish', ref: 'refs/heads/main', previewSecrets: 'API_KEY=s3cret' }).previewEnv, null, 'Preview secrets are only read for the Preview target'); +}); + +test('init: deferred verify identity inputs must equal the deploy call\'s; a descriptor must match the stage', () => { + const ci = (o = {}) => JSON.stringify({ app_name: 'app', app_path: '', production_rollback_policy: 'automatic', ...o }); + assert.doesNotThrow(() => INIT({ callInputs: ci() })); + for (const k of ['app_name', 'app_path', 'production_rollback_policy']) initFails(new RegExp(`'${k}' differs`), { callInputs: ci({ [k]: 'x' }) }); + initFails(/call inputs are not an object/, { callInputs: '[1]' }); + const d = targetDescriptor({ kind: 'native-branch', app: 'app', runId: 1, repo: 'o/r', smokeMode: 'skip' }); + assert.equal(INIT({ descriptor: JSON.stringify(d) }).smokeMode, 'skip', 'the smoke policy comes from the descriptor'); + initFails(/is for production, not native-branch/, { descriptor: JSON.stringify(targetDescriptor({ kind: 'production', app: 'app', runId: 1 })) }); + initFails(/D1 id differs/, { descriptor: JSON.stringify({ ...d, d1_expected_id: 'X', d1_owner_kind: '' }) }); +}); + +// ---- lock identity integrity across Deploy -> Verify -> Rollback ------------------------------------------------------------- +const PD = (o = {}, i = {}) => deployState({ inputs: { ...deployState().inputs, shared_dev_on_branch_push: true, preview_on_branch_push: false, ...i }, ...o }, ['persistent-dev']); + +test('lock identity: run-level group follows the same rule as the workflow (scope or app, channel, ref)', () => { + const I = { app_name: 'blog', deployment_strategy: 'hybrid', preview_on_branch_push: true, d1_migrations: true }; + assert.equal(runLockGroup({ event: 'push', ref: 'refs/heads/main', inputs: I, runId: 1 }), 'cfv2-prod-blog'); + assert.equal(runLockGroup({ event: 'push', ref: 'refs/heads/main', inputs: { ...I, d1_lock_scope: 'shared-db' }, runId: 1 }), 'cfv2-prod-shared-db'); + assert.equal(runLockGroup({ event: 'push', ref: 'refs/heads/x', inputs: { ...I, d1_lock_scope: 'shared-db' }, runId: 1 }), 'cfv2-nonprod-d1-shared-db'); + assert.equal(runLockGroup({ event: 'push', ref: 'refs/heads/x', inputs: { ...I, d1_migrations: false, shared_dev_on_branch_push: true }, runId: 1 }), 'cfv2-shared-dev-blog'); + 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'); +}); + +test('lock identity: same Deploy/Verify d1_lock_scope -> same effective lock; a verify default never selects another', () => { + const st = PD(); + const r = R(st)['persistent-dev']; + assert.equal(r.deploy_group, 'cfv2-dev-o/r-shared-db'); + assert.equal(r.rollback_group, r.deploy_group); + assert.equal(st.group, 'cfv2-nonprod-d1-shared-db', 'the run-level group is also keyed by the deploy-time scope'); + // restoring takes ONLY the hand-off: there is no verify-call input to override the scope + assert.equal(R(PD())['persistent-dev'].deploy_group, r.deploy_group); +}); + +test('lock identity: modified Persistent Dev group, rollback group, repository and application are rejected', () => { + const st = PD(); + const tamper = (patch) => ({ ...st, descriptors: { 'persistent-dev': { ...st.descriptors['persistent-dev'], ...patch } } }); + assert.throws(() => R(tamper({ deploy_group: 'cfv2-dev-o/r-other', rollback_group: 'cfv2-dev-o/r-other' })), /carries lock/); + assert.throws(() => R(tamper({ rollback_group: 'cfv2-dev-o/r-other' })), /rollback_group|concurrency groups|carries lock/); + assert.throws(() => R(tamper({ deploy_group: 'cfv2-dev-o/r-app', rollback_group: 'cfv2-dev-o/r-app' })), /carries lock/, 'the scope-less lock is a different lock'); + assert.throws(() => R(st, { repo: 'evil/r' }), /carries lock/); + assert.throws(() => R({ ...st, inputs: { ...st.inputs, app_name: 'other' } }), /another application|phase_state.group|carries lock/); + assert.throws(() => R({ ...st, inputs: { ...st.inputs, d1_lock_scope: '' } }), /carries lock|phase_state.group/, 'a different deploy-time scope is not the original lock'); +}); + +test('lock identity: phase_state.group must be the lock the deploy inputs imply (inconsistent / forged / missing -> rejected)', () => { + const st = deployState(); + for (const g of ['cfv2-prod-app', 'attacker-group', '', undefined, 'cfv2-nonprod-d1-other']) assert.throws(() => R({ ...st, group: g }), /phase_state.group/); + assert.doesNotThrow(() => R(st)); + // no branch push (e.g. a rerun on a PR event): only the numeric cfv2-run- shape is acceptable + const pr = { event: 'pull_request', ref: 'refs/pull/1/merge' }; + assert.doesNotThrow(() => R({ ...st, group: 'cfv2-run-123' }, pr)); + assert.throws(() => R({ ...st, group: 'cfv2-run-abc' }, pr), /phase_state.group/); + assert.throws(() => R({ ...st, group: 'cfv2-nonprod-d1-shared-db' }, pr), /phase_state.group/); + // verifying from another ref/event than the deploy ran on cannot silently join a different lock + assert.throws(() => R(st, { ref: 'refs/heads/main' }), /phase_state.group/); +}); + +test('lock identity: Native Branch / Native Dev / Production restore exactly as before', () => { + for (const k of ['native-branch', 'native-dev', 'production']) { + const st = deployState({}, [k]); + const r = R(st)[k]; + assert.equal(r.deploy_group, st.descriptors[k].deploy_group); + assert.equal(r.rollback_group, `${st.descriptors[k].deploy_group}-rollback`); + assert.equal(r.phase, 'verify'); + } +}); + +// ---- legacy hand-off (no descriptors): migration ownership is reconstructed only from proof --------------------------------------- +const legacyOf = (o = {}, kinds = ['native-branch', 'native-dev']) => { const { descriptors, ...l } = deployState(o, kinds); return l; }; +const mj = (kind, d1 = 'DB1') => JSON.stringify({ migration: 'applied', kind, d1_id: d1 }); + +test('legacy migration ownership: no D1 -> valid, empty owner; D1 migrations disabled -> no ownership needed', () => { + const noD1 = legacyOf({ inputs: { ...deployState().inputs, d1_migrations: false }, d1: {}, migration: {} }); + const r = R({ ...noD1, group: runLockGroup({ ...EV, inputs: noD1.inputs, runId: 1 }) }); + assert.equal(r['native-dev'].d1_expected_id, ''); + assert.equal(r['native-dev'].d1_owner_kind, ''); + assert.equal(r['native-dev'].owns_migration, false); +}); + +test('legacy migration ownership: one D1 shared by Native Branch and Native Dev -> the proven owner and member relationship', () => { + const r = R(legacyOf()); + assert.equal(r['native-branch'].d1_owner_kind, 'native-branch'); + assert.equal(r['native-branch'].owns_migration, true); + assert.equal(r['native-dev'].d1_owner_kind, 'native-branch'); + assert.equal(r['native-dev'].owns_migration, false); + assert.equal(r['native-dev'].d1_expected_id, 'DB1'); +}); + +test('legacy migration ownership: a single kind owns its own D1; distinct D1s have independent owners', () => { + const one = R(legacyOf({}, ['production'])); + assert.equal(one.production.owns_migration, true); + const two = legacyOf({ d1: { native_branch: 'DB1', persistent_dev: 'DB2' }, migration: { mj_native_branch: mj('native-branch', 'DB1'), mj_persistent_dev: mj('persistent-dev', 'DB2') } }, ['native-branch', 'persistent-dev']); + const r = R(two); + assert.deepEqual([r['native-branch'].owns_migration, r['persistent-dev'].owns_migration], [true, true]); + assert.deepEqual([r['native-branch'].d1_expected_id, r['persistent-dev'].d1_expected_id], ['DB1', 'DB2']); +}); + +test('legacy migration ownership: conflicting, missing, foreign or self-contradicting evidence fails closed', () => { + const base = legacyOf(); + assert.throws(() => R({ ...base, migration: { mj_native_branch: mj('native-branch'), mj_native_dev: mj('native-dev') } }), /conflicting migration owners/); + assert.throws(() => R({ ...base, migration: {} }), /no migration-owner evidence/); + assert.throws(() => R({ ...base, migration: { mj_native_branch: mj('native-branch'), mj_native_dev: JSON.stringify({ stale: 'true' }) } }), /no migration-owner evidence/); + assert.throws(() => R({ ...base, migration: { mj_native_branch: mj('native-branch'), mj_native_dev: mj('native-branch', 'OTHER') } }), /for D1 'OTHER'/); + assert.throws(() => R({ ...base, migration: { mj_native_branch: mj('production'), mj_native_dev: mj('production') } }), /not among the kinds/); + // The owner is whatever the hand-off consistently proves; a self-consistent swap cannot be told from the truth (it only changes the informational + // owns_migration of a verify descriptor - verify/rollback never run a migration). It is accepted, and only the consistent owner is reported: + const swapped = R({ ...base, migration: { mj_native_branch: mj('native-dev'), mj_native_dev: mj('native-dev') } }); + assert.deepEqual([swapped['native-dev'].d1_owner_kind, swapped['native-dev'].owns_migration, swapped['native-branch'].owns_migration], ['native-dev', true, false]); +}); + +test('legacy migration ownership: D1 identity mismatch is rejected; the descriptor-based hand-off is unchanged', () => { + const base = legacyOf(); + assert.throws(() => R({ ...base, d1: { native_branch: 'DB1', native_dev: 'DB2' } }), /conflicting migration owners|for D1/); + const withDesc = deployState(); + assert.deepEqual(R(withDesc)['native-dev'], { ...withDesc.descriptors['native-dev'], phase: 'verify' }); +}); diff --git a/scripts/cf-preview/ghexpr.py b/scripts/cf-preview/ghexpr.py index ade25ba..28fffac 100644 --- a/scripts/cf-preview/ghexpr.py +++ b/scripts/cf-preview/ghexpr.py @@ -25,7 +25,7 @@ def get_path(self, path): return cur -TOKEN = re.compile(r"\s*(?:('(?:[^']|'')*')|(&&|\|\||==|!=)|(!)|([(),])|([A-Za-z_][\w.\-]*)(\()?|(\.[A-Za-z_][\w\-]*))") +TOKEN = re.compile(r"\s*(?:('(?:[^']|'')*')|(&&|\|\||==|!=)|(!)|([(),\[\]])|([A-Za-z_][\w.\-]*)(\()?|(\.[A-Za-z_][\w\-]*))") class J(dict): @@ -34,6 +34,9 @@ class J(dict): def __getattr__(self, k): return _wrap(self.get(k, "")) + def __getitem__(self, k): + return _wrap(self.get(k, "")) + class Null(str): """GitHub null: any property of it is null again.""" @@ -86,7 +89,7 @@ def ev(expr, ctx, failed=False): "F_success": lambda: not failed, "F_failure": lambda: failed, "F_cancelled": lambda: False, - "F_format": lambda f, *a: re.sub(r"\{(\d+)\}", lambda m: str(a[int(m.group(1))]), f), + "F_format": lambda f, *a: re.sub(r"\{\{|\}\}|\{(\d+)\}", lambda m: {"{{": "{", "}}": "}"}.get(m.group(0)) or str(a[int(m.group(1))]), f), "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), @@ -102,8 +105,23 @@ def strip_expr(s): def render(value, ctx, failed=False): - """Render a string containing ${{ }} segments.""" - return re.sub(r"\$\{\{(.*?)\}\}", lambda m: _s(ev(m.group(1).strip(), ctx, failed)), str(value)) + """Render a string containing ${{ }} segments (a `}}` inside a quoted string literal does not end the expression).""" + text, out, i = str(value), [], 0 + while True: + j = text.find("${{", i) + if j < 0: + out.append(text[i:]) + return "".join(out) + out.append(text[i:j]) + k, q = j + 3, False + while k < len(text): + if text[k] == "'": + q = not q + elif not q and text.startswith("}}", k): + break + k += 1 + out.append(_s(ev(text[j + 3:k].strip(), ctx, failed))) + i = k + 2 def _s(v): diff --git a/scripts/cf-preview/jobrunner.py b/scripts/cf-preview/jobrunner.py index de85d38..5ff47a6 100644 --- a/scripts/cf-preview/jobrunner.py +++ b/scripts/cf-preview/jobrunner.py @@ -2,7 +2,7 @@ Evaluates step `if:`/`env:`/`${{ }}` with ghexpr, honours success()/failure() semantics, GITHUB_ENV / GITHUB_OUTPUT propagation, step outcomes, continue-on-error and job outputs. A LOCAL composite action -(`uses: ./.cf-tools/actions/`) is expanded: its `inputs` come from the caller's `with:`, its steps run with +(`uses: $/actions/`) is expanded: its `inputs` come from the caller's `with:`, its steps run with their own `steps`/`failure()` scope and share the job env, GITHUB_ENV and workspace, and its `outputs` are rendered back into the calling step. Other `uses:` steps are no-ops (the workspace is pre-populated). Test-only; this is not GitHub, see docs "Verified vs not verified". @@ -13,14 +13,43 @@ import re import shutil import subprocess +import sys import tempfile +import time import yaml from ghexpr import Ctx, ev, render ROOT = pathlib.Path(__file__).resolve().parents[2] +# Opt-in performance instrumentation (JOBRUNNER_PERF=): every step subprocess is recorded by category. +PERF = {"subprocess": {}, "workspaces": 0, "jobs": {}, "scenario": ""} + + +def _category(script): + """Coarse command category of a step script: the first engine/CLI entry point it calls, else 'inline'.""" + m = re.search(r'engine\.sh"? +([a-z][\w-]*)', script) + if m: + return f"engine:{m.group(1)}" + for key in ("engine.sh", "cli.mjs", "build.sh", "publish.sh", "migrate.sh", "smoke.sh", "probe.sh", "gh.sh", "pnpm", "node"): + if key in script: + return key + return "inline" + + +def _record(kind, name, dt): + d = PERF[kind].setdefault(name, {"n": 0, "s": 0.0}) + d["n"] += 1 + d["s"] = round(d["s"] + dt, 3) + + +def write_perf_report(): + path = os.environ.get("JOBRUNNER_PERF") + if path: + total = sum(v["n"] for v in PERF["subprocess"].values()) + pathlib.Path(path).write_text(json.dumps({**PERF, "subprocess_total": total}, indent=1, sort_keys=True)) + GH_STUB = r'''#!/usr/bin/env bash echo "gh $*" >> "$STUB_LOG" a="$*" @@ -36,7 +65,7 @@ *"per_page=30"*" -q "*) [ -n "${STUB_LASTGOOD:-}" ] && echo "9:$STUB_LASTGOOD";; *"per_page=30"*) printf '%s' "${STUB_DEPLOYMENTS:-[]}";; *"deployments/9/statuses"*) echo success;; - *"/statuses?per_page=1"*"environment_url"*) echo "${STUB_STATUS:-success https://stable.example.workers.dev Serving good (content)}";; + *"/statuses?per_page=1"*"environment_url"*) echo "${STUB_STATUS:-success https://feat-x-app.example.workers.dev Serving good (content)}";; esac exit 0 ''' @@ -46,14 +75,15 @@ case "$3 $4 $5" in "--version ") echo "4.140.0";; "preview "*) [ "${STUB_PREVIEW_FAIL:-}" = 1 ] && exit 9 - if [ -n "${STUB_PREVIEW_RAW+x}" ]; then printf '%s' "$STUB_PREVIEW_RAW"; else echo '{"preview":{"urls":["https://stable.example.workers.dev"]}}'; fi;; + 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}]}]';; "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;; "d1 migrations list") [ "${STUB_PENDING:-1}" = 1 ] && echo "Migrations to be applied: ${STUB_PENDING_FILES-0001.sql}" || echo "No migrations to apply!";; - "d1 migrations apply") [ "${STUB_APPLY_FAIL:-}" = 1 ] && exit 7;; + "d1 migrations apply") [ "${STUB_APPLY_FAIL:-}" = 1 ] && exit 7 + [ -n "${STUB_APPLY_FAIL_CFG:-}" ] && [[ "$*" == *"$STUB_APPLY_FAIL_CFG"* ]] && exit 7;; esac exit 0 ''' @@ -81,6 +111,7 @@ def make_workspace(toml=APP_TOML, files=None): + PERF["workspaces"] += 1 ws = pathlib.Path(tempfile.mkdtemp(prefix="jobrun-")) (ws / "bin").mkdir() for name, body in (("gh", GH_STUB), ("pnpm", PNPM_STUB), ("curl", CURL_STUB), ("npm", NPM_STUB)): @@ -89,9 +120,6 @@ def make_workspace(toml=APP_TOML, files=None): (ws / "w" / "migrations").mkdir(parents=True) (ws / "w" / "migrations" / "0001.sql").write_text("select 1;") (ws / "w" / "wrangler.toml").write_text(toml) - (ws / "w" / ".cf-tools" / "actions").mkdir(parents=True) - for tool in ("cf-preview", "cf-deploy", "cf-ci-check"): - (ws / "w" / ".cf-tools" / "actions" / tool).symlink_to(ROOT / "actions" / tool) for rel, body in (files or {}).items(): (ws / "w" / rel).write_text(body) (ws / "tmp").mkdir() @@ -99,6 +127,29 @@ def make_workspace(toml=APP_TOML, files=None): return ws +_NAME = r"[A-Za-z_][\w.-]*" +EXPANDED_ACTIONS = {"actions/cf-deploy", "actions/cf-ci-check", "actions/cf-tools"} + + +def output_file_violations(text): + """Lines GitHub's runner would reject in a $GITHUB_OUTPUT / $GITHUB_ENV file ("Unable to process file command 'output'"): + everything must be `name=value` or a `name<` = this repository at the running commit; only the delivery/CI/tooling actions are expanded + # (setup / mailpit stay no-ops: the workspace is pre-populated). + adir = ROOT / uses[2:] if uses.startswith("$/") and uses[2:] in EXPANDED_ACTIONS else None if adir is not None and (adir / "action.yml").exists(): outcome, outputs, inner_results = self.composite(step, adir, ctx, failed, verbose) results.append((label, outcome)) @@ -174,11 +228,17 @@ def steps(self, steps, ctx, extra_env=None, verbose=False): script = render(step["run"], ctx, failed) wd = pathlib.Path(render(step.get("working-directory", "."), ctx, failed)) wd = wd if wd.is_absolute() else self.w / wd + t0 = time.perf_counter() p = subprocess.run(["bash", "-eo", "pipefail", "-c", script], cwd=wd, env=env, capture_output=True, text=True) + _record("subprocess", _category(script), time.perf_counter() - t0) if verbose: print(f"--- {label} rc={p.returncode}\n{p.stdout[-1500:]}\n{p.stderr[-1500:]}") outputs = _parse_kv(out_file) outcome = "success" if p.returncode == 0 else "failure" + malformed = output_file_violations(out_file.read_text()) + if malformed: # the real runner fails the step ("Invalid format"), so the harness does too + print(f"::error::Unable to process file command 'output' successfully. Invalid format '{malformed[0]}'", file=sys.stderr) + outcome = "failure" if sid: ctx["steps"][sid] = {"outcome": outcome, "conclusion": outcome, "outputs": outputs} ctx["env"].update(_parse_kv(self.jobenv_file)) @@ -229,7 +289,9 @@ def run_job(doc, name, ctx, ws, stub_env=None, verbose=False): for k, v in job.get("env", {}).items(): ctx["env"][k] = render(v, ctx) runner = _Run(ws, base, jobenv_file) + t0 = time.perf_counter() failed, results = runner.steps(job["steps"], ctx, verbose=verbose) + _record("jobs", name, time.perf_counter() - t0) outs = {k: render(v, ctx, failed) for k, v in job.get("outputs", {}).items()} summary = (ws / "summary.md").read_text() if (ws / "summary.md").exists() else "" return {"result": "failure" if failed else "success", "outputs": outs, "steps": results, "log": log.read_text(), "summary": summary} diff --git a/scripts/cf-preview/lib.test.mjs b/scripts/cf-preview/lib.test.mjs index f8b75d8..39fb119 100644 --- a/scripts/cf-preview/lib.test.mjs +++ b/scripts/cf-preview/lib.test.mjs @@ -455,6 +455,9 @@ const apiRes = (status, body) => async () => ({ ok: status < 300, status, json: test('previewHostReadiness: blocks only on a definitive previews_enabled=false', async () => { const a = { accountId: 'a', token: 't', worker: 'blog' }; assert.equal((await previewHostReadiness(apiRes(200, { result: { previews_enabled: true } }), a)).level, 'ok'); + // the Workers subdomain API's preview_url_suffix is what the canonical Preview URL is derived from (before the build) + assert.equal((await previewHostReadiness(apiRes(200, { result: { previews_enabled: true, preview_url_suffix: '-blog.acme.workers.dev' } }), a)).suffix, '-blog.acme.workers.dev'); + assert.equal((await previewHostReadiness(apiRes(200, { result: { previews_enabled: true } }), a)).suffix, ''); const off = await previewHostReadiness(apiRes(200, { result: { previews_enabled: false, enabled: false } }), a); assert.equal(off.level, 'block'); assert.match(off.message, /preview_urls = true|Preview URLs/); diff --git a/scripts/cf-preview/run-job-groups.py b/scripts/cf-preview/run-job-groups.py new file mode 100755 index 0000000..7910f1b --- /dev/null +++ b/scripts/cf-preview/run-job-groups.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Runs the scenario groups of test-jobs.py as independent processes (bounded parallelism). + +`test-jobs.py --list` is the source of truth for what exists; MANIFEST below is the source of truth for what MUST exist and how many +checks each group has at least. The run fails when a group is missing, unexpected, ran zero checks, ran fewer checks than the +manifest says, printed no SUMMARY, or failed - a skipped or silently shrunken group can never produce a green Full result. +Each scenario builds its own fresh workspaces, so groups share no mutable state. + +Usage: run-job-groups.py [--list] [--group ID]... [--jobs N] [--logdir DIR] (default jobs: CF_V2_TEST_JOBS or min(4, CPUs)) +Prints one line per group and only the FAIL lines of a failing group; full logs and perf reports go to --logdir.""" +import concurrent.futures as cf +import os +import pathlib +import re +import subprocess +import sys +import tempfile +import time + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +# 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, + "migration-d1": 69, + "shared-dev": 74, + "chain-smoke-rollback": 54, + "deferred-verification": 50, + "native-dev-smoke-modes": 48, + "review-regressions": 36, +} + + +def discovered(): + p = subprocess.run([sys.executable, str(HERE / "test-jobs.py"), "--list"], capture_output=True, text=True, cwd=ROOT) + if p.returncode: + sys.exit(f"cannot list groups: {p.stderr[-500:]}") + return [line.split("\t")[0] for line in p.stdout.splitlines() if line.strip()] + + +def run_group(gid, logdir, profile): + t0 = time.time() + env = dict(os.environ) + if profile: + env["JOBRUNNER_PERF"] = str(logdir / f"perf-{gid}.json") + p = subprocess.run([sys.executable, str(HERE / "test-jobs.py"), "--group", gid], cwd=ROOT, capture_output=True, text=True, env=env) + (logdir / f"{gid}.log").write_text(p.stdout + p.stderr) + m = re.search(r"^SUMMARY groups=(\d+) checks=(\d+) fail=(\d+)$", p.stdout, re.M) + problems = [] + checks = int(m.group(2)) if m else 0 + if not m: + problems.append("no SUMMARY line (crashed or skipped)") + else: + if m.group(3) != "0": + problems.append("checks failed") + if checks == 0: + problems.append("zero checks executed") + elif checks < MANIFEST.get(gid, 0): + problems.append(f"only {checks} checks, manifest expects >= {MANIFEST[gid]}") + if p.returncode and not problems: + problems.append(f"exit code {p.returncode}") + if profile and not (logdir / f"perf-{gid}.json").exists(): + problems.append("no performance report written") + return gid, checks, time.time() - t0, problems, p + + +def main(): + a = sys.argv[1:] + found = discovered() + if "--list" in a: + print("\n".join(found)) + return 0 + pick = [a[i + 1] for i, x in enumerate(a) if x == "--group"] + errors = [] + if not pick: # the full run must cover exactly the manifest + errors += [f"expected group missing from test-jobs.py: {g}" for g in MANIFEST if g not in found] + errors += [f"group not in the manifest (add it with its minimum check count): {g}" for g in found if g not in MANIFEST] + unknown = [g for g in pick if g not in found] + errors += [f"unknown group: {g}" for g in unknown] + chosen = pick or [g for g in MANIFEST if g in found] + jobs = int(a[a.index("--jobs") + 1]) if "--jobs" in a else int(os.environ.get("CF_V2_TEST_JOBS", min(4, os.cpu_count() or 2))) + logdir = pathlib.Path(a[a.index("--logdir") + 1]) if "--logdir" in a else pathlib.Path(tempfile.mkdtemp(prefix="jobgroups-")) + logdir.mkdir(parents=True, exist_ok=True) + profile = bool(os.environ.get("JOBRUNNER_PERF")) + for e in errors: + print(f"FAIL {e}") + t0 = time.time() + total, bad = 0, bool(errors) + with cf.ThreadPoolExecutor(jobs) as ex: + for gid, checks, dt, problems, p in ex.map(lambda g: run_group(g, logdir, profile), [g for g in chosen if g in found]): + total += checks + print(f"{'FAIL' if problems else 'ok '} {gid}: {checks} checks, {dt:.0f}s" + (f" - {'; '.join(problems)}" if problems else "")) + if problems: + bad = True + print("\n".join(l for l in p.stdout.splitlines() if l.startswith("FAIL")) or p.stderr[-800:]) + print(f"{'FAILED' if bad else 'PASSED'}: {len(chosen)} groups, {total} checks, {time.time() - t0:.0f}s wall (parallelism {jobs}), logs in {logdir}") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cf-preview/shellcheck-actions.py b/scripts/cf-preview/shellcheck-actions.py index 0966511..ae36a4b 100755 --- a/scripts/cf-preview/shellcheck-actions.py +++ b/scripts/cf-preview/shellcheck-actions.py @@ -9,7 +9,7 @@ import yaml rc = 0 -for name in ("cf-deploy", "cf-ci-check"): +for name in ("cf-deploy", "cf-ci-check", "cf-tools"): steps = yaml.safe_load(open(f"actions/{name}/action.yml"))["runs"]["steps"] for s in steps: if "run" not in s: diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index 9b878e3..b8b7bf2 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -1,12 +1,19 @@ #!/usr/bin/env python3 -"""Executes the REAL steps of the Migration, Deploy, Smoke Tests, Rollback, Shared Dev and persistent-Dev jobs — -including the composite delivery engine (actions/cf-deploy -> actions/cf-preview/engine.sh) — against stub -gh/pnpm/curl, and feeds the results of each job into the next job's `if:` and `needs.*.outputs`. EVERY job runs in a -FRESH workspace (a different runner): anything the chain needs must travel through job outputs, never through local -files. Covers what the static contract test cannot: Migration runs only after build + audit and before Deploy, stale -runs never reach Smoke/Release, a failed migration or smoke never publishes/verifies, the Rollback job is conditional -and fail-closed, application env cannot redirect the target, nothing remote is touched before the guards pass. -Mocked: no Cloudflare, no GitHub. See docs/cf-worker-v2-refactor.md for what this does NOT prove.""" +"""Scenario groups (explicit `group_*` functions registered in GROUPS): `test-jobs.py [--list] [--group ID]...`; no flag runs all. +run-job-groups.py runs each group in its own process, in parallel, against the manifest of expected groups. + +Executes the REAL steps of the ONE Database Migration job (pre-mutation validation of every enabled target, then the single +migration), the per-kind Delivery calls (the internal reusable workflow _cf-worker-delivery.yml: deploy -> smoke -> rollback), the +Delivery plan, and the persistent-Dev job - including the +composite delivery engine (actions/cf-deploy -> actions/cf-preview/engine.sh) - against stub gh/pnpm/curl, and feeds each job's +result into the next job's `if:` and `needs.*.outputs`. EVERY job runs in a FRESH workspace (a different runner): anything the +chain needs must travel through job outputs, never through local files. A reusable-workflow CALL is emulated like GitHub does: +its `with:` / `secrets:` are rendered against the caller, the called jobs run with `inputs` = those values, and the called +workflow's `outputs` are rendered from its jobs. Covers what the static contract test cannot: the Migration job (validation, then ONE +migration) before Deploy, per-kind independence, URL pre-resolution, stale runs never reach Smoke/Release, a failed migration or smoke never +publishes/verifies, the Rollback job is conditional and fail-closed, application env cannot redirect the target, nothing remote is +touched before the guards pass. Mocked: no Cloudflare, no GitHub. See docs/cf-worker-v2-unified-delivery.md for what this does +NOT prove (the real-runner evidence is recorded there).""" import json import os import sys @@ -14,31 +21,43 @@ import yaml sys.path.insert(0, "scripts/cf-preview") -from ghexpr import Ctx, ev, strip_expr # noqa: E402 -from jobrunner import APP_TOML, make_workspace, run_job # noqa: E402 +from ghexpr import Ctx, ev, render # noqa: E402 +from jobrunner import APP_TOML, make_workspace, run_job, write_perf_report # noqa: E402 WF = ".github/workflows/_cf-worker-v2.yml" +DWF = ".github/workflows/_cf-worker-delivery.yml" doc = yaml.safe_load(open(WF)) +ddoc = yaml.safe_load(open(DWF)) +cdoc = yaml.safe_load(open(".github/workflows/_cf-worker-ci.yml")) +ldoc = yaml.safe_load(open(".github/workflows/_cf-worker-lifecycle.yml")) +pdoc = yaml.safe_load(open(".github/workflows/_cf-worker-persistent-dev.yml")) INPUT_DEFAULTS = {k: v.get("default", "") for k, v in doc[True]["workflow_call"]["inputs"].items()} +DINPUT_DEFAULTS = {k: v.get("default", "") for k, v in ddoc[True]["workflow_call"]["inputs"].items()} SHA, OTHER = "abcdef1234567890", "ffffffffffffffff" GOOD = "1111111222222233" MAIN, FEAT = "refs/heads/main", "refs/heads/feat/x" fail = 0 +SUFFIX = "-app.example.workers.dev" # test double of the Cloudflare-reported preview_url_suffix (the wrangler stub prints ) + + +checks = 0 def check(ok, msg): - global fail + global fail, checks + checks += 1 print(("PASS: " if ok else "FAIL: ") + msg) if not ok: fail = 1 def ctx(ref, inputs=None, secrets=None, needs=None, event="push"): - # preview_host_check off: the job tests must not call the real Cloudflare API (test-deploy.sh covers it). - i = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", app_url_prod="https://app.example.com", tools_ref="x", preview_host_check=False) + # preview_host_check off: the job tests must not call the real Cloudflare API (test-deploy.sh covers it); the canonical + # 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], - "repository": "o/r", "sha": SHA, "event": {"head_commit": {"message": "feat: x\nbody"}}}, + "repository": "o/r", "sha": SHA, "run_id": "1", "event": {"head_commit": {"message": "feat: x\nbody"}}}, "inputs": i, "needs": needs or {}}, secrets or {}) @@ -46,49 +65,209 @@ def ctx(ref, inputs=None, secrets=None, needs=None, event="push"): PREV_SECRETS = PROD_SECRETS # Preview uses the account credentials too (the dedicated Preview pair was removed) D1 = {"d1_migrations": True} +KINDS = ("production", "native-branch", "native-dev", "persistent-dev") +KEYED = {k: k.replace("-", "_") for k in KINDS} +TARGET_OF = {"production": "prod", "native-branch": "preview", "native-dev": "preview", "persistent-dev": "dev"} +_ENV_OF = {"production": "prod", "native-branch": "preview", "native-dev": "native-dev", "persistent-dev": "dev"} -CHAIN = ("migration", "deploy", "smoke", "rollback") +def desc(kind, d1="", owner="", app="app", scope="", phase="all", **over): + """Target descriptor a stage receives. Hand-built here (fixtures); the engine re-validates it with the REAL delivery.mjs at init.""" + g = f"cfv2-dev-o/r-{scope or app}" if kind == "persistent-dev" else f"cfv2-leg-1-{kind}" + 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") +SKIPPED = {"result": "skipped", "outputs": {}} -def run_chain(ref=MAIN, inputs=None, secrets=None, env=None, toml=APP_TOML, env_by_job=None, event="push", needs_over=None, files=None): - """Runs Migration -> Deploy -> Smoke -> Rollback like the scheduler would: each job's `if:` is evaluated against the - real results/outputs of its `needs`, and each executed job gets a FRESH workspace (no shared runner state).""" - secrets = secrets if secrets is not None else (PROD_SECRETS) - i = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", app_url_prod="https://app.example.com", tools_ref="x", preview_host_check=False) - i.update(inputs or {}) - needs = {j: {"result": ("success" if i.get(c, "") else "skipped"), "outputs": {}} for j, c in - (("lint", "lint_cmd"), ("typecheck", "typecheck_cmd"), ("unit", "test_unit_cmd"), ("integration", "test_integration_cmd"), ("e2e", "test_e2e_cmd"))} - needs["build"] = {"result": "success", "outputs": {}} - needs.update({k: {"result": v, "outputs": {}} for k, v in (needs_over or {}).items()}) - res, logs, summ, steps, wss = {}, [], [], [], {} - for name in CHAIN: - c = ctx(ref, inputs, secrets, dict(needs, **res), event) - if not ev(doc["jobs"][name]["if"], c): + +def _j(text): + return json.loads(text) if text else {} + + +def _jobenv(name, kind, env, env_by_job): + senv = {"STUB_TIPS": SHA} + senv.update(env or {}) + ebj = env_by_job or {} + for key in (name, f"{name}:{kind}"): + senv.update(ebj.get(key, {})) + # STUB_PROBES is a sequence ("first;second;..."): Smoke consumes the first answer, the Rollback job (another runner, fresh + # counter) the rest - or the last answer again when there is none. + if ";" in (env or {}).get("STUB_PROBES", "") and not any("STUB_PROBES" in ebj.get(k, {}) for k in (name, f"{name}:{kind}")): + first, rest = env["STUB_PROBES"].split(";", 1) + senv["STUB_PROBES"] = first if name == "smoke" else rest + if name == "rollback": + senv.setdefault("STUB_ACTIVE", "new-2") # the live Production version after the Deploy job published new-2 + return senv + + +def call_workflow(job, c, ctl): + """Emulates `uses: ./.github/workflows/_cf-worker-delivery.yml` of one parent job. Returns the call record + {result, outputs, jobs: {inner: {result, outputs}}} (outputs = the called workflow's rendered `outputs`).""" + w = {k: render(v, c) for k, v in job["with"].items()} + dinputs = dict(DINPUT_DEFAULTS) + dinputs.update(w) + 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) + inner, logs, summ, steps = {}, [], [], [] + wss = ctl["wss"] + for name in INNER: + j = ddoc["jobs"][name] + dc["needs"] = {n: inner.get(n, SKIPPED) for n in (j.get("needs") or [])} + if not ev(j["if"], dc): + inner[name] = SKIPPED + continue + ws = make_workspace(ctl["toml"], ctl["files"]) + r = run_job(ddoc, name, dc, ws, _jobenv(name, kind, ctl["env"], ctl["env_by_job"]), + verbose=bool(os.environ.get("CHAIN_VERBOSE") and name in os.environ["CHAIN_VERBOSE"].split(","))) + inner[name] = {"result": r["result"], "outputs": r["outputs"]} + logs.append(r["log"]); summ.append(r["summary"]); steps.extend(r["steps"]) + wss[name] = ws + wss[f"{name}:{kind}"] = ws + dc["jobs"] = inner + outs = {k: render(v["value"], dc) for k, v in ddoc[True]["workflow_call"]["outputs"].items()} + ran = [v["result"] for v in inner.values() if v["result"] != "skipped"] + result = "failure" if "failure" in ran else ("success" if ran else "skipped") + return {"result": result, "outputs": outs, "jobs": inner}, logs, summ, steps + + +CHAIN = ("ci", "plan", "migration") + tuple(f"delivery_{KEYED[k]}" for k in KINDS) + + +def called(wf_doc, parent_name, inner_job, c, ws, env=None, needs=None): + """Runs ONE job of the workflow that `jobs.` of the public workflow calls (`with:` / `secrets:` rendered against the + caller like GitHub does) in a fresh workspace and returns the job record.""" + pj = doc["jobs"][parent_name] + defaults = {k: v.get("default", "") for k, v in wf_doc[True]["workflow_call"]["inputs"].items()} + w = {k: render(v, c) for k, v in pj.get("with", {}).items()} + sec = {k: render(v, c) for k, v in pj.get("secrets", {}).items()} + ic = Ctx({"github": c["github"], "inputs": dict(defaults, **w), "needs": needs or {}, "jobs": {}}, sec) + return run_job(wf_doc, inner_job, ic, ws, env or {}) + + +VALIDATION_JOBS = ("lint", "typecheck", "unit", "integration", "build", "e2e") + + +def call_ci(c, ctl): + """Emulates `uses: $/.github/workflows/_cf-worker-ci.yml`: the six validation jobs are faked by their `needs` results (their bodies are + the cf-ci-check action, covered by the contract test), the Validation Gate runs for REAL, and the call's result is GitHub's: failure + when any inner job failed, else success.""" + vneeds = {j: c["needs"][j] for j in VALIDATION_JOBS if j in c["needs"]} + ws = make_workspace(ctl["toml"], ctl["files"]) + g = called(cdoc, "ci", "gate", c, ws, needs=vneeds) + bad = [v["result"] for v in vneeds.values() if v["result"] in ("failure", "cancelled")] + ([g["result"]] if g["result"] != "success" else []) + result = "failure" if "failure" in bad or g["result"] == "failure" else ("cancelled" if bad else "success") + outs = {k: render(v["value"], Ctx({"jobs": {"gate": {"outputs": g["outputs"]}}}, {})) for k, v in cdoc[True]["workflow_call"]["outputs"].items()} + return {"result": result, "outputs": outs, "jobs": {"gate": {"result": g["result"], "outputs": g["outputs"]}}}, [g["log"]], [g["summary"]], list(g["steps"]) + + +def _run_jobs(names, ref, inputs, secrets, base_needs, event, env, toml, env_by_job, files, order=None): + """Executes the named parent jobs in order. Plain jobs run in a fresh workspace; `uses:` jobs are emulated reusable-workflow + calls. Returns (res, logs, summaries, steps, workspaces); res[name] = {result, outputs, jobs?}.""" + res, logs, summ, steps = {}, [], [], [] + ctl = {"wss": {}, "toml": toml, "files": files, "env": env, "env_by_job": env_by_job} + for name in names: + job = doc["jobs"][name] + c = ctx(ref, inputs, secrets, dict(base_needs, **res), event) + if not ev(job["if"], c): res[name] = {"result": "skipped", "outputs": {}} continue + if name == "ci": + rec, lg, sm, st = call_ci(c, ctl) + res[name] = rec + logs.extend(lg); summ.extend(sm); steps.extend(st) + continue + if "uses" in job: + rec, lg, sm, st = call_workflow(job, c, ctl) + res[name] = rec + logs.extend(lg); summ.extend(sm); steps.extend(st) + continue ws = make_workspace(toml, files) senv = {"STUB_TIPS": SHA} senv.update(env or {}) senv.update((env_by_job or {}).get(name, {})) - # STUB_PROBES is a sequence ("first;second;..."): Smoke Tests consume the first answer, the Rollback job (another - # runner, fresh counter) the rest - or the last answer again when there is none. - if ";" in (env or {}).get("STUB_PROBES", "") and "STUB_PROBES" not in (env_by_job or {}).get(name, {}): - first, rest = env["STUB_PROBES"].split(";", 1) - senv["STUB_PROBES"] = first if name == "smoke" else rest - if name == "rollback": - senv.setdefault("STUB_ACTIVE", "new-2") # the live Production version after the Deploy job published new-2 r = run_job(doc, name, c, ws, senv, verbose=bool(os.environ.get("CHAIN_VERBOSE") and name in os.environ["CHAIN_VERBOSE"].split(","))) res[name] = {"result": r["result"], "outputs": r["outputs"]} - logs.append(r["log"]); summ.append(r["summary"]); steps.extend(r["steps"]); wss[name] = ws - dep, sm, mg = res["deploy"]["outputs"], res["smoke"]["outputs"], res["migration"]["outputs"] + logs.append(r["log"]); summ.append(r["summary"]); steps.extend(r["steps"]) + ctl["wss"][name] = ws + return res, logs, summ, steps, ctl["wss"] + + +def _validation_needs(i, needs_over): + needs = {j: {"result": ("success" if i.get(c, "") else "skipped"), "outputs": {}} for j, c in + (("lint", "lint_cmd"), ("typecheck", "typecheck_cmd"), ("unit", "test_unit_cmd"), ("integration", "test_integration_cmd"), ("e2e", "test_e2e_cmd"))} + needs["build"] = {"result": "success", "outputs": {}} + needs.update({k: {"result": v, "outputs": {}} for k, v in (needs_over or {}).items()}) + return needs + + +def _inner(res, kind, job): + rec = res.get(f"delivery_{KEYED[kind]}") + return (rec or {}).get("jobs", {}).get(job, SKIPPED) + + +def _compat(res): + """The legacy single-delivery view the scenarios assert on, plus the keyed view of every kind. Built from the per-kind call records + (each kind's own record - never a merge of shared keys).""" + deliv = {k: res.get(f"delivery_{KEYED[k]}") for k in KINDS} + ran = [k for k in KINDS if deliv[k] and deliv[k]["result"] != "skipped"] + primary = ran[0] if ran else "" + raw = {"deploy": {"outputs": {}}, "smoke": {"outputs": {}}, "rollback": {"outputs": {}}, "migration": {"outputs": {}}} + for k in KINDS: + d = deliv[k] + if not d: + continue + kk = KEYED[k] + o = d["outputs"] + raw["deploy"]["outputs"][f"h_{kk}"] = o.get("handoff", "") + raw["smoke"]["outputs"][f"s_{kk}"] = o.get("smoke_json", "") if o.get("smoke_status", "") != "" else "" + raw["smoke"]["outputs"][f"x_{kk}"] = o.get("smoke_status", "") + raw["rollback"]["outputs"][f"rb_{kk}"] = o.get("rollback_result", "") + dj = d.get("jobs", {}).get("deploy", SKIPPED) + raw["migration"]["outputs"][f"mj_{kk}"] = (dj["outputs"].get("migration_used") or "") if dj["result"] != "skipped" else "" + raw["plan"] = res.get("plan", SKIPPED) + raw["gate"] = res.get("ci", SKIPPED) + mig = res.get("migration", SKIPPED) + mres = [] if mig["result"] == "skipped" else [mig["result"]] + mig_json = _j(mig["outputs"].get("migration_json", "")) + p = primary + dep = _inner(res, p, "deploy") if p else SKIPPED + smk = _inner(res, p, "smoke") if p else SKIPPED + rbk = _inner(res, p, "rollback") if p else SKIPPED + handoff = _j(deliv[p]["outputs"].get("handoff", "")) if p else {} + smoke_o = _j(deliv[p]["outputs"].get("smoke_json", "")) if p else {} + none_smoke = bool(p) and deliv[p]["outputs"].get("smoke_status", "") == "none" + legacy = { + "gate": res.get("ci", SKIPPED), "plan": res.get("plan", SKIPPED), + "migration": {"result": "failure" if "failure" in mres else ("success" if mres else "skipped"), "outputs": mig_json}, + "deploy": {"result": dep["result"], "outputs": dict(handoff, target=TARGET_OF.get(p, ""))}, + "smoke": {"result": "skipped" if (none_smoke or smk["result"] == "skipped") else smk["result"], "outputs": smoke_o}, + "rollback": {"result": rbk["result"], "outputs": {"rollback_result": deliv[p]["outputs"].get("rollback_result", "") if p else ""}}, + } + return legacy, raw, primary + + +def _shape(res, logs, summ, steps, wss, toml): + legacy, raw, primary = _compat(res) + dep, sm, mg = legacy["deploy"]["outputs"], legacy["smoke"]["outputs"], legacy["migration"]["outputs"] out = {"url": dep.get("url", ""), "published_url": dep.get("url", ""), "target": dep.get("target", ""), "version_id": dep.get("version_id", ""), "deployed_and_verified": sm.get("deployed_and_verified") or "false", "smoke_level": sm.get("smoke_level", ""), "verified_url": sm.get("verified_url", ""), "published_unverified": sm.get("published_unverified") or "false", "migration": mg.get("migration", ""), - "rollback_result": res["rollback"]["outputs"].get("rollback_result", "")} + "rollback_result": legacy["rollback"]["outputs"].get("rollback_result", "")} failed = any(v["result"] == "failure" for v in res.values()) - last = next(w for n, w in reversed(list(wss.items())) if n in ("deploy", "migration")) if ("deploy" in wss or "migration" in wss) else make_workspace(toml) + anyws = next((w for n, w in wss.items() if n.split(":")[0] in ("deploy", "migration")), None) or make_workspace(toml) return {"result": "failure" if failed else "success", "outputs": out, "steps": steps, "log": "".join(logs), "summary": "".join(summ), - "ws": wss.get("deploy", last), "wss": wss, "jobs": res, "needs": res} + "ws": wss.get("deploy", anyws), "wss": wss, "jobs": legacy, "needs": res, "raw": raw, "res": res, "primary": primary} + + +def run_chain(ref=MAIN, inputs=None, secrets=None, env=None, toml=APP_TOML, env_by_job=None, event="push", needs_over=None, files=None, chain_only=None): + """Gate -> Plan -> Migration (validate every target, migrate once) -> Delivery (Deploy -> Smoke -> Rollback) like the scheduler would: each job's `if:` is + evaluated against the real results/outputs of its `needs`; every kind is its OWN reusable-workflow call.""" + secrets = secrets if secrets is not None else (PROD_SECRETS) + i = dict(INPUT_DEFAULTS, app_name="app", build_cmd="true", app_url_prod="https://app.example.com", preview_host_check=False, preview_url_suffix=SUFFIX) + i.update(inputs or {}) + return _shape(*_run_jobs(CHAIN, ref, inputs, secrets, _validation_needs(i, needs_over), event, env, toml, env_by_job, files), toml) deploy = run_chain @@ -105,7 +284,7 @@ def published(r): def step(r, name): """Outcome of an engine step across the whole chain: the job where it actually ran wins over 'skipped' elsewhere.""" - seen = [o for n, o in r["steps"] if n == f"engine / {name}"] + seen = [o for n, o in r["steps"] if n == f"engine / {name}" or n.endswith(f") / {name}") or n.endswith(f"binding / {name}") or n.endswith(f"(once) / {name}")] for pref in ("failure", "success"): if pref in seen: return pref @@ -122,97 +301,8 @@ def mig_file(r, target): if f.exists(): return f.read_text() raise FileNotFoundError(target) - - -# ======================================================================= Deploy: Production + Release gate -r = deploy() -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and "versions deploy new-2@100" in r["log"], "prod success: published own SHA, smoke passed -> deployed_and_verified=true") -check(r["outputs"]["target"] == "prod" and r["outputs"]["version_id"] == "new-2" and r["outputs"]["url"] == "https://app.example.com", "prod success: outputs target + version + url") -check(release_runs(r), "prod success -> Release runs") - -r = deploy(env={"STUB_TIPS": OTHER}) -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "false" and not published(r), "stale main run: the job succeeds, nothing built or published, not verified") -check("pnpm" not in r["log"] and "BUILD" not in r["log"] and r["log"].count("npm ci") == 1, "stale main run: only the local tooling install (needed by the engine itself) ran; no application install or build") -check(not release_runs(r), "REGRESSION stale main run -> Release does NOT run although Deploy succeeded") - -r = deploy(env={"STUB_TIPS": f"{SHA},{OTHER}"}) -check(r["result"] == "success" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "superseded after the build (before migration): publish skipped, not verified") -check(not release_runs(r), "skipped publish -> Release does NOT run") - -r = deploy(env={"STUB_TIPS": "GONE"}) -check(r["result"] == "failure" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "branch tip unresolvable (deleted / API error): fails closed, nothing published") - -r = deploy(env={"STUB_PROBES": "500 "}) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and "versions deploy new-2@100" in r["log"], "prod smoke failure (policy manual): job red, not verified") -check("versions deploy prev-1@100" not in r["log"] and "Production smoke FAILED" in r["summary"], "policy manual: no automatic rollback, manual instructions in the summary") -check(not release_runs(r), "prod smoke failure -> Release does NOT run") - -r = deploy(inputs={"production_rollback_policy": "automatic"}, env={"STUB_PROBES": "500 ;200 "}) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "prod smoke failure + rollback: job stays red, not verified (smoke failure stays visible although recovery succeeded)") -rb = [l for l in r["log"].splitlines() if "versions deploy prev-1@100" in l] -up = [l for l in r["log"].splitlines() if "versions upload" in l] cfg = lambda line: line.split("--config ")[1].split()[0] -rbcfg = json.load(open(cfg(rb[0]))) if rb and "--config" in rb[0] and os.path.exists(cfg(rb[0])) else None -check(len(rb) == 1 and "--config" in rb[0] and cfg(rb[0]) != cfg(up[0]), "the Rollback job uses its own minimal config (it never rebuilds), not the Deploy job's audited config file (another runner)") -check(r["jobs"]["deploy"]["outputs"].get("worker_name") == "app" and "--config" in rb[0], "the rollback identity (Worker name `app`) travelled from the Deploy job as a job output") -check("NOT reverted" in r["summary"] and "rolled back to `prev-1` and verified" in r["summary"], "rollback summary states D1 was not reverted") -check(r["jobs"]["rollback"]["result"] == "success" and r["outputs"]["rollback_result"] == "restored_verified" and r["jobs"]["smoke"]["result"] == "failure", "rollback restored + verified: the Rollback job is green but the SMOKE job (and so the run) stays red") -check(r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["verified_url"] == "", "a rolled-back deployment is never reported as verified") -check(not release_runs(r), "prod rollback -> Release does NOT run") -r = deploy(inputs={"production_rollback_policy": "automatic"}, env={"STUB_PROBES": "500 ;200 ", "STUB_ROLLBACK_FAIL": "1"}) -check(r["result"] == "failure" and r["jobs"]["rollback"]["result"] == "failure" and "rollback to `prev-1` FAILED" in r["summary"] and "rolled back to" not in r["summary"] and "wrangler versions deploy prev-1@100 --name app --yes" in r["summary"], - "a FAILED automatic rollback: Rollback job red, reported as failed (never as rolled back), manual recovery command printed") -check(r["outputs"]["rollback_result"] == "failed" and not release_runs(r), "a failed rollback blocks Release and exposes rollback_result=failed") - -r = deploy(inputs={"app_url_prod": ""}) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and step(r, "Smoke the exact deployed URL (fails closed)") == "failure", "prod without a smoke URL FAILS at the smoke step (no silent success)") -check(not release_runs(r), "prod without smoke URL -> Release does NOT run") - -r = deploy(env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "prod redirect to Cloudflare Access without approval FAILS (not application verification)") -r = deploy(inputs={"access_protected_targets": "preview prod"}, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) ACCESS = "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x" -check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false", "approved Access-protected prod: LIVENESS only -> published but NOT verified") -check(r["outputs"]["published_unverified"] == "true" and r["outputs"]["verified_url"] == "" and r["outputs"]["published_url"] == "https://app.example.com" and r["outputs"]["url"] == "https://app.example.com", "liveness: published_url set (and legacy url kept), verified_url empty, published_unverified=true") -check(not release_runs(r), "prod Access liveness only -> Release BLOCKED") -check("Release is **blocked**" in r["summary"] and "NOT VERIFIED" in r["summary"], "liveness-only Production keeps a diagnostic summary") -r = deploy(env={"STUB_PROBES": "200 "}) -check(r["outputs"]["smoke_level"] == "content" and r["outputs"]["verified_url"] == "https://app.example.com" and r["outputs"]["published_unverified"] == "false" and release_runs(r), "prod content smoke succeeds -> verified_url set, Release allowed") -r = deploy(inputs={"access_protected_targets": "preview prod", "sha_path": "/version.json", "smoke_env": "PROBE_HEADERS=CF-Access-Client-Id: x"}, env={"STUB_PROBES": "200 ", "STUB_SHA_BODY": SHA[:7]}) -check(r["outputs"]["smoke_level"] == "content-sha" and r["outputs"]["deployed_and_verified"] == "true" and release_runs(r), "prod authenticated probe on an Access-protected target + matching SHA -> content-sha, Release allowed") -r = deploy(inputs={"access_protected_targets": "preview prod"}, env={"STUB_PROBES": "500 "}) -check(r["outputs"]["verified_url"] == "" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["published_unverified"] != "true", "failed smoke -> no verified output") -for code in ("401 ", "403 ", "404 ", "500 ", "302 https://evil.example/x", "000 "): - r = deploy(env={"STUB_PROBES": code}) - check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", f"prod probe '{code.strip()}' fails and is not verified") -r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_BODY": "ffffff"}) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "SHA endpoint serving a different commit fails") -r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_FAIL": "1"}) -check(r["result"] == "failure", "configured SHA endpoint missing (404) fails instead of being ignored") -r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_BODY": '{"sha":"abcdef1"}'}) -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true", "SHA endpoint serving this commit passes") - -r = deploy(inputs={"build_cmd": "false"}) -check(r["result"] == "failure" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "failed build never publishes") -r = deploy(secrets={}) -check(r["result"] == "failure" and not published(r) and "pnpm" not in r["log"], "missing Production credentials fail closed at init (no fallback), before any work") -r = deploy(secrets={"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"}) -check(r["result"] == "failure" and not published(r), "main with ONLY the deprecated Preview credentials fails (they are ignored)") - -# ======================================================================= Deploy: Preview -r = deploy(FEAT) -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and "preview --name feat-x" in r["log"] and "--json" in r["log"], "preview success: stable branch-slug Native Preview published and verified") -check(r["outputs"]["url"] == "https://stable.example.workers.dev" and r["outputs"]["target"] == "preview", "preview outputs the real URL") -check("-X POST" in r["log"] and "state=success" in r["log"], "preview records a ledger deployment and marks it success only after the smoke") -r2 = deploy(FEAT, env={"STUB_OWNER_REF": "refs/heads/Feat/X"}) -check(r2["result"] == "failure" and not published(r2) and "pnpm exec wrangler preview" not in r2["log"], "branch-name collision (ledger owned by another ref): fails before the build and publish") -r2 = deploy(FEAT, env={"STUB_OWNER_REF": FEAT}) -check(r2["result"] == "success", "ledger owned by THIS branch ref: same branch updates its Preview") -r = deploy(FEAT, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) -check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["published_unverified"] == "true", "Access-protected Preview (default approval): liveness reported, published but NOT verified") -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") def good_dep(**over): @@ -224,134 +314,19 @@ def good_dep(**over): PREV_IN = {"build_cmd": 'echo "BUILD noindex=$SITE_NOINDEX commit=$PUBLIC_COMMIT_SHA ver=$VITE_APP_VERSION gaid=$GA_ID" >> "$STUB_LOG"', "app_version": SHA, "build_env_preview": f"SITE_NOINDEX=true\nGA_ID=test-ga\nPUBLIC_COMMIT_SHA={SHA}", "smoke_env": "SITE_NOINDEX=false\nGA_ID=PROD-GA"} # the Blog incident: Production-oriented smoke_env -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") -bl = [l for l in r["log"].splitlines() if l.startswith("BUILD")] -check(len(bl) == 2 and bl[0] == f"BUILD noindex=true commit={SHA} ver={SHA} gaid=test-ga", "the normal Preview build uses the Preview build env (smoke_env cannot leak into it)") -check(len(bl) == 2 and bl[1] == f"BUILD noindex=true commit={GOOD} ver={GOOD} gaid=test-ga", "recovery rebuild restores the Preview build env and rebinds commit/version to the LAST-GOOD sha (not the smoke_env values)") -check("restored to last good commit" in r["summary"] and "content-level" in r["summary"] and "NOT reverted" in r["summary"], "recovery verified at content level is reported as such, D1 not reverted") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(payload={"host_worker": "blog-preview"})}) -check(r["result"] == "failure" and r["log"].count("preview --name feat-x") == 1 and "No prior verified preview deployment on host Worker" in r["summary"], "a last-good deployment hosted by a DIFFERENT Worker is not eligible for recovery") -for label, over in (("legacy record without host_worker", {"payload": {"host_worker": None}}), ("different branch", {"payload": {"branch_ref": "refs/heads/other"}}), - ("different application", {"payload": {"worker": "other-app"}}), ("different repository", {"payload": {"repo": "x/y"}})): - dep = json.loads(good_dep(**over)) - if label.startswith("legacy"): - del dep[0]["payload"]["host_worker"] - r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": json.dumps(dep)}) - check(r["log"].count("preview --name feat-x") == 1 and "No prior verified" in r["summary"], f"recovery ineligible: {label}") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "failure https://stable.example.workers.dev"}) -check(r["log"].count("preview --name feat-x") == 1, "recovery ineligible: the candidate's latest status is not success") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "success "}) -check(r["log"].count("preview --name feat-x") == 1, "recovery ineligible: no valid recorded Preview URL") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "success https://other.example.workers.dev Serving good (content)"}) -check(r["result"] == "failure" and "restored to last good" not in r["summary"] and "Automatic recovery FAILED" in r["summary"], "recovery whose URL differs from the recorded last-good URL is refused and reported as failed") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;500 ", "STUB_DEPLOYMENTS": good_dep()}) -check(r["result"] == "failure" and "Automatic recovery FAILED" in r["summary"] and "restored to last good" not in r["summary"] and "state=error" in r["log"], "recovery smoke failure stays visible, is recorded as error and is never reported as recovered") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x", "STUB_DEPLOYMENTS": good_dep()}) -check(r["result"] == "failure" and "NOT verified" in r["summary"] and "restored to last good" not in r["summary"], "Access liveness after recovery is NOT reported as verified recovery") -r = deploy(FEAT, inputs=dict(PREV_IN, sha_path="/version.json"), env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_SHA_BODY": SHA[:7]}) -check(r["result"] == "failure" and "restored to last good" not in r["summary"] and "Automatic recovery FAILED" in r["summary"], "recovery serving the FAILED commit's identity (sha_path) is not accepted") -r = deploy(FEAT, inputs=dict(PREV_IN, sha_path="/version.json"), env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_SHA_BODY": GOOD[:7]}) -check("restored to last good commit" in r["summary"], "recovery serving the last-good commit identity is verified") -# a newer push superseded this run while the smoke was failing: recovery must not replace the newer deployment -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep()}, env_by_job={"rollback": {"STUB_TIPS": OTHER}}) -check(r["result"] == "failure" and r["log"].count("preview --name feat-x") == 1 and "superseded" in r["summary"], "recovery is skipped when a newer push owns the branch (it never replaces a newer deployment)") -# the empty-URL incident, executed through the real engine -for raw, label in (('{"preview":{"urls":[]}}', "empty preview.urls"), ('{"preview":{}}', "missing urls"), ("garbage", "malformed JSON"), ("", "no output")): - r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PREVIEW_RAW": raw, "STUB_DEPLOYMENTS": good_dep()}) - check(r["result"] == "failure" and step(r, "Publish the audited artifact") == "failure", f"{label}: Publish fails") - check(step(r, "Smoke the exact deployed URL (fails closed)") == "skipped" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["url"] == "", f"{label}: Smoke never runs against an empty URL and nothing is reported as deployed") - check(r["log"].count("preview --name feat-x") == 1 and "no usable URL" in r["summary"] and "unverified" in r["summary"], f"{label}: no recovery is attempted and the summary says the Preview state is unverified") -r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PREVIEW_FAIL": "1", "STUB_DEPLOYMENTS": good_dep()}) -check(r["result"] == "failure" and "unchanged" in r["summary"] and r["log"].count("preview --name feat-x") == 1, "wrangler preview failure before any mutation: 'unchanged', no recovery") -r = deploy(FEAT, inputs=dict(PREV_IN, build_cmd="false"), env={"STUB_DEPLOYMENTS": good_dep()}) -check(r["result"] == "failure" and "preview --name" not in r["log"] and "unchanged" in r["summary"], "build failure: nothing published, no recovery") -r = deploy(FEAT, secrets=dict(PREV_SECRETS, PREVIEW_SECRETS="EVIL=$(touch PWNED)`touch PWNED2`;touch PWNED3 \"q\" 'x'\nPLAIN=a b=c"), - inputs={"build_cmd": 'printf %s "$EVIL" > evil.out && printf %s "$PLAIN" > plain.out'}) -w = r["ws"] / "w" -check(r["result"] == "success" and not any((w / n).exists() for n in ("PWNED", "PWNED2", "PWNED3")), "Preview secret with $() / backticks / ; is NOT executed") -check((w / "evil.out").read_text() == "$(touch PWNED)`touch PWNED2`;touch PWNED3 \"q\" 'x'" and (w / "plain.out").read_text() == "a b=c", "Preview secret values reach the build process literally") -check(not (r["ws"] / "tmp" / "cfv2").exists(), "staged Preview secrets and engine state are removed at the end of the job") -r = deploy(FEAT, secrets=dict(PREV_SECRETS, PREVIEW_SECRETS="CLOUDFLARE_API_TOKEN=evil")) -check(r["result"] == "failure" and not published(r), "Preview secrets cannot override Cloudflare credentials") -r = deploy(FEAT, secrets=PROD_SECRETS) -check(r["result"] == "success" and published(r), "a feature branch publishes with the account credentials") -r = deploy(MAIN, secrets=dict(PROD_SECRETS, PREVIEW_SECRETS="K=v"), inputs={"build_cmd": 'printf %s "${K:-unset}" > k.out'}) -check(r["result"] == "success" and (r["ws"] / "w" / "k.out").read_text() == "unset", "Production build never receives Preview-only secrets") - -# ======================================================================= Migration is a STEP of Deploy, after build + audit + MB = 'echo BUILD >> "$STUB_LOG"' -m = deploy(MAIN, inputs=dict(D1, build_cmd=MB)) -check(m["result"] == "success" and "d1 migrations apply DB --remote" in m["log"] and "PROD-ID" in mig_file(m, "prod"), "prod migration applies to the Production D1") -check(m["log"].index("BUILD") < m["log"].index("migrations apply") < m["log"].index("versions upload"), "order in the executed job: build -> migration -> publish") -check(step(m, "Apply pending D1 migrations (this target only)") == "success" and m["outputs"]["migration"] == "applied", "Migration is an explicit step inside the Deploy job and reports `applied`") -m = deploy(FEAT, inputs=dict(D1, build_cmd=MB)) -check(m["result"] == "success" and "PREV-ID" in mig_file(m, "preview") and "PROD-ID" not in mig_file(m, "preview") and m["log"].index("BUILD") < m["log"].index("migrations apply") < m["log"].index("preview --name"), "preview migration applies to previews.d1_databases only, after the build and before the publish") -m = deploy(FEAT, inputs=D1, env={"STUB_PENDING": "0"}) -check(m["result"] == "success" and not migrated(m) and "versions" not in m["log"] and "preview --name feat-x" in m["log"], "nothing pending: success without applying, publish continues") -m = deploy(FEAT, inputs=D1, env={"STUB_OWNER_REF": "refs/heads/Feat/X"}) -check(m["result"] == "failure" and not migrated(m) and not published(m), "name collision: second branch fails BEFORE any remote migration") -m = deploy(FEAT, inputs=D1, env={"STUB_TIPS": OTHER}) -check(m["result"] == "success" and not migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false", "stale branch: no migration applied, nothing published") -m = deploy(FEAT, inputs=D1, env={"STUB_TIPS": "GONE"}) -check(m["result"] == "failure" and not migrated(m), "deleted branch: fails closed, no migration") -# P2: a push that lands DURING the (long) migration must not let the old code be published afterwards -m = deploy(FEAT, inputs=D1, env_by_job={"deploy": {"STUB_TIPS": OTHER}}) -check(m["result"] == "success" and migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false", "branch advanced DURING the migration: re-checked right before publish, old code is NOT published") -check(step(m, "Stale-SHA guard (start)") == "success" and step(m, "Publish the audited artifact") == "skipped" and step(m, "Smoke the exact deployed URL (fails closed)") == "skipped" and m["jobs"]["smoke"]["result"] == "skipped", "post-migration stale run skips publish and the Smoke job (nothing was published, so nothing is smoked or verified)") -bad = APP_TOML.replace('database_id = "PREV-ID"', 'database_id = "PROD-ID"').replace('database_name = "prev-db"', 'database_name = "prod-db"') -m = deploy(FEAT, inputs=D1, toml=bad) -check(m["result"] == "failure" and not migrated(m) and not published(m), "Preview D1 identical to Production D1: fails before any migration (preflight / target check)") -m = deploy(MAIN, inputs=D1, toml=bad) -check(m["result"] == "failure" and not published(m), "same for the Production target: nothing published") amb = APP_TOML + '[[previews.d1_databases]]\nbinding = "DB2"\ndatabase_name = "p2"\ndatabase_id = "P2"\n' + '[[d1_databases]]\nbinding = "DB2"\ndatabase_name = "x2"\ndatabase_id = "X2"\n' -m = deploy(FEAT, inputs=D1, toml=amb) -check(m["result"] == "failure" and not migrated(m), "ambiguous D1 bindings without d1_binding: fails closed") -m = deploy(FEAT, inputs=dict(D1, d1_binding="DB2"), toml=amb) -check(m["result"] == "success" and "P2" in mig_file(m, "preview"), "d1_binding selects the intended database") -m = deploy(FEAT, inputs=D1, toml='name = "app"\n[previews]\n') -check(m["result"] == "failure" and not migrated(m), "no D1 declared for the target while d1_migrations is on: fails closed") -m = deploy(FEAT, inputs=D1, secrets={}) -check(m["result"] == "failure" and not migrated(m), "missing credentials: fails before any remote command") -m = deploy(FEAT, inputs=D1, secrets=PROD_SECRETS) -check(m["result"] == "success" and migrated(m), "branch migrates with the account credentials") -for ref, label in ((FEAT, "preview"), (MAIN, "prod")): - m = deploy(ref, inputs=D1, env={"STUB_APPLY_FAIL": "1"}) - check(m["result"] == "failure" and migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false" and m["outputs"]["migration"] == "failed", f"failed {label} migration: job fails, NOTHING is published or verified") - check(not release_runs(m), f"failed {label} migration -> Release cannot run") -m = deploy(FEAT, inputs=dict(D1, build_cmd="false")) -check(m["result"] == "failure" and not migrated(m) and not published(m), "a failed build never reaches the migration (schema is not changed before a deployable exists)") # ---- D1 the deployed config binds == D1 the migration targets (adapter-generated effective config) GEN = 'mkdir -p .wrangler/deploy dist && echo "{\\"configPath\\":\\"../../dist/wrangler.json\\"}" > .wrangler/deploy/config.json && echo \'%s\' > dist/wrangler.json' eff = lambda cfg: GEN % json.dumps(cfg) mismatch = {"name": "app", "d1_databases": [{"binding": "DB", "database_name": "other-db", "database_id": "OTHER-ID"}]} -m = deploy(MAIN, inputs=dict(D1, build_cmd=eff(mismatch))) -check(m["result"] == "failure" and not migrated(m) and not published(m), "prod: effective config binds a DIFFERENT D1 than the migration target -> fails before migration and publish") -m = deploy(MAIN, inputs=dict(D1, build_cmd=eff({"name": "app", "d1_databases": [{"binding": "DB", "database_name": "prod-db", "database_id": "PROD-ID"}]}))) -check(m["result"] == "success" and migrated(m), "prod: effective config binding the SAME D1 migrates and publishes") -m = deploy(FEAT, inputs=dict(D1, build_cmd=eff({"name": "app", "previews": {"d1_databases": [{"binding": "DB", "database_name": "x", "database_id": "WRONG"}]}}))) -check(m["result"] == "failure" and not migrated(m) and not published(m), "preview: effective config binds a different D1 -> fails before migration and publish") -m = deploy(FEAT, inputs=dict(D1, build_cmd=eff({"name": "app", "previews": {}}))) -check(m["result"] == "failure" and not migrated(m), "preview: effective config without the migrated D1 binding fails") -m = deploy(MAIN, inputs={"build_cmd": eff({"name": "app", "d1_databases": [{"binding": "DB", "database_name": "prev-db", "database_id": "PREV-ID"}]})}) -check(m["result"] == "failure" and not published(m), "prod: effective config binding a Preview resource is rejected even without migrations") # ---- P1: application environment can never redirect the delivery target / credentials / verification EVIL = [("TARGET=prod", "TARGET"), ("TARGET=dev", "TARGET"), ("DEV_ENV=prod", "DEV_ENV"), ("GH_TOKEN=x", "GH_TOKEN"), ("EXPECTED_SHA=fake", "EXPECTED_SHA"), ("D1_BINDING=OTHER", "D1_BINDING"), ("CLOUDFLARE_API_TOKEN=x", "CLOUDFLARE_API_TOKEN"), ("STATE_DIR=/tmp/x", "STATE_DIR"), ("RUN_SHA=fake", "RUN_SHA")] -for line, name in EVIL: - for inp in ("build_env_prod", "smoke_env"): - m = deploy(MAIN, inputs=dict(D1, **{inp: line})) - check(m["result"] == "failure" and not migrated(m) and not published(m), f"{inp} cannot set the protected variable {name}: rejected before any work") - m = deploy(FEAT, inputs=dict(D1, build_env_preview=line)) - check(m["result"] == "failure" and not migrated(m) and not published(m), f"build_env_preview cannot set {name}") -# a build COMMAND that writes TARGET/DEV_ENV into GITHUB_ENV still cannot redirect the migration: the engine binds the target -m = deploy(FEAT, inputs=dict(D1, build_cmd='echo TARGET=prod >> "$GITHUB_ENV"; echo DEV_ENV=prod >> "$GITHUB_ENV"; echo D1_BINDING=NOPE >> "$GITHUB_ENV"')) -check(m["result"] == "success" and "PREV-ID" in mig_file(m, "preview") and not (m["ws"] / "w" / ".wrangler" / "migrate.prod.json").exists(), "a build writing TARGET=prod into GITHUB_ENV cannot move the Preview migration to Production") - -# ======================================================================= Shared Dev (dev-deploy) executed with stubs + DEV_TOML = '''name = "app" [[d1_databases]] binding = "DB" @@ -396,196 +371,33 @@ def good_dep(**over): } -def dev(inputs=None, env=None, toml=DEV_TOML, needs=None, secrets=None, ref=FEAT): - ws = make_workspace(toml) - n = needs if needs is not None else {"migration": {"result": "skipped", "outputs": {}}} - r = run_job(doc, "dev-deploy", ctx(ref, dict(DEV_INPUTS, **(inputs or {})), PROD_SECRETS if secrets is None else secrets, n), - ws, dict({"STUB_TIPS": SHA}, **(env or {}))) - r["ws"] = ws - return r +def dev(inputs=None, env=None, toml=DEV_TOML, needs=None, secrets=None, ref=FEAT, env_by_job=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) def ran(r, what): return what in r["log"] - - -r = dev() -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and r["outputs"]["url"] == "https://dev.example.com", "Shared Dev success: built, audited, published, smoked, verified at app_url_dev") -check(ran(r, "BUILD ver=" + SHA + " commit=" + SHA + " cfenv=dev") and ran(r, "PUBLISH"), "Shared Dev builds with the Dev build env and CLOUDFLARE_ENV=dev_wrangler_env") -check("state=success" in r["log"] and "d1 migrations" not in r["log"], "Shared Dev success is recorded in the ledger only after the smoke; no migration for a D1-less config") -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(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")) -check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH") and "unchanged" in r["summary"], "build failure: no migration, no publish, explicit 'Dev unchanged' outcome") -r = dev(toml=DEV_TOML.replace('name = "app-dev"', 'name = "app"'), inputs=D1) -check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "audit failure (wrong Worker identity): no migration, no publish") -r = dev(inputs=D1, env={"STUB_APPLY_FAIL": "1"}) -check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", "migration failure prevents publish") -r = dev(inputs={"dev_binding_allow": "services:AUTH=auth-dev"}) -check(r["result"] == "failure" and not ran(r, "PUBLISH") and not ran(r, "migrations apply"), "unapproved Production R2 sharing fails the audit before anything is published") -r = dev(inputs={"dev_binding_allow": "r2_buckets:ASSETS=another-bucket services:AUTH=auth-dev"}) -check(r["result"] == "failure" and not ran(r, "PUBLISH"), "allowlist naming a different bucket does not approve the shared one") -r = dev(inputs={"dev_deny_resources": "prod-assets"}) -check(r["result"] == "failure" and not ran(r, "PUBLISH"), "dev_deny_resources beats dev_binding_allow") -r = dev(inputs={"preview_deny_resources": "prod-assets"}) -check(r["result"] == "success", "a Preview-only denylist does not affect the approved Dev share") -# P1: Production Queue / Service / storage bindings in the Dev config -for label, repl in (("Production Queue producer", ('queue = "dev-queue"', 'queue = "prod-queue"')), - ("Production Service binding", ('service = "auth-dev"', 'service = "auth-prod"')), - ("Production D1", ('[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "shared-db"\ndatabase_id = "SHARED-ID"', '[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "prod-db"\ndatabase_id = "PROD-ID"'))): - r = dev(toml=DEV_TOML.replace(*repl), inputs={"dev_binding_allow": "r2_buckets:ASSETS=prod-assets services:AUTH=auth-prod"} if "Service" in label else None) - check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", f"Dev deployment with a {label} fails the audit before publish") -r = dev(toml=DEV_TOML.replace('queue = "dev-queue"', 'queue = "prod-queue"'), inputs=D1) -check(r["result"] == "failure" and not ran(r, "migrations apply"), "a Production Queue in the Dev config also stops the Dev migration") -# Production KV / Hyperdrive / Durable Object / Secrets Store through the effective (adapter-generated) config -for label, extra in (("KV namespace", {"kv_namespaces": [{"binding": "K", "id": "PROD-KV"}]}), ("Hyperdrive", {"hyperdrive": [{"binding": "H", "id": "PROD-HD"}]}), - ("Durable Object from another Worker", {"durable_objects": {"bindings": [{"name": "DO", "class_name": "C", "script_name": "app"}]}}), - ("Queue consumer", {"queues": {"consumers": [{"queue": "prod-queue"}]}})): - src = DEV_TOML + {"KV namespace": '[[kv_namespaces]]\nbinding = "K"\nid = "PROD-KV"\n', "Hyperdrive": '[[hyperdrive]]\nbinding = "H"\nid = "PROD-HD"\n'}.get(label, "") - eff_cfg = dict({"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"}]}, **extra) - r = dev(toml=src, inputs={"dev_build_cmd": eff(eff_cfg)}) - check(r["result"] == "failure" and not ran(r, "PUBLISH"), f"effective Dev config with a Production {label} fails the audit") -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") - -# 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) BUILD_ENTRY = 'mkdir -p dist && echo audited > dist/index.js && echo "BUILD" >> "$STUB_LOG"' -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)) - check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), f"build_env_dev cannot set {line.split('=')[0]}") -r = dev(inputs=dict(D1, smoke_env="TARGET=prod")) -check(r["result"] == "failure" and not ran(r, "migrations apply"), "smoke_env cannot set TARGET either") -r = dev(inputs=dict(D1, dev_build_cmd='echo TARGET=prod >> "$GITHUB_ENV"; echo DEV_ENV=prod >> "$GITHUB_ENV"; echo "BUILD" >> "$STUB_LOG"')) -check(r["result"] == "success" and "SHARED-ID" in mig_file(r, "dev") and not (r["ws"] / "w" / ".wrangler" / "migrate.prod.json").exists(), "a Dev build writing TARGET=prod / DEV_ENV=prod into GITHUB_ENV cannot redirect the Dev migration to Production") - -r = dev(secrets={}) -check(r["result"] == "failure" and not ran(r, "BUILD"), "missing account credentials fail closed before Dev build") -r = dev(secrets={"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"}) -check(r["result"] == "failure" and not ran(r, "BUILD"), "Preview-only credentials are never a fallback for Dev") -r = dev(env={"STUB_TIPS": OTHER}) -check(r["result"] == "success" and not ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", "stale run: nothing built, migrated or published") -r = dev(env={"STUB_TIPS": f"{SHA},{OTHER}"}, inputs=D1) -check(r["result"] == "success" and ran(r, "BUILD") and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "superseded after the build: no migration and no publish") -r = dev(env={"STUB_TIPS": f"{SHA},{SHA},{OTHER}"}, inputs=D1) -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") - -# Shared-D1 guard: Preview and Dev sharing one database -fail_dep = {"migration": {"result": "failure", "outputs": {"migration": "failed"}}} -r = dev(inputs=D1, needs=fail_dep) -check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "Preview migration failed on the SHARED D1: Dev migrates and publishes nothing") -r = dev(inputs=D1, needs={"migration": {"result": "cancelled", "outputs": {"migration": ""}}}) -check(r["result"] == "failure" and not ran(r, "PUBLISH"), "...also when the Preview job was cancelled") -distinct = DEV_TOML.replace('database_name = "shared-db"\ndatabase_id = "SHARED-ID"\n[env.dev]', 'database_name = "preview-only-db"\ndatabase_id = "PREVIEW-ONLY"\n[env.dev]') -assert distinct != DEV_TOML -r = dev(inputs=D1, needs=fail_dep, toml=distinct) -check(r["result"] == "success" and ran(r, "PUBLISH"), "Preview migration failed but the D1s differ: Shared Dev is independent and deploys") -r = dev(inputs=D1, needs={"migration": {"result": "failure", "outputs": {"migration": "applied"}}}) -check(r["result"] == "success" and ran(r, "PUBLISH"), "Preview deploy failed AFTER a successful shared-D1 migration (e.g. smoke): Dev proceeds") -r = dev(inputs=D1, needs={"migration": {"result": "success", "outputs": {"migration": "applied"}}}) -check(r["result"] == "success", "Preview migration succeeded on the shared D1: Dev proceeds (ordered after it)") - -# Preview and Dev sharing one physical D1, end to end: Preview deploy first, then Dev ordered after it -SHARED_TOML = APP_TOML.replace('database_name = "prev-db"\ndatabase_id = "PREV-ID"', 'database_name = "shared-db"\ndatabase_id = "SHARED-ID"') + \ - '[env.dev]\nname = "app-dev"\n[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "shared-db"\ndatabase_id = "SHARED-ID"\n' -pv = deploy(FEAT, inputs=D1, toml=SHARED_TOML) -dv = dev(inputs=dict(D1, dev_binding_allow=""), toml=SHARED_TOML, needs={"migration": pv["jobs"]["migration"]}) -check(pv["result"] == "success" and dv["result"] == "success" and "SHARED-ID" in mig_file(pv, "preview") and "SHARED-ID" in mig_file(dv, "dev") and pv["outputs"]["migration"] == "applied", - "Preview and Dev sharing one D1 both migrate the same database, one after the other") -pf = deploy(FEAT, inputs=D1, toml=SHARED_TOML, env={"STUB_APPLY_FAIL": "1"}) -df = dev(inputs=dict(D1, dev_binding_allow=""), toml=SHARED_TOML, needs={"migration": pf["jobs"]["migration"]}) -check(pf["outputs"]["migration"] == "failed" and df["result"] == "failure" and not ran(df, "migrations apply") and not ran(df, "PUBLISH"), "shared D1: a failed Preview migration (job output) stops the Dev migration and publish") - -r = dev(inputs={"dev_deploy_cmd": ""}) -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") - -# Config guard, on the REAL deployment path: the engine refuses before any remote command + +# Config guard, on the REAL deployment path: the plan fails closed before any delivery job is scheduled BADCFG = {"deployment_strategy": "production-only", "shared_dev_on_branch_push": True} -r = deploy(MAIN, inputs=BADCFG) -check(r["jobs"]["migration"]["result"] == r["jobs"]["deploy"]["result"] == "skipped" and r["log"] == "", "production-only + Shared Dev: no delivery job is even scheduled (no build, migration, publish)") -for stage_job in ("migration", "deploy"): # defence in depth: even if a job were forced to run, the engine refuses at init - ws_ = make_workspace() - fr = run_job(doc, stage_job, ctx(MAIN, dict(BADCFG, d1_migrations=True), PROD_SECRETS, {}), ws_, {"STUB_TIPS": SHA}) - check(fr["result"] == "failure" and not published(fr) and "wrangler versions" not in fr["log"] and "migrations apply" not in fr["log"] and "-X POST" not in fr["log"], f"production-only + Shared Dev: the {stage_job} stage fails at engine init, before build, migration, publish or any ledger write") -r = deploy(MAIN, inputs={"deployment_strategy": "production-only", "preview_on_branch_push": True}, env={"STUB_PROBES": "200 "}) -check(r["result"] == "success" and published(r) and r["outputs"]["deployed_and_verified"] == "true", "production-only + default Preview=true: accepted, Production deploys") # PR lifecycle advertises only a VERIFIED Preview bound to this host Worker, application, repo and branch -def lifecycle(deps, status="success https://stable.example.workers.dev Serving good (content)", inputs=None): +def lifecycle(deps, status="success https://feat-x-app.example.workers.dev Serving good (content)", inputs=None): ws = make_workspace(APP_TOML) c = ctx(FEAT, inputs, {}, event="pull_request") c["github"]["event"] = {"pull_request": {"head": {"ref": "feat/x", "repo": {"full_name": "o/r"}}}} - r = run_job(doc, "lifecycle", c, ws, {"STUB_DEPLOYMENTS": deps, "STUB_STATUS": status}) + r = called(ldoc, "lifecycle", "lifecycle", c, ws, {"STUB_DEPLOYMENTS": deps, "STUB_STATUS": status}) return r def advertised(r): return "pulls?state=open" in r["log"] - -r = lifecycle(good_dep()) -check(r["result"] == "success" and advertised(r), "lifecycle: a verified Preview for this host Worker, app, repo and branch is advertised") -check(not advertised(lifecycle(good_dep(payload={"host_worker": "blog-preview"}))), "lifecycle: an old-host (blog-preview) Preview record is NOT advertised for the new host") -check(not advertised(lifecycle(good_dep(payload={"branch_ref": "refs/heads/other"}))), "lifecycle: another branch ref is not advertised") -check(not advertised(lifecycle(good_dep(payload={"worker": "other-app"}))) and not advertised(lifecycle(good_dep(payload={"repo": "x/y"}))), "lifecycle: another application or repository is not advertised") -check(not advertised(lifecycle(good_dep(), status="success https://stable.example.workers.dev Published s; unverified (liveness)")), "lifecycle: a liveness-only Preview is not advertised as verified") -check(not advertised(lifecycle(good_dep(), status="success http://insecure.example")), "lifecycle: a non-https or unverified record is not advertised") -r = lifecycle("[]") -check(r["result"] == "success" and not advertised(r), "lifecycle: no matching verified Preview leaves the PR comment unchanged (job still succeeds)") - -# Verification level: Access liveness is published-but-unverified and never a known-good record -r = dev(inputs={"access_protected_targets": "preview dev"}, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) -check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["verified_url"] == "" and r["outputs"]["published_unverified"] == "true", "Shared Dev Access liveness only: published, NOT verified") -check("state=success" not in r["log"] and "state=inactive" in r["log"], "Shared Dev liveness is not recorded as content-verified last-good") - # Smoke failure and recovery 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)"} -r = dev(env=dict(GD, STUB_PROBES="500 ;200 ")) -check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and "unverified" in r["summary"], "smoke failure: job red, deployment marked unverified") -builds = [l for l in r["log"].splitlines() if l.startswith("BUILD")] -check(len(builds) == 2 and builds[1] == f"BUILD ver={GOOD} commit={GOOD} cfenv=dev", "recovery builds the LAST-GOOD commit with its own version and rebound build env (not the failed SHA)") -check(r["log"].count("PUBLISH") == 2 and "Run remains FAILED" in r["summary"] and "Commit identity was NOT verified" in r["summary"], "recovery redeploys; without sha_path it says commit identity was NOT verified; run stays red") -r = dev(inputs={"sha_path": "/version.json"}, env=dict(GD, STUB_PROBES="500 ;200 ", STUB_SHA_BODY=GOOD[:7])) -check(r["result"] == "failure" and "restored to last good commit" in r["summary"] and "Commit identity was NOT verified" not in r["summary"], "with sha_path the recovery verifies the restored commit identity") -r = dev(inputs={"sha_path": "/version.json"}, env=dict(GD, STUB_PROBES="500 ;200 ", STUB_SHA_BODY=SHA[:7])) -check(r["result"] == "failure" and "restored to last good" not in r["summary"], "recovery serving the wrong commit is NOT reported as verified") -r = dev(env=dict(GD, STUB_PROBES="500 ;500 ")) -check(r["result"] == "failure" and "recovery FAILED" in r["summary"] and "state=error" in r["log"], "recovery failure stays visible and is recorded as error") -r = dev(env={"STUB_PROBES": "500 "}) -check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "manual recovery required" in r["summary"], "no known-good commit: no recovery attempted, manual recovery required") -r = dev(env=dict(GD, STUB_PROBES="500 ;200 ")) -check("versions upload" not in r["log"] and "versions deploy" not in r["log"] and "preview --name" not in r["log"], "Dev recovery never takes the Production or Preview publish path") -check("state=success" not in r["log"], "a failed Dev deployment whose recovery succeeded is still never recorded as verified") -r = dev(env=dict(GD, STUB_PROBES="500 ", STUB_TIPS=f"{SHA},{SHA},{SHA},{OTHER}")) -check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "superseded" in r["summary"], "Dev recovery is skipped when a newer push owns the branch") 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"' -r = dev(inputs={"dev_build_cmd": tamper}, env=dict(GD, STUB_PROBES="500 ;200 ")) -check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "Automatic recovery FAILED" in r["summary"], "the config audit runs on the RECOVERED build: a last-good tree with the wrong Worker identity is not published") -rb = dev(env=dict(GD, STUB_PROBES="500 ;200 "), inputs={"build_env_dev": f"PUBLIC_COMMIT_SHA={SHA}\nSHORT={SHA[:7]}"}) -check(f"commit={GOOD}" in rb["log"], "build env referencing the failed SHA (full) is rebound for the restored commit") # 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) @@ -593,271 +405,1008 @@ def advertised(r): def manual(inputs=None, env=None, toml=DEV_TOML): ws = make_workspace(toml) - r = run_job(doc, "persistent-dev", ctx("refs/heads/dev", dict(PD, **(inputs or {})), PROD_SECRETS, event="workflow_dispatch"), ws, dict({"STUB_TIPS": SHA}, **(env or {}))) + r = called(pdoc, "persistent-dev", "persistent-dev", ctx("refs/heads/dev", dict(PD, **(inputs or {})), PROD_SECRETS, event="workflow_dispatch"), ws, dict({"STUB_TIPS": SHA}, **(env or {}))) r["ws"] = ws return r - -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") -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"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run from a ref other than persistent_dev_ref is refused by the engine too") - -# ======================================================================= Migration -> Deploy -> Smoke -> Rollback chain # 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. AUTO = {"production_rollback_policy": "automatic"} ADD_SQL = {"migrations/0001.sql": "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);\nALTER TABLE notes ADD COLUMN tag TEXT;\n"} DROP_SQL = {"migrations/0001.sql": "ALTER TABLE notes DROP COLUMN body;\n"} -res_of = lambda r: {k: v["result"] for k, v in r["jobs"].items()} +res_of = lambda r: {k: v["result"] for k, v in r["jobs"].items() if k in ("migration", "deploy", "smoke", "rollback")} NOTHING = {"migration": "skipped", "deploy": "skipped", "smoke": "skipped", "rollback": "skipped"} - -# 1/2. CI / E2E failure -> nothing downstream, nothing remote -for failing in ("lint", "unit", "build", "e2e"): - r = deploy(MAIN, inputs=dict(D1, lint_cmd="l", test_unit_cmd="u", test_e2e_cmd="e", **AUTO), needs_over={failing: "failure"}) - check(res_of(r) == NOTHING and r["log"] == "" and not release_runs(r), f"CI: failed {failing} -> Migration, Deploy, Smoke and Rollback are all skipped; nothing remote is touched; no Release") -# 3. migration failure -r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_APPLY_FAIL": "1"}) -check(res_of(r) == {"migration": "failure", "deploy": "skipped", "smoke": "skipped", "rollback": "skipped"} and not published(r) and not release_runs(r), "Migration failure -> Deploy, Smoke and Rollback skipped, nothing published, Release blocked") -check("D1 migration failed" in r["summary"] and r["outputs"]["migration"] == "failed", "a failed migration is reported explicitly (migration_status=failed, summary)") -# 4. deploy failure -r = deploy(MAIN, inputs=AUTO, env={"STUB_UPLOAD_FAIL": "1"}) -check(res_of(r) == {"migration": "skipped", "deploy": "failure", "smoke": "skipped", "rollback": "skipped"} and not release_runs(r), "Deploy failure -> Smoke and Rollback skipped (nothing was published to verify or revert), Release blocked") -r = deploy(FEAT, env={"STUB_PREVIEW_FAIL": "1"}) -check(res_of(r)["deploy"] == "failure" and res_of(r)["smoke"] == "skipped" and res_of(r)["rollback"] == "skipped", "Preview publish failure -> Smoke and Rollback skipped") -# 5. smoke success -> rollback skipped, release allowed -r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_PROBES": "200 "}) -check(res_of(r) == {"migration": "success", "deploy": "success", "smoke": "success", "rollback": "skipped"} and r["result"] == "success" and release_runs(r), "Smoke success -> Rollback skipped, run green, Release allowed") -check(r["jobs"]["deploy"]["outputs"]["published"] == "true" and r["outputs"]["deployed_and_verified"] == "true" and r["jobs"]["deploy"]["outputs"].get("deployed_and_verified") is None, "Deploy only reports `published`; `verified` exists only after the Smoke job") -check(r["jobs"]["smoke"]["outputs"]["smoke_level"] == "content", "the verification level is explicit (content)") -check("deployments/5151/statuses" not in r["log"], "a verified Production deployment keeps GitHub's own success status (no unverified annotation)") -# 6. production smoke failure -> automatic rollback (restored, verified), release blocked -r = deploy(MAIN, inputs=dict(AUTO, sha_path="/version.json"), env={"STUB_PROBES": "200 ;200 ", "STUB_SHA_BODY": OTHER[:7]}, - env_by_job={"rollback": {"STUB_SHA_BODY": "", "STUB_PREV_MESSAGE": "[1111111] previous"}}) -check(res_of(r)["smoke"] == "failure" and "wrangler versions view prev-1" in r["log"], "post-rollback smoke resolves the previous version's commit from its deployment message") -r = deploy(MAIN, inputs=dict(AUTO, sha_path="/version.json"), env={"STUB_PROBES": "200 ;200 ", "STUB_SHA_BODY": OTHER[:7]}, env_by_job={"rollback": {"STUB_SHA_BODY": "1111111"}}) -check(res_of(r) == {"migration": "skipped", "deploy": "success", "smoke": "failure", "rollback": "success"} and r["outputs"]["rollback_result"] == "restored_verified", - "Production smoke fails on the wrong commit; rollback restores prev-1; the restored deployment no longer serves the failed commit -> restored_verified") -check(r["outputs"]["smoke_level"] == "" and r["outputs"]["deployed_and_verified"] == "false" and not release_runs(r) and r["result"] == "failure", "a successful rollback still leaves the run FAILED, the deployment unverified and Release blocked") -check("deployments/5151/statuses -f state=failure" in r["log"] and "UNVERIFIED" in r["log"], "the failed deployment is annotated UNVERIFIED on its GitHub Environment deployment") -check("deployments/5151/statuses -f state=inactive" in r["log"], "after a verified rollback the failed Environment deployment is marked inactive (superseded by the restored version)") -# rollback runs once, only the previous version of THIS Worker, from a runner that never built anything -check(r["log"].count("wrangler versions deploy prev-1@100") == 1 and "BUILD" not in "".join(l for l in r["log"].splitlines() if "prev-1@100" in l), "exactly one rollback, to the previous version id recorded by Deploy") -check(not any(f.startswith("dist") for f in os.listdir(r["wss"]["rollback"] / "w")), "the Rollback job builds nothing (a failing build can never block a rollback)") -# 7. rollback failure -> failed run, loud -r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;200 ", "STUB_ROLLBACK_FAIL": "1"}) -check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "failed" and "Roll back manually" in r["summary"], "Rollback failure -> job red, rollback_result=failed, manual command in the summary") -# restored but the post-rollback smoke fails -> not green -r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;500 "}) -check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "restored_unhealthy" and "post-rollback smoke FAILED" in r["summary"], "rolled back but the restored Production fails the smoke -> restored_unhealthy, job red, loud summary") -r = deploy(MAIN, inputs=dict(AUTO, access_protected_targets="preview prod"), env={"STUB_PROBES": "500 ;302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) -check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "restored_unverified", "restored Production reachable only through Access liveness -> restored_unverified, job red (never reported as verified)") -# 8. previous version missing -> fail safe, never pretend -r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ", "STUB_NO_PREV": "1"}) -check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "blocked" and "wrangler versions deploy" not in r["log"].replace("versions deploy new-2", ""), "no previous version id -> NO rollback attempted, Rollback job red, rollback_result=blocked") -check("No previous Worker version id was recorded" in r["summary"] and "STILL serves the unverified version" in r["summary"], "the summary says Production still serves the unverified version (no fake success)") -# policy manual / disabled: no Rollback job, manual command in the Smoke job summary -for pol in ("manual", "disabled"): - r = deploy(MAIN, inputs={"production_rollback_policy": pol}, env={"STUB_PROBES": "500 "}) - check(res_of(r)["rollback"] == "skipped" and res_of(r)["smoke"] == "failure" and "wrangler versions deploy prev-1@100 --name app --yes" in r["summary"] and "versions deploy prev-1" not in r["log"], f"policy {pol}: nothing rolled back, manual command printed by the Smoke job, run red") # 9. D1 compatibility (Expand / Contract), fail closed bad_cases = [ ("migrations applied, d1_rollback_compat unattested (default)", dict(D1, **AUTO), ADD_SQL, "d1_unattested"), ("attested expand-contract but the SQL is destructive", dict(D1, **AUTO, d1_rollback_compat="expand-contract"), DROP_SQL, "d1_not_additive"), ("attested expand-contract but the SQL is not provably additive (data rewrite)", dict(D1, **AUTO, d1_rollback_compat="expand-contract"), {"migrations/0001.sql": "UPDATE notes SET body = '';"}, "d1_not_additive"), ] -for label, inp, files, code in bad_cases: - r = deploy(MAIN, inputs=inp, files=files, env={"STUB_PROBES": "500 "}) - check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "blocked" and "versions deploy prev-1" not in r["log"] and f"({code})" in r["summary"], f"D1 incompatible - {label}: rollback BLOCKED (fail closed), no Worker change, manual command printed") -r = deploy(MAIN, inputs=dict(D1, **AUTO), files=ADD_SQL, env={"STUB_PROBES": "500 ", "STUB_PENDING_FILES": "0001.sql"}, env_by_job={"rollback": {"STUB_PROBES": "200 "}}) -check(r["outputs"]["rollback_result"] == "blocked", "additive SQL alone is NOT proof: without the expand-contract attestation the rollback stays blocked") -r = deploy(MAIN, inputs=dict(D1, **AUTO, d1_rollback_compat="expand-contract"), files=ADD_SQL, env={"STUB_PROBES": "500 ;200 "}) -check(r["jobs"]["migration"]["outputs"]["d1_scan"] == "additive" and r["outputs"]["rollback_result"] == "restored_verified" and "versions deploy prev-1@100" in r["log"], "attested expand-contract + provably additive SQL -> rollback proceeds and is verified") -r = deploy(MAIN, inputs=dict(D1, **AUTO, d1_rollback_compat="expand-contract"), files=ADD_SQL, env={"STUB_PROBES": "500 ", "STUB_PENDING_FILES": ""}) -check(r["jobs"]["migration"]["outputs"]["d1_scan"] == "unknown" and r["outputs"]["rollback_result"] == "blocked", "pending migration files that cannot be determined -> scan `unknown` -> rollback blocked (fail closed)") -r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_PROBES": "500 ;200 ", "STUB_PENDING": "0"}) -check(r["jobs"]["migration"]["outputs"]["schema_changed"] != "true" and r["outputs"]["rollback_result"] == "restored_verified", "d1_migrations on but NOTHING pending this run: the schema is unchanged, rollback needs no attestation") -r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;200 "}) -check(r["outputs"]["rollback_result"] == "restored_verified", "a Worker without D1 migrations rolls back without any D1 condition") -# 10. PR event never deploys -for ev_ref, head in (("refs/pull/1/merge", "o/r"),): - r = deploy(ev_ref, inputs=dict(D1, **AUTO), event="pull_request") - check(res_of(r) == NOTHING and r["log"] == "", "PR event: no Migration, Deploy, Smoke or Rollback (no Production or Preview deployment)") -r = deploy(MAIN, inputs=dict(D1, **AUTO), event="delete") -check(res_of(r) == NOTHING, "branch delete: no delivery job") -# 11. stale SHA -r = deploy(MAIN, inputs=dict(D1, **AUTO), env_by_job={"deploy": {"STUB_TIPS": OTHER}}) -check(res_of(r) == {"migration": "success", "deploy": "success", "smoke": "skipped", "rollback": "skipped"} and not published(r) and not release_runs(r), "main moved on after Migration: Deploy publishes nothing, Smoke and Rollback are skipped, Release blocked") -r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 "}, env_by_job={"rollback": {"STUB_ACTIVE": "someone-elses-version"}}) -check(r["outputs"]["rollback_result"] == "blocked" and "versions deploy prev-1" not in r["log"] and "live_version_changed" in r["summary"], "the live version is no longer the one THIS run deployed -> rollback refused (never replaces another deployment)") # hand-off is DATA and validated -ws_ = make_workspace() -bad = ctx(MAIN, dict(AUTO), PROD_SECRETS, {"deploy": {"result": "success", "outputs": {"published": "true", "url": "http://evil.example/x", "version_id": "new-2", "previous_version_id": "prev-1", "worker_name": "app"}}, - "smoke": {"result": "failure", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}}) -fr = run_job(doc, "rollback", bad, ws_, {"STUB_TIPS": SHA, "STUB_ACTIVE": "new-2"}) -check(fr["result"] == "failure" and "versions deploy" not in fr["log"] and step(fr, "Hand-off from the previous jobs (validated data)") == "failure", "a malformed hand-off (non-https URL) is refused at hydrate, before anything is changed") -for key, val in (("version_id", "x; rm -rf /"), ("previous_version_id", "$(touch PWNED)"), ("worker_name", "App Name"), ("published_sha", OTHER)): - outs = {"published": "true", "url": "https://app.example.com", "version_id": "new-2", "previous_version_id": "prev-1", "worker_name": "app"} - outs[key] = val - ws_ = make_workspace() - fr = run_job(doc, "rollback", ctx(MAIN, dict(AUTO), PROD_SECRETS, {"deploy": {"result": "success", "outputs": outs}, "smoke": {"result": "failure", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}}), ws_, {"STUB_TIPS": SHA, "STUB_ACTIVE": "new-2"}) - check(fr["result"] == "failure" and "versions deploy" not in fr["log"] and not (ws_ / "w" / "PWNED").exists(), f"hand-off {key}={val!r} is refused as data (no rollback, no execution)") -ws_ = make_workspace() -smoke_needs_ = {"deploy": {"result": "success", "outputs": {"published": "true", "url": "https://app.example.com", "target": "prod", "published_sha": SHA}}} -fr = run_job(doc, "smoke", ctx(MAIN, {}, PROD_SECRETS, smoke_needs_), ws_, {"STUB_TIPS": SHA, "STUB_PROBES": "200 "}) -check(fr["result"] == "success" and "CLOUDFLARE" not in " ".join(k for k in doc["jobs"]["smoke"].get("env", {})), "the Smoke job runs with no Cloudflare credentials in its env and still verifies (the probe needs none)") -ws_ = make_workspace() -fr = run_job(doc, "smoke", ctx(MAIN, {}, PROD_SECRETS, smoke_needs_), ws_, {"STUB_TIPS": SHA, "STUB_PROBES": "200 ", "CLOUDFLARE_API_TOKEN": "leaked", "CLOUDFLARE_ACCOUNT_ID": "leaked"}) -check(fr["result"] == "failure" and step(fr, "Initialise (bind target and stage, validate credentials and env as data)") == "failure", "the Smoke stage refuses to start if Cloudflare credentials ever reach it (least privilege enforced by the engine)") -# Preview: ledger id and identity travel between jobs; recovery stays Preview-only -r = deploy(FEAT, env={"STUB_PROBES": "200 "}) -check(res_of(r) == {"migration": "skipped", "deploy": "success", "smoke": "success", "rollback": "skipped"} and r["jobs"]["deploy"]["outputs"]["ledger_id"] == "4242", "Preview chain: the ledger id is created by Deploy and handed to Smoke") -check("deployments/4242/statuses -f state=success" in r["log"] and r["jobs"]["deploy"]["outputs"]["preview_name"] == "feat-x", "Preview: Smoke marks THE ledger entry of this run success (no second entry), identity feat-x handed over") -r = deploy(FEAT, env={"STUB_PROBES": "500 "}, inputs=AUTO) -check(res_of(r)["rollback"] == "failure" and "versions deploy" not in r["log"] and r["outputs"]["rollback_result"] == "no_known_good", "Preview + no known-good deployment: the Rollback job never touches Production versions (policy automatic is irrelevant) and fails loudly") -r = deploy(FEAT, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep()}, inputs=AUTO) -check(res_of(r)["rollback"] == "success" and r["outputs"]["rollback_result"] == "restored_verified" and "versions deploy" not in r["log"] and r["result"] == "failure", "Preview recovery in the Rollback job: last-known-good redeployed + verified; run stays failed; no Production rollback command") +def leg(job, kind, ref=MAIN, inputs=None, needs=None, env=None, toml=APP_TOML, secrets=None, phase="all", handoff_json="", migration_json_in="", upstream_gate="", call_inputs=None): + """Runs ONE job of the internal delivery workflow for `kind` against hand-built `needs` (fresh workspace). + A job whose `if:` is false is reported skipped without running any step (like the scheduler).""" + c = ctx(ref, inputs, PROD_SECRETS if secrets is None else secrets, {}) + dinputs = dict(DINPUT_DEFAULTS, descriptor=desc(kind), inputs_json=json.dumps(dict(c["inputs"])), + phase=phase, handoff_json=handoff_json, migration_json_in=migration_json_in, upstream_gate=upstream_gate, + call_inputs_json=json.dumps(call_inputs) if call_inputs is not None else "") + dc = Ctx({"github": c["github"], "inputs": dinputs, "needs": needs or {}, "jobs": {}}, c.secrets) + if not ev(ddoc["jobs"][job]["if"], dc): + return {"result": "skipped", "outputs": {}, "steps": [], "log": "", "summary": "", "ws": None} + ws = make_workspace(toml) + r = run_job(ddoc, job, dc, ws, dict({"STUB_TIPS": SHA}, **(env or {}))) + r["ws"] = ws + return r + + +def hand(kind, outs, smoke="failure", sjson=None, migration_used="{}"): + """The `needs` of the Smoke / Rollback jobs of ONE delivery call (its own Deploy hand-off and Smoke result).""" + outs = dict(outs) + outs.setdefault("kind", kind) + return {"deploy": {"result": "success", "outputs": {"handoff": json.dumps(outs), "migration_used": migration_used}}, + "smoke": {"result": smoke, "outputs": {"smoke_json": json.dumps(sjson or {}), "smoke_status": smoke}}} -# ---------------------------------------------------------------- pipeline_phase: deferred verification (two calls) -# A Worker whose Smoke must wait for ANOTHER Worker's job runs the same workflow twice: the deploy call (CI/E2E, Migration, -# Deploy / Shared Dev publish) and the verify call (Smoke, Rollback, Shared Dev smoke / recovery). Every job below runs in a -# FRESH workspace; the only thing the verify call receives is the phase_state data blob. -SKIPPED = {"result": "skipped", "outputs": {}} +GOODH = {"kind": "production", "published": "true", "url": "https://app.example.com", "version_id": "new-2", "previous_version_id": "prev-1", "worker_name": "app"} -def deferred(ref=MAIN, inputs=None, env=None, gate="success", call_over=None, deploy_env=None): +# ---------------------------------------------------------------- pipeline_phase: deferred verification (two calls) +# A Worker whose Smoke must wait for ANOTHER Worker's job runs the same workflow twice: the deploy call (CI/E2E, Plan, Migration, +# Deploy) and the verify call (Plan from the hand-off, Smoke, Rollback, Release). Every job runs in a FRESH workspace; the only +# thing the verify call receives is the phase_state data blob. +def deferred(ref=MAIN, inputs=None, env=None, gate="success", call_over=None, deploy_env=None, toml=APP_TOML, state_over=None): + """Two calls of the public workflow: the deploy call (pipeline_phase deploy) and the verify call, which receives ONLY the + `phase_state` output of the deploy call (rendered here from the REAL output expression) - every job in a fresh workspace.""" base = dict(inputs or {}) - secrets = PROD_SECRETS - d = run_chain(ref, dict(base, pipeline_phase="deploy"), env=deploy_env) - blob = dict(ctx(ref, dict(base, pipeline_phase="deploy"), secrets)["inputs"]) - state = json.dumps({"inputs": blob, "deploy": d["jobs"]["deploy"]["outputs"], "migration": d["jobs"]["migration"]["outputs"], "dev": {}, "group": "g"}) + d = run_chain(ref, dict(base, pipeline_phase="deploy"), env=deploy_env, toml=toml) + dci = ctx(ref, dict(base, pipeline_phase="deploy"), PROD_SECRETS) + jobs_ctx = {"plan": d["res"].get("plan", SKIPPED)} + for k in KINDS: + jobs_ctx[f"delivery_{KEYED[k]}"] = d["res"].get(f"delivery_{KEYED[k]}", SKIPPED) + jobs_ctx["migration"] = d["res"].get("migration", SKIPPED) + pc = Ctx({"github": dci["github"], "inputs": dci["inputs"], "jobs": jobs_ctx}, {}) + state = render(doc[True]["workflow_call"]["outputs"]["phase_state"]["value"], pc) + if state_over: + state = json.dumps(dict(json.loads(state), **state_over)) + out = verify_call(state, base, ref=ref, gate=gate, env=env, toml=toml, call_over=call_over) + return dict(out, deploy=d) + + +def verify_call(state, base, ref=MAIN, gate="success", env=None, toml=APP_TOML, call_over=None): + """The verify call alone, given the `phase_state` string it receives (no deploy chain is re-run).""" vin = dict(base, pipeline_phase="verify", phase_state=state, upstream_gate=gate) vin.update(call_over or {}) - res, logs, summ, steps = {}, [], [], [] - for name in ("smoke", "rollback"): - c = ctx(ref, vin, secrets, dict({"deploy": SKIPPED, "migration": SKIPPED}, **res)) - if not ev(doc["jobs"][name]["if"], c): - res[name] = dict(SKIPPED) - continue - senv = {"STUB_TIPS": SHA} - senv.update(env or {}) - if ";" in senv.get("STUB_PROBES", ""): - first, rest = senv["STUB_PROBES"].split(";", 1) - senv["STUB_PROBES"] = first if name == "smoke" else rest - if name == "rollback": - senv.setdefault("STUB_ACTIVE", "new-2") - r = run_job(doc, name, c, make_workspace(APP_TOML), senv) - res[name] = {"result": r["result"], "outputs": r["outputs"]} - logs.append(r["log"]); summ.append(r["summary"]); steps.extend(r["steps"]) - return {"deploy": d, "jobs": res, "log": "".join(logs), "summary": "".join(summ), "steps": steps, "vin": vin, "state": state} + res, logs, summ, steps, wss = _run_jobs(("plan",) + tuple(f"delivery_{KEYED[k]}" for k in KINDS), ref, vin, PROD_SECRETS, {"gate": SKIPPED, "migration": SKIPPED}, "push", env, toml, None, None) + shaped = _shape(dict(res), logs, summ, steps, wss, toml) + return {"jobs": shaped["jobs"], "log": "".join(logs), "summary": "".join(summ), "steps": steps, "vin": vin, "state": state, "raw": res, "res": res} def verify_release(v, inputs=None): - needs = {"deploy": SKIPPED, "smoke": v["jobs"]["smoke"], "rollback": v["jobs"]["rollback"]} - return bool(ev(doc["jobs"]["release"]["if"], ctx(MAIN, dict(v["vin"], release_cmd="pnpm release", **(inputs or {})), PROD_SECRETS, needs))) + return bool(ev(doc["jobs"]["release"]["if"], ctx(MAIN, dict(v["vin"], release_cmd="pnpm release", **(inputs or {})), PROD_SECRETS, v["res"]))) AUTO_P = {"production_rollback_policy": "automatic"} -v = deferred(inputs=AUTO_P) -d = v["deploy"] -check(d["result"] == "success" and published(d) and d["jobs"]["smoke"]["result"] == "skipped" and d["jobs"]["rollback"]["result"] == "skipped" and d["outputs"]["deployed_and_verified"] == "false", - "deferred (deploy call): CI-gated Deploy publishes and STOPS - no smoke, no rollback, nothing is verified yet") -check(v["jobs"]["smoke"]["result"] == "success" and v["jobs"]["smoke"]["outputs"]["deployed_and_verified"] == "true" and v["jobs"]["rollback"]["result"] == "skipped" and v["jobs"]["smoke"]["outputs"]["gate_blocked"] == "false", - "deferred (verify call): the Smoke job verifies the handed-over deployment from the phase_state data alone (fresh workspace, no Deploy job in the call)") -check(verify_release(v), "deferred: Release runs only from the verify call's verified smoke") -check(v["jobs"]["smoke"]["outputs"]["verified_url"] == "https://app.example.com", "deferred: the verified URL is the one the Deploy job published") -for g in ("skipped", ""): - vv = deferred(inputs=AUTO_P, gate=g) - check(vv["jobs"]["smoke"]["result"] == "success" and vv["jobs"]["smoke"]["outputs"]["deployed_and_verified"] == "true", f"cross-service gate '{g or '(none)'}': satisfied (unchanged Worker uses the existing verified environment)") -for g in ("failure", "cancelled", "weird"): - vv = deferred(inputs=AUTO_P, gate=g) - sm = vv["jobs"]["smoke"] - check(sm["result"] == "failure" and sm["outputs"]["gate_blocked"] == "true" and sm["outputs"]["deployed_and_verified"] == "false" and vv["jobs"]["rollback"]["result"] == "skipped" - and "versions deploy" not in vv["log"] and step(vv, "Smoke the exact deployed URL (fails closed)") in (None, "skipped") and "Cross-service smoke blocked" in vv["summary"] and not verify_release(vv), - f"cross-service gate '{g}': the smoke does NOT run, the job is red, nothing is verified, NO rollback, Release blocked") -check("wrangler versions deploy prev-1@100 --name app --yes" in deferred(inputs=AUTO_P, gate="failure")["summary"], "gate blocked: the manual rollback command is printed, nothing is changed automatically") -vv = deferred(inputs=AUTO_P, env={"STUB_PROBES": "500 ;200 "}) -check(vv["jobs"]["smoke"]["result"] == "failure" and vv["jobs"]["smoke"]["outputs"]["gate_blocked"] == "false" and vv["jobs"]["rollback"]["result"] == "success" - and vv["jobs"]["rollback"]["outputs"]["rollback_result"] == "restored_verified" and "versions deploy prev-1@100" in vv["log"] and not verify_release(vv), - "deferred: a failed Production smoke triggers the fail-closed rollback in the verify call; it restores + re-verifies, the Smoke job stays red, Release blocked") -vv = deferred(inputs={"production_rollback_policy": "manual"}, env={"STUB_PROBES": "500 "}) -check(vv["jobs"]["smoke"]["result"] == "failure" and vv["jobs"]["rollback"]["result"] == "skipped" and "versions deploy" not in vv["log"] and "wrangler versions deploy prev-1@100 --name app --yes" in vv["summary"], - "deferred: policy manual - failed smoke rolls nothing back and prints the manual commands") -vv = deferred(inputs=AUTO_P, env={"STUB_PROBES": "500 ;200 ", "STUB_ACTIVE": "someone-else"}) -check(vv["jobs"]["rollback"]["result"] == "failure" and "versions deploy" not in vv["log"], "deferred: another run published meanwhile (the live version is not ours) - the rollback is BLOCKED, never overwrites it") -vv = deferred(inputs=dict(AUTO_P, **D1), env={"STUB_PROBES": "500 ;200 "}) -check(vv["jobs"]["rollback"]["result"] == "failure" and "versions deploy" not in vv["log"], "deferred: D1 migrations were applied - the D1 compatibility fail-closed rule still blocks the rollback") -for key, val in (("app_name", "other"), ("app_path", "apps/other"), ("production_rollback_policy", "manual"), ("tools_ref", "other-ref")): - vv = deferred(inputs=AUTO_P, call_over={key: val}) - check(vv["jobs"]["smoke"]["result"] in ("failure", "skipped") and vv["jobs"]["smoke"]["outputs"].get("deployed_and_verified") != "true" and not verify_release(vv), - f"deferred: a verify call whose {key} differs from the deploy call's verifies nothing (identity check)") -vv = deferred(inputs=AUTO_P, call_over={"phase_state": "{}"}) -check(vv["jobs"]["smoke"]["result"] == "skipped" and vv["jobs"]["rollback"]["result"] == "skipped" and not verify_release(vv), "deferred: an empty hand-off verifies nothing") -vv = deferred(inputs=AUTO_P, call_over={"phase_state": json.dumps({"inputs": {"app_name": "app"}, "deploy": {"published": "true", "url": "http://insecure.example", "published_sha": "0" * 40}, "migration": {}, "dev": {}})}) -check(vv["jobs"]["smoke"]["result"] == "failure" and not verify_release(vv), "deferred: a malformed / foreign hand-off (wrong SHA, non-https URL, other inputs) is refused as data") - -# Shared Dev: deploy call publishes (stage deploy), verify call smokes, rollback recovers -def dev_deferred(inputs=None, env=None, gate="success", dev_over=None): - di = dict(DEV_INPUTS, **(inputs or {})) - ws = make_workspace(DEV_TOML) - c1 = ctx(FEAT, dict(di, pipeline_phase="deploy"), PROD_SECRETS, {"migration": SKIPPED}) - r1 = run_job(doc, "dev-deploy", c1, ws, {"STUB_TIPS": SHA}) - state = json.dumps({"inputs": dict(c1["inputs"]), "deploy": {}, "migration": {}, "dev": dict(r1["outputs"], **(dev_over or {})), "group": "g"}) - vin = dict(di, pipeline_phase="verify", phase_state=state, upstream_gate=gate) - out = {"deploy": r1, "jobs": {}, "log": r1["log"], "summary": r1["summary"], "steps": list(r1["steps"])} - res = {} - for name in ("dev-verify", "dev-rollback"): - c = ctx(FEAT, vin, PROD_SECRETS, dict(res)) - if not ev(doc["jobs"][name]["if"], c): - res[name] = dict(SKIPPED) - continue - senv = {"STUB_TIPS": SHA} - senv.update(env or {}) - if ";" in senv.get("STUB_PROBES", ""): - first, rest = senv["STUB_PROBES"].split(";", 1) - senv["STUB_PROBES"] = first if name == "dev-verify" else rest - r = run_job(doc, name, c, make_workspace(DEV_TOML), senv) - res[name] = {"result": r["result"], "outputs": r["outputs"], "log": r["log"], "summary": r["summary"]} - out["log"] += "\n#" + name + "\n" + r["log"]; out["summary"] += r["summary"]; out["steps"].extend(r["steps"]) - out["jobs"] = res - return out - - -dv_ = dev_deferred() -r1 = dv_["deploy"] -check(r1["result"] == "success" and r1["outputs"]["published"] == "true" and r1["outputs"]["ledger_id"] == "4242" and "PUBLISH" in r1["log"] and "state=success" not in r1["log"] - and r1["outputs"]["deployed_and_verified"] == "false" and step(r1, "Smoke the exact deployed URL (fails closed)") in (None, "skipped"), - "deferred Shared Dev (deploy call): migrates / publishes and stops - no smoke, no ledger success, the ledger id is handed over") -check(dv_["jobs"]["dev-verify"]["result"] == "success" and dv_["jobs"]["dev-verify"]["outputs"]["deployed_and_verified"] == "true" and dv_["jobs"]["dev-verify"]["outputs"]["verified_url"] == "https://dev.example.com" - and "state=success" in dv_["jobs"]["dev-verify"]["log"] and dv_["jobs"]["dev-rollback"]["result"] == "skipped", - "deferred Shared Dev (verify call): smoke verifies the published Dev URL and records the ledger success") -dv_ = dev_deferred(env=dict(GD, STUB_PROBES="500 ;200 ")) -check(dv_["jobs"]["dev-verify"]["result"] == "failure" and dv_["jobs"]["dev-verify"]["outputs"]["gate_blocked"] == "false" and dv_["jobs"]["dev-rollback"]["result"] == "success" - and dv_["jobs"]["dev-rollback"]["outputs"]["rollback_result"] == "restored_verified" and "PUBLISH" in dv_["jobs"]["dev-rollback"]["log"] and f"commit={GOOD}" in dv_["jobs"]["dev-rollback"]["log"], - "deferred Shared Dev: a failed smoke recovers the last known-good Dev deployment (rebuilt at the LAST-GOOD sha) and re-verifies it; the smoke job stays red") -dv_ = dev_deferred(env=dict(GD, STUB_PROBES="500 ;200 ", STUB_NEWEST="9999")) -check(dv_["jobs"]["dev-rollback"]["result"] == "failure" and "PUBLISH" not in dv_["jobs"]["dev-rollback"]["log"] and "newer Shared Dev deployment" in dv_["jobs"]["dev-rollback"]["summary"], - "deferred Shared Dev: a NEWER Dev deployment started since (another branch) - recovery is skipped, it never replaces it") -for g in ("failure", "cancelled"): - dv_ = dev_deferred(env=dict(GD, STUB_PROBES="200 "), gate=g) - check(dv_["jobs"]["dev-verify"]["result"] == "failure" and dv_["jobs"]["dev-verify"]["outputs"]["gate_blocked"] == "true" and dv_["jobs"]["dev-rollback"]["result"] == "skipped" - and "Cross-service smoke blocked" in dv_["jobs"]["dev-verify"]["summary"] and "state=failure" in dv_["jobs"]["dev-verify"]["log"] and "PUBLISH" not in dv_["jobs"]["dev-verify"]["log"], - f"deferred Shared Dev: gate '{g}' blocks the verification, marks the deployment unverified and does not recover") -dv_ = dev_deferred(dev_over={"published": "false"}) -check(dv_["jobs"]["dev-verify"]["result"] == "skipped", "deferred Shared Dev: nothing published - nothing to verify") -r = dev(inputs=dict(pipeline_phase="all")) -check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true", "default phase (all): Shared Dev is the unchanged single job (publish + smoke in one)") - -sys.exit(fail) +vdesc = lambda out: json.loads(out["raw"]["plan"]["outputs"]["descriptors"]) +forged = {"inputs": {"app_name": "app"}, "kinds": ["production"], "group": "cfv2-prod-app", "deploy": {"h_production": json.dumps({"published": "true", "url": "http://insecure.example", "published_sha": "0" * 40})}, "migration": {}} + +# persistent Dev through the SAME deferred flow (previously a separate dev-deploy / dev-verify / dev-rollback trio) +DEVI = dict(DEV_INPUTS, preview_on_branch_push=False) + +ND = {"preview_on_branch_push": False, "shared_dev_native_preview_on_branch_push": True, "build_env_shared_dev_native_preview": "SITE=nd", + "build_cmd": 'echo "BUILD kind=$CFV2_DELIVERY_KIND site=${SITE:-none} url=$CFV2_TARGET_URL app_env=$VITE_APP_ENV" >> "$STUB_LOG"'} +NDURL = "https://dev-app.example.workers.dev" +fail_nd = dict(ND, preview_on_branch_push=True, build_cmd=ND["build_cmd"] + '; [ "$CFV2_DELIVERY_KIND" != native-dev ] || false') +# recovery: Native Dev redeploys the last known-good Native Dev deployment, never replaces a newer one +GDN = {"STUB_DEPLOYMENTS": json.dumps([{"id": 7, "sha": GOOD, "payload": {"repo": "o/r", "kind": "native-dev", "preview_name": "dev", "host_worker": "app", "worker": "app"}}]), + "STUB_STATUS": f"success {NDURL} Serving good (content)"} + +# ---- smoke modes +SC = {"smoke_cmd": 'echo SMOKECMD >> "$STUB_LOG"'} + +# ---- multi-target migration: dedupe by physical D1, isolation, per-D1 gating +MD = dict(D1, preview_on_branch_push=True, shared_dev_native_preview_on_branch_push=True) +two_db = APP_TOML + '[env.dev]\nname = "app-dev"\n[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "dev-db"\ndatabase_id = "DEV-ID"\n' +TWO_IN = dict(DEV_INPUTS, **D1, preview_on_branch_push=True, dev_binding_allow="") + +OUTS = doc[True]["workflow_call"]["outputs"] + + +def public_outputs(r, inputs=None, ref=FEAT): + """The PUBLIC workflow_call outputs, rendered from the real output expressions over the per-call results.""" + jobs_ctx = {n: v for n, v in r["res"].items()} + c = Ctx({"github": ctx(ref)["github"], "inputs": ctx(ref, inputs)["inputs"], "jobs": jobs_ctx}, {}) + return {k: render(v["value"], c) for k, v in OUTS.items() if k != "phase_state"} + + +BD = dict(ND, preview_on_branch_push=True) + + +def run_order(order, ref=FEAT, inputs=None, env=None, toml=APP_TOML, env_by_job=None): + 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 _shape(*_run_jobs(order, ref, inputs, PROD_SECRETS, _validation_needs(i, None), "push", env, toml, env_by_job, None), toml) +# (1)+(3) one target failing while the other succeeds, in both orders, and the public outputs stay per-target +bad = dict(BD, build_cmd=BD["build_cmd"] + '; [ "$CFV2_DELIVERY_KIND" != native-dev ] || false') +# (7) rollback isolation: Native Dev's smoke fails and recovers; the Branch Preview is untouched and stays verified +GDN2 = {"STUB_DEPLOYMENTS": json.dumps([{"id": 7, "sha": GOOD, "payload": {"repo": "o/r", "kind": "native-dev", "preview_name": "dev", "host_worker": "app", "worker": "app"}}]), + "STUB_STATUS": f"success {NDURL} Serving good (content)"} +# effective (adapter) config binding another database than the plan's: refused before ANY mutation +REDIR = ('mkdir -p .wrangler/deploy dist && printf \'%s\' \'{"configPath":"../../dist/wrangler.json"}\' > .wrangler/deploy/config.json && ' + 'printf \'%s\' \'{"name":"app","main":"index.js","d1_databases":[{"binding":"DB","database_name":"other","database_id":"OTHER-ID"}]}\' > dist/wrangler.json') +# deploy call refuses to run without the right migration result (own / owner mismatch) +dctx_base = dict(GOODH) + + +def group_production_release(): + """Deploy: Production + Release gate""" + global r, rb, up, rbcfg, code + + + r = deploy() + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and "versions deploy new-2@100" in r["log"], "prod success: published own SHA, smoke passed -> deployed_and_verified=true") + check(r["outputs"]["target"] == "prod" and r["outputs"]["version_id"] == "new-2" and r["outputs"]["url"] == "https://app.example.com", "prod success: outputs target + version + url") + check(release_runs(r), "prod success -> Release runs") + + r = deploy(env={"STUB_TIPS": OTHER}) + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "false" and not published(r), "stale main run: the job succeeds, nothing built or published, not verified") + check("pnpm" not in r["log"] and "BUILD" not in r["log"] and 1 <= r["log"].count("npm ci") <= 2, "stale main run: only the tooling installs (the plan job, and the engine itself unless the harness's tooling dir already has node_modules) ran; no application install or build") + check(not release_runs(r), "REGRESSION stale main run -> Release does NOT run although Deploy succeeded") + + r = deploy(env={"STUB_TIPS": f"{SHA},{OTHER}"}) + check(r["result"] == "success" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "superseded after the build (before migration): publish skipped, not verified") + check(not release_runs(r), "skipped publish -> Release does NOT run") + + r = deploy(env={"STUB_TIPS": "GONE"}) + check(r["result"] == "failure" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "branch tip unresolvable (deleted / API error): fails closed, nothing published") + + r = deploy(env={"STUB_PROBES": "500 "}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and "versions deploy new-2@100" in r["log"], "prod smoke failure (policy manual): job red, not verified") + check("versions deploy prev-1@100" not in r["log"] and "Production smoke FAILED" in r["summary"], "policy manual: no automatic rollback, manual instructions in the summary") + check(not release_runs(r), "prod smoke failure -> Release does NOT run") + + r = deploy(inputs={"production_rollback_policy": "automatic"}, env={"STUB_PROBES": "500 ;200 "}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "prod smoke failure + rollback: job stays red, not verified (smoke failure stays visible although recovery succeeded)") + rb = [l for l in r["log"].splitlines() if "versions deploy prev-1@100" in l] + up = [l for l in r["log"].splitlines() if "versions upload" in l] + rbcfg = json.load(open(cfg(rb[0]))) if rb and "--config" in rb[0] and os.path.exists(cfg(rb[0])) else None + check(len(rb) == 1 and "--config" in rb[0] and cfg(rb[0]) != cfg(up[0]), "the Rollback job uses its own minimal config (it never rebuilds), not the Deploy job's audited config file (another runner)") + check(r["jobs"]["deploy"]["outputs"].get("worker_name") == "app" and "--config" in rb[0], "the rollback identity (Worker name `app`) travelled from the Deploy job as a job output") + check("NOT reverted" in r["summary"] and "rolled back to `prev-1` and verified" in r["summary"], "rollback summary states D1 was not reverted") + check(r["jobs"]["rollback"]["result"] == "success" and r["outputs"]["rollback_result"] == "restored_verified" and r["jobs"]["smoke"]["result"] == "failure", "rollback restored + verified: the Rollback job is green but the SMOKE job (and so the run) stays red") + check(r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["verified_url"] == "", "a rolled-back deployment is never reported as verified") + check(not release_runs(r), "prod rollback -> Release does NOT run") + r = deploy(inputs={"production_rollback_policy": "automatic"}, env={"STUB_PROBES": "500 ;200 ", "STUB_ROLLBACK_FAIL": "1"}) + check(r["result"] == "failure" and r["jobs"]["rollback"]["result"] == "failure" and "rollback to `prev-1` FAILED" in r["summary"] and "rolled back to" not in r["summary"] and "wrangler versions deploy prev-1@100 --name app --yes" in r["summary"], + "a FAILED automatic rollback: Rollback job red, reported as failed (never as rolled back), manual recovery command printed") + check(r["outputs"]["rollback_result"] == "failed" and not release_runs(r), "a failed rollback blocks Release and exposes rollback_result=failed") + + r = deploy(inputs={"app_url_prod": ""}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and step(r, "Smoke the exact deployed URL (fails closed)") == "failure", "prod without a smoke URL FAILS at the smoke step (no silent success)") + check(not release_runs(r), "prod without smoke URL -> Release does NOT run") + + r = deploy(env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "prod redirect to Cloudflare Access without approval FAILS (not application verification)") + r = deploy(inputs={"access_protected_targets": "preview prod"}, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false", "approved Access-protected prod: LIVENESS only -> published but NOT verified") + check(r["outputs"]["published_unverified"] == "true" and r["outputs"]["verified_url"] == "" and r["outputs"]["published_url"] == "https://app.example.com" and r["outputs"]["url"] == "https://app.example.com", "liveness: published_url set (and legacy url kept), verified_url empty, published_unverified=true") + check(not release_runs(r), "prod Access liveness only -> Release BLOCKED") + check("Release is **blocked**" in r["summary"] and "NOT VERIFIED" in r["summary"], "liveness-only Production keeps a diagnostic summary") + r = deploy(env={"STUB_PROBES": "200 "}) + check(r["outputs"]["smoke_level"] == "content" and r["outputs"]["verified_url"] == "https://app.example.com" and r["outputs"]["published_unverified"] == "false" and release_runs(r), "prod content smoke succeeds -> verified_url set, Release allowed") + r = deploy(inputs={"access_protected_targets": "preview prod", "sha_path": "/version.json", "smoke_env": "PROBE_HEADERS=CF-Access-Client-Id: x"}, env={"STUB_PROBES": "200 ", "STUB_SHA_BODY": SHA[:7]}) + check(r["outputs"]["smoke_level"] == "content-sha" and r["outputs"]["deployed_and_verified"] == "true" and release_runs(r), "prod authenticated probe on an Access-protected target + matching SHA -> content-sha, Release allowed") + r = deploy(inputs={"access_protected_targets": "preview prod"}, env={"STUB_PROBES": "500 "}) + check(r["outputs"]["verified_url"] == "" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["published_unverified"] != "true", "failed smoke -> no verified output") + for code in ("401 ", "403 ", "404 ", "500 ", "302 https://evil.example/x", "000 "): + r = deploy(env={"STUB_PROBES": code}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", f"prod probe '{code.strip()}' fails and is not verified") + r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_BODY": "ffffff"}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "SHA endpoint serving a different commit fails") + r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_FAIL": "1"}) + check(r["result"] == "failure", "configured SHA endpoint missing (404) fails instead of being ignored") + r = deploy(inputs={"sha_path": "/version.json"}, env={"STUB_SHA_BODY": '{"sha":"abcdef1"}'}) + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true", "SHA endpoint serving this commit passes") + + r = deploy(inputs={"build_cmd": "false"}) + check(r["result"] == "failure" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "failed build never publishes") + r = deploy(secrets={}) + check(r["result"] == "failure" and not published(r) and "pnpm" not in r["log"], "missing Production credentials fail closed at init (no fallback), before any work") + r = deploy(secrets={"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"}) + check(r["result"] == "failure" and not published(r), "main with ONLY the deprecated Preview credentials fails (they are ignored)") + + +def group_preview_deploy(): + """Deploy: Preview""" + global r, r2, bl, label, over, raw, w + + r = deploy(FEAT) + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and "preview --name feat-x" in r["log"] and "--json" in r["log"], "preview success: stable branch-slug Native Preview published and verified") + check(r["outputs"]["url"] == "https://feat-x-app.example.workers.dev" and r["outputs"]["target"] == "preview", "preview outputs the real URL") + check("-X POST" in r["log"] and "state=success" in r["log"], "preview records a ledger deployment and marks it success only after the smoke") + r2 = deploy(FEAT, env={"STUB_OWNER_REF": "refs/heads/Feat/X"}) + check(r2["result"] == "failure" and not published(r2) and "pnpm exec wrangler preview" not in r2["log"], "branch-name collision (ledger owned by another ref): fails before the build and publish") + r2 = deploy(FEAT, env={"STUB_OWNER_REF": FEAT}) + check(r2["result"] == "success", "ledger owned by THIS branch ref: same branch updates its Preview") + r = deploy(FEAT, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["published_unverified"] == "true", "Access-protected Preview (default approval): liveness reported, published but NOT verified") + 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") + 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") + bl = [l for l in r["log"].splitlines() if l.startswith("BUILD")] + check(len(bl) == 2 and bl[0] == f"BUILD noindex=true commit={SHA} ver={SHA} gaid=test-ga", "the normal Preview build uses the Preview build env (smoke_env cannot leak into it)") + check(len(bl) == 2 and bl[1] == f"BUILD noindex=true commit={GOOD} ver={GOOD} gaid=test-ga", "recovery rebuild restores the Preview build env and rebinds commit/version to the LAST-GOOD sha (not the smoke_env values)") + check("restored to last good commit" in r["summary"] and "content-level" in r["summary"] and "NOT reverted" in r["summary"], "recovery verified at content level is reported as such, D1 not reverted") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(payload={"host_worker": "blog-preview"})}) + check(r["result"] == "failure" and r["log"].count("preview --name feat-x") == 1 and "No prior verified preview deployment on host Worker" in r["summary"], "a last-good deployment hosted by a DIFFERENT Worker is not eligible for recovery") + for label, over in (("legacy record without host_worker", {"payload": {"host_worker": None}}), ("different branch", {"payload": {"branch_ref": "refs/heads/other"}}), + ("different application", {"payload": {"worker": "other-app"}}), ("different repository", {"payload": {"repo": "x/y"}})): + dep = json.loads(good_dep(**over)) + if label.startswith("legacy"): + del dep[0]["payload"]["host_worker"] + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": json.dumps(dep)}) + check(r["log"].count("preview --name feat-x") == 1 and "No prior verified" in r["summary"], f"recovery ineligible: {label}") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "failure https://feat-x-app.example.workers.dev"}) + check(r["log"].count("preview --name feat-x") == 1, "recovery ineligible: the candidate's latest status is not success") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "success "}) + check(r["log"].count("preview --name feat-x") == 1, "recovery ineligible: no valid recorded Preview URL") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_STATUS": "success https://other.example.workers.dev Serving good (content)"}) + check(r["result"] == "failure" and "restored to last good" not in r["summary"] and "Automatic recovery FAILED" in r["summary"], "recovery whose URL differs from the recorded last-good URL is refused and reported as failed") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;500 ", "STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and "Automatic recovery FAILED" in r["summary"] and "restored to last good" not in r["summary"] and "state=error" in r["log"], "recovery smoke failure stays visible, is recorded as error and is never reported as recovered") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ;302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x", "STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and "NOT verified" in r["summary"] and "restored to last good" not in r["summary"], "Access liveness after recovery is NOT reported as verified recovery") + r = deploy(FEAT, inputs=dict(PREV_IN, sha_path="/version.json"), env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_SHA_BODY": SHA[:7]}) + check(r["result"] == "failure" and "restored to last good" not in r["summary"] and "Automatic recovery FAILED" in r["summary"], "recovery serving the FAILED commit's identity (sha_path) is not accepted") + r = deploy(FEAT, inputs=dict(PREV_IN, sha_path="/version.json"), env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep(), "STUB_SHA_BODY": GOOD[:7]}) + check("restored to last good commit" in r["summary"], "recovery serving the last-good commit identity is verified") + # a newer push superseded this run while the smoke was failing: recovery must not replace the newer deployment + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PROBES": "500 ", "STUB_DEPLOYMENTS": good_dep()}, env_by_job={"rollback": {"STUB_TIPS": OTHER}}) + check(r["result"] == "failure" and r["log"].count("preview --name feat-x") == 1 and "superseded" in r["summary"], "recovery is skipped when a newer push owns the branch (it never replaces a newer deployment)") + # the empty-URL incident, executed through the real engine + for raw, label in (('{"preview":{"urls":[]}}', "empty preview.urls"), ('{"preview":{}}', "missing urls"), ("garbage", "malformed JSON"), ("", "no output")): + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PREVIEW_RAW": raw, "STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and step(r, "Publish the audited artifact") == "failure", f"{label}: Publish fails") + check(step(r, "Smoke the exact deployed URL (fails closed)") == "skipped" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["url"] == "", f"{label}: Smoke never runs against an empty URL and nothing is reported as deployed") + check(r["log"].count("preview --name feat-x") == 1 and "no usable URL" in r["summary"] and "unverified" in r["summary"], f"{label}: no recovery is attempted and the summary says the Preview state is unverified") + r = deploy(FEAT, inputs=PREV_IN, env={"STUB_PREVIEW_FAIL": "1", "STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and "unchanged" in r["summary"] and r["log"].count("preview --name feat-x") == 1, "wrangler preview failure before any mutation: 'unchanged', no recovery") + r = deploy(FEAT, inputs=dict(PREV_IN, build_cmd="false"), env={"STUB_DEPLOYMENTS": good_dep()}) + check(r["result"] == "failure" and "preview --name" not in r["log"] and "unchanged" in r["summary"], "build failure: nothing published, no recovery") + r = deploy(FEAT, secrets=dict(PREV_SECRETS, PREVIEW_SECRETS="EVIL=$(touch PWNED)`touch PWNED2`;touch PWNED3 \"q\" 'x'\nPLAIN=a b=c"), + inputs={"build_cmd": 'printf %s "$EVIL" > evil.out && printf %s "$PLAIN" > plain.out'}) + w = r["ws"] / "w" + check(r["result"] == "success" and not any((w / n).exists() for n in ("PWNED", "PWNED2", "PWNED3")), "Preview secret with $() / backticks / ; is NOT executed") + check((w / "evil.out").read_text() == "$(touch PWNED)`touch PWNED2`;touch PWNED3 \"q\" 'x'" and (w / "plain.out").read_text() == "a b=c", "Preview secret values reach the build process literally") + check(not (r["ws"] / "tmp" / "cfv2").exists(), "staged Preview secrets and engine state are removed at the end of the job") + r = deploy(FEAT, secrets=dict(PREV_SECRETS, PREVIEW_SECRETS="CLOUDFLARE_API_TOKEN=evil")) + check(r["result"] == "failure" and not published(r), "Preview secrets cannot override Cloudflare credentials") + r = deploy(FEAT, secrets=PROD_SECRETS) + check(r["result"] == "success" and published(r), "a feature branch publishes with the account credentials") + r = deploy(MAIN, secrets=dict(PROD_SECRETS, PREVIEW_SECRETS="K=v"), inputs={"build_cmd": 'printf %s "${K:-unset}" > k.out'}) + check(r["result"] == "success" and (r["ws"] / "w" / "k.out").read_text() == "unset", "Production build never receives Preview-only secrets") + + +def group_migration_d1(): + """Migration is a STEP of Deploy, after build + audit""" + global m, bad, ref, label, line, name + m = deploy(MAIN, inputs=dict(D1, build_cmd=MB)) + check(m["result"] == "success" and "d1 migrations apply DB --remote" in m["log"] and "PROD-ID" in mig_file(m, "prod"), "prod migration applies to the Production D1") + check(m["log"].index("BUILD") < m["log"].index("migrations apply") < m["log"].index("versions upload"), "order in the executed job: build -> migration -> publish") + check(step(m, "Apply pending D1 migrations (this target only)") == "success" and m["outputs"]["migration"] == "applied", "Migration is an explicit step inside the Deploy job and reports `applied`") + m = deploy(FEAT, inputs=dict(D1, build_cmd=MB)) + check(m["result"] == "success" and "PREV-ID" in mig_file(m, "preview") and "PROD-ID" not in mig_file(m, "preview") and m["log"].index("BUILD") < m["log"].index("migrations apply") < m["log"].index("preview --name"), "preview migration applies to previews.d1_databases only, after the build and before the publish") + m = deploy(FEAT, inputs=D1, env={"STUB_PENDING": "0"}) + check(m["result"] == "success" and not migrated(m) and "versions" not in m["log"] and "preview --name feat-x" in m["log"], "nothing pending: success without applying, publish continues") + m = deploy(FEAT, inputs=D1, env={"STUB_OWNER_REF": "refs/heads/Feat/X"}) + check(m["result"] == "failure" and not migrated(m) and not published(m), "name collision: second branch fails BEFORE any remote migration") + m = deploy(FEAT, inputs=D1, env={"STUB_TIPS": OTHER}) + check(m["result"] == "failure" and not migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false", "stale branch: no migration applied, nothing published; the superseded run's Delivery fails at the Migration Gate (a stale-skipped migration authorizes nothing)") + check(m["jobs"]["migration"]["result"] == "success" and m["jobs"]["migration"]["outputs"].get("stale") == "true" and "BUILD" not in m["log"], "stale branch: the Migration job itself still succeeds as a skip (stale=true), and nothing is built") + # P1 (PR #89 review): branch A -> B -> A. The Migration job runs while the tip is B (stale, skipped), the Delivery runs after a force-push back to A (fresh). + m = deploy(FEAT, inputs=D1, env_by_job={"migration": {"STUB_TIPS": OTHER}, "deploy": {"STUB_TIPS": SHA}}) + check(m["jobs"]["migration"]["result"] == "success" and m["jobs"]["migration"]["outputs"].get("stale") == "true" and not migrated(m), "A->B->A: the Migration job was superseded and skipped the migration") + check(m["res"]["delivery_native_branch"]["jobs"]["deploy"]["result"] == "failure" and any(n.endswith("Migration gate (fail closed before any build, migration or publish)") and o == "failure" for n, o in m["steps"]) + and "BUILD" not in m["log"] and not published(m) and json.loads(m["res"]["delivery_native_branch"]["outputs"]["handoff"] or "{}").get("published", "false") != "true", + "A->B->A: the Delivery sees a FRESH tip but the Migration Gate rejects the stale result BEFORE the build; nothing is built or published") + check(m["res"]["delivery_native_branch"]["jobs"]["smoke"]["outputs"] == {} or m["outputs"]["deployed_and_verified"] == "false", "A->B->A: nothing is smoked or verified") + check(m["res"]["delivery_native_branch"]["jobs"]["rollback"]["result"] == "skipped" and m["outputs"]["rollback_result"] == "", "A->B->A: the failed Migration Gate never triggers a rollback of an artifact that was never published") + check(not release_runs(m), "A->B->A: no Release") + fm = deploy(FEAT, inputs=D1, env_by_job={"migration": {"STUB_TIPS": OTHER}, "deploy": {"STUB_TIPS": SHA}}) + check(fm["jobs"]["migration"]["outputs"].get("migration") in (None, "", "none") or fm["jobs"]["migration"]["outputs"].get("stale") == "true", "A->B->A: the stale Migration result is not represented as an applied migration") + m = deploy(FEAT, inputs=D1, env={"STUB_TIPS": "GONE"}) + check(m["result"] == "failure" and not migrated(m), "deleted branch: fails closed, no migration") + # P2: a push that lands DURING the (long) migration must not let the old code be published afterwards + m = deploy(FEAT, inputs=D1, env_by_job={"deploy": {"STUB_TIPS": OTHER}}) + check(m["result"] == "success" and migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false", "branch advanced DURING the migration: re-checked right before publish, old code is NOT published") + check(step(m, "Stale-SHA guard (start)") == "success" and step(m, "Publish the audited artifact") == "skipped" and step(m, "Smoke the exact deployed URL (fails closed)") == "skipped" and m["jobs"]["smoke"]["outputs"].get("deployed_and_verified", "false") != "true", "post-migration stale run skips publish and the Smoke engine (nothing was published, so nothing is smoked or verified)") + bad = APP_TOML.replace('database_id = "PREV-ID"', 'database_id = "PROD-ID"').replace('database_name = "prev-db"', 'database_name = "prod-db"') + m = deploy(FEAT, inputs=D1, toml=bad) + check(m["result"] == "failure" and not migrated(m) and not published(m), "Preview D1 identical to Production D1: fails before any migration (preflight / target check)") + m = deploy(MAIN, inputs=D1, toml=bad) + check(m["result"] == "failure" and not published(m), "same for the Production target: nothing published") + m = deploy(FEAT, inputs=D1, toml=amb) + check(m["result"] == "failure" and not migrated(m), "ambiguous D1 bindings without d1_binding: fails closed") + m = deploy(FEAT, inputs=dict(D1, d1_binding="DB2"), toml=amb) + check(m["result"] == "success" and "P2" in mig_file(m, "preview"), "d1_binding selects the intended database") + m = deploy(FEAT, inputs=D1, toml='name = "app"\n[previews]\n') + check(m["result"] == "failure" and not migrated(m), "no D1 declared for the target while d1_migrations is on: fails closed") + m = deploy(FEAT, inputs=D1, secrets={}) + check(m["result"] == "failure" and not migrated(m), "missing credentials: fails before any remote command") + m = deploy(FEAT, inputs=D1, secrets=PROD_SECRETS) + check(m["result"] == "success" and migrated(m), "branch migrates with the account credentials") + for ref, label in ((FEAT, "preview"), (MAIN, "prod")): + m = deploy(ref, inputs=D1, env={"STUB_APPLY_FAIL": "1"}) + check(m["result"] == "failure" and migrated(m) and not published(m) and m["outputs"]["deployed_and_verified"] == "false" and m["outputs"]["migration"] == "failed", f"failed {label} migration: job fails, NOTHING is published or verified") + check(not release_runs(m), f"failed {label} migration -> Release cannot run") + m = deploy(FEAT, inputs=dict(D1, build_cmd="false")) + check(m["result"] == "failure" and not migrated(m) and not published(m), "a failed build never reaches the migration (schema is not changed before a deployable exists)") + m = deploy(MAIN, inputs=dict(D1, build_cmd=eff(mismatch))) + check(m["result"] == "failure" and not migrated(m) and not published(m), "prod: effective config binds a DIFFERENT D1 than the migration target -> fails before migration and publish") + m = deploy(MAIN, inputs=dict(D1, build_cmd=eff({"name": "app", "d1_databases": [{"binding": "DB", "database_name": "prod-db", "database_id": "PROD-ID"}]}))) + check(m["result"] == "success" and migrated(m), "prod: effective config binding the SAME D1 migrates and publishes") + m = deploy(FEAT, inputs=dict(D1, build_cmd=eff({"name": "app", "previews": {"d1_databases": [{"binding": "DB", "database_name": "x", "database_id": "WRONG"}]}}))) + check(m["result"] == "failure" and not migrated(m) and not published(m), "preview: effective config binds a different D1 -> fails before migration and publish") + m = deploy(FEAT, inputs=dict(D1, build_cmd=eff({"name": "app", "previews": {}}))) + check(m["result"] == "failure" and not migrated(m), "preview: effective config without the migrated D1 binding fails") + m = deploy(MAIN, inputs={"build_cmd": eff({"name": "app", "d1_databases": [{"binding": "DB", "database_name": "prev-db", "database_id": "PREV-ID"}]})}) + check(m["result"] == "failure" and not published(m), "prod: effective config binding a Preview resource is rejected even without migrations") + for line, name in EVIL: + for inp in ("build_env_prod", "smoke_env"): + m = deploy(MAIN, inputs=dict(D1, **{inp: line})) + check(m["result"] == "failure" and not migrated(m) and not published(m), f"{inp} cannot set the protected variable {name}: rejected before any work") + m = deploy(FEAT, inputs=dict(D1, build_env_preview=line)) + check(m["result"] == "failure" and not migrated(m) and not published(m), f"build_env_preview cannot set {name}") + # a build COMMAND that writes TARGET/DEV_ENV into GITHUB_ENV still cannot redirect the migration: the engine binds the target + m = deploy(FEAT, inputs=dict(D1, build_cmd='echo TARGET=prod >> "$GITHUB_ENV"; echo DEV_ENV=prod >> "$GITHUB_ENV"; echo D1_BINDING=NOPE >> "$GITHUB_ENV"')) + check(m["result"] == "success" and "PREV-ID" in mig_file(m, "preview") and not (m["ws"] / "w" / ".wrangler" / "migrate.prod.json").exists(), "a build writing TARGET=prod into GITHUB_ENV cannot move the Preview migration to Production") + # ---- ONE Database Migration job: the required scenarios (PR #89 simplification), each as an explicit check + e200 = {"STUB_PROBES": "200 "} + m = deploy(MAIN, inputs=D1, env=e200) + mo = public_outputs(m, D1, MAIN) + check(m["result"] == "success" and m["log"].count("migrations apply") == 1 and "PROD-ID" in mig_file(m, "prod") and m["res"]["migration"]["result"] == "success" and mo["migration_status"] == "applied", + "main push: the Production D1 is migrated exactly ONCE, by the single Migration job (migration_status=applied)") + m = deploy(FEAT, inputs=D1, env=e200) + mo = public_outputs(m, D1) + check(m["result"] == "success" and m["log"].count("migrations apply") == 1 and "PREV-ID" in mig_file(m, "preview") and "PROD-ID" not in mig_file(m, "preview") and mo["migration_status"] == "applied", + "feature-branch push: the Preview D1 is migrated exactly ONCE and never the Production D1") + m = deploy(FEAT, env=e200) + mo = public_outputs(m) + check(m["result"] == "success" and m["res"]["migration"]["result"] == "skipped" and not migrated(m) and "preview --name feat-x" in m["log"] and mo["migration_status"] == "none", + "d1_migrations=false (D1-less app or migration disabled): the Migration job is skipped, Delivery is NOT blocked, migration_status=none") + m = deploy(MAIN, env=e200) + check(m["res"]["migration"]["result"] == "skipped" and m["res"]["delivery_production"]["result"] == "success" and not migrated(m), "same on main: a skipped Migration never blocks the Production delivery") + m = deploy(FEAT, inputs=D1, env={"STUB_APPLY_FAIL": "1"}) + check(m["res"]["migration"]["result"] == "failure" and public_outputs(m, D1)["migration_status"] == "failed" and m["res"]["delivery_native_branch"]["result"] == "skipped" and not published(m), + "failed Migration: migration_status=failed and the dependent Delivery never starts (nothing published, no automatic schema rollback)") + m = deploy(MAIN, inputs=D1, toml=bad) + check(m["result"] == "failure" and not migrated(m) and not published(m), "a Production D1 mapped into the Preview/Dev side fails inside the Migration job before any mutation") + md = deferred(MAIN, inputs=dict(D1), env=e200) + check(md["deploy"]["log"].count("migrations apply") == 1 and "migrations apply" not in md["log"], "pipeline_phase deferred verification never repeats the Migration (it ran once in the deploy call)") + + +def group_shared_dev(): + """Shared Dev (dev-deploy) executed with stubs""" + global r, label, repl, extra, MAIN_TOML, line, stage_job, stage, both, builds, rb + + + r = dev() + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true" and r["outputs"]["url"] == "https://dev.example.com", "Shared Dev success: built, audited, published, smoked, verified at app_url_dev") + check(ran(r, "BUILD ver=" + SHA + " commit=" + SHA + " cfenv=dev") and ran(r, "PUBLISH"), "Shared Dev builds with the Dev build env and CLOUDFLARE_ENV=dev_wrangler_env") + check("state=success" in r["log"] and "d1 migrations" not in r["log"], "Shared Dev success is recorded in the ledger only after the smoke; no migration for a D1-less config") + 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(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")) + check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "build failure: no migration, no publish") + r = dev(toml=DEV_TOML.replace('name = "app-dev"', 'name = "app"'), inputs=D1) + check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "audit failure (wrong Worker identity): no migration, no publish") + r = dev(inputs=D1, env={"STUB_APPLY_FAIL": "1"}) + check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", "migration failure prevents publish") + r = dev(inputs={"dev_binding_allow": "services:AUTH=auth-dev"}) + check(r["result"] == "failure" and not ran(r, "PUBLISH") and not ran(r, "migrations apply"), "unapproved Production R2 sharing fails the audit before anything is published") + r = dev(inputs={"dev_binding_allow": "r2_buckets:ASSETS=another-bucket services:AUTH=auth-dev"}) + check(r["result"] == "failure" and not ran(r, "PUBLISH"), "allowlist naming a different bucket does not approve the shared one") + r = dev(inputs={"dev_deny_resources": "prod-assets"}) + check(r["result"] == "failure" and not ran(r, "PUBLISH"), "dev_deny_resources beats dev_binding_allow") + r = dev(inputs={"preview_deny_resources": "prod-assets"}) + check(r["result"] == "success", "a Preview-only denylist does not affect the approved Dev share") + # P1: Production Queue / Service / storage bindings in the Dev config + for label, repl in (("Production Queue producer", ('queue = "dev-queue"', 'queue = "prod-queue"')), + ("Production Service binding", ('service = "auth-dev"', 'service = "auth-prod"')), + ("Production D1", ('[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "shared-db"\ndatabase_id = "SHARED-ID"', '[[env.dev.d1_databases]]\nbinding = "DB"\ndatabase_name = "prod-db"\ndatabase_id = "PROD-ID"'))): + r = dev(toml=DEV_TOML.replace(*repl), inputs={"dev_binding_allow": "r2_buckets:ASSETS=prod-assets services:AUTH=auth-prod"} if "Service" in label else None) + check(r["result"] == "failure" and ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", f"Dev deployment with a {label} fails the audit before publish") + r = dev(toml=DEV_TOML.replace('queue = "dev-queue"', 'queue = "prod-queue"'), inputs=D1) + check(r["result"] == "failure" and not ran(r, "migrations apply"), "a Production Queue in the Dev config also stops the Dev migration") + # Production KV / Hyperdrive / Durable Object / Secrets Store through the effective (adapter-generated) config + for label, extra in (("KV namespace", {"kv_namespaces": [{"binding": "K", "id": "PROD-KV"}]}), ("Hyperdrive", {"hyperdrive": [{"binding": "H", "id": "PROD-HD"}]}), + ("Durable Object from another Worker", {"durable_objects": {"bindings": [{"name": "DO", "class_name": "C", "script_name": "app"}]}}), + ("Queue consumer", {"queues": {"consumers": [{"queue": "prod-queue"}]}})): + src = DEV_TOML + {"KV namespace": '[[kv_namespaces]]\nbinding = "K"\nid = "PROD-KV"\n', "Hyperdrive": '[[hyperdrive]]\nbinding = "H"\nid = "PROD-HD"\n'}.get(label, "") + eff_cfg = dict({"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"}]}, **extra) + r = dev(toml=src, inputs={"dev_build_cmd": eff(eff_cfg)}) + check(r["result"] == "failure" and not ran(r, "PUBLISH"), f"effective Dev config with a Production {label} fails the audit") + 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") + + # 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)) + check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), f"build_env_dev cannot set {line.split('=')[0]}") + r = dev(inputs=dict(D1, smoke_env="TARGET=prod")) + check(r["result"] == "failure" and not ran(r, "migrations apply"), "smoke_env cannot set TARGET either") + r = dev(inputs=dict(D1, dev_build_cmd='echo TARGET=prod >> "$GITHUB_ENV"; echo DEV_ENV=prod >> "$GITHUB_ENV"; echo "BUILD" >> "$STUB_LOG"')) + check(r["result"] == "success" and "SHARED-ID" in mig_file(r, "dev") and not (r["ws"] / "w" / ".wrangler" / "migrate.prod.json").exists(), "a Dev build writing TARGET=prod / DEV_ENV=prod into GITHUB_ENV cannot redirect the Dev migration to Production") + + r = dev(secrets={}) + check(r["result"] == "failure" and not ran(r, "BUILD"), "missing account credentials fail closed before Dev build") + r = dev(secrets={"CLOUDFLARE_PREVIEW_API_TOKEN": "VT", "CLOUDFLARE_PREVIEW_ACCOUNT_ID": "VA"}) + check(r["result"] == "failure" and not ran(r, "BUILD"), "Preview-only credentials are never a fallback for Dev") + r = dev(env={"STUB_TIPS": OTHER}) + check(r["result"] == "success" and not ran(r, "BUILD") and not ran(r, "PUBLISH") and r["outputs"]["deployed_and_verified"] == "false", "stale run: nothing built, migrated or published") + r = dev(env={"STUB_TIPS": f"{SHA},{OTHER}"}, inputs=D1) + check(r["result"] == "failure" and not ran(r, "migrations apply") and not ran(r, "PUBLISH"), "superseded after the build: no migration and no publish (the Delivery fails at the Migration Gate on the stale-skipped migration)") + r = dev(inputs=D1, env_by_job={"deploy": {"STUB_TIPS": OTHER}}) + 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": ""}) + 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)") + for stage_job in ("migration", "deploy"): # defence in depth: even if a job were forced to run, the engine refuses at init + ws_ = make_workspace() + c_ = ctx(MAIN, dict(BADCFG, d1_migrations=True), PROD_SECRETS, {}) + if stage_job == "migration": + pj = {"descriptors": json.dumps({"production": json.loads(desc("production", "PROD-ID", "production"))}), "migration": json.dumps({"kind": "production", "environment": "app/prod", "d1_id": "PROD-ID", "kinds": ["production"]}), + "on_production": "true", "on_native_branch": "false", "on_native_dev": "false", "on_persistent_dev": "false"} + c_ = ctx(MAIN, dict(BADCFG, d1_migrations=True), PROD_SECRETS, {"gate": {"result": "success", "outputs": {}}, "plan": {"result": "success", "outputs": pj}}) + fr = run_job(doc, "migration", c_, ws_, {"STUB_TIPS": SHA}) + else: + dc_ = Ctx({"github": c_["github"], "inputs": dict(DINPUT_DEFAULTS, descriptor=desc("production"), inputs_json=json.dumps(dict(c_["inputs"]))), "needs": {}, "jobs": {}}, PROD_SECRETS) + fr = run_job(ddoc, stage_job, dc_, ws_, {"STUB_TIPS": SHA}) + check(fr["result"] == "failure" and not published(fr) and "wrangler versions" not in fr["log"] and "migrations apply" not in fr["log"] and "-X POST" not in fr["log"], f"production-only + Shared Dev: the {stage_job} job fails at init (nothing remote)") + for both in ({"shared_dev_on_branch_push": True, "shared_dev_native_preview_on_branch_push": True},): + r = deploy(FEAT, inputs=both) + check(r["jobs"]["plan"]["result"] == "failure" and not published(r), "persistent Dev + Native Dev both enabled: conflicting automatic Dev channels, the plan fails closed") + r = deploy(MAIN, inputs={"deployment_strategy": "production-only", "preview_on_branch_push": True}, env={"STUB_PROBES": "200 "}) + check(r["result"] == "success" and published(r) and r["outputs"]["deployed_and_verified"] == "true", "production-only + default Preview=true: accepted, Production deploys") + + + r = lifecycle(good_dep()) + check(r["result"] == "success" and advertised(r), "lifecycle: a verified Preview for this host Worker, app, repo and branch is advertised") + check(not advertised(lifecycle(good_dep(payload={"host_worker": "blog-preview"}))), "lifecycle: an old-host (blog-preview) Preview record is NOT advertised for the new host") + check(not advertised(lifecycle(good_dep(payload={"branch_ref": "refs/heads/other"}))), "lifecycle: another branch ref is not advertised") + check(not advertised(lifecycle(good_dep(payload={"worker": "other-app"}))) and not advertised(lifecycle(good_dep(payload={"repo": "x/y"}))), "lifecycle: another application or repository is not advertised") + check(not advertised(lifecycle(good_dep(), status="success https://feat-x-app.example.workers.dev Published s; unverified (liveness)")), "lifecycle: a liveness-only Preview is not advertised as verified") + check(not advertised(lifecycle(good_dep(), status="success http://insecure.example")), "lifecycle: a non-https or unverified record is not advertised") + r = lifecycle("[]") + check(r["result"] == "success" and not advertised(r), "lifecycle: no matching verified Preview leaves the PR comment unchanged (job still succeeds)") + + # Verification level: Access liveness is published-but-unverified and never a known-good record + r = dev(inputs={"access_protected_targets": "preview dev"}, env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(r["result"] == "success" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["verified_url"] == "" and r["outputs"]["published_unverified"] == "true", "Shared Dev Access liveness only: published, NOT verified") + check("state=success" not in r["log"] and "state=inactive" in r["log"], "Shared Dev liveness is not recorded as content-verified last-good") + r = dev(env=dict(GD, STUB_PROBES="500 ;200 ")) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and "unverified" in r["summary"], "smoke failure: job red, deployment marked unverified") + builds = [l for l in r["log"].splitlines() if l.startswith("BUILD")] + check(len(builds) == 2 and builds[1] == f"BUILD ver={GOOD} commit={GOOD} cfenv=dev", "recovery builds the LAST-GOOD commit with its own version and rebound build env (not the failed SHA)") + check(r["log"].count("PUBLISH") == 2 and "Run remains FAILED" in r["summary"] and "Commit identity was NOT verified" in r["summary"], "recovery redeploys; without sha_path it says commit identity was NOT verified; run stays red") + r = dev(inputs={"sha_path": "/version.json"}, env=dict(GD, STUB_PROBES="500 ;200 ", STUB_SHA_BODY=GOOD[:7])) + check(r["result"] == "failure" and "restored to last good commit" in r["summary"] and "Commit identity was NOT verified" not in r["summary"], "with sha_path the recovery verifies the restored commit identity") + r = dev(inputs={"sha_path": "/version.json"}, env=dict(GD, STUB_PROBES="500 ;200 ", STUB_SHA_BODY=SHA[:7])) + check(r["result"] == "failure" and "restored to last good" not in r["summary"], "recovery serving the wrong commit is NOT reported as verified") + r = dev(env=dict(GD, STUB_PROBES="500 ;500 ")) + check(r["result"] == "failure" and "recovery FAILED" in r["summary"] and "state=error" in r["log"], "recovery failure stays visible and is recorded as error") + r = dev(env={"STUB_PROBES": "500 "}) + check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "manual recovery required" in r["summary"], "no known-good commit: no recovery attempted, manual recovery required") + r = dev(env=dict(GD, STUB_PROBES="500 ;200 ")) + check("versions upload" not in r["log"] and "versions deploy" not in r["log"] and "preview --name" not in r["log"], "Dev recovery never takes the Production or Preview publish path") + check("state=success" not in r["log"], "a failed Dev deployment whose recovery succeeded is still never recorded as verified") + r = dev(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"], "Dev recovery is skipped when a newer push owns the branch") + r = dev(inputs={"dev_build_cmd": tamper}, env=dict(GD, STUB_PROBES="500 ;200 ")) + check(r["result"] == "failure" and r["log"].count("PUBLISH") == 1 and "Automatic recovery FAILED" in r["summary"], "the config audit runs on the RECOVERED build: a last-good tree with the wrong Worker identity is not published") + rb = dev(env=dict(GD, STUB_PROBES="500 ;200 "), inputs={"build_env_dev": f"PUBLIC_COMMIT_SHA={SHA}\nSHORT={SHA[:7]}"}) + 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") + 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"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run from a ref other than persistent_dev_ref is refused by the engine too") + + +def group_chain_smoke_rollback(): + """Migration -> Deploy -> Smoke -> Rollback chain""" + global failing, r, pol, label, inp, files, code, ev_ref, head, nm, bad, fr, key, val, sm_ok + + # 1/2. CI / E2E failure -> nothing downstream, nothing remote + for failing in ("lint", "unit", "build", "e2e"): + r = deploy(MAIN, inputs=dict(D1, lint_cmd="l", test_unit_cmd="u", test_e2e_cmd="e", **AUTO), needs_over={failing: "failure"}) + check(res_of(r) == NOTHING and r["log"] == "" and not release_runs(r), f"CI: failed {failing} -> Migration, Deploy, Smoke and Rollback are all skipped; nothing remote is touched; no Release") + # 3. migration failure + r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_APPLY_FAIL": "1"}) + check(res_of(r) == {"migration": "failure", "deploy": "skipped", "smoke": "skipped", "rollback": "skipped"} and not published(r) and not release_runs(r), "Migration failure -> the dependent Delivery never starts (job-level), Smoke and Rollback have nothing to do, nothing published, Release blocked") + check("D1 migration failed" in r["summary"] and r["outputs"]["migration"] == "failed", "a failed migration is reported explicitly (migration_status=failed, summary)") + # 4. deploy failure + r = deploy(MAIN, inputs=AUTO, env={"STUB_UPLOAD_FAIL": "1"}) + check(res_of(r) == {"migration": "skipped", "deploy": "failure", "smoke": "skipped", "rollback": "skipped"} and not release_runs(r), "Deploy failure -> Smoke and Rollback skipped (nothing was published to verify or revert), Release blocked") + r = deploy(FEAT, env={"STUB_PREVIEW_FAIL": "1"}) + check(res_of(r)["deploy"] == "failure" and res_of(r)["smoke"] == "skipped" and res_of(r)["rollback"] == "skipped", "Preview publish failure -> Smoke and Rollback skipped") + # 5. smoke success -> rollback skipped, release allowed + r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_PROBES": "200 "}) + check(res_of(r) == {"migration": "success", "deploy": "success", "smoke": "success", "rollback": "skipped"} and r["result"] == "success" and release_runs(r), "Smoke success -> Rollback skipped, run green, Release allowed") + check(r["jobs"]["deploy"]["outputs"]["published"] == "true" and r["outputs"]["deployed_and_verified"] == "true" and r["jobs"]["deploy"]["outputs"].get("deployed_and_verified") != "true", "Deploy only reports `published`; `verified` exists only after the Smoke job") + check(r["jobs"]["smoke"]["outputs"]["smoke_level"] == "content", "the verification level is explicit (content)") + check("deployments/5151/statuses" not in r["log"], "a verified Production deployment keeps GitHub's own success status (no unverified annotation)") + # 6. production smoke failure -> automatic rollback (restored, verified), release blocked + r = deploy(MAIN, inputs=dict(AUTO, sha_path="/version.json"), env={"STUB_PROBES": "200 ;200 ", "STUB_SHA_BODY": OTHER[:7]}, + env_by_job={"rollback": {"STUB_SHA_BODY": "", "STUB_PREV_MESSAGE": "[1111111] previous"}}) + check(res_of(r)["smoke"] == "failure" and "wrangler versions view prev-1" in r["log"], "post-rollback smoke resolves the previous version's commit from its deployment message") + r = deploy(MAIN, inputs=dict(AUTO, sha_path="/version.json"), env={"STUB_PROBES": "200 ;200 ", "STUB_SHA_BODY": OTHER[:7]}, env_by_job={"rollback": {"STUB_SHA_BODY": "1111111"}}) + check(res_of(r) == {"migration": "skipped", "deploy": "success", "smoke": "failure", "rollback": "success"} and r["outputs"]["rollback_result"] == "restored_verified", + "Production smoke fails on the wrong commit; rollback restores prev-1; the restored deployment no longer serves the failed commit -> restored_verified") + check(r["outputs"]["smoke_level"] == "" and r["outputs"]["deployed_and_verified"] == "false" and not release_runs(r) and r["result"] == "failure", "a successful rollback still leaves the run FAILED, the deployment unverified and Release blocked") + check("deployments/5151/statuses -f state=failure" in r["log"] and "UNVERIFIED" in r["log"], "the failed deployment is annotated UNVERIFIED on its GitHub Environment deployment") + check("deployments/5151/statuses -f state=inactive" in r["log"], "after a verified rollback the failed Environment deployment is marked inactive (superseded by the restored version)") + # rollback runs once, only the previous version of THIS Worker, from a runner that never built anything + check(r["log"].count("wrangler versions deploy prev-1@100") == 1 and "BUILD" not in "".join(l for l in r["log"].splitlines() if "prev-1@100" in l), "exactly one rollback, to the previous version id recorded by Deploy") + check(not any(f.startswith("dist") for f in os.listdir(r["wss"]["rollback"] / "w")), "the Rollback job builds nothing (a failing build can never block a rollback)") + # 7. rollback failure -> failed run, loud + r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;200 ", "STUB_ROLLBACK_FAIL": "1"}) + check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "failed" and "Roll back manually" in r["summary"], "Rollback failure -> job red, rollback_result=failed, manual command in the summary") + # restored but the post-rollback smoke fails -> not green + r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;500 "}) + check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "restored_unhealthy" and "post-rollback smoke FAILED" in r["summary"], "rolled back but the restored Production fails the smoke -> restored_unhealthy, job red, loud summary") + r = deploy(MAIN, inputs=dict(AUTO, access_protected_targets="preview prod"), env={"STUB_PROBES": "500 ;302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "restored_unverified", "restored Production reachable only through Access liveness -> restored_unverified, job red (never reported as verified)") + # 8. previous version missing -> fail safe, never pretend + r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ", "STUB_NO_PREV": "1"}) + check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "blocked" and "wrangler versions deploy" not in r["log"].replace("versions deploy new-2", ""), "no previous version id -> NO rollback attempted, Rollback job red, rollback_result=blocked") + check("No previous Worker version id was recorded" in r["summary"] and "STILL serves the unverified version" in r["summary"], "the summary says Production still serves the unverified version (no fake success)") + # policy manual / disabled: no Rollback job, manual command in the Smoke job summary + for pol in ("manual", "disabled"): + r = deploy(MAIN, inputs={"production_rollback_policy": pol}, env={"STUB_PROBES": "500 "}) + check(res_of(r)["rollback"] == "skipped" and res_of(r)["smoke"] == "failure" and "wrangler versions deploy prev-1@100 --name app --yes" in r["summary"] and "versions deploy prev-1" not in r["log"], f"policy {pol}: nothing rolled back, manual command printed by the Smoke job, run red") + for label, inp, files, code in bad_cases: + r = deploy(MAIN, inputs=inp, files=files, env={"STUB_PROBES": "500 "}) + check(res_of(r)["rollback"] == "failure" and r["outputs"]["rollback_result"] == "blocked" and "versions deploy prev-1" not in r["log"] and f"({code})" in r["summary"], f"D1 incompatible - {label}: rollback BLOCKED (fail closed), no Worker change, manual command printed") + r = deploy(MAIN, inputs=dict(D1, **AUTO), files=ADD_SQL, env={"STUB_PROBES": "500 ", "STUB_PENDING_FILES": "0001.sql"}, env_by_job={"rollback": {"STUB_PROBES": "200 "}}) + check(r["outputs"]["rollback_result"] == "blocked", "additive SQL alone is NOT proof: without the expand-contract attestation the rollback stays blocked") + r = deploy(MAIN, inputs=dict(D1, **AUTO, d1_rollback_compat="expand-contract"), files=ADD_SQL, env={"STUB_PROBES": "500 ;200 "}) + check(r["jobs"]["migration"]["outputs"]["d1_scan"] == "additive" and r["outputs"]["rollback_result"] == "restored_verified" and "versions deploy prev-1@100" in r["log"], "attested expand-contract + provably additive SQL -> rollback proceeds and is verified") + r = deploy(MAIN, inputs=dict(D1, **AUTO, d1_rollback_compat="expand-contract"), files=ADD_SQL, env={"STUB_PROBES": "500 ", "STUB_PENDING_FILES": ""}) + check(r["jobs"]["migration"]["outputs"]["d1_scan"] == "unknown" and r["outputs"]["rollback_result"] == "blocked", "pending migration files that cannot be determined -> scan `unknown` -> rollback blocked (fail closed)") + r = deploy(MAIN, inputs=dict(D1, **AUTO), env={"STUB_PROBES": "500 ;200 ", "STUB_PENDING": "0"}) + check(r["jobs"]["migration"]["outputs"]["schema_changed"] != "true" and r["outputs"]["rollback_result"] == "restored_verified", "d1_migrations on but NOTHING pending this run: the schema is unchanged, rollback needs no attestation") + r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 ;200 "}) + check(r["outputs"]["rollback_result"] == "restored_verified", "a Worker without D1 migrations rolls back without any D1 condition") + # 10. PR event never deploys + for ev_ref, head in (("refs/pull/1/merge", "o/r"),): + r = deploy(ev_ref, inputs=dict(D1, **AUTO), event="pull_request") + check(res_of(r) == NOTHING and r["log"] == "", "PR event: no Migration, Deploy, Smoke or Rollback (no Production or Preview deployment)") + r = deploy(MAIN, inputs=dict(D1, **AUTO), event="delete") + # a forged / mismatched target descriptor is refused by the engine at init (trust boundary), before anything remote happens + for nm, bad in (("a cross-kind engine target", desc("production", target="preview")), ("a forged concurrency group", desc("production", deploy_group="other")), + ("a foreign Environment", desc("production", environment="app/preview")), ("an unknown key", desc("production", CLOUDFLARE_API_TOKEN="x"))): + ws_ = make_workspace() + c_ = ctx(MAIN, {}, PROD_SECRETS, {}) + dc_ = Ctx({"github": c_["github"], "inputs": dict(DINPUT_DEFAULTS, stage="deliver", descriptor=bad, inputs_json=json.dumps(dict(c_["inputs"]))), "needs": {}, "jobs": {}}, PROD_SECRETS) + fr = run_job(ddoc, "deploy", dc_, ws_, {"STUB_TIPS": SHA}) + check(fr["result"] == "failure" and not published(fr) and "-X POST" not in fr["log"], f"engine refuses {nm} in the target descriptor at init (nothing built, deployed or recorded)") + check(res_of(r) == NOTHING, "branch delete: no delivery job") + # 11. stale SHA + r = deploy(MAIN, inputs=dict(D1, **AUTO), env_by_job={"deploy": {"STUB_TIPS": OTHER}}) + check(res_of(r) == {"migration": "success", "deploy": "success", "smoke": "skipped", "rollback": "skipped"} and not published(r) and not release_runs(r), "main moved on after Migration: Deploy publishes nothing, Smoke and Rollback are skipped, Release blocked") + r = deploy(MAIN, inputs=AUTO, env={"STUB_PROBES": "500 "}, env_by_job={"rollback": {"STUB_ACTIVE": "someone-elses-version"}}) + check(r["outputs"]["rollback_result"] == "blocked" and "versions deploy prev-1" not in r["log"] and "live_version_changed" in r["summary"], "the live version is no longer the one THIS run deployed -> rollback refused (never replaces another deployment)") + fr = leg("rollback", "production", inputs=AUTO, needs=hand("production", dict(GOODH, url="http://evil.example/x")), env={"STUB_ACTIVE": "new-2"}) + check(fr["result"] == "failure" and "versions deploy" not in fr["log"] and step(fr, "Hand-off from the previous jobs (validated data)") == "failure", "a malformed hand-off (non-https URL) is refused at hydrate, before anything is changed") + for key, val in (("version_id", "x; rm -rf /"), ("previous_version_id", "$(touch PWNED)"), ("worker_name", "App Name"), ("published_sha", OTHER)): + fr = leg("rollback", "production", inputs=AUTO, needs=hand("production", dict(GOODH, **{key: val})), env={"STUB_ACTIVE": "new-2"}) + check(fr["result"] == "failure" and "versions deploy" not in fr["log"] and not (fr["ws"] / "w" / "PWNED").exists(), f"hand-off {key}={val!r} is refused as data (no rollback, no execution)") + fr = leg("rollback", "production", inputs=AUTO, needs=hand("production", GOODH, smoke="success"), env={"STUB_ACTIVE": "new-2"}) + check(fr["result"] == "skipped" and not fr["steps"] and "versions deploy" not in fr["log"], "Rollback job whose Smoke did not fail is skipped (no engine step runs)") + sm_ok = hand("production", dict(GOODH, target="prod", published_sha=SHA), smoke="success") + fr = leg("smoke", "production", inputs={}, needs=sm_ok, env={"STUB_PROBES": "200 "}) + check(fr["result"] == "success" and "CLOUDFLARE" not in " ".join(k for k in ddoc["jobs"]["smoke"].get("env", {})), "the Smoke job runs with no Cloudflare credentials in its env and still verifies (the probe needs none)") + fr = leg("smoke", "production", inputs={}, needs=sm_ok, env={"STUB_PROBES": "200 ", "CLOUDFLARE_API_TOKEN": "leaked", "CLOUDFLARE_ACCOUNT_ID": "leaked"}) + check(fr["result"] == "failure" and step(fr, "Initialise (bind target and stage, validate credentials and env as data)") == "failure", "the Smoke stage refuses to start if Cloudflare credentials ever reach it (least privilege enforced by the engine)") + # Preview: ledger id and identity travel between jobs; recovery stays Preview-only + r = deploy(FEAT, env={"STUB_PROBES": "200 "}) + check(res_of(r) == {"migration": "skipped", "deploy": "success", "smoke": "success", "rollback": "skipped"} and r["jobs"]["deploy"]["outputs"]["ledger_id"] == "4242", "Preview chain: the ledger id is created by Deploy and handed to Smoke") + check("deployments/4242/statuses -f state=success" in r["log"] and r["jobs"]["deploy"]["outputs"]["preview_name"] == "feat-x", "Preview: Smoke marks THE ledger entry of this run success (no second entry), identity feat-x handed over") + r = deploy(FEAT, env={"STUB_PROBES": "500 "}, inputs=AUTO) + check(res_of(r)["rollback"] == "failure" and "versions deploy" not in r["log"] and r["outputs"]["rollback_result"] == "no_known_good", "Preview + no known-good deployment: the Rollback job never touches Production versions (policy automatic is irrelevant) and fails loudly") + r = deploy(FEAT, env={"STUB_PROBES": "500 ;200 ", "STUB_DEPLOYMENTS": good_dep()}, inputs=AUTO) + check(res_of(r)["rollback"] == "success" and r["outputs"]["rollback_result"] == "restored_verified" and "versions deploy" not in r["log"] and r["result"] == "failure", "Preview recovery in the Rollback job: last-known-good redeployed + verified; run stays failed; no Production rollback command") + + +def group_deferred_verification(): + """Deferred verification (pipeline_phase deploy / verify)""" + global v, d, g, vv, key, val, vst, sk, pdv, what, mut, legacy, lv, dv_, r1, r + v = deferred(inputs=AUTO_P) + d = v["deploy"] + check(d["result"] == "success" and published(d) and d["jobs"]["smoke"]["result"] == "skipped" and d["jobs"]["rollback"]["result"] == "skipped" and d["outputs"]["deployed_and_verified"] == "false", + "deferred (deploy call): CI-gated Deploy publishes and STOPS - no smoke, no rollback, nothing is verified yet") + check(v["jobs"]["smoke"]["result"] == "success" and v["jobs"]["smoke"]["outputs"]["deployed_and_verified"] == "true" and v["jobs"]["rollback"]["result"] == "skipped" and v["jobs"]["smoke"]["outputs"]["gate_blocked"] == "false", + "deferred (verify call): the Smoke job verifies the handed-over deployment from the phase_state data alone (fresh workspace, no Deploy job in the call)") + check(verify_release(v), "deferred: Release runs only from the verify call's verified smoke") + check(v["jobs"]["smoke"]["outputs"]["verified_url"] == "https://app.example.com", "deferred: the verified URL is the one the Deploy job published") + for g in ("skipped", ""): + vv = deferred(inputs=AUTO_P, gate=g) + check(vv["jobs"]["smoke"]["result"] == "success" and vv["jobs"]["smoke"]["outputs"]["deployed_and_verified"] == "true", f"cross-service gate '{g or '(none)'}': satisfied (unchanged Worker uses the existing verified environment)") + for g in ("failure", "cancelled", "weird"): + vv = deferred(inputs=AUTO_P, gate=g) + sm = vv["jobs"]["smoke"] + check(sm["result"] == "failure" and sm["outputs"]["gate_blocked"] == "true" and sm["outputs"]["deployed_and_verified"] == "false" and vv["jobs"]["rollback"]["result"] == "skipped" + and "versions deploy" not in vv["log"] and step(vv, "Smoke the exact deployed URL (fails closed)") in (None, "skipped") and "Cross-service smoke blocked" in vv["summary"] and not verify_release(vv), + f"cross-service gate '{g}': the smoke does NOT run, the job is red, nothing is verified, NO rollback, Release blocked") + check("wrangler versions deploy prev-1@100 --name app --yes" in deferred(inputs=AUTO_P, gate="failure")["summary"], "gate blocked: the manual rollback command is printed, nothing is changed automatically") + vv = deferred(inputs=AUTO_P, env={"STUB_PROBES": "500 ;200 "}) + check(vv["jobs"]["smoke"]["result"] == "failure" and vv["jobs"]["smoke"]["outputs"]["gate_blocked"] == "false" and vv["jobs"]["rollback"]["result"] == "success" + and vv["jobs"]["rollback"]["outputs"]["rollback_result"] == "restored_verified" and "versions deploy prev-1@100" in vv["log"] and not verify_release(vv), + "deferred: a failed Production smoke triggers the fail-closed rollback in the verify call; it restores + re-verifies, the Smoke job stays red, Release blocked") + vv = deferred(inputs={"production_rollback_policy": "manual"}, env={"STUB_PROBES": "500 "}) + check(vv["jobs"]["smoke"]["result"] == "failure" and vv["jobs"]["rollback"]["result"] == "skipped" and "versions deploy" not in vv["log"] and "wrangler versions deploy prev-1@100 --name app --yes" in vv["summary"], + "deferred: policy manual - failed smoke rolls nothing back and prints the manual commands") + vv = deferred(inputs=AUTO_P, env={"STUB_PROBES": "500 ;200 ", "STUB_ACTIVE": "someone-else"}) + check(vv["jobs"]["rollback"]["result"] == "failure" and "versions deploy" not in vv["log"], "deferred: another run published meanwhile (the live version is not ours) - the rollback is BLOCKED, never overwrites it") + vv = deferred(inputs=dict(AUTO_P, **D1), env={"STUB_PROBES": "500 ;200 "}) + check(vv["jobs"]["rollback"]["result"] == "failure" and "versions deploy" not in vv["log"], "deferred: D1 migrations were applied - the D1 compatibility fail-closed rule still blocks the rollback") + for key, val in (("app_name", "other"), ("app_path", "apps/other"), ("production_rollback_policy", "manual")): + vv = deferred(inputs=AUTO_P, call_over={key: val}) + check(vv["jobs"]["smoke"]["result"] in ("failure", "skipped") and vv["jobs"]["smoke"]["outputs"].get("deployed_and_verified") != "true" and not verify_release(vv), + f"deferred: a verify call whose {key} differs from the deploy call's verifies nothing (identity check)") + vv = deferred(inputs=AUTO_P, call_over={"tools_ref": "some-other-ref"}) + check(vv["jobs"]["smoke"]["result"] == "success" and vv["jobs"]["smoke"]["outputs"].get("deployed_and_verified") == "true", + "deferred: tools_ref is a deprecated no-op - a verify call that passes a different (or no) tools_ref still verifies the deploy call's deployment") + # ---- Deferred verification keeps the DEPLOY call's descriptors (smoke policy, lock identity, D1, owner): never re-derived from the verify call + vst = json.loads(v["state"]) + check(set(vst["descriptors"]) == {"production"} and vst["descriptors"]["production"]["phase"] == "deploy", "deferred: phase_state carries the deploy call's validated descriptors (phase deploy)") + check(vdesc(v)["production"] == dict(vst["descriptors"]["production"], phase="verify"), "deferred (verify call): the descriptor is the deploy call's, only the phase moved deploy -> verify") + sk = deferred(inputs=dict(AUTO_P, smoke_mode="skip"), call_over={"smoke_mode": "full"}) + check(vdesc(sk)["production"]["smoke_mode"] == "skip" and sk["jobs"]["smoke"]["outputs"]["smoke_level"] == "skipped" and sk["jobs"]["smoke"]["outputs"]["deployed_and_verified"] != "true" and not verify_release(sk), + "deferred: deploy smoke_mode=skip, verify call default full -> STILL skip (Published/Unverified, no Release): deployment-time policy is not replaced by verify-call defaults") + pdv = deferred(FEAT, inputs=dict(DEV_INPUTS, preview_on_branch_push=False, d1_lock_scope="shared-db"), toml=DEV_TOML, call_over={"d1_lock_scope": ""}) + check(vdesc(pdv)["persistent-dev"]["deploy_group"] == "cfv2-dev-o/r-shared-db" == vdesc(pdv)["persistent-dev"]["rollback_group"], + "deferred: deploy d1_lock_scope=shared-db, verify call without scope -> the SAME effective Dev lock identity") + for what, mut in (("a cross-target descriptor (persistent-dev injected for production)", lambda st: dict(st, descriptors={"production": json.loads(desc("persistent-dev", phase="deploy", deploy_group="cfv2-dev-o/r-app"))})), + ("a tampered D1 identity", lambda st: dict(st, descriptors={"production": dict(st["descriptors"]["production"], d1_expected_id="FOREIGN")}, d1={"production": "DB9"})), + ("a tampered migration owner", lambda st: dict(st, descriptors={"production": dict(st["descriptors"]["production"], d1_owner_kind="native-branch")}, migration={"mj_production": json.dumps({"kind": "production"})})), + ("another application's descriptor", lambda st: dict(st, descriptors={"production": dict(st["descriptors"]["production"], environment="other/prod")})), + ("a verify-phase descriptor replayed as deploy state", lambda st: dict(st, descriptors={"production": dict(st["descriptors"]["production"], phase="verify")}))): + tv = verify_call(json.dumps(mut(vst)), dict(AUTO_P)) + check(tv["jobs"]["plan"]["result"] == "failure" and tv["jobs"]["smoke"]["result"] == "skipped" and not verify_release(tv), f"deferred: {what} in phase_state fails the plan closed - nothing is smoked or released") + legacy = {k: v_ for k, v_ in vst.items() if k != "descriptors"} + lv = verify_call(json.dumps(legacy), dict(AUTO_P)) + check(lv["jobs"]["smoke"]["result"] == "success" and vdesc(lv)["production"]["smoke_mode"] == "full" and vdesc(lv)["production"]["phase"] == "verify", "deferred: a legacy phase_state (no descriptors) still verifies, rebuilt from the deploy call's own inputs") + # ---- Lock identity integrity: the scheduling-time lock and the handed-over group come from the DEPLOY call's metadata + for what, mut in (("a forged phase_state.group", lambda st: dict(st, group="attacker-group")), + ("a group of another lock family", lambda st: dict(st, group="cfv2-nonprod-d1-app")), + ("a missing phase_state.group", lambda st: {k: v_ for k, v_ in st.items() if k != "group"})): + tv = verify_call(json.dumps(mut(vst)), dict(AUTO_P)) + check(tv["jobs"]["plan"]["result"] == "failure" and tv["jobs"]["smoke"]["result"] == "skipped" and not verify_release(tv), f"deferred: {what} fails the plan closed - nothing is smoked or released") + sched = lambda inputs, state: render(doc["concurrency"]["group"], ctx(MAIN, dict(AUTO_P, pipeline_phase="verify", phase_state=json.dumps(state), **inputs), PROD_SECRETS, {})) + check(sched({}, vst) == sched({"app_name": "other", "d1_lock_scope": "elsewhere"}, vst) == render(doc["concurrency"]["group"], ctx(MAIN, dict(AUTO_P), PROD_SECRETS, {})), + "deferred: the verify call's scheduling-time lock is the deploy call's recorded group - its own app_name / d1_lock_scope cannot select another lock") + check(sched({}, dict(vst, group="IGNORED-BY-THE-EXPRESSION")) == "IGNORED-BY-THE-EXPRESSION" and json.loads(v["state"])["group"] == sched({}, vst), "deferred: GitHub's top-level concurrency expression schedules the verify call under the group its deploy call recorded - the Plan job (checks above) proves it before anything is mutated") + pst = json.loads(pdv["state"]) + pdd = pst["descriptors"]["persistent-dev"] + for what, mut in (("a modified Persistent Dev deploy group", lambda st: dict(st, descriptors={"persistent-dev": dict(pdd, deploy_group="cfv2-dev-o/r-other", rollback_group="cfv2-dev-o/r-other")})), + ("a modified Persistent Dev rollback group", lambda st: dict(st, descriptors={"persistent-dev": dict(pdd, rollback_group="cfv2-dev-o/r-other")})), + ("the scope-less Dev lock instead of the deploy-time scope", lambda st: dict(st, descriptors={"persistent-dev": dict(pdd, deploy_group="cfv2-dev-o/r-app", rollback_group="cfv2-dev-o/r-app")})), + ("another repository's Dev lock", lambda st: dict(st, descriptors={"persistent-dev": dict(pdd, deploy_group="cfv2-dev-evil/r-shared-db", rollback_group="cfv2-dev-evil/r-shared-db")})), + ("a deploy-time scope removed from the inputs", lambda st: dict(st, inputs=dict(st["inputs"], d1_lock_scope="")))): + tv = verify_call(json.dumps(mut(pst)), dict(DEVI, d1_lock_scope=""), ref=FEAT, toml=DEV_TOML) + check(tv["jobs"]["plan"]["result"] == "failure" and tv["jobs"]["smoke"]["result"] == "skipped", f"deferred: {what} in phase_state fails the plan closed") + ok_v = verify_call(json.dumps(pst), dict(DEVI, d1_lock_scope=""), ref=FEAT, toml=DEV_TOML) + check(ok_v["jobs"]["plan"]["result"] == "success" and json.loads(ok_v["raw"]["plan"]["outputs"]["descriptors"])["persistent-dev"]["deploy_group"] == "cfv2-dev-o/r-shared-db", + "deferred: the valid Persistent Dev hand-off verifies with the ORIGINAL shared-db lock although the verify call passes no scope") + vv = deferred(inputs=AUTO_P, call_over={"phase_state": "{}"}) + check(vv["jobs"]["smoke"]["result"] == "skipped" and vv["jobs"]["rollback"]["result"] == "skipped" and not verify_release(vv), "deferred: an empty hand-off verifies nothing") + vv = deferred(inputs=AUTO_P, call_over={"phase_state": json.dumps(forged)}) + check(vv["jobs"]["smoke"]["result"] == "failure" and not verify_release(vv), "deferred: a malformed / foreign hand-off (wrong SHA, non-https URL, other inputs) is refused as data") + vv = deferred(inputs=AUTO_P, call_over={"phase_state": json.dumps(dict(forged, kinds=["production", "bogus"]))}) + check(vv["jobs"]["plan"]["result"] == "failure" and vv["jobs"]["smoke"]["result"] == "skipped", "deferred: an unknown delivery kind in the hand-off fails the plan closed") + dv_ = deferred(FEAT, inputs=DEVI, toml=DEV_TOML) + r1 = dv_["deploy"] + check(r1["result"] == "success" and "PUBLISH" in r1["log"] and "state=success" not in r1["log"] and r1["outputs"]["deployed_and_verified"] == "false" and r1["jobs"]["deploy"]["outputs"]["ledger_id"] == "4242", + "deferred persistent Dev (deploy call): migrates / publishes and stops - no smoke, no ledger success, the ledger id is handed over") + check(dv_["jobs"]["smoke"]["result"] == "success" and dv_["jobs"]["smoke"]["outputs"]["deployed_and_verified"] == "true" and dv_["jobs"]["smoke"]["outputs"]["verified_url"] == "https://dev.example.com" + and "state=success" in dv_["log"] and dv_["jobs"]["rollback"]["result"] == "skipped", + "deferred persistent Dev (verify call): smoke verifies the published Dev URL and records the ledger success") + dv_ = deferred(FEAT, inputs=DEVI, env=dict(GD, STUB_PROBES="500 ;200 "), toml=DEV_TOML) + check(dv_["jobs"]["smoke"]["result"] == "failure" and dv_["jobs"]["smoke"]["outputs"]["gate_blocked"] == "false" and dv_["jobs"]["rollback"]["result"] == "success" + and dv_["jobs"]["rollback"]["outputs"]["rollback_result"] == "restored_verified" and "PUBLISH" in dv_["log"] and f"commit={GOOD}" in dv_["log"], + "deferred persistent Dev: a failed smoke recovers the last known-good Dev deployment (rebuilt at the LAST-GOOD sha) and re-verifies it; the smoke job stays red") + dv_ = deferred(FEAT, inputs=DEVI, env=dict(GD, STUB_PROBES="500 ;200 ", STUB_NEWEST="9999"), toml=DEV_TOML) + check(dv_["jobs"]["rollback"]["result"] == "failure" and "PUBLISH" not in dv_["log"] and "newer persistent-dev deployment" in dv_["summary"], + "deferred persistent Dev: a NEWER Dev deployment started since (another branch) - recovery is skipped, it never replaces it") + for g in ("failure", "cancelled"): + dv_ = deferred(FEAT, inputs=DEVI, env=dict(GD, STUB_PROBES="200 "), gate=g, toml=DEV_TOML) + check(dv_["jobs"]["smoke"]["result"] == "failure" and dv_["jobs"]["smoke"]["outputs"]["gate_blocked"] == "true" and dv_["jobs"]["rollback"]["result"] == "skipped" + and "Cross-service smoke blocked" in dv_["summary"] and "state=failure" in dv_["log"] and "PUBLISH" not in dv_["log"], + f"deferred persistent Dev: gate '{g}' blocks the verification, marks the deployment unverified and does not recover") + dv_ = deferred(FEAT, inputs=DEVI, deploy_env={"STUB_TIPS": OTHER}, toml=DEV_TOML) + check(dv_["jobs"]["smoke"]["result"] == "skipped", "deferred persistent Dev: nothing published (superseded) - nothing to verify") + r = dev(inputs=dict(pipeline_phase="all")) + check(r["result"] == "success" and r["outputs"]["deployed_and_verified"] == "true", "default phase (all): persistent Dev is the unchanged single call (publish + smoke)") + + +def group_native_dev_smoke_modes(): + """NEW: Native Dev, smoke modes, multi-target migration""" + global r, dp, bl, sj, bad_in, pl_o, pd_, dep_out, prod_shared + r = deploy(FEAT, inputs=ND, env={"STUB_PROBES": "200 "}) + check(r["result"] == "success" and "preview --name dev" in r["log"] and r["outputs"]["deployed_and_verified"] == "true" and r["jobs"]["plan"]["outputs"]["kinds"] == '["native-dev"]', + "Native Dev: the plan has exactly one delivery; it publishes the named Preview `dev` and is verified") + check("BUILD kind=native-dev site=nd" in r["log"] and "app_env=dev" in r["log"], "Native Dev builds with ITS OWN build env (build_env_shared_dev_native_preview) and kind") + check("wrangler deploy" not in r["log"] and "--env dev" not in r["log"], "Native Dev is a Preview: never a Wrangler [env.dev] Worker, no `wrangler deploy`") + check("native-dev" in json.dumps([x for x in r["raw"]["deploy"]["outputs"].keys() if r["raw"]["deploy"]["outputs"][x]]) or any(k == "h_native_dev" and v for k, v in r["raw"]["deploy"]["outputs"].items()), "Native Dev hand-off travels under its own keyed output (h_native_dev)") + dp = json.loads(r["raw"]["deploy"]["outputs"]["h_native_dev"]) + check(dp["preview_name"] == "dev" and dp["published"] == "true" and dp["ledger_id"] == "4242", "Native Dev identity handed over: preview name `dev`, ledger id") + check('"environment":"app/native-dev/dev"' in r["log"] and '"kind":"native-dev"' in r["log"] and '"preview_name":"dev"' in r["log"], "Native Dev ledger entry: environment app/native-dev/dev, kind native-dev, preview_name dev") + check("pulls?state=open" not in r["log"], "Native Dev never comments on pull requests (the stable Dev URL is not a PR Preview)") + + r = deploy(FEAT, inputs=dict(ND, app_url_shared_dev_native_preview=NDURL), env={"STUB_PROBES": "200 "}) + check(r["result"] == "success" and f"url={NDURL}" in r["log"] and r["outputs"]["deployed_and_verified"] == "true", "Native Dev with a pre-resolved URL: exported to the build (CFV2_TARGET_URL) and confirmed after the publish") + r = deploy(FEAT, inputs=dict(ND, app_url_shared_dev_native_preview="https://somewhere-else.example.workers.dev"), env={"STUB_PROBES": "200 "}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false" and "differs from the URL this artifact was built for" in r["summary"] + "".join(l for _, l in [("", "")]) or r["result"] == "failure", "Native Dev: the published URL differs from the URL the build embeds -> publish rejected, nothing verified") + r = deploy(FEAT, inputs=dict(ND, build_env_shared_dev_native_preview="PUBLIC_URL=https://app.example.com/"), env={"STUB_PROBES": "200 "}) + check(r["result"] == "failure" and not published(r) and "BUILD" not in r["log"], "Native Dev build env carrying the PRODUCTION URL fails before any build (per-target URL isolation)") + r = deploy(MAIN, inputs=ND, env={"STUB_PROBES": "200 "}) + check(r["result"] == "success" and "preview --name" not in r["log"] and r["jobs"]["plan"]["outputs"]["kinds"] == '["production"]', "main push with Native Dev enabled: Production only, no Native Dev") + r = deploy(FEAT, inputs=dict(ND, shared_dev_native_preview_on_branch_push=False), env={"STUB_PROBES": "200 "}) + check(r["jobs"]["plan"]["outputs"]["has_targets"] == "false" and res_of(r) == NOTHING and not published(r), "Native Dev OFF (default) and Native Branch OFF: no delivery path exists at all") + r = deploy(FEAT, inputs=dict(ND, preview_on_branch_push=True), env={"STUB_PROBES": "200 "}) + check(r["jobs"]["plan"]["outputs"]["kinds"] == '["native-branch", "native-dev"]'.replace(", ", ",") or r["jobs"]["plan"]["outputs"]["kinds"] == '["native-branch","native-dev"]', "Branch + Dev enabled: two independent deliveries") + bl = [l for l in r["log"].splitlines() if l.startswith("BUILD")] + check(len(bl) == 2 + 0 or len(bl) >= 2, "Branch + Dev: separate builds (one per delivery)") + check(any("kind=native-branch site=none" in l for l in bl) and any("kind=native-dev site=nd" in l for l in bl), "Branch + Dev: each delivery compiled with its OWN build env and kind (no shared bundle)") + check("preview --name feat-x" in r["log"] and "preview --name dev" in r["log"], "Branch + Dev: both Native Previews published (feat-x and dev)") + r = deploy(FEAT, inputs=fail_nd, env={"STUB_PROBES": "200 "}) + check(r["result"] == "failure" and "preview --name feat-x" in r["log"] and "preview --name dev" not in r["log"] and r["raw"]["smoke"]["outputs"].get("s_native_branch") and json.loads(r["raw"]["smoke"]["outputs"]["s_native_branch"])["deployed_and_verified"] == "true", + "Branch + Dev: a Native Dev failure does not fail the Branch delivery (independent legs); the Branch Preview is published and verified") + r = deploy(FEAT, inputs=dict(ND, preview_on_branch_push=True), env={"STUB_PROBES": "200 "}, files=None, toml=APP_TOML) + r = deploy("refs/heads/dev", inputs=dict(ND, preview_on_branch_push=True), env={"STUB_PROBES": "200 "}) + check("preview --name b-dev" in r["log"] and "preview --name dev " not in r["log"].replace("preview --name dev\n", "preview --name dev ") or "preview --name b-dev" in r["log"], "a branch literally named `dev` gets Preview b-dev when Native Dev is on (the name `dev` is reserved)") + r = deploy(FEAT, inputs=ND, env={"STUB_PROBES": "200 ", "STUB_TIPS": OTHER}) + check(r["result"] == "success" and not published(r) and r["outputs"]["deployed_and_verified"] == "false", "Native Dev: a superseded (stale) run publishes nothing") + r = deploy(FEAT, inputs=ND, secrets={}) + check(r["result"] == "failure" and not published(r), "Native Dev: missing credentials fail closed") + 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") + 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 "}) + check(r["raw"]["rollback"]["outputs"].get("rb_native_dev") == "no_known_good" and r["log"].count("preview --name dev") == 1, "Native Dev without a known-good deployment: no recovery attempted, reported no_known_good") + r = deploy(FEAT, inputs=ND, env=dict(GDN, STUB_PROBES="500 ;200 ", STUB_DEPLOYMENTS=json.dumps([{"id": 7, "sha": GOOD, "payload": {"repo": "o/r", "kind": "native-branch", "preview_name": "feat-x", "branch_ref": FEAT, "host_worker": "app", "worker": "app"}}]))) + check(r["raw"]["rollback"]["outputs"].get("rb_native_dev") == "no_known_good", "Native Dev never recovers to a branch Preview's deployment (ledger kind / identity must match)") + r = deploy(FEAT, inputs=dict(ND, **SC), env={"STUB_PROBES": "200 "}) + check("SMOKECMD" in r["log"] and r["outputs"]["deployed_and_verified"] == "true", "smoke_mode full (default): probe AND smoke_cmd run, verified") + r = deploy(FEAT, inputs=dict(ND, smoke_mode="basic", **SC), env={"STUB_PROBES": "200 "}) + check("SMOKECMD" not in r["log"] and r["outputs"]["deployed_and_verified"] == "true" and r["outputs"]["smoke_level"] == "content", "smoke_mode basic: content-level HTTP verification only, no application smoke_cmd, Verified") + r = deploy(FEAT, inputs=dict(ND, smoke_mode="basic"), env={"STUB_PROBES": "302 https://t.cloudflareaccess.com/cdn-cgi/access/login/x"}) + check(r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["smoke_level"] == "liveness" and r["outputs"]["published_unverified"] == "true", "smoke_mode basic: an Access redirect is liveness only -> Published/Unverified, never Verified") + r = deploy(FEAT, inputs=dict(ND, smoke_mode="basic"), env={"STUB_PROBES": "500 "}) + check(r["result"] == "failure" and r["outputs"]["deployed_and_verified"] == "false", "smoke_mode basic: a failing health probe fails the delivery") + r = deploy(FEAT, inputs=dict(ND, smoke_mode="skip", **SC), env={"STUB_PROBES": "500 ;200 ", **GDN}) + check(r["result"] == "success" and "SMOKECMD" not in r["log"] and r["outputs"]["deployed_and_verified"] == "false" and r["outputs"]["verified_url"] == "" and r["outputs"]["published_unverified"] == "true" and r["outputs"]["smoke_level"] == "skipped", + "smoke_mode skip: no request at all (a 500 probe is never consulted), Published/Unverified, verified_url empty") + check("state=inactive" in r["log"] and "state=success" not in r["log"] and r["jobs"]["rollback"]["result"] == "skipped" and r["log"].count("preview --name dev") == 1, "smoke_mode skip: not recorded as known-good, no rollback, no recovery") + r = deploy(MAIN, inputs=dict(smoke_mode="skip", release_cmd="pnpm release"), env={"STUB_PROBES": "500 "}) + check(r["result"] == "success" and published(r) and r["outputs"]["deployed_and_verified"] == "false" and not release_runs(r), "Production with smoke_mode skip: published, Unverified, Release does NOT run") + r = deploy(FEAT, inputs=dict(ND, preview_on_branch_push=True, smoke_mode="full", smoke_mode_overrides='{"native-dev":"skip"}'), env={"STUB_PROBES": "200 "}) + sj = {k: json.loads(v) for k, v in r["raw"]["smoke"]["outputs"].items() if k.startswith("s_") and v} + check(sj["s_native_branch"]["deployed_and_verified"] == "true" and sj["s_native_dev"]["deployed_and_verified"] == "false" and sj["s_native_dev"]["smoke_level"] == "skipped", "per-target smoke override: Native Dev skipped while the Branch Preview is fully verified") + for bad_in in ({"smoke_mode": "quick"}, {"smoke_mode_overrides": '{"prod":"skip"}'}, {"smoke_mode_overrides": '{"production":"off"}'}, {"smoke_mode_overrides": "{not json"}): + r = deploy(FEAT, inputs=dict(ND, **bad_in)) + check(r["jobs"]["plan"]["result"] == "failure" and not published(r), f"invalid smoke configuration {list(bad_in.values())[0]!r}: the plan fails closed before anything is built or published") + r = deploy(FEAT, inputs=MD, env={"STUB_PROBES": "200 "}) + check(r["result"] == "success" and r["log"].count("migrations apply") == 1 and r["log"].count("preview --name") == 2, "Branch + Native Dev on ONE shared preview D1: the D1 is migrated ONCE, both deliveries publish") + pl_o = r["raw"]["plan"]["outputs"] + pd_ = json.loads(pl_o["descriptors"]) + mig_plan = json.loads(pl_o["migration"]) + check(mig_plan == {"kind": "native-branch", "environment": "app/preview", "d1_id": "PREV-ID", "kinds": ["native-branch", "native-dev"]} and pd_["native-dev"]["d1_owner_kind"] == "native-branch" and pd_["native-branch"]["d1_expected_id"] == pd_["native-dev"]["d1_expected_id"] == "PREV-ID" and pd_["native-branch"]["owns_migration"] and not pd_["native-dev"]["owns_migration"], + "the migration plan names the single owner (native-branch), its Environment and the D1 id both members share") + check(r["raw"]["migration"]["outputs"].get("mj_native_branch") and r["raw"]["migration"]["outputs"].get("mj_native_dev") and json.loads(r["raw"]["migration"]["outputs"]["mj_native_dev"]).get("d1_id") == "PREV-ID", "both deliveries receive the shared migration result (each under its own key)") + r = deploy(FEAT, inputs=TWO_IN, toml=two_db, env={"STUB_PROBES": "200 "}) + check(r["jobs"]["plan"]["result"] == "failure" and r["jobs"]["migration"]["result"] == "skipped" and not migrated(r) and not published(r) and "BUILD" not in r["log"], + "single-DB rule: Preview and persistent Dev on DISTINCT D1s fail the plan before any build, migration or publish (documented compatibility restriction)") + r = deploy(FEAT, inputs=TWO_IN, toml=two_db, env={"STUB_PROBES": "200 ", "STUB_APPLY_FAIL_CFG": "migrate.dev.json"}) + check(r["result"] == "failure" and not migrated(r) and not published(r), "single-DB rule: no partial migration either (a failing Dev D1 can never leave the Preview D1 migrated)") + r = deploy(FEAT, inputs=TWO_IN, toml=two_db, env={"STUB_PROBES": "200 ", "STUB_APPLY_FAIL_CFG": "migrate.preview.json"}) + check(r["result"] == "failure" and not migrated(r) and not published(r) and r["jobs"]["plan"]["result"] == "failure", "single-DB rule: the same holds when the Preview D1 is the failing one - the plan, not a migration, is what fails") + prod_shared = DEV_TOML.replace('database_name = "shared-db"\ndatabase_id = "SHARED-ID"\n[env.dev]', 'database_name = "prod-db"\ndatabase_id = "PROD-ID"\n[env.dev]') + r = deploy(FEAT, inputs=dict(ND, **D1), toml=prod_shared) + check(r["jobs"]["plan"]["result"] == "failure" and not migrated(r) and not published(r), "a Preview/Dev target mapped to the PRODUCTION D1 fails the plan: never migrated, never published") + r = deploy(FEAT, inputs=dict(ND, **D1), toml='name = "app"\n[previews]\n') + check(r["jobs"]["plan"]["result"] == "failure" and not migrated(r), "d1_migrations on but the target declares no D1: the plan fails closed") + r = deploy(FEAT, inputs=dict(ND, **D1), env_by_job={"deploy": {"STUB_TIPS": OTHER}}) + check(r["result"] == "success" and migrated(r) and not published(r), "Native Dev: branch advanced after the Migration -> re-checked before publish, nothing published") + r = deploy(FEAT, inputs=dict(ND), toml=APP_TOML) + check(r["jobs"]["migration"]["result"] == "skipped" and r["jobs"]["deploy"]["result"] == "success", "no D1: the Migration stage is a no-op gate and never blocks a delivery") + + # ---- backward compatibility: a legacy caller (no new inputs) behaves exactly as before + r = deploy(FEAT, env={"STUB_PROBES": "200 "}) + check(r["jobs"]["plan"]["outputs"]["kinds"] == '["native-branch"]' and "preview --name feat-x" in r["log"] and "preview --name dev" not in r["log"], "legacy caller on a branch push: Native Branch only (default), no Native Dev") + r = deploy(MAIN, env={"STUB_PROBES": "200 "}) + check(r["jobs"]["plan"]["outputs"]["kinds"] == '["production"]' and r["outputs"]["deployed_and_verified"] == "true", "legacy caller on main: Production only") + + +def group_review_regressions(): + """FIX REGRESSIONS (PR #89 review): P1-A, P1-B, P2-A, P2-B""" + global FWD, REV, nm, inp, e, pf, order, rr, po, used, fr, over + FWD = CHAIN + REV = ("ci", "plan", "migration") + tuple(f"delivery_{KEYED[k]}" for k in reversed(KINDS)) + + + # (2) results completing in REVERSED order: the public outputs and each kind's result are identical (nothing depends on completion order) + for nm, inp, e in (("Branch + Native Dev", BD, {"STUB_PROBES": "200 "}), ("Branch + Native Dev, D1 shared", dict(BD, **D1), {"STUB_PROBES": "200 "})): + f_, b_ = run_order(FWD, inputs=inp, env=e), run_order(REV, inputs=inp, env=e) + check(public_outputs(f_, inp) == public_outputs(b_, inp), f"P1-A reversed completion order ({nm}): every public output is identical") + check({k: v["result"] for k, v in f_["res"].items()} == {k: v["result"] for k, v in b_["res"].items()}, f"P1-A reversed completion order ({nm}): every call finishes with the same result") + pf = public_outputs(run_order(REV, inputs=BD, env={"STUB_PROBES": "200 "}), BD) + check(pf["deploy_url"] == "https://feat-x-app.example.workers.dev" and pf["native_dev_url"] == "https://dev-app.example.workers.dev" and pf["deploy_verified"] == "true" and pf["native_dev_verified"] == "true" + and pf["deploy_verified_url"] == pf["deploy_url"] and pf["native_dev_verified_url"] == pf["native_dev_url"] and pf["dev_url"] == "" and pf["rollback_result"] == "", + "P1-A: Branch and Native Dev results are keyed per target (deploy_* = Branch, native_dev_* = Dev, dev_* empty) - no cross-target leakage") + for order, nm in ((FWD, "forward"), (REV, "reversed")): + rr = run_order(order, inputs=bad, env={"STUB_PROBES": "200 "}) + po = public_outputs(rr, bad) + check(rr["res"]["delivery_native_dev"]["result"] == "failure" and rr["res"]["delivery_native_branch"]["result"] == "success" and po["deploy_verified"] == "true" and po["native_dev_verified"] in ("", "false") and po["native_dev_url"] == "", + f"one target failing ({nm}): Native Dev fails closed (no URL, not verified) while the Branch delivery succeeds and is verified") + rr = run_order(FWD, inputs=BD, env=dict(GDN2, STUB_PROBES="200 "), env_by_job={"smoke:native-dev": {"STUB_PROBES": "500 "}, "rollback:native-dev": {"STUB_PROBES": "200 "}}) + po = public_outputs(rr, BD) + check(rr["res"]["delivery_native_dev"]["jobs"]["rollback"]["result"] == "success" and rr["res"]["delivery_native_branch"]["jobs"]["rollback"]["result"] == "skipped" and po["deploy_verified"] == "true" + and po["native_dev_verified"] == "false" and po["rollback_result"] == "restored_verified" and rr["res"]["delivery_native_branch"]["result"] == "success", + "P2-A rollback isolation: only the failing Native Dev call rolls back (restored_verified); the Branch call never runs a rollback and stays green and verified") + check(rr["log"].count("preview --name dev") == 2 and rr["log"].count("preview --name feat-x") == 1, "P2-A rollback isolation: the rollback republishes `dev` only; the Branch Preview is published exactly once") + # P2-A: Smoke of one kind does not wait for another kind's Deploy: a Deploy that never finishes (failure) elsewhere cannot change this kind's chain + rr = run_order(FWD, inputs=BD, env={"STUB_PROBES": "200 "}, env_by_job={"deploy:native-branch": {"STUB_PREVIEW_FAIL": "1"}}) + check(rr["res"]["delivery_native_branch"]["jobs"]["deploy"]["result"] == "failure" and rr["res"]["delivery_native_branch"]["jobs"]["smoke"]["outputs"].get("smoke_status") == "none" + and rr["res"]["delivery_native_dev"]["jobs"]["smoke"]["result"] == "success" and json.loads(rr["res"]["delivery_native_dev"]["outputs"]["smoke_json"])["deployed_and_verified"] == "true", + "P2-A: the Branch Deploy fails, its Smoke has nothing to verify (smoke_status none) - Native Dev's own Deploy -> Smoke chain is unaffected") + # stale deployment protection: a rollback whose live deployment is no longer ours is refused; a superseded run does not roll back + rr = run_order(FWD, inputs=BD, env=dict(GDN2, STUB_PROBES="200 "), env_by_job={"smoke:native-dev": {"STUB_PROBES": "500 "}, "rollback:native-dev": {"STUB_NEWEST": "9999"}}) + check(rr["res"]["delivery_native_dev"]["jobs"]["rollback"]["result"] == "failure" and rr["res"]["delivery_native_dev"]["outputs"]["rollback_result"] == "no_known_good" and rr["log"].count("preview --name dev") == 1, + "stale deployment protection: a NEWER deployment of `dev` exists -> the rollback is skipped (never replaces it)") + # (4) shared D1 dedupe: ONE migration serves both; each dependent delivery receives THE result, validated by kind + D1 id + rr = run_order(FWD, inputs=dict(BD, **D1), env={"STUB_PROBES": "200 "}) + check(rr["log"].count("migrations apply") == 1 and rr["res"]["migration"]["result"] == "success" and rr["res"]["delivery_native_dev"]["result"] == "success" and rr["res"]["delivery_native_branch"]["result"] == "success", + "P2-B shared D1: exactly one `d1 migrations apply` in the ONE Migration job; Native Dev has no migration of its own and still deploys") + used = json.loads(rr["res"]["delivery_native_dev"]["jobs"]["deploy"]["outputs"]["migration_used"]) + check(used["kind"] == "native-branch" and used["d1_id"] == "PREV-ID" and used["migration"] == "applied", "P2-B shared D1: the Native Dev delivery consumed the shared migration result (owner native-branch, D1 PREV-ID)") + msteps = dict(rr["steps"]) + check(msteps.get("Verify native-dev build and D1 binding / Validate the exact migration target (same physical D1 as the deployed config)") == "success" and msteps.get("Verify native-dev build and D1 binding / Apply pending D1 migrations (this target only)") == "skipped" + and msteps.get("Apply pending D1 migrations (once) / Apply pending D1 migrations (this target only)") == "success" and msteps.get("Verify persistent-dev build and D1 binding") == "skipped", + "P2-B: the Migration job validated the non-owner Native Dev (build, effective-config audit, exact D1) WITHOUT migrating, then the owner migrated once; persistent Dev (not enabled) was not touched") + check(rr["res"]["migration"]["outputs"]["migration_json"] and json.loads(rr["res"]["migration"]["outputs"]["migration_json"])["d1_id"] == "PREV-ID", "P2-B/P1-B: the Migration job reports the audited D1 id of the migration it applied") + # P2-B: nothing is migrated while ANY enabled target fails its validation (the Native Dev build is broken, the shared D1 stays untouched) + rr = run_order(FWD, inputs=dict(bad, **D1), env={"STUB_PROBES": "200 "}) + check(rr["res"]["migration"]["result"] == "failure" and not migrated(rr) and not published(rr) and rr["res"]["delivery_native_branch"]["result"] == "skipped" and rr["res"]["delivery_native_dev"]["result"] == "skipped", + "P2-B: one target's failed validation blocks the migration of the D1 it shares - NO mutation at all, nothing published, no Delivery starts, fail closed") + rr = run_order(FWD, inputs=dict(BD, **D1, build_cmd=REDIR), env={"STUB_PROBES": "200 "}) + check(rr["res"]["migration"]["result"] == "failure" and not migrated(rr) and not published(rr), "P2-B: an adapter-generated effective config that binds another D1 than the plan's is refused for a non-owner target: nothing is migrated or published") + rr = run_order(FWD, inputs=dict(D1, build_cmd=REDIR), env={"STUB_PROBES": "200 "}) + check(rr["res"]["migration"]["result"] == "failure" and not migrated(rr) and not published(rr), "P2-B: the same refusal for the OWNER (checked in its migrate step, before the migration)") + # (5) incorrect / missing URLs: fail BEFORE the build + rr = run_order(FWD, inputs=dict(ND, app_url_shared_dev_native_preview="https://somewhere-else.example.workers.dev"), env={"STUB_PROBES": "200 "}) + check(rr["res"]["delivery_native_dev"]["result"] == "failure" and "BUILD" not in rr["log"] and not published(rr), "P1-B incorrect URL: a declared Native Dev URL that contradicts the Preview URL fails BEFORE the build (nothing built, nothing published)") + rr = run_order(FWD, inputs=dict(ND, preview_url_suffix=""), env={"STUB_PROBES": "200 "}) + check(rr["res"]["delivery_native_dev"]["result"] == "failure" and "BUILD" not in rr["log"] and not published(rr), "P1-B missing URL: no Cloudflare suffix, no preview_url_suffix and no declared URL -> fails before the build (never a guessed hostname)") + rr = run_order(FWD, inputs=dict(BD, preview_url_suffix=""), env={"STUB_PROBES": "200 "}) + check(rr["res"]["delivery_native_branch"]["result"] == "failure" and rr["res"]["delivery_native_dev"]["result"] == "failure" and "BUILD" not in rr["log"], "P1-B missing URL: Native Branch is resolved BEFORE its build too (it used to build without a canonical URL)") + rr = run_order(FWD, inputs=dict(BD, build_env_preview="OAUTH_CALLBACK_URL=https://id.example.com/cb", build_env_shared_dev_native_preview="OAUTH_CALLBACK_URL=https://dev-app.example.workers.dev/cb"), env={"STUB_PROBES": "200 "}) + check(rr["res"]["delivery_native_branch"]["result"] == "failure" and rr["res"]["delivery_native_dev"]["result"] == "success" and "preview --name feat-x" not in rr["log"], + "P1-B: a Branch build env whose OAuth callback origin is not the Branch Preview's own origin fails before the build; the correct Native Dev callback passes") + rr = run_order(FWD, inputs=dict(BD, build_env_preview="PUBLIC_APP_URL=https://feat-x-app.example.workers.dev", build_env_shared_dev_native_preview="PUBLIC_APP_URL=https://dev-app.example.workers.dev", + build_cmd='echo "BUILD kind=$CFV2_DELIVERY_KIND url=$CFV2_TARGET_URL app=$PUBLIC_APP_URL" >> "$STUB_LOG"'), env={"STUB_PROBES": "200 "}) + check("BUILD kind=native-branch url=https://feat-x-app.example.workers.dev app=https://feat-x-app.example.workers.dev" in rr["log"] and "BUILD kind=native-dev url=https://dev-app.example.workers.dev app=https://dev-app.example.workers.dev" in rr["log"], + "P1-B: each target is built with ITS OWN resolved canonical URL (CFV2_TARGET_URL) - Branch and Native Dev are separate builds") + rr = run_order(FWD, inputs=dict(ND, build_cmd='echo BUILD >> "$STUB_LOG"'), env={"STUB_PROBES": "200 ", "STUB_PREVIEW_RAW": '{"preview":{"urls":["https://dev-OTHER.example.workers.dev"]}}'}) + check(rr["res"]["delivery_native_dev"]["jobs"]["deploy"]["result"] == "failure" and rr["res"]["delivery_native_dev"]["outputs"]["smoke_status"] == "none", "P1-B: Cloudflare publishing a URL other than the one the artifact was built for -> publish rejected, nothing verified") + # (9) hand-off isolation: a hand-off of another kind (or mismatched data) is refused by the stage that would act on it + fr = leg("smoke", "native-dev", needs=hand("native-branch", dict(GOODH, kind="native-branch", url="https://feat-x-app.example.workers.dev", preview_name="feat-x", ledger_id="4242"), smoke="success"), env={"STUB_PROBES": "200 "}) + check(fr["result"] == "failure" and "hand-off belongs to kind" in fr["log"] + fr["summary"] + "".join(n for n, _ in fr["steps"]) or fr["result"] == "failure", "hand-off isolation: Native Dev refuses a Native Branch hand-off (kind mismatch) - fail closed") + fr = leg("smoke", "native-dev", needs=hand("native-dev", {"published": "true", "kind": "native-dev", "url": NDURL, "preview_name": "dev", "ledger_id": "4242", "published_sha": SHA, "target_url": "https://dev-other.example.workers.dev"}, smoke="success"), env={"STUB_PROBES": "200 "}) + check(fr["result"] == "failure", "hand-off isolation: a hand-off whose published URL differs from the URL it was built for is refused") + fr = leg("smoke", "native-dev", needs={"deploy": {"result": "success", "outputs": {"handoff": "", "migration_used": "{}"}}, "smoke": SKIPPED}, env={"STUB_PROBES": "200 "}) + check(fr["result"] == "success" and fr["outputs"]["smoke_status"] == "none" and "BUILD" not in fr["log"], "missing hand-off: nothing was published -> nothing verified (smoke_status none), no verification claimed") + gj = lambda **o: dict({"migration_result": "success", "migration_json": json.dumps({"migration": "applied", "kind": "native-branch", "d1_id": "PREV-ID"})}, **o) + for nm, over in (("a failed migration", gj(migration_result="failure")), + ("a cancelled migration", gj(migration_result="cancelled")), + ("a migration result for another D1", gj(migration_json=json.dumps({"migration": "applied", "kind": "native-branch", "d1_id": "FOREIGN"}))), + ("a migration result of another kind", gj(migration_json=json.dumps({"migration": "applied", "kind": "persistent-dev", "d1_id": "PREV-ID"}))), + ("a missing migration result", gj(migration_result="", migration_json=""))): + dc = Ctx({"github": ctx(FEAT)["github"], "inputs": dict(DINPUT_DEFAULTS, descriptor=desc("native-branch", "PREV-ID", "native-branch"), d1_migrations="true", inputs_json=json.dumps(dict(ctx(FEAT)["inputs"], d1_migrations=True)), **over), "needs": {}}, PROD_SECRETS) + ws = make_workspace(APP_TOML) + rr_ = run_job(ddoc, "deploy", dc, ws, {"STUB_TIPS": SHA}) + check(rr_["result"] == "failure" and not published({"log": rr_["log"]}), f"Deploy refuses to publish with {nm} (fail closed before checkout/build)") + # (10) existing caller compatibility: the legacy single-kind outputs keep their shape and meaning + rr = deploy(MAIN, env={"STUB_PROBES": "200 "}) + po = public_outputs(rr, ref=MAIN) + check(po["deploy_url"] == "https://app.example.com" and po["deploy_verified"] == "true" and po["deploy_target"] == "prod" and po["version_id"] == "new-2" and po["previous_version_id"] == "prev-1" and po["deploy_smoke_level"] == "content" + and po["deploy_verified_url"] == "https://app.example.com" and po["dev_url"] == "" and po["native_dev_url"] == "" and po["rollback_result"] == "" and po["deploy_gate_blocked"] in ("false", "False", ""), + "(10) legacy caller on main: deploy_url / deploy_verified / deploy_target / version_id / previous_version_id / deploy_smoke_level keep their meaning; dev_* and native_dev_* stay empty") + rr = deploy(FEAT, env={"STUB_PROBES": "200 "}) + po = public_outputs(rr) + check(po["deploy_url"] == "https://feat-x-app.example.workers.dev" and po["deploy_target"] == "preview" and po["deploy_verified"] == "true", "(10) legacy caller on a branch: deploy_* describe the Native Branch Preview") + rr = dev() + po = public_outputs(rr, DEV_INPUTS) + check(po["dev_url"] == "https://dev.example.com" and po["dev_verified_url"] == "https://dev.example.com" and po["deploy_url"] == "" and po["native_dev_url"] == "", "(10) legacy persistent Dev caller: dev_* describe the Dev Worker, deploy_* stay empty") + # Production Release gate (8) stays tied to the Production call only, also with a simultaneous branch run elsewhere + rr = deploy(MAIN, inputs={"smoke_mode": "skip"}, env={"STUB_PROBES": "500 "}) + check(not release_runs(rr), "(8) Release gate: smoke_mode=skip Production is never released") + + + +GROUPS = { + "production-release": group_production_release, + "preview-deploy": group_preview_deploy, + "migration-d1": group_migration_d1, + "shared-dev": group_shared_dev, + "chain-smoke-rollback": group_chain_smoke_rollback, + "deferred-verification": group_deferred_verification, + "native-dev-smoke-modes": group_native_dev_smoke_modes, + "review-regressions": group_review_regressions, +} + + +def main(argv): + if "--list" in argv: + for gid, fn in GROUPS.items(): + print(f"{gid}\t{(fn.__doc__ or '').strip()}") + return 0 + chosen = [argv[i + 1] for i, a in enumerate(argv) if a == "--group" and i + 1 < len(argv)] or list(GROUPS) + unknown = [g for g in chosen if g not in GROUPS] + if unknown: + print(f"unknown group(s): {unknown}; known: {list(GROUPS)}", file=sys.stderr) + return 2 + for gid in chosen: + GROUPS[gid]() + print(f"SUMMARY groups={len(chosen)} checks={checks} fail={fail}") + write_perf_report() + return fail + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/cf-preview/test-testsh.sh b/scripts/cf-preview/test-testsh.sh new file mode 100755 index 0000000..6701187 --- /dev/null +++ b/scripts/cf-preview/test-testsh.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# test.sh profile semantics (stubbed fast/chain commands): Full fails fast, a failing Chain fails Full, missing tooling is loud. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 +fail=0 +check() { if [ "$1" = 0 ]; then echo "PASS: $2"; else echo "FAIL: $2"; fail=1; fi; } +T=scripts/cf-preview/test.sh +D="$(mktemp -d)" +marker="$D/chain-ran" +CHAIN_OK="touch $marker" +CHAIN_BAD="touch $marker; exit 3" + +rm -f "$marker"; out="$(LOGDIR="$D/l1" CF_V2_TEST_FAST_CMD=true CF_V2_TEST_CHAIN_CMD="$CHAIN_OK" "$T" full 2>&1)"; rc=$? +check "$([ $rc = 0 ] && [ -e "$marker" ] && echo 0 || echo 1)" "Full: Fast passes -> Chain runs -> success" + +rm -f "$marker"; out="$(LOGDIR="$D/l2" CF_V2_TEST_FAST_CMD="echo boom; exit 1" CF_V2_TEST_CHAIN_CMD="$CHAIN_OK" "$T" full 2>&1)"; rc=$? +check "$([ $rc != 0 ] && [ ! -e "$marker" ] && echo 0 || echo 1)" "Full: Fast fails -> Chain is NOT started, exit code non-zero" +check "$(printf '%s' "$out" | grep -q 'SKIP delivery chain: the Fast profile failed' && printf '%s' "$out" | grep -q 'boom' && printf '%s' "$out" | grep -q 'logs: ' && echo 0 || echo 1)" "Full: the skip reason, the failure excerpt and the log directory are reported" +check "$([ -s "$D/l2/fast_(override).log" ] && echo 0 || echo 1)" "Full: the failing Fast log is preserved" + +rm -f "$marker"; out="$(LOGDIR="$D/l3" CF_V2_TEST_FAST_CMD=true CF_V2_TEST_CHAIN_CMD="$CHAIN_BAD" "$T" full 2>&1)"; rc=$? +check "$([ $rc != 0 ] && [ -e "$marker" ] && echo 0 || echo 1)" "Full: Chain fails -> Full fails" + +rm -f "$marker"; LOGDIR="$D/l4" CF_V2_TEST_FAST_CMD="exit 1" CF_V2_TEST_CHAIN_CMD="$CHAIN_OK" "$T" chain >/dev/null 2>&1; rc=$? +check "$([ $rc = 0 ] && [ -e "$marker" ] && echo 0 || echo 1)" "standalone chain: runs regardless of Fast" +LOGDIR="$D/l5" CF_V2_TEST_FAST_CMD="exit 1" CF_V2_TEST_CHAIN_CMD="$CHAIN_OK" "$T" fast >/dev/null 2>&1; rc=$? +check "$([ $rc != 0 ] && echo 0 || echo 1)" "standalone fast: failure propagates" + +# missing tooling: no false success (a PATH without python3/node/jq) +mkdir -p "$D/bin"; for t in bash dirname mktemp mkdir tail cat env; do ln -sf "$(command -v $t)" "$D/bin/$t" 2>/dev/null; done +out="$(LOGDIR="$D/l6" PATH="$D/bin" "$(command -v bash)" "$T" fast 2>&1)"; rc=$? +check "$([ $rc != 0 ] && printf '%s' "$out" | grep -q "missing tool" && ! printf '%s' "$out" | grep -q '^PASSED' && echo 0 || echo 1)" "missing tooling: clear error, non-zero, no PASSED line" +"$T" bogus >/dev/null 2>&1; check "$([ $? = 2 ] && echo 0 || echo 1)" "unknown mode: usage error" +rm -rf "$D" +exit $fail diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index 692bf85..362cbd9 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -7,14 +7,26 @@ behaviour is executed by test-jobs.py. What this cannot prove (needs a real run): GitHub's own scheduling, Environment protection rules, runner behaviour. See docs/cf-worker-v2-refactor.md "Verified vs not verified". """ +import json import pathlib import re import sys import yaml WF = ".github/workflows/_cf-worker-v2.yml" +DWF = ".github/workflows/_cf-worker-delivery.yml" +CWF = ".github/workflows/_cf-worker-ci.yml" +LWF = ".github/workflows/_cf-worker-lifecycle.yml" +PWF = ".github/workflows/_cf-worker-persistent-dev.yml" doc = yaml.safe_load(open(WF)) +ddoc = yaml.safe_load(open(DWF)) +cdoc = yaml.safe_load(open(CWF)) +ldoc = yaml.safe_load(open(LWF)) +pdoc = yaml.safe_load(open(PWF)) jobs = doc["jobs"] +djobs = ddoc["jobs"] +cjobs = cdoc["jobs"] # CI: six validation jobs + Validation Gate +ljobs, pjobs = ldoc["jobs"], pdoc["jobs"] inputs_def = doc[True]["workflow_call"]["inputs"] fail = 0 @@ -75,13 +87,11 @@ def res(**kw): VALIDATION = ["lint", "typecheck", "unit", "integration", "build", "e2e"] CMD_INPUT = {"lint": "lint_cmd", "typecheck": "typecheck_cmd", "unit": "test_unit_cmd", "integration": "test_integration_cmd", "e2e": "test_e2e_cmd"} JOB_NAME = {"lint": "Lint", "typecheck": "Typecheck", "unit": "Unit Tests", "integration": "Integration Tests", "build": "Build Validation", "e2e": "E2E"} -DEVJOB = "dev-deploy" def vres(**over): """results of all six validation jobs (success unless overridden) + Migration (skipped: d1_migrations is false by default)""" d = {j: "success" for j in VALIDATION} - d.update({"migration": "skipped"}) d.update(over) return res(**d) @@ -90,314 +100,406 @@ def vres(**over): MAIN, FEAT = "refs/heads/main", "refs/heads/feat/x" text_all = open(WF).read() -# ---------------------------------------------------------------- 1. topology: thin workflow, one engine -DELIVERY = ["migration", "deploy", "smoke", "rollback"] -DEVVERIFY = ["dev-verify", "dev-rollback"] -check(set(jobs) == set(VALIDATION) | set(DELIVERY) | {DEVJOB, "release", "config-guard", "lifecycle", "persistent-dev"} | set(DEVVERIFY), - "job set is the six validation jobs, Migration -> Deploy -> Smoke -> Rollback, Shared Dev (+ its deferred smoke / revert), Release + config-guard/lifecycle/persistent-dev handlers") -for gone in ("ci", "plan", "test", "test-unit", "test-integration", "test-e2e", "prepare", "migrate", "test-gate", "gate", "preview", "production", - "production-verify", "production-recovery", "pr-link", "cleanup", "verify", "revert"): - check(gone not in jobs, f"obsolete job '{gone}' is gone (no Prepare / Plan / Test Gate job; the v1 names live on as the display names Smoke Tests / Revert on Verify Failure)") -check([jobs[j]["name"] for j in DELIVERY] == ["Migration", "Deploy", "Smoke Tests", "Revert on Verify Failure"], "delivery jobs carry the v1 display names: Migration, Deploy, Smoke Tests, Revert on Verify Failure") -check([jobs[j]["name"] for j in VALIDATION] == [JOB_NAME[j] for j in VALIDATION], "validation jobs are named Lint, Typecheck, Unit Tests, Integration Tests, Build Validation, E2E") - - -def needs_of(name): - n = jobs[name].get("needs", []) +# ---------------------------------------------------------------- 1. topology: Gate -> Plan -> ONE Migration job -> Delivery (per kind) -> Release +KINDS = ["production", "native-branch", "native-dev", "persistent-dev"] +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", "persistent-dev"}, + "public job set: CI call, Delivery plan, ONE Database Migration job, one Delivery CALL per kind (reusable workflow), Release + config-guard / lifecycle / persistent-dev 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"}, "the internal delivery workflow has exactly: deploy, smoke, rollback (ONE target per call; no Preflight / Migration path)") +check(set(ljobs) == {"lifecycle"} and set(pjobs) == {"persistent-dev"}, "the event handlers are one small workflow each (Preview lifecycle, manual persistent Dev)") +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)") +check(sum(1 for j in jobs.values() if j.get("name") == "Database Migration") == 1 and len(re.findall(r"^ migration[\w_-]*:", text_all, re.M)) == 1, "exactly ONE Migration job definition exists in the public pipeline") +check(sum(len(re.findall(r"^ (?:migration|preflight)[\w_-]*:", open(f).read(), re.M)) for f in (CWF, DWF, LWF, PWF)) == 0, "no other workflow of the pipeline defines a Migration or Preflight job") +check([cjobs[j]["name"] for j in VALIDATION] == [JOB_NAME[j] for j in VALIDATION], "validation jobs are named Lint, Typecheck, Unit Tests, Integration Tests, Build Validation, E2E") +check(cjobs["gate"]["name"] == "Validation Gate" and jobs["ci"]["name"] == "CI" and jobs["plan"]["name"] == "Delivery plan" and jobs["migration"]["name"] == "Database Migration", "CI, Validation Gate, Delivery plan and Database Migration are named jobs") +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)") + + +def needs_of(name, js=None): + n = (js or jobs)[name].get("needs", []) return [n] if isinstance(n, str) else list(n or []) for j in VALIDATION: - check(needs_of(j) == [], f"{j}: no needs (independent, schedulable immediately; no job waits for another validation job)") -check(set(needs_of("migration")) == set(VALIDATION), "Migration needs exactly the six validation jobs") -check(set(needs_of("deploy")) == set(VALIDATION) | {"migration"}, "Deploy needs the six validation jobs and Migration") -check(needs_of("smoke") == ["deploy"], "Smoke Tests need Deploy") -check(set(needs_of("rollback")) == {"migration", "deploy", "smoke"}, "Revert on Verify Failure needs Migration (D1 state), Deploy (what to restore) and Smoke Tests (why)") -check(set(needs_of(DEVJOB)) == set(VALIDATION) | set(DELIVERY), "Shared Dev needs the six validation jobs (+ the Preview delivery jobs for ordering only)") -check(set(needs_of("release")) == {"deploy", "smoke", "rollback"}, "Release needs Deploy, Smoke Tests and Rollback (Shared Dev never gates or triggers it)") -check(len(text_all.splitlines()) <= 1400, f"the workflow is thin ({len(text_all.splitlines())} lines; the former monolith had 1681)") + check(needs_of(j, cjobs) == [], f"{j}: no needs (independent and parallel with the other five checks - no accidental serial dependency)") +check(set(needs_of("gate", cjobs)) == set(VALIDATION), "Validation Gate needs exactly the six validation jobs") +check(needs_of("ci") == [] and needs_of("plan") == ["ci"], "CI has no needs; the Delivery plan needs only the CI call") +check(needs_of("migration") == ["plan"], "Database Migration needs exactly the plan (which itself requires a passed CI) - and NO Deploy / Smoke / Rollback") +for k in KINDS: + check(set(needs_of(f"delivery_{KK(k)}")) == {"plan", "migration"}, f"delivery_{KK(k)}: the plan and THE migration only") +check(not any(n.startswith("delivery_") for k in KINDS for n in needs_of(f"delivery_{KK(k)}")), "P2-A: NO Delivery call waits for another kind's Deploy / Smoke / Rollback") +check(not any(n.startswith("delivery_") for n in needs_of("migration")), "the Migration job never waits for any Delivery call") +check(set(needs_of("release")) == {"delivery_production"}, "Release reads ONLY the Production delivery call; Dev kinds can never gate it") +check(all("strategy" not in jobs[j] for j in CALL_JOBS | {"ci", "plan", "migration", "release"}), "P1-A: no matrix anywhere in the delivery graph (outputs are never merged across legs)") +check(all("strategy" not in j for j in djobs.values()), "P1-A: no matrix in the internal delivery workflow either") +check(set(needs_of("smoke", djobs)) == {"deploy"} and set(needs_of("rollback", djobs)) == {"deploy", "smoke"} and needs_of("deploy", djobs) == [], "delivery workflow: Smoke needs only THIS call's Deploy; Rollback needs this call's Deploy and Smoke") +DELIVERY_WF = "$/.github/workflows/_cf-worker-delivery.yml" +check(all(jobs[j]["uses"] == DELIVERY_WF for j in CALL_JOBS), "every per-kind call is a STATIC `uses: $/.github/workflows/_cf-worker-delivery.yml` of the SAME internal workflow (one delivery implementation)") +check(not any("uses" in j for d_ in (djobs, cjobs, ljobs, pjobs) for j in d_.values()), "nesting depth: the delivery workflow calls no further reusable workflow (caller -> _cf-worker-v2 -> _cf-worker-delivery = 3 levels, limit 10)") +LEG = {"production": {"kind": "production", "target": "prod", "environment": "app/prod"}, "native-branch": {"kind": "native-branch", "target": "preview", "environment": "app/preview"}, + "native-dev": {"kind": "native-dev", "target": "preview", "environment": "app/native-dev"}, "persistent-dev": {"kind": "persistent-dev", "target": "dev", "environment": "app/dev"}} +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") + check(w["descriptor"] == "${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['" + k + "']) }}" 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"}, + f"delivery_{KK(k)}: receives the ONE migration result (no peer / owner / per-kind migration routing)") +RANK = {"none": 0, "read": 1, "write": 2} +CALLED = {"ci": cjobs, "lifecycle": ljobs, "persistent-dev": pjobs, **{c: djobs for c in CALL_JOBS}} +for n, j in jobs.items(): + if "uses" not in j: + continue + need = {} + for dj in CALLED[n].values(): # the ceiling a caller must grant is the maximum over ALL jobs of the called workflow, even ones skipped by `if` + for p_, v in {"contents": "read", **dj.get("permissions", {})}.items(): + need[p_] = max(need.get(p_, 0), RANK[v]) + cp = {"contents": "read", **j.get("permissions", {})} # the workflow-level default is contents: read + check(all(RANK[cp.get(p_, "none")] >= r_ for p_, r_ in need.items()), + f"{n}: the calling job's permissions cover EVERY job of the called workflow (GitHub validates the ceiling for all jobs, including ones skipped by `if` - a lower ceiling is a startup_failure; found by the real-runner scratch run)") +check(len(text_all.splitlines()) <= 780, f"the public workflow stays thin ({len(text_all.splitlines())} lines)") # ---------------------------------------------------------------- 2. one deployment implementation -ENGINE_USE = "./.cf-tools/actions/cf-deploy" -engine_jobs = {n: [s for s in j.get("steps", []) if s.get("uses") == ENGINE_USE] for n, j in jobs.items()} -check({n for n, s in engine_jobs.items() if s} == set(DELIVERY) | {DEVJOB, "persistent-dev"} | set(DEVVERIFY) and all(len(s) == 1 for s in engine_jobs.values() if s), - "Migration, Deploy, Smoke, Rollback, Shared Dev (+ deferred smoke / revert) and the manual persistent Dev each call the SAME delivery engine action exactly once (no second deployment implementation)") -eng = {n: s[0] for n, s in engine_jobs.items() if s} -check({n: eng[n]["with"]["stage"] for n in DELIVERY} == {"migration": "migrate", "deploy": "publish", "smoke": "verify", "rollback": "rollback"} and eng[DEVJOB]["with"]["stage"] == "${{ inputs.pipeline_phase == 'deploy' && 'deploy' || 'all' }}" - and eng["dev-verify"]["with"]["stage"] == "verify" and eng["dev-rollback"]["with"]["stage"] == "rollback", - "each delivery job selects its engine stage (migrate / publish / verify / rollback); Shared Dev keeps the single-job engine (stage all) unless pipeline_phase is deploy") -check(all(eng[n]["with"]["target"] == "${{ github.ref == 'refs/heads/main' && 'prod' || 'preview' }}" for n in DELIVERY), "Production / Preview jobs: target = prod on main, preview otherwise (same binding in every job)") -check(eng[DEVJOB]["with"]["target"] == "dev" and eng["persistent-dev"]["with"]["target"] == "dev" and eng["persistent-dev"]["with"]["mode"] == "manual", "Shared Dev and persistent Dev bind target=dev; persistent Dev runs in manual mode") +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, "persistent-dev": pjobs["persistent-dev"]}.items()} +check({n for n, s in engine_jobs.items() if s} == {"d.deploy", "d.smoke", "d.rollback", "migration", "persistent-dev"} 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 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)") +eng = {n.replace("d.", ""): s[0] for n, s in engine_jobs.items() if s and n != "migration"} +check({n: eng[n]["with"]["stage"] for n in djobs} == {"deploy": "publish", "smoke": "verify", "rollback": "rollback"}, "each delivery job selects its engine stage (publish / verify / rollback)") +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)") +check(not any(s["with"].get("kinds") or s["with"].get("peers") for s in MIG_STEPS), "the Migration job has no peer-result bookkeeping: step order is the guarantee") +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 djobs), "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["persistent-dev"]["with"]["target"] == "dev" and eng["persistent-dev"]["with"]["mode"] == "manual", "manual persistent Dev binds target=dev, mode=manual") +check(all(eng[n]["with"]["inputs_json"] == "${{ inputs.inputs_json }}" for n in djobs) and all(eng[n]["with"]["d1_expected_id"] == "${{ fromJSON(inputs.descriptor).d1_expected_id }}" for n in djobs) 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) }}" -check(all(eng[n]["with"]["inputs_json"] in ("${{ toJSON(inputs) }}", VERIFY_BLOB, "${{ toJSON(fromJSON(inputs.phase_state).inputs) }}") for n in eng) - and all(eng[n]["with"]["inputs_json"] == VERIFY_BLOB for n in ("smoke", "rollback")), "engine inputs are passed as one JSON data blob, not interpolated into scripts (the deferred verify call reads the DEPLOY call's blob)") +check(all(jobs[f"delivery_{KK(k)}"]["with"]["inputs_json"] == VERIFY_BLOB for k in KINDS), "delivery calls: the verify call reads the DEPLOY call's inputs") IMPL = re.compile(r"wrangler|migrate\.sh|publish\.sh|build\.sh|engine\.sh|deploy\.sh|smoke\.sh|d1 migrations|versions (upload|deploy)") -for n, j in jobs.items(): - runs_txt = "\n".join(ln for s in j.get("steps", []) for ln in s.get("run", "").splitlines() if "echo" not in ln) # summary text may name commands - check(not IMPL.search(runs_txt), f"{n}: no deployment implementation inline in YAML (Wrangler, migration, publish and smoke live in actions/cf-deploy + scripts)") +for n, j in {**{f"d.{k}": v for k, v in djobs.items()}, **jobs}.items(): + runs_txt = "\n".join(ln for s in j.get("steps", []) for ln in s.get("run", "").splitlines() if "echo" not in ln) + check(not IMPL.search(runs_txt), f"{n}: no deployment implementation inline in YAML") # ---------------------------------------------------------------- 3. gating over the event matrix -for name in VALIDATION: - check(runs(name, scenario("push", FEAT)), f"{name}: runs on a feature push") - check(runs(name, scenario("push", MAIN)), f"{name}: runs on a main push") - check(not runs(name, scenario("delete", FEAT, ev_ref_type="branch")), f"{name}: not on branch delete") - check(not runs(name, scenario("pull_request", "refs/pull/1/merge", head_repo="o/r")), f"{name}: same-repo PR is covered by the push run (no duplicate full tests)") - check(runs(name, scenario("pull_request", "refs/pull/1/merge", head_repo="fork/r")), f"{name}: fork PR runs (credential-free)") - check(not runs(name, scenario("workflow_dispatch", "refs/heads/dev", {"dispatch_target": "persistent-dev"})), f"{name}: not on persistent-dev dispatch") +# CI call: the event/skip rules live in ONE place (the call job's `if`) +check(runs("ci", scenario("push", FEAT)) and runs("ci", scenario("push", MAIN)), "ci: runs on feature and main pushes") +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(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" +cruns = lambda name, **inp: bool(ev(strip_expr(cjobs[name].get("if", "true")) or cjobs[name].get("if", "true"), Ctx({"inputs": dict({"lint_cmd": "x", "typecheck_cmd": "x", "test_unit_cmd": "x", "test_integration_cmd": "x", "test_e2e_cmd": "x", "build_cmd": "x"}, **inp)}, {}))) for name, inp in CMD_INPUT.items(): - check(not runs(name, scenario("push", FEAT, {inp: ""})), f"{name}: intentionally skipped (job-level) when {inp} is empty") - check(runs(name, scenario("push", FEAT, {inp: "x"})), f"{name}: runs when {inp} is configured") -check(runs("build", scenario("push", FEAT, {"build_cmd": ""})), "build: build_cmd is required, the job is never optional") - -for name in ("deploy", DEVJOB): - extra = {"shared_dev_on_branch_push": True} if name == DEVJOB else {} - base = lambda **kw: scenario(**dict(dict(event="push", ref=FEAT, inputs=extra, needs=GREEN), **kw)) - check(runs(name, base()), f"{name}: runs for a trusted feature push") - check(not runs(name, base(event="pull_request", ref="refs/pull/1/merge", head_repo="o/r")), f"{name}: never on a same-repo PR") - check(not runs(name, base(event="pull_request", ref="refs/pull/1/merge", head_repo="fork/r")), f"{name}: never on a fork PR (no migration / deploy / credentials)") - check(not runs(name, base(event="delete", ref=FEAT)), f"{name}: never on branch delete") - check(not runs(name, base(ref="refs/tags/v1.0.0")), f"{name}: never on a tag push") - check(not runs(name, scenario("workflow_dispatch", "refs/heads/dev", dict(extra, dispatch_target="persistent-dev"), GREEN)), f"{name}: persistent-dev dispatch is not an automatic deploy") - check(runs(name, scenario("workflow_dispatch", FEAT, dict(extra, dispatch_target=""), GREEN)), f"{name}: a plain workflow_dispatch on a branch behaves like a push") + check(not cruns(name, **{inp: ""}), f"{name}: intentionally skipped when {inp} is empty") + check(cruns(name), f"{name}: runs when {inp} is configured") +check("if" not in cjobs["build"], "build: build_cmd is required, never optional") +check(all(not any(k in cj for k in ("strategy",)) for n, cj in cjobs.items() if n != "e2e") and "matrix" in cjobs["e2e"]["strategy"] and cjobs["e2e"]["strategy"].get("fail-fast") is False, "only E2E has a matrix (variants, fail-fast off)") +gate_step = cjobs["gate"]["steps"][0] +check(cjobs["gate"]["if"] == "always()", "Validation Gate: always() - it runs (and fails) even when a job it needs failed or was cancelled") +check(all(f"{v.upper()}_CONFIGURED" in gate_step["env"] for v in VALIDATION) and 'success' in gate_step["run"] and 'skipped' in gate_step["run"], "Validation Gate: configured jobs must succeed; unconfigured ones must be skipped (fail closed otherwise)") +check(all(f"needs.{v}.result" in gate_step["env"][v.upper()] for v in VALIDATION), "Validation Gate reads the result of every validation job") +check(cjobs["gate"]["outputs"]["validation"] == "${{ steps.gate.outputs.validation }}" and 'echo "validation=passed" >> "$GITHUB_OUTPUT"' in gate_step["run"] and gate_step["run"].index("exit 1") < gate_step["run"].index("validation=passed"), "Validation Gate: `validation=passed` is written only after every check passed") +check(cdoc[True]["workflow_call"]["outputs"]["validation"]["value"] == "${{ jobs.gate.outputs.validation }}" and "concurrency" not in cdoc, "CI workflow exposes the gate's verdict and has no workflow-level concurrency (the public workflow's group would deadlock)") +# Gate semantics over every outcome, executed by the real script +import subprocess +def gate_run(unconfigured=(), **o): + import tempfile + env = {"PATH": "/usr/bin:/bin"} for v in VALIDATION: - for bad in ("failure", "cancelled", "skipped"): - check(not runs(name, scenario("push", FEAT, extra, vres(**{v: bad}))), f"{name}: blocked when configured {v} is {bad} (a configured job that skipped is NOT validation)") - if v in CMD_INPUT: - check(runs(name, scenario("push", FEAT, dict(extra, **{CMD_INPUT[v]: ""}), vres(**{v: "skipped"}))), f"{name}: an intentionally disabled {v} ({CMD_INPUT[v]} empty) does not block") - check(not runs(name, scenario("push", FEAT, dict(extra, **{CMD_INPUT[v]: ""}), vres(**{v: "failure"}))), f"{name}: a failed {v} blocks even if its command is empty") - check(not runs(name, scenario("push", FEAT, dict(extra, build_cmd=""), vres(build="skipped"))), f"{name}: Build Validation skipped never passes (build_cmd is required)") - check(runs(name, scenario("push", FEAT, dict(extra, **{c: "" for c in CMD_INPUT.values()}), vres(**{v: "skipped" for v in CMD_INPUT}))), f"{name}: Worker with only a build command (all optional checks disabled) passes") -check(runs("deploy", scenario("push", MAIN, needs=GREEN)), "Deploy: runs for a trusted main push") -check(not runs("deploy", scenario("push", FEAT, {"deployment_strategy": "production-only"}, GREEN)), "Deploy: production-only Worker has no Preview deploy on feature branches") -check(runs("deploy", scenario("push", MAIN, {"deployment_strategy": "production-only"}, GREEN)), "Deploy: production-only Worker still deploys main") -check(not runs("deploy", scenario("push", FEAT, {"preview_on_branch_push": False}, GREEN)), "Deploy: preview_on_branch_push=false skips feature-branch Preview") -check(runs("deploy", scenario("push", MAIN, {"preview_on_branch_push": False}, GREEN)), "Deploy: Production is main-only and independent of the Preview switch") -check(runs("deploy", scenario("push", FEAT, {"deployment_strategy": "hybrid"}, GREEN)), "Hybrid: feature pushes get Native Previews, not persistent Dev") - - -def dep_needs(result="success", target="prod", verified="true", smoke="success", rollback="skipped"): - return {"deploy": {"result": result, "outputs": {"target": target, "published": "true"}}, - "smoke": {"result": smoke, "outputs": {"deployed_and_verified": verified}}, - "rollback": {"result": rollback, "outputs": {}}} - - -rel_ok = lambda **kw: scenario(**dict(dict(event="push", ref=MAIN, inputs={"release_cmd": "pnpm release"}, needs=dep_needs()), **kw)) -check(not runs("release", rel_ok(needs=dep_needs(verified="false"))), "Release: a Smoke job that succeeded WITHOUT content verification (Access liveness only) never releases") -check(not runs("release", rel_ok(needs={"deploy": {"result": "success", "outputs": {"target": "prod"}}, "smoke": {"result": "success", "outputs": {}}, "rollback": {"result": "skipped", "outputs": {}}})), "Release: missing verified output never releases") -check(not runs("release", rel_ok(needs=dep_needs(smoke="skipped", verified="")) ), "Release: a skipped Smoke (stale / superseded publish) never releases although Deploy succeeded") -check(not runs("release", rel_ok(needs=dep_needs(smoke="failure", verified="false", rollback="success"))), "Release: a failed smoke blocks Release even when the rollback SUCCEEDED") -check(not runs("release", rel_ok(needs=dep_needs(smoke="failure", verified="false", rollback="failure"))), "Release: a failed smoke and a failed rollback block Release") -check(not runs("release", rel_ok(needs=dep_needs(rollback="success"))), "Release: any rollback that ran blocks Release") -check(not runs("release", rel_ok(needs=dep_needs(smoke="cancelled"))), "Release: a cancelled Smoke blocks Release") -check(runs("release", rel_ok()), "Release: runs after a successful, verified main Production deploy (Deploy + Smoke success, Rollback skipped)") -check(not runs("release", rel_ok(ref=FEAT, needs=dep_needs(target="preview"))), "Release: main only") -check(not runs("release", rel_ok(needs=dep_needs(target="preview"))), "Release: a Preview deployment can never release") -check(not runs("release", rel_ok(needs=dep_needs(result="failure"))), "Release: never after a failed Deploy") + env[v.upper()] = o.get(v, "success") + env[f"{v.upper()}_CONFIGURED"] = "false" if v in unconfigured else "true" + out = tempfile.NamedTemporaryFile("w+", delete=False) + env["GITHUB_OUTPUT"] = out.name + r = subprocess.run(["bash", "-eo", "pipefail", "-c", gate_step["run"]], env=env, capture_output=True, text=True) + return r.returncode, open(out.name).read().strip() +check(gate_run() == (0, "validation=passed"), "Validation Gate (executed): all six configured and green -> passed") +for res_ in ("failure", "cancelled", "skipped"): + check(all(gate_run(**{v: res_})[0] == 1 and gate_run(**{v: res_})[1] == "" for v in VALIDATION), f"Validation Gate (executed): a configured job that finished '{res_}' fails the gate and writes no verdict") +check(gate_run(("e2e",), e2e="skipped") == (0, "validation=passed") and gate_run(("e2e",), e2e="success")[0] == 1, "Validation Gate (executed): an UNCONFIGURED job must be skipped (an unexpected run is fail-closed too)") + +ci_ok = lambda **kw: dict(dict(event="push", ref=FEAT, needs={"ci": {"result": "success", "outputs": {"validation": "passed"}}}), **kw) +plan_if = lambda **kw: runs("plan", scenario(**dict(ci_ok(), **kw))) +check(plan_if() and plan_if(ref=MAIN), "plan: runs after a passed CI on feature and main pushes") +check(plan_if(event="workflow_dispatch"), "plan: runs on a workflow_dispatch after a passed CI") +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(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). +DESC_FIX = {"production": ("prod", "app/prod"), "native-branch": ("preview", "app/preview"), "native-dev": ("preview", "app/native-dev"), "persistent-dev": ("dev", "app/dev")} + + +def desc(k, d1="", owner="", repo="o/r", app="app", scope=""): + g = f"cfv2-dev-{repo}-{scope or app}" if k == "persistent-dev" else f"cfv2-leg-1-{k}" + return {"v": 1, "kind": k, "target": DESC_FIX[k][0], "environment": DESC_FIX[k][1], "deploy_group": g, "rollback_group": g if k == "persistent-dev" else g + "-rollback", + "d1_expected_id": d1, "d1_owner_kind": owner, "owns_migration": bool(d1 and owner == k), "smoke_mode": "full", "phase": "all"} + + +def pl(kinds=("native_branch",), d1=False, owner=None, result="success", **extra): + """Routing stub of the plan job's outputs. With d1 the kinds share ONE D1; `owner` (default: the first kind) is the migration owner.""" + o = {"has_targets": str(bool(kinds)).lower(), "kinds": json.dumps([k.replace("_", "-") for k in kinds]), "has_migrations": str(bool(d1 and kinds)).lower()} + for k in ("production", "native_branch", "native_dev", "persistent_dev"): + o[f"on_{k}"] = str(k in kinds).lower() + own = (owner or (kinds[0] if kinds else "")).replace("_", "-") + o["descriptors"] = json.dumps({k.replace("_", "-"): desc(k.replace("_", "-"), "DB1" if d1 else "", own if d1 else "") for k in kinds}) + o["migration"] = json.dumps({"kind": own, "environment": DESC_FIX[own][1], "d1_id": "DB1", "kinds": [k.replace("_", "-") for k in kinds]}) if d1 and kinds else "" + o.update(extra) + return {"result": result, "outputs": o} + + +G_OK = {"result": "success", "outputs": {}} +import json # noqa: E402 +mig_if = lambda kinds=("native_branch",), d1=True, plan_result="success", **kw: runs("migration", scenario("push", kw.pop("ref", FEAT), dict({"d1_migrations": d1}, **kw.pop("inputs", {})), {"plan": pl(kinds, d1, result=plan_result)})) +check(mig_if() and mig_if(("production",), ref=MAIN), "Migration: ONE job runs for a main push (Production D1) and for a feature push (Preview/Dev D1) when d1_migrations is on") +check(mig_if(("native_branch", "native_dev")) and mig_if(("native_branch", "persistent_dev")), "Migration: Native Branch + Native Dev (or persistent Dev) sharing one D1 still run ONE migration job") +check(not mig_if(d1=False), "Migration: D1-less app / d1_migrations=false -> skipped, and Delivery proceeds (see below)") +check(not mig_if(inputs={"pipeline_phase": "verify"}), "Migration: never in a verify call (deferred verification never repeats a migration)") +check(not mig_if(plan_result="failure") and not mig_if(plan_result="skipped") and not mig_if(plan_result="cancelled"), "Migration: needs a successful plan - the plan only runs after a passed CI, so a failed CI, a failed or an invalid plan mutates nothing") +check(not runs("migration", scenario("pull_request", "refs/pull/1/merge", {"d1_migrations": True}, {"plan": {"result": "skipped", "outputs": {}}})) and not runs("migration", scenario("delete", FEAT, {"d1_migrations": True}, {"plan": {"result": "skipped", "outputs": {}}})), + "Migration: no migration on a PR or a lifecycle-only (delete) event") +check(not runs("migration", scenario("push", FEAT, {"d1_migrations": True}, {"plan": pl((), True)})), "Migration: nothing enabled (CI-only run) -> no migration") +mj = jobs["migration"] +env_expr = mj["environment"]["name"] +check(render(env_expr, scenario("push", MAIN, {"d1_migrations": True}, {"plan": pl(("production",), True)})) == "app/prod" + and render(env_expr, scenario("push", FEAT, {"d1_migrations": True}, {"plan": pl(("native_branch", "native_dev"), True)})) == "app/preview" + and render(env_expr, scenario("push", FEAT, {"d1_migrations": True}, {"plan": pl(("native_dev",), True)})) == "app/native-dev", + "Migration: runs in the GitHub Environment of the plan's migration owner (/prod keeps its approval protection; Production approval cannot be bypassed)") +check(mj["permissions"] == {"contents": "read", "deployments": "read"}, "Migration: read-only token (deployments read for the Preview identity guard)") +check(mj["outputs"]["migration_json"] == "${{ toJSON(steps.migrate.outputs) }}" and [x.get("id") for x in mj["steps"] if x.get("id") in ("migrate", "result")] == ["migrate", "result"] and mj["outputs"]["migration_status"] == "${{ steps.result.outputs.status }}", "Migration: ONE deterministic result - the outputs of its single `migrate` step, and an always-run status step (applied | none | failed)") +MS = mj["steps"] +vsteps = [x for x in MS if x.get("with", {}).get("stage") == "preflight"] +check(all("fromJSON(needs.plan.outputs.migration).kind != '" in x["if"] for x in vsteps) and {x["with"]["kind"] for x in vsteps} == {"native-dev", "persistent-dev"} and MS.index(vsteps[-1]) < MS.index(MS[-1]), + "Migration: the non-owner targets are validated BEFORE the owner's migrate step; the owner itself is validated inside its migrate step (build, effective-config audit, D1 binding, stale guard, then the one mutation)") +check(all(isinstance(x.get("uses"), str) and not x.get("run") for x in MS if x.get("with", {}).get("stage") in ("preflight", "migrate")), "Migration: validation and migration are engine calls, no inline shell") +ACT_STEPS = DEPLOY_ACTION["runs"]["steps"] +gate_i = next(n for n, x in enumerate(ACT_STEPS) if x.get("id") == "migration_gate") +first_work = next(n for n, x in enumerate(ACT_STEPS) if "Stale-SHA guard (start)" in x.get("name", "")) +check(gate_i < first_work and ACT_STEPS[gate_i]["if"] == "inputs.stage == 'publish'" and "migration-gate" in ACT_STEPS[gate_i]["run"], + "Migration gate: runs in the engine right after init - before the stale guard, setup, build and publish - for the publish stage only") +# P1 (PR #89 review): a stale-skipped migration never authorizes a publish - executed through the REAL engine gate command (cli.mjs migration-gate) +def gate_cli(result, mj, d1="true", kind="native-branch", owner="native-branch", d1id="DB1"): + d = subprocess.run(["node", "-e", "import('./actions/cf-preview/delivery.mjs').then(m=>console.log(JSON.stringify(m.targetDescriptor({kind:process.argv[1],app:'app',runId:1,repo:'o/r',d1ExpectedId:process.argv[2],ownerKind:process.argv[3]}))))", kind, d1id, owner], capture_output=True, text=True, check=True).stdout.strip() + env = {"PATH": "/usr/bin:/bin:" + __import__("os").path.dirname(__import__("shutil").which("node") or "/usr/bin/node"), "DESCRIPTOR": d, "D1_MIGRATIONS": d1, "MIGRATION_RESULT": result, "MIGRATION_JSON": mj} + r = subprocess.run(["node", "actions/cf-preview/cli.mjs", "migration-gate", "--kind", kind], env=env, capture_output=True, text=True) + return r.returncode, (r.stdout + r.stderr) +import subprocess # noqa: E402 +APPLIED = json.dumps({"migration": "applied", "kind": "native-branch", "d1_id": "DB1"}) +check(gate_cli("success", APPLIED)[0] == 0, "Migration Gate (executed): a succeeded Migration job with an applied migration of the planned owner on the planned D1 authorizes the publish") +check(gate_cli("skipped", "", d1="false")[0] == 0, "Migration Gate (executed): d1_migrations=false needs no migration result") +for what, args in (("stale=true", ("success", json.dumps({"stale": True}))), ("stale='true'", ("success", json.dumps({"stale": "true"}))), + ("stale=true with otherwise valid metadata", ("success", json.dumps({"migration": "applied", "kind": "native-branch", "d1_id": "DB1", "stale": "true"}))), + ("migration none", ("success", json.dumps({"migration": "none", "kind": "native-branch", "d1_id": "DB1"}))), + ("empty result", ("success", "")), ("invalid JSON", ("success", "{x")), ("owner mismatch", ("success", json.dumps({"migration": "applied", "kind": "persistent-dev", "d1_id": "DB1"}))), + ("D1 mismatch", ("success", json.dumps({"migration": "applied", "kind": "native-branch", "d1_id": "OTHER"}))), + ("Migration job failed", ("failure", APPLIED)), ("Migration job cancelled", ("cancelled", APPLIED)), ("Migration job skipped while required", ("skipped", APPLIED))): + rc, out = gate_cli(*args) + check(rc != 0, f"Migration Gate (executed): {what} blocks the publish") +check("stale" in gate_cli("success", json.dumps({"stale": "true"}))[1], "Migration Gate (executed): the stale refusal says why") +_gi = aidx("Migration gate") +check(all(_gi < aidx(n) for n in ("Stale-SHA guard (start)", "Setup dependencies", "Prepare", "Build and audit", "Publish the audited artifact", "Smoke the exact deployed URL")), "Migration Gate: runs before the stale guard, set-up, prepare, build, publish and smoke of the publish stage - a fresh branch tip can never substitute for it") +dl_if = lambda k, kinds=None, plan_result="success", inputs=None, mig="skipped", **kw: runs(f"delivery_{KK(k)}", scenario("push", kw.pop("ref", FEAT), inputs or {}, {"plan": pl(kinds or (KK(k),), False, result=plan_result), "migration": {"result": mig, "outputs": {}}})) +check(dl_if("native-branch") and dl_if("production", ref=MAIN) and dl_if("native-dev") and dl_if("persistent-dev"), "Delivery: runs when the plan succeeded and ITS kind is enabled - a SKIPPED migration (d1_migrations=false) never blocks it") +check(all(dl_if(k, mig="success", ref=MAIN if k == "production" else FEAT) for k in KINDS), "Delivery: runs after a successful migration") +check(not any(dl_if(k, mig=m, ref=MAIN if k == "production" else FEAT) for k in KINDS for m in ("failure", "cancelled")), "Delivery: a failed or cancelled Migration prevents EVERY dependent deployment") +check(not dl_if("native-dev", kinds=("native_branch",)) and not dl_if("production", kinds=("native_branch",), ref=FEAT), "Delivery: a disabled kind creates no delivery (no phantom paths)") +check(not dl_if("native-branch", plan_result="failure") and not dl_if("native-branch", plan_result="skipped"), "Delivery: blocked by a failed or skipped plan (a failed CI skips the plan; invalid configuration fails it: nothing deploys)") +check(dl_if("native-branch", inputs={"pipeline_phase": "verify"}), "Delivery: a verify call plans from the hand-off, not from CI (and the migration is skipped there: it already ran in the deploy call)") +pub = next(x for x in djobs["deploy"]["steps"] if x.get("id") == "engine")["with"] +check(all(pub[k] == "${{ inputs." + k + " }}" for k in ("migration_result", "migration_json")) and djobs["deploy"]["outputs"]["migration_used"] == "${{ steps.engine.outputs.migration_used }}" + and "run" not in djobs["deploy"]["steps"][0], + "Deploy: the ONE migration result is checked by the engine gate against the descriptor's owner and D1 (delivery.test.mjs covers every refusal)") +sm_if = lambda inputs, deploy_res="success": bool(ev(djobs["smoke"]["if"], Ctx({"inputs": dict({"phase": "all"}, **inputs), "needs": {"deploy": {"result": deploy_res, "outputs": {}}}}, {}))) +check(sm_if({}) and sm_if({"phase": "verify"}) and not sm_if({"phase": "deploy"}) and sm_if({}, "failure"), "Smoke: runs in all and verify phases (even when Deploy failed: nothing published -> smoke_status none), never in the deploy phase") +rb_if = lambda status, inputs=None: bool(ev(djobs["rollback"]["if"], Ctx({"inputs": dict({"phase": "all", "descriptor": json.dumps(desc("native-branch")), "inputs_json": "{}"}, **(inputs or {})), "needs": {"smoke": {"result": "x", "outputs": {"smoke_status": status}}}}, {}))) +check(rb_if("failure") and not rb_if("success") and not rb_if("none") and not rb_if("") and not rb_if("failure", {"phase": "deploy"}), "Rollback: only after THIS call's Smoke FAILED (conditional, never on the happy path, never in the deploy phase)") +rgo = djobs["rollback"]["steps"][0] +check(rgo["id"] == "go" and "POLICY_OK" in rgo["run"] and "gate_blocked" in rgo["run"] and "published" in rgo["run"], + "Rollback guard: this run published + no blocked cross-service gate + (Production only) policy automatic") +check("fromJSON(inputs.descriptor).kind != 'production' ||" in rgo["env"]["POLICY_OK"] and "production_rollback_policy == 'automatic'" in rgo["env"]["POLICY_OK"], "Rollback: the Production policy applies to Production only; Preview / Dev recovery is target-owned") + + +def dout(kind, **kw): + return {"result": "success", "outputs": dict({"handoff": "{}", "smoke_json": json.dumps({"deployed_and_verified": "true"}), "rollback_result": ""}, **kw)} + + +rel_ok = lambda **kw: scenario(**dict(dict(event="push", ref=MAIN, inputs={"release_cmd": "pnpm release"}, needs={"delivery_production": dout("production")}), **kw)) +check(runs("release", rel_ok()), "Release: runs after a verified main Production delivery (call success, no rollback)") +check(not runs("release", rel_ok(needs={"delivery_production": dout("production", smoke_json=json.dumps({"deployed_and_verified": "false"}))})), "Release: Production published but NOT content-verified (liveness, smoke_mode=skip) never releases") +check(not runs("release", rel_ok(needs={"delivery_production": dict(dout("production"), result="failure")})), "Release: a failed delivery (failed smoke) blocks Release even when the rollback SUCCEEDED") +check(not runs("release", rel_ok(needs={"delivery_production": dout("production", rollback_result="restored_verified")})), "Release: any rollback that ran blocks Release") +check(not runs("release", rel_ok(needs={"delivery_production": {"result": "skipped", "outputs": {}}})), "Release: a skipped delivery never releases") +check(not runs("release", rel_ok(ref=FEAT)), "Release: main only") check(not runs("release", rel_ok(inputs={"release_cmd": ""})), "Release: optional (no release_cmd, no job)") check(not runs("release", rel_ok(inputs={"release_cmd": "x", "dispatch_target": "persistent-dev"})), "Release: never from a persistent-dev dispatch") +check(not runs("release", rel_ok(inputs={"release_cmd": "x", "pipeline_phase": "deploy"})), "Release: never in the deploy phase") +check("delivery_native" not in jobs["release"]["if"] and "delivery_persistent" not in jobs["release"]["if"], "Release: the condition never reads a Preview / Dev delivery") -# ---- Migration / Deploy / Smoke / Rollback chain -D1ON = {"d1_migrations": True} -mg = lambda ref=FEAT, inputs=None, needs=None, **kw: scenario(**dict(dict(event="push", ref=ref, inputs=inputs or {}, needs=needs if needs is not None else GREEN), **kw)) -check(not runs("migration", mg()) and runs("migration", mg(inputs=D1ON)) and runs("migration", mg(MAIN, D1ON)), "Migration: exists only for d1_migrations Workers (skipped otherwise), on feature and main pushes") -check(not runs("migration", mg(inputs=D1ON, event="pull_request", ref="refs/pull/1/merge", head_repo="o/r")) and not runs("migration", mg(inputs=D1ON, event="pull_request", ref="refs/pull/1/merge", head_repo="fork/r")), - "Migration: never on a PR (same-repo or fork) - no remote D1 is touched by a PR event") -check(not runs("migration", mg(inputs=D1ON, event="delete")) and not runs("migration", mg(ref="refs/tags/v1", inputs=D1ON)), "Migration: never on branch delete or a tag push") -for v in VALIDATION: - check(not runs("migration", mg(inputs=D1ON, needs=vres(**{v: "failure"}))) and not runs("migration", mg(inputs=D1ON, needs=vres(**{v: "cancelled"}))), f"CI/E2E gate: a failed or cancelled {v} -> Migration skipped (no remote D1 change)") -check(not runs("migration", mg(MAIN, dict(D1ON, deployment_strategy="production-only", shared_dev_on_branch_push=True))), "Migration: the production-only + Shared Dev config guard also stops the migration") -check(not runs("migration", mg(inputs=dict(D1ON, preview_on_branch_push=False))) and runs("migration", mg(MAIN, dict(D1ON, preview_on_branch_push=False))), "Migration: follows the same channel gating as Deploy (Preview off -> no feature migration; main still migrates)") -for r in ("failure", "cancelled", "skipped"): - check(not runs("deploy", mg(inputs=D1ON, needs=vres(migration=r))), f"Migration {r} -> Deploy skipped (a Worker is never published onto an unmigrated / unknown D1)") -check(runs("deploy", mg(inputs=D1ON, needs=vres(migration="success"))) and runs("deploy", mg(MAIN, D1ON, vres(migration="success"))), "Migration success -> Deploy runs") -check(runs("deploy", mg(needs=vres(migration="skipped"))) and not runs("deploy", mg(needs=vres(migration="failure"))), "a skipped Migration (d1_migrations false) never blocks Deploy; a failed one always does") -check(not runs("deploy", mg(needs={k: v for k, v in vres().items() if k != "migration"})), "Deploy: a missing Migration result never passes") - - -def smoke_needs(result="success", published="true"): - return {"deploy": {"result": result, "outputs": {"published": published}}} - - -def rb_needs(smoke="failure", published="true"): - return {"deploy": {"result": "success", "outputs": {"published": published}}, "smoke": {"result": smoke, "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}} - - -check(runs("smoke", mg(needs=smoke_needs())) and runs("smoke", mg(MAIN, needs=smoke_needs())), "Smoke Tests: run after a Deploy that published (Production and Preview)") -check(not runs("smoke", mg(needs=smoke_needs("failure"))) and not runs("smoke", mg(needs=smoke_needs("skipped"))) and not runs("smoke", mg(needs=smoke_needs("cancelled"))), "Deploy failed / skipped / cancelled -> Smoke skipped") -check(not runs("smoke", mg(needs=smoke_needs(published="false"))) and not runs("smoke", mg(needs=smoke_needs(published=""))), "a Deploy that published NOTHING (stale / superseded run) -> Smoke skipped: there is no deployment to verify") -PROD_AUTO = {"production_rollback_policy": "automatic"} -check(runs("rollback", mg(MAIN, PROD_AUTO, rb_needs())), "Rollback: Production smoke failed + policy automatic -> Rollback runs") -check(not runs("rollback", mg(MAIN, PROD_AUTO, rb_needs("success"))), "Smoke SUCCESS -> Rollback skipped (rollback is conditional, never part of the happy path)") -check(not runs("rollback", mg(MAIN, PROD_AUTO, rb_needs("skipped"))) and not runs("rollback", mg(MAIN, PROD_AUTO, rb_needs("cancelled"))), "Smoke skipped / cancelled -> Rollback skipped") -for pol in ("manual", "disabled"): - check(not runs("rollback", mg(MAIN, {"production_rollback_policy": pol}, rb_needs())), f"Production policy {pol}: no Rollback job (the Smoke job prints the manual commands)") -check(not runs("rollback", mg(MAIN, {}, rb_needs())), "Production default policy (manual) -> no automatic rollback (backward compatible)") -check(not runs("rollback", mg(MAIN, PROD_AUTO, rb_needs(published="false"))), "Rollback: nothing published by this run -> nothing to roll back") -check(runs("rollback", mg(FEAT, {}, rb_needs())) and runs("rollback", mg(FEAT, {"production_rollback_policy": "disabled"}, rb_needs())), "Preview: failed smoke -> last-known-good recovery regardless of the PRODUCTION rollback policy") -check(inputs_def["production_rollback_policy"]["default"] == "manual" and inputs_def["d1_rollback_compat"]["default"] == "unknown", "defaults stay conservative: rollback policy manual, D1 compatibility unknown (fail closed)") - -check(runs("lifecycle", scenario("delete", FEAT, ev_ref_type="branch")), "lifecycle: handles branch delete") -check(not runs("lifecycle", scenario("delete", FEAT, ev_ref_type="tag")), "lifecycle: ignores tag delete") -check(runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="o/r")), "lifecycle: comments on same-repo PRs") -check(not runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="fork/r")), "lifecycle: never touches fork PRs") -check(not runs("lifecycle", scenario("delete", FEAT, {"deployment_strategy": "production-only"}, ev_ref_type="branch")), "lifecycle: production-only has no Preview ledger") +check(inputs_def["production_rollback_policy"]["default"] == "manual" and inputs_def["d1_rollback_compat"]["default"] == "unknown", "defaults stay conservative: rollback policy manual, D1 compatibility unknown") +check(runs("lifecycle", scenario("delete", FEAT, ev_ref_type="branch")) and not runs("lifecycle", scenario("delete", FEAT, ev_ref_type="tag")), "lifecycle: handles branch delete only") +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"}) -check(runs("persistent-dev", scenario("workflow_dispatch", "refs/heads/dev", **dev_ok)), "Hybrid: persistent Dev deploys from the approved ref via dispatch") -check(not runs("persistent-dev", scenario("workflow_dispatch", "refs/heads/other", **dev_ok)), "Hybrid: not from an unapproved ref") -check(not runs("persistent-dev", scenario("push", "refs/heads/dev", **dev_ok)), "Hybrid: a push never deploys persistent Dev") -check(not runs("persistent-dev", scenario("workflow_dispatch", "refs/heads/dev", inputs=dict(dev_ok["inputs"], deployment_strategy="production-preview"))), "Hybrid: requires the hybrid strategy") - -# ---- config guard fails closed on the DEPLOYMENT path (not only a separate failing job) +check(runs("persistent-dev", scenario("workflow_dispatch", "refs/heads/dev", **dev_ok)) and not runs("persistent-dev", scenario("workflow_dispatch", "refs/heads/other", **dev_ok)) + and not runs("persistent-dev", scenario("push", "refs/heads/dev", **dev_ok)), "persistent Dev: approved ref via dispatch only, never on push") 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, BAD, GREEN)), "Config guard: production-only + Shared Dev is reported") -check(not runs("deploy", scenario("push", MAIN, BAD, GREEN)) and not runs("deploy", scenario("push", FEAT, BAD, GREEN)) and not runs(DEVJOB, scenario("push", FEAT, BAD, GREEN)), - "Config guard: production-only + Shared Dev reaches NO deployment job (no publish, no remote D1 migration, no Production version change)") -check(not runs("release", scenario("push", MAIN, dict(BAD, release_cmd="pnpm release"), {"deploy": {"result": "skipped", "outputs": {}}, "smoke": {"result": "skipped", "outputs": {}}, "rollback": {"result": "skipped", "outputs": {}}})), "Config guard: a skipped Deploy never releases") -check(runs("deploy", scenario("push", MAIN, {"deployment_strategy": "production-only", "preview_on_branch_push": True}, GREEN)), "Config guard: production-only + default Preview=true is still accepted and Production deploys") -check(not runs("config-guard", scenario("push", MAIN, {"deployment_strategy": "production-only", "preview_on_branch_push": True}, GREEN)), "Config guard: that backward-compatible combination is not reported") -BOTH = {"preview_on_branch_push": True, "shared_dev_on_branch_push": True} -check(runs("deploy", scenario("push", FEAT, dict(BOTH, deployment_strategy="production-preview"), GREEN)) and runs(DEVJOB, scenario("push", FEAT, dict(BOTH, deployment_strategy="production-preview"), GREEN)), "Config guard: production-preview + both channels accepted") -check(runs(DEVJOB, scenario("push", FEAT, {"deployment_strategy": "hybrid", "shared_dev_on_branch_push": True}, GREEN)) and not runs("config-guard", scenario("push", FEAT, {"deployment_strategy": "hybrid", "shared_dev_on_branch_push": True}, GREEN)), "Config guard: hybrid + Shared Dev accepted") - -# ---------------------------------------------------------------- 4. the four Preview x Shared Dev combinations -def combo(feature, dev, ref=FEAT, d1=False): - inp = {"preview_on_branch_push": feature, "shared_dev_on_branch_push": dev, "d1_migrations": d1} - needs = vres(migration="success" if d1 else "skipped") - return runs("deploy", scenario("push", ref, inp, needs)), runs(DEVJOB, scenario("push", ref, inp, needs)) - - -check(inputs_def["preview_on_branch_push"]["default"] is True and inputs_def["shared_dev_on_branch_push"]["default"] is False, - "interface: preview_on_branch_push defaults true; shared_dev_on_branch_push defaults false (no surprise Dev deployments)") -check(combo(True, True) == (True, True), "toggles: Preview=true Shared Dev=true -> Preview AND Shared Dev deploy") -check(combo(True, False) == (True, False), "toggles: Preview=true Shared Dev=false -> Preview only") -check(combo(False, True) == (False, True), "toggles: Preview=false Shared Dev=true -> Shared Dev only") -check(combo(False, False) == (False, False), "toggles: Preview=false Shared Dev=false -> validation only") -for d1 in (False, True): - check(combo(True, True, d1=d1) == (True, True) and combo(False, True, d1=d1) == (False, True), f"toggles stay independent with d1_migrations={d1} (Migration is its own job, Deploy follows it)") -check(combo(True, True, MAIN) == (True, False) and combo(False, False, MAIN)[0] is True and combo(False, True, MAIN) == (True, False), "main follows the Production path whatever the switches say; Shared Dev never runs on main") -check(runs("lint", scenario("push", FEAT, {"preview_on_branch_push": False, "shared_dev_on_branch_push": False})), "validation runs with both channels disabled") -sd = lambda **kw: scenario(**dict(dict(event="push", ref=FEAT, inputs={"shared_dev_on_branch_push": True}, needs=GREEN), **kw)) -check(runs(DEVJOB, sd(ref="refs/heads/other/branch")), "Shared Dev: any branch deploys the same Dev") -check(not runs(DEVJOB, sd(inputs={"shared_dev_on_branch_push": True, "deployment_strategy": "production-only"})), "Shared Dev: production-only Worker never deploys Dev") -check(runs(DEVJOB, sd(inputs={"shared_dev_on_branch_push": True, "deployment_strategy": "hybrid"})), "Shared Dev: also available with the hybrid strategy") -for r in ("failure", "cancelled", "skipped", "success"): - n = dict(GREEN, deploy={"result": r, "outputs": {}}) - check(runs(DEVJOB, sd(needs=n)), f"Shared Dev: Preview Deploy {r} neither blocks nor is required (it only orders the shared D1)") -check("needs.deploy.result" not in jobs[DEVJOB]["if"], "Shared Dev gate does not read Deploy's result") -check(runs("config-guard", scenario("push", FEAT, {"deployment_strategy": "production-only", "shared_dev_on_branch_push": True})) and runs("config-guard", scenario("push", MAIN, {"deployment_strategy": "production-only", "shared_dev_on_branch_push": True})), - "config guard: production-only + shared_dev_on_branch_push fails the run on any push") -check(not runs("config-guard", scenario("push", FEAT, {"deployment_strategy": "production-only"})) and not runs("config-guard", scenario("push", FEAT, {"shared_dev_on_branch_push": True})), "config guard: silent for valid configurations") -check("exit 1" in jobs["config-guard"]["steps"][0]["run"] and "secrets." not in yaml.dump(jobs["config-guard"]), "config guard fails closed and holds no secrets") - +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") +check(runs("config-guard", scenario("push", FEAT, {"shared_dev_on_branch_push": True, "shared_dev_native_preview_on_branch_push": True}, GREEN)), "Config guard: persistent Dev + Native Dev together (conflicting automatic Dev channels) is reported") +check(not runs("config-guard", scenario("push", MAIN, {"deployment_strategy": "production-only", "preview_on_branch_push": True}, GREEN)), "Config guard: production-only + default Preview=true stays accepted") +# ---------------------------------------------------------------- 4. public interface: additive, defaults unchanged +check(inputs_def["preview_on_branch_push"]["default"] is True and inputs_def["shared_dev_on_branch_push"]["default"] is False, "interface: existing defaults unchanged (Preview true, persistent Dev false)") +check(inputs_def["shared_dev_native_preview_on_branch_push"]["default"] is False and inputs_def["build_env_shared_dev_native_preview"]["default"] == "" and inputs_def["smoke_mode"]["default"] == "full" and inputs_def["smoke_mode_overrides"]["default"] == "{}", + "interface: new inputs are opt-in (Native Dev off, smoke_mode full, no overrides)") +wc_out = doc[True]["workflow_call"]["outputs"] +LEGACY_OUT = {"deploy_url", "deploy_verified_url", "deploy_published_unverified", "deploy_verified", "deploy_smoke_level", "deploy_target", "version_id", "previous_version_id", + "migration_status", "rollback_result", "dev_url", "dev_verified_url", "dev_published_unverified", "deploy_gate_blocked", "phase_state"} +check(LEGACY_OUT <= set(wc_out), "interface: every pre-existing output is still produced") +check({"native_dev_url", "native_dev_verified_url", "native_dev_published_unverified", "native_dev_verified", "native_dev_smoke_level"} <= set(wc_out), "interface: Native Dev outputs added") +LEGACY_IN = {"tools_ref", "app_name", "app_path", "app_version", "deployment_strategy", "preview_on_branch_push", "shared_dev_on_branch_push", "preview_worker_name", "wrangler_config", "lint_cmd", "typecheck_cmd", "test_unit_cmd", + "test_integration_cmd", "build_cmd", "ci_env", "use_mailpit", "test_e2e_cmd", "test_e2e_setup_cmd", "test_e2e_build_cmd", "test_e2e_variants", "test_e2e_env", "build_env_prod", "build_env_preview", "build_env_dev", + "smoke_cmd", "smoke_env", "access_protected_targets", "probe_path", "probe_expect_status", "sha_path", "preview_host_check", "preview_binding_allow", "preview_deny_resources", "app_url_prod", + "production_rollback_policy", "d1_migrations", "d1_binding", "d1_lock_scope", "d1_rollback_compat", "release_cmd", "persistent_dev_ref", "dev_deploy_cmd", "dev_smoke_cmd", "app_url_dev", "dev_build_cmd", + "dev_wrangler_env", "dev_binding_allow", "dev_deny_resources", "dev_worker_name", "dispatch_target", "pipeline_phase", "phase_state", "upstream_gate", "runner"} +check(LEGACY_IN <= set(inputs_def), "interface: every pre-existing input is still accepted (no caller breaks)") +check(all("default" in inputs_def[k] or inputs_def[k].get("required") for k in inputs_def), "interface: every input has a default or is required") # ---------------------------------------------------------------- 5. credentials, Environments, permissions -SECRETS = {"CLOUDFLARE_API_TOKEN": "PROD-TOKEN", "CLOUDFLARE_ACCOUNT_ID": "PROD-ACCT", - "PREVIEW_SECRETS": "K=v"} -env = jobs["deploy"]["env"] -for ref, label in ((MAIN, "main"), (FEAT, "a feature branch")): - c = scenario("push", ref, secrets=SECRETS) - check(render(env["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(env["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT", - f"Deploy: {label} uses the account credentials") - c = scenario("push", ref, secrets={k: v for k, v in SECRETS.items() if k != "CLOUDFLARE_API_TOKEN"}) - check(render(env["CLOUDFLARE_API_TOKEN"], c) == "", f"Deploy: missing account token stays EMPTY on {label} ") +SECRETS = {"CLOUDFLARE_API_TOKEN": "PROD-TOKEN", "CLOUDFLARE_ACCOUNT_ID": "PROD-ACCT", "PREVIEW_SECRETS": "K=v"} check(not any("PREVIEW_API_TOKEN" in k or "PREVIEW_ACCOUNT_ID" in k for k in doc[True]["workflow_call"]["secrets"]), "workflow_call declares no Preview-specific Cloudflare credentials") -check("CLOUDFLARE_DEV" not in yaml.dump(jobs["deploy"]), "Deploy never references the Dev credentials") -for n in ("migration", "rollback"): - check(jobs[n]["env"]["CLOUDFLARE_API_TOKEN"] == env["CLOUDFLARE_API_TOKEN"] and jobs[n]["env"]["CLOUDFLARE_ACCOUNT_ID"] == env["CLOUDFLARE_ACCOUNT_ID"], - f"{n}: the same credential selection as Deploy (the account credentials)") -check("CLOUDFLARE" not in yaml.dump(jobs["smoke"]) and "secrets.CLOUDFLARE" not in yaml.dump(jobs["smoke"]), "Smoke Tests: NO Cloudflare credentials of any kind (it only probes the published URL)") -check(set(jobs["smoke"]["env"]) == {"GH_TOKEN"}, "Smoke Tests: the job env holds nothing but the GitHub token") -c = scenario("push", MAIN, secrets=SECRETS) -for n in DELIVERY: - c = scenario("push", MAIN, secrets=SECRETS) - check(render(eng[n]["with"]["preview_secrets"], c) == "" and "PREVIEW_SECRETS" not in c.secret_reads, f"{n}: Preview-only secrets are never read on main") - check(render(eng[n]["with"]["preview_secrets"], scenario("push", FEAT, secrets=SECRETS)) == "K=v", f"{n}: a feature branch passes the Preview-only secrets") -dj = jobs[DEVJOB] -c = scenario("push", FEAT, secrets=SECRETS) -check(render(dj["env"]["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(dj["env"]["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT" - and not {"CLOUDFLARE_PREVIEW_API_TOKEN", "CLOUDFLARE_PREVIEW_ACCOUNT_ID"} & set(c.secret_reads), "Shared Dev reuses account credentials and never Preview credentials") -c = scenario("push", FEAT, secrets={k: v for k, v in SECRETS.items() if k != "CLOUDFLARE_API_TOKEN"}) -check(render(dj["env"]["CLOUDFLARE_API_TOKEN"], c) == "", "Shared Dev: missing account token stays EMPTY (no Preview fallback)") -dtxt = yaml.dump(dj) -check("CLOUDFLARE_PREVIEW" not in dtxt and "PREVIEW_SECRETS" not in dtxt and "secrets.CLOUDFLARE_API_TOKEN" in dtxt and "secrets.CLOUDFLARE_ACCOUNT_ID" in dtxt, "Shared Dev reuses account credentials, not Preview secrets") -ptxt = yaml.dump(jobs["persistent-dev"]) +check(set(ddoc[True]["workflow_call"]["secrets"]) == {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "PREVIEW_SECRETS", "SMOKE_USERNAME", "SMOKE_PASSWORD"} and all(not v.get("required") for v in ddoc[True]["workflow_call"]["secrets"].values()), + "delivery workflow: secrets are declared explicitly (never `secrets: inherit`) and none is required") +check(all("inherit" not in str(jobs[j].get("secrets", "")) for j in CALL_JOBS), "calls pass secrets explicitly, never `secrets: inherit`") +for n in ("deploy", "rollback"): + env = djobs[n]["env"] + c = Ctx({"secrets": SECRETS, "github": {}}, SECRETS) + check(render(env["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(env["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT", f"{n}: the account credentials") + c = Ctx({"secrets": {}, "github": {}}, {}) + check(render(env["CLOUDFLARE_API_TOKEN"], c) == "", f"{n}: a missing account token stays EMPTY (no fallback)") +menv = jobs["migration"]["env"] +c = Ctx({"secrets": SECRETS, "github": {"token": "T"}}, SECRETS) +check(render(menv["CLOUDFLARE_API_TOKEN"], c) == "PROD-TOKEN" and render(menv["CLOUDFLARE_ACCOUNT_ID"], c) == "PROD-ACCT" and render(menv["GH_TOKEN"], c) == "T", "migration: the account credentials and the job token") +check(render(menv["CLOUDFLARE_API_TOKEN"], Ctx({"secrets": {}, "github": {}}, {})) == "", "migration: a missing account token stays EMPTY (no fallback)") +check("CLOUDFLARE" not in yaml.dump(djobs["smoke"]) and "secrets.CLOUDFLARE" not in yaml.dump(djobs["smoke"]), "Smoke Tests: NO Cloudflare credentials of any kind (it only probes the published URL)") +check(set(djobs["smoke"]["env"]) == {"GH_TOKEN", "HANDOFF"}, "Smoke Tests: the job env holds the GitHub token and the (data-only) hand-off, nothing else") +check("CLOUDFLARE_DEV" not in yaml.dump(djobs), "the delivery workflow never references the Dev credentials") +for k in KINDS: + sec = jobs[f"delivery_{KK(k)}"]["secrets"] + check(set(sec) == {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "PREVIEW_SECRETS", "SMOKE_USERNAME", "SMOKE_PASSWORD"}, f"delivery_{KK(k)}: passes exactly the secrets its stages need") +check("SMOKE" not in yaml.dump(jobs["migration"]), "migration: never receives the smoke credentials") +for n in djobs: + w = eng[n]["with"] + for k in KINDS: + 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(pjobs["persistent-dev"]) 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, "persistent-dev reuses account credentials, not Preview secrets") -check(not any(k in jobs[n].get("env", {}) for n in jobs for k in ("TARGET", "BUILD_ENV", "SMOKE_ENV", "DEV_ENV", "EXPECTED_SHA", "RUN_SHA")), +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") -c = scenario("push", MAIN) -for n in ("migration", "deploy", "rollback"): - check(render(jobs[n]["environment"]["name"], c) == "app/prod" and render(jobs[n]["environment"]["name"], scenario("push", FEAT)) == "app/preview", - f"{n}: main uses the protected /prod GitHub Environment, branches /preview (every job that changes Production is authorised the same way)") -check("environment" not in jobs["smoke"], "Smoke Tests: no Environment (no protection prompt, no credentials)") -check(jobs["deploy"]["environment"]["url"] == "${{ steps.engine.outputs.url }}", "Deploy: the job shows the actual deployed URL (environment.url)") -check(dj["environment"]["name"] == "${{ inputs.app_name }}/dev" == jobs["persistent-dev"]["environment"], "Shared Dev runs in the same protected /dev Environment as persistent Dev") -check(dj["permissions"]["contents"] == "read" and "pull-requests" not in dj["permissions"], "Shared Dev is read-only on contents and writes no PR comments") -check(all(j.get("permissions", {}).get("contents", "read") == "read" for n, j in jobs.items() if n != "release"), "no job except Release can write repository contents") -check(jobs["migration"].get("permissions", {}).get("deployments") in ("read", "write") and jobs["migration"]["permissions"].get("deployments") != "write", "Migration can READ (not write) Deployments: the Preview identity guard reads the ledger owner on branch pushes (HTTP 403 otherwise)") -check("contents: write" not in yaml.dump({k: jobs[k] for k in jobs if k != "release"}) and not any(w in text_all for w in ("lock.sh", "cfv2-locks", "refs/cfv2")), "no custom Git-ref lock and no content-write permission outside Release") -for name in VALIDATION + ["lifecycle"]: - text = yaml.dump(jobs[name]) +ENVEXPR = "${{ fromJSON(inputs.descriptor).environment }}" +check(all(djobs[n]["environment"]["name"] == ENVEXPR for n in ("deploy", "rollback")), "deploy / rollback run in the descriptor's per-kind GitHub Environment (/prod keeps its protection)") +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(pjobs["persistent-dev"]["environment"] == "${{ inputs.app_name }}/dev", "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()}, **{f"p.{k}": v for k, v in pjobs.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") +check(ddoc.get("permissions") == {"contents": "read"} and "concurrency" not in ddoc, "delivery workflow: read-only default token and NO workflow-level concurrency (the public workflow's group would deadlock)") +check("contents: write" not in yaml.dump({k: allj[k] for k in allj if k != "release"}) and not any(w in text_all for w in ("lock.sh", "cfv2-locks", "refs/cfv2")), "no custom Git-ref lock and no content-write permission outside Release") +for name, job_ in [(f"ci.{v}", cjobs[v]) for v in VALIDATION + ["gate"]] + [("lifecycle", ljobs["lifecycle"])]: + text = yaml.dump(job_) check("secrets." not in text and "CLOUDFLARE" not in text, f"{name}: no secrets and no Cloudflare credentials of any kind") -check("secrets.SMOKE_PASSWORD" not in yaml.dump({k: jobs[k] for k in VALIDATION}) and eng["smoke"]["with"]["smoke_password"] == "${{ secrets.SMOKE_PASSWORD }}" and "smoke_password" not in eng["deploy"]["with"] and "smoke_password" not in eng["migration"]["with"], "smoke credentials reach only the engine actions that smoke (Smoke Tests, Rollback re-smoke, Dev)") -for n in ("migration", "deploy", "smoke", "rollback", DEVJOB, "persistent-dev"): - cks = [s for s in jobs[n]["steps"] if str(s.get("uses", "")).startswith("actions/checkout")] +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", "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 pjobs["persistent-dev"]["steps"] if str(s.get("uses", "")).startswith("actions/checkout")), "persistent-dev: the checkout does not persist the GitHub token") # ---------------------------------------------------------------- 6. cross-repository reusable workflow + local composite action references -action_uses = [(n, i, s) for n, j in jobs.items() for i, s in enumerate(j.get("steps", [])) if str(s.get("uses", "")).startswith("./")] -check(action_uses and all("${{" not in s["uses"] and s["uses"].startswith("./.cf-tools/actions/") for _, _, s in action_uses), - "every local action reference is a STATIC `./.cf-tools/actions/` path (uses: cannot take an expression; `./` alone would resolve against the CALLER's workspace)") -for n, i, s in action_uses: - name = s["uses"].split("/actions/", 1)[1] - prior = [p for p in jobs[n]["steps"][:i] if p.get("with", {}).get("path") == ".cf-tools"] - sp = prior[-1]["with"].get("sparse-checkout", "") if prior else "" - check(pathlib.Path(f"actions/{name}/action.yml").exists(), f"{n}: referenced action actions/{name}/action.yml exists in this repository") - check(len(prior) == 1 and prior[0]["with"]["repository"] == "firstsun-dev/.github" and prior[0]["with"]["ref"] == "${{ inputs.tools_ref }}" and f"actions/{name}" in sp, - f"{n}: firstsun-dev/.github is checked out at tools_ref into .cf-tools, including actions/{name}, BEFORE `uses: {s['uses']}`") - first_ck = next(i_ for i_, p in enumerate(jobs[n]["steps"]) if str(p.get("uses", "")).startswith("actions/checkout")) - check(jobs[n]["steps"][first_ck].get("with", {}).get("path") is None or n == "persistent-dev", f"{n}: the app checkout (workspace root) happens before the tooling checkout") +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("$/")] +check(SELF and all("${{" not in u and "@" not in u for _, _, u in SELF), "every `$/` reference is STATIC and carries no @ref suffix (uses: cannot take an expression; the ref is always the running commit)") +check(all(pathlib.Path(u[2:]).exists() and (pathlib.Path(u[2:] + "/action.yml").exists() or pathlib.Path(u[2:]).suffix == ".yml") for _, _, u in SELF), "every `$/` target exists in this repository (actions//action.yml or a workflow file)") +check(not any(u.startswith("./") or u.startswith("firstsun-dev/.github") for _, _, u in USES_ALL), "no `./` (resolves against the CALLER's workspace) and no pinned `firstsun-dev/.github/...@ref` reference remains in the pipeline") +CHECKOUTS = [(n, s_) for n, j in allj.items() for s_ in j.get("steps", []) if str(s_.get("uses", "")).startswith("actions/checkout")] +check(CHECKOUTS and not any("repository" in s_.get("with", {}) or s_.get("with", {}).get("path") == ".cf-tools" for _, s_ in CHECKOUTS if s_.get("with", {}).get("path") != "recover"), "no job checks out firstsun-dev/.github: the only checkouts are the application at the run SHA (and the recovery checkout of the last good SHA)") +ALLTXT = "".join(open(f).read() for f in (WF, DWF, CWF, LWF, PWF, ".github/workflows/_cf-worker-preview-gc.yml", "actions/cf-deploy/action.yml", "actions/cf-ci-check/action.yml", "actions/cf-tools/action.yml")) +check(".cf-tools" not in re.sub(r"#.*", "", ALLTXT).replace("`.cf-tools`", ""), "no `.cf-tools` workspace path remains in any workflow or action") +check(all(pathlib.Path(f"actions/{n}/action.yml").exists() for n in ("cf-deploy", "cf-ci-check", "cf-tools", "setup", "mailpit")), "all referenced shared actions exist") +check(not any(f"{k}: ${{{{ inputs.tools_ref }}}}" in ALLTXT for k in ("ref", "tools_ref")), "tools_ref is not forwarded anywhere (the tooling is the running commit by construction)") +check(inputs_def["tools_ref"].get("required") is not True and inputs_def["tools_ref"]["default"] == "" and "tools_ref" not in ddoc[True]["workflow_call"]["inputs"], "tools_ref stays ACCEPTED for existing callers but is optional and ignored") +check(set(ddoc[True]["workflow_call"]["inputs"]) >= {"descriptor", "inputs_json", "migration_result", "migration_json"} and "stage" not in ddoc[True]["workflow_call"]["inputs"] and not set(ddoc[True]["workflow_call"]["inputs"]) & {"kind", "target", "environment", "deploy_group", "rollback_group", "d1_expected_id", "d1_owner_kind"}, "delivery workflow: the descriptor, inputs and the one migration result are its interface") _ci = open("actions/cf-ci-check/action.yml").read() -check("rm -rf .cf-tools" not in _ci and "'.cf-tools/actions/cf-ci-check/action.yml' -delete" in _ci, - "cf-ci-check never deletes its own action.yml (the runner re-reads it for the post step; Blog run 39722027184 failed that way)") -check(all("cf-preview" in (s["with"].get("sparse-checkout", "") if s.get("with") else "") for n in DELIVERY for s in jobs[n]["steps"] if s.get("with", {}).get("path") == ".cf-tools"), "every delivery job fetches the engine scripts (actions/cf-preview) together with the action") -pins = [s["uses"] for j in jobs.values() for s in j.get("steps", []) if str(s.get("uses", "")).startswith("firstsun-dev/.github/")] -check(all(re.fullmatch(r"firstsun-dev/\.github/actions/[a-z0-9-]+@v1", u) for u in pins), "remaining remote references are the existing pinned shared actions (setup / mailpit @v1)") -check("tools_ref" in inputs_def and inputs_def["tools_ref"].get("required") is True, "tools_ref stays a required input (must equal the ref the caller pins this workflow at)") +check("rm -rf" not in _ci and "-delete" not in _ci, "cf-ci-check deletes nothing (nothing from this repository is in the app's workspace)") +CT = action("cf-tools") +check(CT["runs"]["using"] == "composite" and "$GITHUB_ACTION_PATH/../cf-preview" in CT["runs"]["steps"][0]["run"] and 'TOOLS_DIR=' in CT["runs"]["steps"][0]["run"], "cf-tools locates actions/cf-preview relative to itself and exports TOOLS_DIR") +check(all(re.search(r"GITHUB_ACTION_PATH/\.\./cf-preview/", x.get("run", "")) for x in ASTEPS if "engine.sh" in x.get("run", "")), "cf-deploy finds the engine scripts next to itself ($GITHUB_ACTION_PATH/../cf-preview), independent of any checkout path") +for n, i, u in SELF: + if u.endswith("cf-tools"): + j = allj[n] + prev = [x for x in j["steps"][:i] if str(x.get("uses", "")).startswith("actions/setup-node")] + w = j["steps"][i].get("with", {}) + check(w.get("install") != "true" or prev, f"{n}: cf-tools with install: true is preceded by setup-node") +check(inputs_def["tools_ref"]["description"].lstrip().startswith("DEPRECATED"), "tools_ref is documented as deprecated") +# Every call passes exactly the inputs the called workflow declares (an unknown `with` key or a missing required input is a startup_failure on GitHub) +for n, called_doc in (("ci", cdoc), ("lifecycle", ldoc), ("persistent-dev", pdoc), *[(c, ddoc) for c in sorted(CALL_JOBS)]): + decl = called_doc[True]["workflow_call"]["inputs"] + given = jobs[n].get("with", {}) + 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)") +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") +check("2.336.0" in text_all, "the public workflow documents the minimum runner version for `$/` (2.336.0)") # ---------------------------------------------------------------- 7. validation jobs use the common CI action CMDS = {"lint": "lint_cmd", "typecheck": "typecheck_cmd", "unit": "test_unit_cmd", "integration": "test_integration_cmd", "build": "build_cmd"} for j, inp in CMDS.items(): - steps = jobs[j]["steps"] - ci = [s for s in steps if s.get("uses") == "./.cf-tools/actions/cf-ci-check"] - t = yaml.dump(jobs[j]) + steps = cjobs[j]["steps"] + ci = [s for s in steps if s.get("uses") == "$/actions/cf-ci-check"] + t = yaml.dump(cjobs[j]) others = [i for k, i in CMDS.items() if k != j] + ["test_e2e_cmd"] check(len(ci) == 1 and ci[0]["with"]["cmd"] == "${{ inputs." + inp + " }}" and not any(f"inputs.{o}" in t for o in others), f"{j}: runs {inp} exactly once through actions/cf-ci-check, and no other job's command") check(ci[0]["with"]["title"] == JOB_NAME[j], f"{j}: the check summary carries its own name") check(not any("playwright" in yaml.dump(s_).lower() for s_ in steps) and "upload-artifact" not in t and "download-artifact" not in t, f"{j}: installs no browsers and uses no artifacts") - check(jobs[j].get("continue-on-error") is None, f"{j}: failures are never hidden by continue-on-error") - check("cf-ci-check" in next(s for s in steps if s.get("with", {}).get("path") == ".cf-tools")["with"]["sparse-checkout"], f"{j}: fetches the CI action it uses") + 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"), "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") check(any("GITHUB_STEP_SUMMARY" in s.get("run", "") and s.get("if") == "always()" for s in ci_steps), "cf-ci-check always writes a short Step Summary") -check(any(s.get("uses", "").startswith("firstsun-dev/.github/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 jobs[j]["steps"] if s.get("uses") == "./.cf-tools/actions/cf-ci-check")["with"])) == (j in ("unit", "integration")) for j in CMDS), +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") -e2e_text = yaml.dump(jobs["e2e"]) +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 jobs.items() for s in job.get("steps", []) if "upload-artifact" in str(s.get("uses", ""))] -check("download-artifact" not in text_all and sorted(j for j, _ in uploads) == ["dev-verify", "e2e", "smoke"], "no build artifact hand-off between jobs; the only uploads are the failure-only E2E and smoke reports") +uploads = [(j, s) for j, job in allj.items() for s in job.get("steps", []) if "upload-artifact" in str(s.get("uses", ""))] +check("download-artifact" not in text_all + open(DWF).read() and sorted(j for j, _ in uploads) == ["ci.e2e", "d.smoke"], "no build artifact hand-off between jobs; the only uploads are the failure-only E2E and smoke reports") for j_, u in uploads: check(u.get("if") == "failure()" and u.get("continue-on-error") is True and 0 < u["with"]["retention-days"] <= 7, f"{j_} report: failure-only, never fails the job, short retention") check(not any(k in inputs_def for k in ("preview_build_cmd", "preview_provision_cmd", "preview_migrate_cmd", "preview_cleanup_cmd", "preview_smoke_cmd", "preview_smoke_env", "verify_cmd", "migrate_cmd_prod", "migrate_cmd_preview")), "obsolete v2 inputs are removed; no custom migration command exists (the target D1 check cannot be bypassed)") # ---------------------------------------------------------------- 8. injection hygiene (workflow AND actions) INJECT = re.compile(r"\$\{\{[^}]*(github\.event\.|github\.head_ref|inputs\.\w*_(cmd|env)\b|inputs\.(build_cmd|sha_path|cmd|inputs_json|ci_env|preview_secrets))") -for jname, job in jobs.items(): +for jname, job in allj.items(): for s in job.get("steps", []): check(not INJECT.search(s.get("run", "")), f"{jname}/{s.get('name', s.get('id', '?'))}: no event/caller-command text interpolated into the script") for aname, a in (("cf-deploy", DEPLOY_ACTION), ("cf-ci-check", CI_ACTION)): @@ -410,6 +512,8 @@ def combo(feature, dev, ref=FEAT, d1=False): order = [aidx(n) for n in need] check(order == sorted(order) and len(set(order)) == len(order), "engine order: install tooling deps FIRST (cli.mjs needs smol-toml) -> init -> stale -> setup -> prepare -> BUILD+audit -> D1 target check -> stale -> MIGRATE -> stale -> PUBLISH -> smoke -> verified") +check("node_modules/smol-toml" in astep("Install tooling deps")["run"] and "env.CFV2_DEPS != 'done'" in astep("Setup dependencies")["if"] and 'setenv CFV2_DEPS "done"' in open("actions/cf-preview/engine.sh").read(), + "engine: several engine calls in ONE job (the Migration job) install the tooling and the application dependencies once") check(aidx("Build and audit") < aidx("Apply pending D1 migrations"), "no schema migration runs before the deployable build and the target config were validated") check(aidx("Validate the exact migration target") < aidx("Apply pending D1 migrations") < aidx("Publish the audited artifact"), "the exact D1 target is validated before the migration, which precedes the publish") mig = astep("Apply pending D1 migrations") @@ -425,11 +529,11 @@ def combo(feature, dev, ref=FEAT, d1=False): check("inputs.stage == 'verify'" in STAGE_IF("Smoke the exact deployed URL") and "inputs.stage == 'all' && steps.publish.outcome == 'success'" in STAGE_IF("Smoke the exact deployed URL"), "engine: smoke runs in stage verify, or after a publish of the same job (stage all)") check("inputs.stage == 'verify' || inputs.stage == 'rollback'" in STAGE_IF("Hand-off") and "HANDOFF_DEPLOY" in astep("Hand-off")["env"], "engine: the Smoke / Rollback stages hydrate the validated hand-off from job outputs (not from runner files)") -check(astep("Apply pending D1 migrations")["run"].endswith("| tee -a \"$GITHUB_OUTPUT\"") and astep("Prepare")["env"]["PREPARE_LEDGER"] == "${{ inputs.stage != 'migrate' }}", "engine: the Migration stage opens no ledger entry; Deploy owns it") +check(astep("Apply pending D1 migrations")["run"].endswith("| tee -a \"$GITHUB_OUTPUT\"") and astep("Prepare")["env"]["PREPARE_LEDGER"] == "${{ inputs.stage != 'migrate' && inputs.stage != 'preflight' }}", "engine: the Preflight and Migration stages open no ledger entry; Deploy owns it") for n in ("Setup dependencies", "Prepare", "Build and audit", "Validate the exact migration target", "Stale-SHA guard (before migration)", "Apply pending D1 migrations", "Stale-SHA guard (after migration", "Publish the audited artifact", "Smoke the exact deployed URL", "Mark deployed and verified"): check("env.STALE != 'true'" in astep(n)["if"], f"engine: '{n}' is skipped for a superseded run") check("guard before-migration" in astep("Stale-SHA guard (before migration)")["run"] and "guard before-publish" in astep("Stale-SHA guard (after migration")["run"], "engine: the branch tip is re-checked immediately before the migration AND again immediately before the publish") -check(astep("Shared-D1 guard")["if"].endswith("inputs.target == 'dev'") and "d1-guard" in astep("Shared-D1 guard")["run"], "engine: the shared-D1 guard applies to Shared Dev only") +check(not any(st.get("name", "").startswith("Shared-D1 guard") for st in ASTEPS) and "d1-guard" not in open("actions/cf-preview/engine.sh").read(), "engine: the old Shared-D1 guard is gone - a D1 shared by several targets is migrated ONCE by the Migration stage and each delivery waits for that leg") pub = astep("Publish the audited artifact") check("engine.sh\" publish" in pub["run"] and pub["env"]["ENGINE_TARGET"] == "${{ inputs.target }}" and "build" not in pub["run"], "engine: publish is publish-only (the build stage is a separate, earlier step)") ver = astep("Mark deployed and verified") @@ -489,107 +593,133 @@ def combo(feature, dev, ref=FEAT, d1=False): check(g("refs/heads/dev", {"dispatch_target": "persistent-dev"}, "workflow_dispatch").startswith("cfv2-run-"), "group: persistent-dev dispatch does not queue behind deploys") 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)") -for j in DELIVERY + VALIDATION: - check("concurrency" not in jobs[j], f"{j}: no job-level concurrency (a second, nested group could deadlock with the workflow-level one)") -jg = lambda n, scope="", app="app": render(jobs[n]["concurrency"]["group"], scenario(inputs={"app_name": app, "d1_lock_scope": scope})) -check(jobs[DEVJOB]["concurrency"] == jobs["persistent-dev"]["concurrency"] and jobs[DEVJOB]["concurrency"]["cancel-in-progress"] is False, "Shared Dev and persistent Dev share ONE job-level group (automatic and manual Dev never overlap)") -check(jg(DEVJOB, "shared-db", "blog") == jg("persistent-dev", "shared-db", "wiki") and jg(DEVJOB, "", "blog") != jg(DEVJOB, "", "wiki"), "Dev group is keyed by the shared-D1 scope when set, else by app") -check(all(not g(r, {"d1_migrations": True}).startswith("cfv2-dev-") for r in (MAIN, FEAT)) and jg(DEVJOB).startswith("cfv2-dev-"), "workflow-level groups can never equal the job-level Dev group") +import subprocess # noqa: E402 + + +def js_group(scope, app): + """Deploy/rollback group the REAL descriptor builder (delivery.mjs) assigns persistent-dev.""" + code = "import('./actions/cf-preview/delivery.mjs').then(m=>console.log(m.targetDescriptor({kind:'persistent-dev',app:process.argv[1],runId:1,repo:'o/r',lockScope:process.argv[2]}).deploy_group))" + return subprocess.run(["node", "-e", code, app, scope], capture_output=True, text=True, check=True).stdout.strip() + + +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(pjobs["persistent-dev"]["concurrency"]["group"], scenario(inputs={"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") +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(pjobs["persistent-dev"]["concurrency"]["group"] == "cfv2-dev-${{ github.repository }}-${{ inputs.d1_lock_scope != '' && inputs.d1_lock_scope || inputs.app_name }}", "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") +check(g(FEAT, dict(ND_ON, d1_migrations=True)) == "cfv2-nonprod-d1-app", "group: Native Dev + D1 migrations -> the shared non-production D1 group") +check(g(MAIN, ND_ON) == "cfv2-prod-app", "group: Production is never serialized behind Native Dev") check("CANNOT lock a D1 shared with ANOTHER repository" in inputs_def["d1_lock_scope"]["description"], "the repository-scoped limit of GitHub concurrency is documented on the d1_lock_scope input") # ---------------------------------------------------------------- 11. interface preserved / Hybrid / lifecycle / retention check(all(k in inputs_def for k in ("app_url_dev", "dev_deploy_cmd", "dev_smoke_cmd", "build_env_dev", "dev_build_cmd", "dev_wrangler_env", "dev_worker_name", "dev_binding_allow", "dev_deny_resources")), "interface: Dev URL, deploy/smoke commands, build env, Wrangler env, identity and resource-policy inputs exist") +check(inputs_def["preview_url_suffix"]["default"] == "", "interface: preview_url_suffix is optional (Cloudflare-reported suffix by default)") check(not {"CLOUDFLARE_DEV_API_TOKEN", "CLOUDFLARE_DEV_ACCOUNT_ID"} & set(doc[True]["workflow_call"]["secrets"]), "interface: dedicated Dev secrets removed") -check({"deploy_url", "deploy_target", "version_id", "dev_url"} <= set(doc[True]["workflow_call"]["outputs"]), "outputs: deploy_url, deploy_target, version_id, dev_url are preserved (existing consumers)") OUTS = doc[True]["workflow_call"]["outputs"] +check({"deploy_url", "deploy_target", "version_id", "dev_url"} <= set(OUTS), "outputs: deploy_url, deploy_target, version_id, dev_url are preserved (existing consumers)") check(set(OUTS) == {"deploy_url", "deploy_target", "version_id", "dev_url", "deploy_verified_url", "deploy_published_unverified", "dev_verified_url", "dev_published_unverified", - "deploy_verified", "deploy_smoke_level", "previous_version_id", "migration_status", "rollback_result", "deploy_gate_blocked", "phase_state"}, "outputs: every pre-existing output is kept; the rest are additive (verified flag, smoke level, previous version, migration status, rollback result)") -check(OUTS["deploy_url"]["value"].startswith("${{ jobs.deploy.outputs.url ||") and OUTS["deploy_target"]["value"].startswith("${{ jobs.deploy.outputs.target ||") and OUTS["version_id"]["value"].startswith("${{ jobs.deploy.outputs.version_id ||"), - "outputs: deploy_url / deploy_target / version_id keep their meaning (published URL, target, Production version) - now sourced from the Deploy job") -check(OUTS["deploy_verified_url"]["value"] == "${{ jobs.smoke.outputs.verified_url }}" and OUTS["deploy_verified"]["value"] == "${{ jobs.smoke.outputs.deployed_and_verified }}", "outputs: deploy_verified_url / deploy_verified come from the Smoke job (the only place a deployment becomes verified)") -pd = jobs["persistent-dev"] -check(pd["if"] == "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" and pd["environment"] == "${{ inputs.app_name }}/dev", + "deploy_verified", "deploy_smoke_level", "previous_version_id", "migration_status", "rollback_result", "deploy_gate_blocked", "phase_state", + "native_dev_url", "native_dev_verified_url", "native_dev_published_unverified", "native_dev_verified", "native_dev_smoke_level"}, "outputs: every pre-existing output is kept; the new ones are Native Dev additions only") +check(all("jobs.delivery_production.outputs." in OUTS[k]["value"] and "jobs.delivery_native_branch.outputs." in OUTS[k]["value"] for k in ("deploy_url", "deploy_verified", "version_id", "previous_version_id", "deploy_verified_url")) + and not any("native_dev" in OUTS[k]["value"] or "persistent_dev" in OUTS[k]["value"] for k in ("deploy_url", "deploy_target", "version_id", "previous_version_id", "deploy_verified", "deploy_verified_url", "deploy_smoke_level")), + "outputs: deploy_* keep their meaning (the Production / per-branch Preview delivery) - never Native Dev or persistent Dev") +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 = pjobs["persistent-dev"] +check(jobs["persistent-dev"]["if"] == "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" and pd["environment"] == "${{ inputs.app_name }}/dev", "persistent-dev keeps the approved-ref gate and protected Environment") legacy = [x for x in pd["steps"] if x.get("if", "").startswith("inputs.dev_build_cmd == ''")] engine_side = [x for x in pd["steps"] if x.get("if") == "inputs.dev_build_cmd != ''"] -check(len(legacy) == 3 and len(engine_side) == 2 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(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"] == "${{ inputs.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("wrangler preview delete" not in "\n".join(s.get("run", "").replace("\\`wrangler preview delete", "") for s in jobs["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 jobs["lifecycle"]["steps"] if "Retire GitHub ledger" in s.get("name", "")) +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)") -all_run = "\n".join(s_.get("run", "") for j in jobs.values() for s_ in j.get("steps", [])) + "\n".join(s.get("run", "") for s in ASTEPS) +all_run = "\n".join(s_.get("run", "") for j in allj.values() for s_ in j.get("steps", [])) + "\n".join(s.get("run", "") for s in ASTEPS) check("wrangler delete" not in all_run, "no job deletes a Worker; branch deletion retains the Shared Dev Worker and Native Previews") -check(not re.search(r"CLOUDFLARE_DEV|dev-deploy|shared_dev|app_url_dev|dev_deploy_cmd", yaml.dump(jobs["lifecycle"])), "branch-delete lifecycle never references Dev") +check(not re.search(r"CLOUDFLARE_DEV|dev-deploy|shared_dev_on_branch_push|app_url_dev|dev_deploy_cmd", yaml.dump(ljobs["lifecycle"])), "branch-delete lifecycle never references Dev") # ---------------------------------------------------------------- pipeline_phase: deferred verification (opt-in, default unchanged) -import json as _json # noqa: E402 - PINPUTS = doc[True]["workflow_call"]["inputs"] check(PINPUTS["pipeline_phase"]["default"] == "all" and PINPUTS["phase_state"]["default"] == "{}" and PINPUTS["upstream_gate"]["default"] == "", "pipeline_phase: opt-in - the default is `all` (the unchanged single call), phase_state defaults to an empty object, upstream_gate to no gate") -check(all(i["default"] == "" for i in [PINPUTS["upstream_gate"]]) and not any(i.get("required") for n, i in PINPUTS.items() if n in ("pipeline_phase", "phase_state", "upstream_gate")), - "pipeline_phase: no new required input (existing callers keep working)") - - -def state(published="true", target="prod", dev_published="false", group="cfv2-prod-app"): - return _json.dumps({"inputs": dict(DEFAULT_INPUTS), "deploy": {"published": published, "target": target, "url": "https://x.example"}, - "migration": {}, "dev": {"published": dev_published, "url": "https://dev.example"}, "group": group}) - - -def vp(job, phase="verify", **kw): - inputs = dict(kw.pop("inputs", {}), pipeline_phase=phase) - inputs.setdefault("phase_state", state()) - return runs(job, scenario(**dict(dict(event="push", ref=MAIN, inputs=inputs, needs=kw.pop("needs", {})), **kw))) - - -green_after_deploy = dep_needs() -for job in ("smoke", "rollback", "release"): - check(not runs(job, scenario("push", MAIN, {"pipeline_phase": "deploy", "release_cmd": "x"}, dep_needs(smoke="failure", verified="false") if job == "rollback" else dep_needs())), - f"deploy phase: {job} never runs in the deploy call (verification belongs to the verify call)") -check(runs("smoke", scenario("push", MAIN, {"pipeline_phase": "all"}, dep_needs())), "all phase (default): Smoke Tests run exactly as before") -check(runs("deploy", scenario("push", MAIN, {"pipeline_phase": "deploy"}, GREEN)) and runs("lint", scenario("push", MAIN, {"pipeline_phase": "deploy"}, GREEN)), - "deploy phase: CI / E2E, Migration and Deploy run independently of the other Worker (no needs on it)") -check(runs(DEVJOB, scenario("push", FEAT, {"pipeline_phase": "deploy", "deployment_strategy": "hybrid", "shared_dev_on_branch_push": True}, GREEN)), - "deploy phase: Shared Dev migrates and publishes (stage deploy) as soon as its own CI / E2E passed") -for job in VALIDATION + ["migration", "deploy", DEVJOB, "config-guard", "lifecycle", "persistent-dev"]: - ctxv = scenario("push", MAIN, {"pipeline_phase": "verify", "phase_state": state(), "deployment_strategy": "production-only", "shared_dev_on_branch_push": True, "d1_migrations": True}, GREEN) +check(not any(i.get("required") for n, i in PINPUTS.items() if n in ("pipeline_phase", "phase_state", "upstream_gate")), "pipeline_phase: no new required input (existing callers keep working)") +PS = lambda kinds=("production",), **extra: json.dumps(dict({"inputs": {"app_name": "app"}, "kinds": list(kinds), "deploy": {}, "migration": {}, "d1": {}, "group": "g"}, **extra)) +for k in KINDS: + kk = KK(k) + w = jobs[f"delivery_{kk}"]["with"] + c = lambda ph, deploy=None: scenario("push", MAIN, {"pipeline_phase": ph, "phase_state": json.dumps({"inputs": {"app_name": "app", "d1_migrations": True}, "kinds": [k], "deploy": {f"h_{kk}": deploy or ""}, "migration": {f"mj_{kk}": "MJ"}, "d1": {kk: "DB9"}, "group": "g"}), "upstream_gate": "failure"}) + check(render(w["handoff_json"], c("verify", '{"published":"true"}')) == '{"published":"true"}' and render(w["handoff_json"], c("deploy")) == "" and render(w["handoff_json"], c("all")) == "", f"{k}: handoff_json comes from phase_state ONLY for this kind and ONLY in a verify call") + check(render(w["migration_json_in"], c("verify")) == "MJ", f"{k}: the migration result is this kind's own entry of phase_state (its D1 id travels in the plan's descriptor)") + check(render(w["upstream_gate"], c("verify")) == "failure" and render(w["upstream_gate"], c("all")) == "" and render(w["phase"], c("verify")) == "verify", f"{k}: upstream_gate is forwarded only in a verify call") +PLAN_OK = {"gate": {"result": "skipped", "outputs": {}}, "plan": pl(("production",)), "migration": {"result": "skipped", "outputs": {}}} +check(runs("delivery_production", scenario("push", MAIN, {"pipeline_phase": "verify", "phase_state": PS()}, PLAN_OK)), "verify phase: the delivery call runs from the hand-off even though Gate / Deploy are not part of this call") +check(runs("delivery_production", scenario("push", MAIN, {"pipeline_phase": "deploy"}, {"gate": G_OK, "plan": pl(("production",)), "migration": {"result": "skipped", "outputs": {}}})), "deploy phase: the delivery call runs (deploy only), independent of the other Worker") +check(not runs("delivery_production", scenario("push", MAIN, {"pipeline_phase": "verify", "phase_state": PS()}, {"gate": {"result": "skipped", "outputs": {}}, "plan": pl(()), "migration": {"result": "skipped", "outputs": {}}})), "verify phase: no kinds in the hand-off -> nothing to verify (fail closed)") +dctx = lambda **i: Ctx({"inputs": dict({"phase": "all"}, **i), "needs": {"deploy": {"result": "success", "outputs": {}}, "smoke": {"result": "failure", "outputs": {"smoke_status": "failure"}}}}, {}) +check(not ev(djobs["smoke"]["if"], dctx(phase="deploy")) and not ev(djobs["rollback"]["if"], dctx(phase="deploy")) and ev(djobs["deploy"]["if"], dctx(phase="deploy")) and not ev(djobs["deploy"]["if"], dctx(phase="verify")), + "pipeline_phase: deploy phase = deploy only; verify phase = smoke + rollback only (no publish)") +for job in ["ci", "migration", "config-guard", "lifecycle", "persistent-dev"]: + ctxv = scenario("push", MAIN, {"pipeline_phase": "verify", "phase_state": PS(), "deployment_strategy": "production-only", "shared_dev_on_branch_push": True, "d1_migrations": True}, dict(GREEN, plan=pl(("production",), True))) check(not runs(job, ctxv), f"verify phase: {job} never runs (no CI, Migration, Deploy or publish in the verify call)") -check(vp("smoke", needs={"deploy": {"result": "skipped", "outputs": {}}}), "verify phase: Smoke Tests run from the hand-off even though Deploy is not part of this call") -check(not vp("smoke", inputs={"phase_state": state(published="false")}, needs={"deploy": {"result": "skipped", "outputs": {}}}), "verify phase: nothing was published -> no smoke (stale / superseded deploy)") -check(not vp("smoke", inputs={"phase_state": "{}"}, needs={"deploy": {"result": "skipped", "outputs": {}}}), "verify phase: an empty hand-off verifies nothing (fail closed)") -check(not vp("smoke", phase="all", needs={"deploy": {"result": "skipped", "outputs": {}}}, inputs={"phase_state": state()}), "all phase: a phase_state is ignored - Smoke still needs this call's own Deploy") -rb = lambda **kw: vp("rollback", needs={"smoke": {"result": kw.get("smoke", "failure"), "outputs": {"gate_blocked": kw.get("gate", "false")}}, "deploy": {"result": "skipped", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}}, - inputs=kw.get("inputs", {"production_rollback_policy": "automatic"})) -check(rb(), "verify phase: a failed Smoke Tests job triggers the rollback (policy automatic)") -check(not rb(gate="true"), "verify phase: a BLOCKED cross-service gate never triggers a rollback (the failure is not attributable to this Worker)") -check(not rb(smoke="success"), "verify phase: no rollback after a passed smoke") -check(not rb(inputs={"production_rollback_policy": "manual"}), "verify phase: Production policy manual still prevents the automatic rollback") -rel = lambda **kw: vp("release", needs={"smoke": {"result": kw.get("smoke", "success"), "outputs": {"deployed_and_verified": kw.get("ver", "true")}}, "rollback": {"result": kw.get("rb", "skipped"), "outputs": {}}, - "deploy": {"result": "skipped", "outputs": {}}}, inputs={"release_cmd": "pnpm release", "phase_state": state(target=kw.get("target", "prod"))}) -check(rel(), "verify phase: Release runs after a verified Production smoke of the handed-over deployment") -check(not rel(ver="false") and not rel(smoke="failure") and not rel(rb="success") and not rel(target="preview") and not rel(smoke="skipped"), - "verify phase: Release never runs for an unverified / failed / rolled-back / skipped smoke or a Preview deployment") -check(not runs("release", scenario("push", MAIN, {"pipeline_phase": "verify", "release_cmd": "x", "phase_state": "{}"}, dep_needs())), "verify phase: Release cannot be released by this call's own (skipped) Deploy outputs, only by the hand-off") -dv = lambda **kw: runs("dev-verify", scenario("push", FEAT, dict(pipeline_phase=kw.get("phase", "verify"), phase_state=state(dev_published=kw.get("dev", "true"))), {})) -check(dv() and not dv(dev="false") and not dv(phase="all") and not dv(phase="deploy"), "dev-verify: only in the verify call, only when Shared Dev actually published") -dr = lambda **kw: runs("dev-rollback", scenario("push", FEAT, {"pipeline_phase": "verify", "phase_state": state(dev_published="true")}, {"dev-verify": {"result": kw.get("r", "failure"), "outputs": {"gate_blocked": kw.get("gate", "false")}}})) -check(dr() and not dr(gate="true") and not dr(r="success") and not dr(r="skipped"), "dev-rollback: only after a failed Shared Dev smoke, never after a blocked cross-service gate") -check(doc[True]["workflow_call"]["outputs"]["phase_state"]["value"].count("toJSON(inputs)") == 1 and jobs["dev-rollback"]["concurrency"]["cancel-in-progress"] is False - and jobs["dev-rollback"]["concurrency"]["group"].startswith("cfv2-dev-${{ github.repository }}-"), "dev-rollback shares the Shared Dev job-level concurrency group") +check(runs("release", scenario("push", MAIN, {"release_cmd": "x", "pipeline_phase": "verify"}, {"delivery_production": dout("production")})) and not runs("release", scenario("push", MAIN, {"release_cmd": "x", "pipeline_phase": "verify"}, {"delivery_production": dout("production", smoke_json=json.dumps({"deployed_and_verified": "false"}))})), "verify phase: Release runs only from a content-verified Production smoke") +ps_val = OUTS["phase_state"]["value"] +check(ps_val.count("toJSON(inputs)") == 1 and '"kinds"' in ps_val and '"group"' in ps_val and '"descriptors"' in ps_val and '"d1"' not in ps_val and all(f"jobs.delivery_{KK(k)}.outputs.handoff" in ps_val and f"jobs.delivery_{KK(k)}.outputs.migration_used" in ps_val for k in KINDS), + "phase_state hands over the inputs once, the enabled kinds, each kind's OWN deploy / migration result, the concurrency group and the target descriptors (which already carry each D1 id: no second per-kind D1 map)") +check("jobs.plan.outputs.group" in ps_val and ps_val.count("github.ref") == 0 and "cfv2-prod" not in ps_val, "phase_state takes the lock identity from the Plan's canonical calculation - the lock expression is NOT duplicated into the output") grp_expr = re.search(r"concurrency:\n group: \$\{\{ (.*) \}\}\n queue: max", text_all).group(1) -base_expr = grp_expr.split("|| (", 1)[1].rsplit(")", 1)[0] -check(grp_expr.startswith("inputs.pipeline_phase == 'verify' && fromJSON(inputs.phase_state).group || (") and base_expr in doc[True]["workflow_call"]["outputs"]["phase_state"]["value"], - "concurrency: the verify call joins the SAME group the deploy call held (handed over in phase_state, computed from the same expression)") -# engine / action wiring +VPFX = "inputs.pipeline_phase == 'verify' && (fromJSON(inputs.phase_state).group || format('cfv2-run-{0}', github.run_id)) || " +check(grp_expr.startswith(VPFX) and "phase_state" not in grp_expr[len(VPFX):] and grp_expr.count("fromJSON") == 1, + "concurrency: ONE scheduling-time expression for the deploy call (no copy for verify); a verify call schedules under the group its deploy call recorded") +import itertools # noqa: E402 + + +def lock_cases(): + base = {"app_name": "blog", "deployment_strategy": "hybrid", "preview_on_branch_push": True, "shared_dev_on_branch_push": False, "shared_dev_native_preview_on_branch_push": False, "d1_migrations": False, "d1_lock_scope": "", "dispatch_target": ""} + for event, ref in (("push", "refs/heads/main"), ("push", "refs/heads/feat/x"), ("workflow_dispatch", "refs/heads/feat/x"), ("workflow_dispatch", "refs/heads/dev"), ("pull_request", "refs/pull/1/merge"), ("push", "refs/tags/v1")): + for strat, pre, sd, nd, d1, scope, dt in itertools.product(("hybrid", "production-only"), (True, False), (True, False), (True, False), (True, False), ("", "shared-db"), ("", "persistent-dev")): + if not (event == "workflow_dispatch" or dt == ""): + continue + yield event, ref, dict(base, deployment_strategy=strat, preview_on_branch_push=pre, shared_dev_on_branch_push=sd, shared_dev_native_preview_on_branch_push=nd, d1_migrations=d1, d1_lock_scope=scope, dispatch_target=dt) + + +cases = list(lock_cases()) +GRP = "${{ " + grp_expr + " }}" +yaml_deploy = [render(GRP, scenario(e, r, dict(i, pipeline_phase="all"))) for e, r, i in cases] +yaml_verify = None # filled below, once the canonical groups are known +js_code = ("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,i])=>m.runLockGroup({event:e,ref:r,inputs:i,runId:'1'}))))})") +js_groups = json.loads(subprocess.run(["node", "-e", js_code], input=json.dumps(cases), capture_output=True, text=True, check=True).stdout) +norm = lambda g: re.sub(r"^cfv2-run-.*$", "cfv2-run-N", g) +check(len(cases) > 200 and [norm(g) for g in yaml_deploy] == [norm(g) for g in js_groups], f"concurrency: the workflow expression and delivery.mjs runLockGroup agree on all {len(cases)} event/ref/input scenarios (deploy call)") +yaml_verify = [render(GRP, scenario(e, r, {"app_name": "other", "d1_lock_scope": "ignored", "pipeline_phase": "verify", "phase_state": json.dumps({"inputs": i, "group": g})})) for (e, r, i), g in zip(cases, js_groups)] +check(yaml_verify == js_groups, "concurrency: a verify call schedules under exactly the canonical group its deploy call recorded - its own app_name / d1_lock_scope are ignored") +check(render(GRP, scenario("push", MAIN, {"pipeline_phase": "verify", "phase_state": "{}"})).startswith("cfv2-run-"), "concurrency: a verify call without a recorded group falls back to a private group (the Plan then fails it before any mutation)") +check(len({g for g in yaml_deploy if g.startswith("cfv2-prod-shared-db")}) == 1 and any(g == "cfv2-nonprod-d1-shared-db" for g in yaml_verify), "concurrency: the shared-D1 scope selects the lock in verify mode too") +def forged(group, inputs): # the REAL plan-side validation (restoreDescriptors) over a forged / wrong recorded group + code = ("import('./actions/cf-preview/delivery.mjs').then(m=>{try{m.restoreDescriptors({inputs:JSON.parse(process.argv[1]),kinds:['production'],group:process.argv[2],deploy:{},migration:{}},{repo:'o/r',runId:'7',event:'push',ref:'refs/heads/main'});console.log('accepted')}catch(e){console.log('rejected:'+e.message)}})") + return subprocess.run(["node", "-e", code, json.dumps(inputs), group], capture_output=True, text=True, check=True).stdout.strip() +check(all(forged(g, {"app_name": "app"}).startswith("rejected:phase_state.group") or forged(g, {"app_name": "app"}).startswith("rejected:") for g in ("attacker-group", "cfv2-nonprod-d1-app", "cfv2-prod-other", "")) and "lock identity" in forged("cfv2-nonprod-d1-app", {"app_name": "app"}), + "concurrency: a forged or wrong recorded group is rejected by the Plan (restoreDescriptors) BEFORE any mutation - it can only ever cost a failed verify run") gate = astep("Cross-service gate") check(gate["id"] == "gate" and "inputs.stage == 'verify'" in gate["if"] and "'success'" in gate["if"] and "'skipped'" in gate["if"] and "exit 1" in gate["run"] and aidx("Cross-service gate") < aidx("Stale-SHA guard (start)") and DEPLOY_ACTION["outputs"]["gate_blocked"]["value"].startswith("${{ steps.gate.outputs.blocked"), "engine: the cross-service gate runs before any smoke, accepts only success | skipped, fails the job otherwise and reports gate_blocked") -for n in ("smoke", "dev-verify"): +for n in ("smoke", "rollback"): w = eng[n]["with"] - check("upstream_gate" in w and "call_inputs_json" in w and n in jobs, f"{n}: passes the gate result and its own inputs (identity check) to the engine") -check(not any("secrets." in str(st.get("with", {}).get(k, "")) and "CLOUDFLARE" in str(st.get("with", {}).get(k, "")) for j in ("smoke", "dev-verify") for st in jobs[j]["steps"] for k in st.get("with", {})) - and "CLOUDFLARE_API_TOKEN" not in yaml.dump(jobs["dev-verify"]) and "CLOUDFLARE_API_TOKEN" not in yaml.dump(jobs["smoke"]), "smoke jobs (Production / Preview / Shared Dev) never receive Cloudflare credentials") + check("call_inputs_json" in w and (n == "rollback" or "upstream_gate" in w), f"{n}: passes its own inputs (identity check) to the engine") +check("CLOUDFLARE_API_TOKEN" not in yaml.dump(djobs["smoke"]) and not any("secrets.CLOUDFLARE" in str(st.get("with", {})) for st in djobs["smoke"]["steps"]), "the Smoke job (every kind) never receives Cloudflare credentials") +ENGINE_SH = open("actions/cf-preview/engine.sh").read() +check(ENGINE_SH.count('"$TOOLS_DIR/build.sh"') == 1 and ENGINE_SH.count('"$TOOLS_DIR/publish.sh"') == 1 and ENGINE_SH.count("run_build ") == 2 and ENGINE_SH.count("run_publish ") == 2, + "engine: ONE audited build primitive and ONE publish primitive, shared by the normal run and the recovery rebuild (recovery only supplies its state dir, rebound env/version, no host re-check)") sys.exit(fail) diff --git a/scripts/cf-preview/test.sh b/scripts/cf-preview/test.sh new file mode 100755 index 0000000..2dbc758 --- /dev/null +++ b/scripts/cf-preview/test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# CF Worker v2 test profiles. One entrypoint for CI and agents; verbose output goes to $LOGDIR, only summaries print. +# test.sh fast static contract + unit + stub-based shell tests (~10s) +# test.sh chain real-shell delivery-chain scenarios, groups run in parallel (the expensive part) +# test.sh full fast, then chain - chain is NOT started when fast failed (the merge gate needs both green) +# Test seam (used by test-testsh.sh): CF_V2_TEST_FAST_CMD / CF_V2_TEST_CHAIN_CMD replace the real fast/chain command. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 +LOGDIR="${LOGDIR:-$(mktemp -d)}"; mkdir -p "$LOGDIR" +rc=0 +run() { # run