From e8a049fecfcc47866a54ae6c7991a8ee0f54fc7f Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 6 Nov 2026 02:36:22 +0000 Subject: [PATCH] refactor(cf-v2): fold the manual persistent-dev dispatch into the delivery workflow The engine had two jobs named `persistent-dev`: `delivery_persistent_dev` (automatic DAG delivery) and a separate `persistent-dev` job calling `_cf-worker-persistent-dev.yml` (manual workflow_dispatch). There is now ONE: `delivery_persistent_dev` also fires for the protected dispatch and passes `manual: true`; `_cf-worker-delivery.yml` runs its new `manual` job instead of deploy / smoke / rollback. `_cf-worker-persistent-dev.yml` is deleted. Preserved: manual-only (dispatch, hybrid, non-empty persistent_dev_ref, github.ref == persistent_dev_ref, never on push or in a verify call); engine manual mode (no automatic recovery) or the legacy two-command contract (never in the last-good ledger); `/dev` Environment; same concurrency group; narrower permissions (contents read, deployments write); the automatic path and every public workflow_call input/output/secret unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/_cf-worker-delivery.yml | 79 ++++++++++++- .../workflows/_cf-worker-persistent-dev.yml | 109 ------------------ .github/workflows/_cf-worker-v2.yml | 35 ++---- .github/workflows/cf-worker-v2-ci.yml | 4 +- docs/cf-worker-v2-unified-delivery.md | 6 +- scripts/cf-preview/test-jobs.py | 27 ++++- scripts/cf-preview/test-workflow-contract.py | 100 +++++++++------- 7 files changed, 166 insertions(+), 194 deletions(-) delete mode 100644 .github/workflows/_cf-worker-persistent-dev.yml diff --git a/.github/workflows/_cf-worker-delivery.yml b/.github/workflows/_cf-worker-delivery.yml index 45a78f3..a1892c5 100644 --- a/.github/workflows/_cf-worker-delivery.yml +++ b/.github/workflows/_cf-worker-delivery.yml @@ -11,6 +11,12 @@ name: CF Worker delivery (one target) # deploy -> smoke -> conditional rollback of THIS kind, independent of every other kind # (pipeline_phase deploy = deploy only; verify = smoke + rollback from the handed-over deploy result) # +# `manual: true` (kind persistent-dev only; the public workflow sets it for the protected workflow_dispatch of the approved ref) runs the +# single `manual` job INSTEAD of deploy / smoke / rollback: the same engine in manual mode (stage all: build -> audit -> migration -> publish +# -> smoke -> ledger; NO automatic recovery) or, when dev_build_cmd is empty, the legacy two-command contract (never recorded in the +# last-good ledger). Plan and Migration do not run for a dispatch, so there is no descriptor and no migration hand-over; the job reads its +# settings from inputs_json. Its Environment `/dev`, narrower permissions and concurrency group are properties of the job itself. +# # 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`). # @@ -62,9 +68,13 @@ on: type: string default: '' descriptor: - description: toJSON of THIS kind's target descriptor (versioned, validated by the Plan job and re-validated by the engine). Carries the engine target, GitHub Environment, concurrency groups, expected physical D1, migration owner and smoke mode; never a secret. + description: toJSON of THIS kind's target descriptor (versioned, validated by the Plan job and re-validated by the engine). Carries the engine target, GitHub Environment, concurrency groups, expected physical D1, migration owner and smoke mode; never a secret. `{}` with manual (no Plan runs for the dispatch; unused). type: string required: true + manual: + description: persistent-dev only - the protected manual dispatch of the approved ref; runs the `manual` job instead of deploy / smoke / rollback + type: boolean + default: false outputs: handoff: description: the validated deploy result of THIS kind (own publish in the deploy phase, the handed-over one in the verify phase) @@ -108,7 +118,7 @@ jobs: # ------------------------------------------------------------------------- deploy: name: Deploy - if: inputs.phase != 'verify' + if: inputs.phase != 'verify' && !inputs.manual runs-on: ${{ inputs.runner }} timeout-minutes: 45 concurrency: @@ -155,7 +165,7 @@ jobs: smoke: name: Smoke Tests needs: [deploy] - if: always() && inputs.phase != 'deploy' + if: always() && inputs.phase != 'deploy' && !inputs.manual runs-on: ${{ inputs.runner }} timeout-minutes: 30 permissions: @@ -241,7 +251,7 @@ jobs: 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' + if: always() && inputs.phase != 'deploy' && !inputs.manual && needs.smoke.outputs.smoke_status == 'failure' && (fromJSON(inputs.descriptor).kind != 'production' || fromJSON(inputs.inputs_json).production_rollback_policy == 'automatic') && fromJSON(needs.smoke.outputs.smoke_json || '{}').gate_blocked != 'true' runs-on: ${{ inputs.runner }} timeout-minutes: 45 concurrency: @@ -295,3 +305,64 @@ jobs: deploy_outputs: ${{ env.HANDOFF }} migration_outputs: ${{ env.MIGJSON }} smoke_outputs: ${{ env.SMOKEJSON }} + + # ------------------------------------------------------------------------- + # Manual persistent Dev (Hybrid) — explicit dispatch of the approved ref only (the caller's `if` checks dispatch_target, strategy hybrid and + # persistent_dev_ref; the engine re-checks the ref). Never a side effect of a feature-branch push. Runs INSTEAD of deploy / smoke / rollback. + # dev_build_cmd set : the SAME delivery engine in manual mode, ONE job (build -> audit -> migration -> publish -> smoke -> ledger, + # recorded verified ONLY after the smoke passes; no automatic recovery) + # dev_build_cmd empty : the legacy two-command contract, unchanged (dev_deploy_cmd builds AND publishes, dev_smoke_cmd checks). It is never + # recorded in the Shared Dev last-good ledger, so automatic recovery can never treat it as known-good. + # The Environment `/dev` is a property of this job, so its protection rules and approvals apply exactly as for the automatic kind. + # ------------------------------------------------------------------------- + manual: + name: manual + if: inputs.manual + runs-on: ${{ inputs.runner }} + timeout-minutes: 45 + environment: ${{ fromJSON(inputs.inputs_json).app_name }}/dev + concurrency: + group: cfv2-dev-${{ github.repository }}-${{ fromJSON(inputs.inputs_json).d1_lock_scope != '' && fromJSON(inputs.inputs_json).d1_lock_scope || fromJSON(inputs.inputs_json).app_name }} + cancel-in-progress: false + permissions: + contents: read + deployments: write # engine mode only: Dev ledger entry, recorded verified ONLY after the smoke passes + env: + GH_TOKEN: ${{ github.token }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + # ---- Engine contract (dev_build_cmd set) + - id: engine + if: fromJSON(inputs.inputs_json).dev_build_cmd != '' + uses: $/actions/cf-deploy + with: + target: dev + mode: manual + inputs_json: ${{ inputs.inputs_json }} + smoke_username: ${{ secrets.SMOKE_USERNAME }} + smoke_password: ${{ secrets.SMOKE_PASSWORD }} + # ---- Legacy contract (dev_build_cmd empty) + - uses: $/actions/setup + if: fromJSON(inputs.inputs_json).dev_build_cmd == '' + with: + setup-node: 'false' + setup-pnpm: 'false' + - name: Deploy persistent Dev + if: fromJSON(inputs.inputs_json).dev_build_cmd == '' + working-directory: ${{ fromJSON(inputs.inputs_json).app_path }} + env: + CMD: ${{ fromJSON(inputs.inputs_json).dev_deploy_cmd }} + run: | + [ -n "$CMD" ] || { echo "::error::dev_deploy_cmd required"; exit 1; } + bash -c "$CMD" + - name: Dev smoke (Cron/Queue-adjacent checks) + if: fromJSON(inputs.inputs_json).dev_build_cmd == '' && fromJSON(inputs.inputs_json).dev_smoke_cmd != '' + working-directory: ${{ fromJSON(inputs.inputs_json).app_path }} + env: + CMD: ${{ fromJSON(inputs.inputs_json).dev_smoke_cmd }} + run: bash -c "$CMD" diff --git a/.github/workflows/_cf-worker-persistent-dev.yml b/.github/workflows/_cf-worker-persistent-dev.yml deleted file mode 100644 index 351b1f0..0000000 --- a/.github/workflows/_cf-worker-persistent-dev.yml +++ /dev/null @@ -1,109 +0,0 @@ -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: manual - 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-v2.yml b/.github/workflows/_cf-worker-v2.yml index 0e78c5f..89601a3 100644 --- a/.github/workflows/_cf-worker-v2.yml +++ b/.github/workflows/_cf-worker-v2.yml @@ -15,8 +15,8 @@ name: CF Worker Pipeline v2 # | # Release optional, main only, only after a VERIFIED Production delivery with no rollback # -# Event handlers outside that DAG: `lifecycle` (_cf-worker-lifecycle.yml: PR comment / branch delete), `persistent-dev` -# (_cf-worker-persistent-dev.yml: Hybrid, protected manual dispatch, same engine in manual mode) and `config-guard`. +# Event handlers outside that DAG: `lifecycle` (_cf-worker-lifecycle.yml: PR comment / branch delete) and `config-guard`. +# The Hybrid protected manual dispatch of persistent Dev is NOT a separate job: it is the `manual` entry of `delivery_persistent_dev`. # 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. # @@ -670,11 +670,14 @@ jobs: delivery_persistent_dev: name: 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') + # Two entries, one job: the automatic DAG delivery (plan-driven), or the protected manual dispatch of the approved ref (Hybrid only; + # Plan and Migration are skipped for a dispatch, so this branch needs no `needs` result and never runs on a push). + if: (always() && needs.plan.result == 'success' && needs.plan.outputs.on_persistent_dev == 'true' && (needs.migration.result == 'success' || needs.migration.result == 'skipped')) || (!cancelled() && inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && inputs.deployment_strategy == 'hybrid' && inputs.persistent_dev_ref != '' && github.ref == inputs.persistent_dev_ref) uses: $/.github/workflows/_cf-worker-delivery.yml permissions: *delivery_permissions with: - descriptor: ${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) }} + descriptor: ${{ needs.plan.outputs.descriptors && toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) || '{}' }} + manual: ${{ inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' }} 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) || '' }} @@ -730,7 +733,7 @@ jobs: # ------------------------------------------------------------------------- # Event handlers outside the delivery DAG, one small reusable workflow each: `lifecycle` (same-repo PR -> comment the verified Preview URL; - # branch delete -> retire the ledger, the Preview is KEPT) and `persistent-dev` (Hybrid, explicit dispatch on the approved ref only). + # branch delete -> retire the ledger, the Preview is KEPT). The Hybrid manual dispatch of persistent Dev is the `manual` entry of `delivery_persistent_dev`. # ------------------------------------------------------------------------- lifecycle: name: lifecycle @@ -745,25 +748,3 @@ jobs: preview_worker_name: ${{ inputs.preview_worker_name }} reserve_native_dev: ${{ inputs.shared_dev_native_preview_on_branch_push }} runner: ${{ inputs.runner }} - - persistent-dev: - name: 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 - uses: $/.github/workflows/_cf-worker-persistent-dev.yml - permissions: - contents: read - 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 }} - 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 61f963c..d95299a 100644 --- a/.github/workflows/cf-worker-v2-ci.yml +++ b/.github/workflows/cf-worker-v2-ci.yml @@ -7,7 +7,6 @@ on: - '.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' @@ -24,7 +23,6 @@ on: - '.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/**' @@ -67,4 +65,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-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 + ./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-preview-gc.yml .github/workflows/cf-worker-v2-ci.yml diff --git a/docs/cf-worker-v2-unified-delivery.md b/docs/cf-worker-v2-unified-delivery.md index 593043e..477a2cb 100644 --- a/docs/cf-worker-v2-unified-delivery.md +++ b/docs/cf-worker-v2-unified-delivery.md @@ -5,7 +5,7 @@ It supersedes the job topology in `cf-worker-v2-refactor.md` ("Job DAG", "Shared and recovery rules described there still apply. `_cf-worker-v2.yml` remains the only public entrypoint; `_cf-worker-delivery.yml` is an **internal** reusable workflow called only by it. v1 / v1.5 and the floating `@v2` tag are untouched by this change. -> **Revised again (PR #89 Phase 1 simplification):** validation lives in `_cf-worker-ci.yml`, the Preview lifecycle 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 again (PR #89 Phase 1 simplification):** validation lives in `_cf-worker-ci.yml`, the Preview lifecycle in its own workflow, the manual persistent Dev dispatch as the `manual` job of the delivery workflow (one `persistent-dev` delivery job), every shared action/workflow is referenced with `$/` — see [cf-worker-v2-refactor.md](cf-worker-v2-refactor.md#phase-1-simplification-pr-89-refactor--current-structure). > > **Revised (PR #89 simplification).** No separate Preflight stage. **One Migration job with integrated safety validation; reusable target-driven Delivery.** > The earlier Preflight-per-kind / Migration-per-D1-owner topology, and the real-runner tables further down that exercised it, describe the superseded design; @@ -41,7 +41,7 @@ matrix anywhere in the delivery graph: every `needs..outputs.*` belongs to | --- | --- | | trusted push to `main` | `production` | | trusted push to another branch | `native-branch` if `preview_on_branch_push`; `native-dev` if `shared_dev_native_preview_on_branch_push`; `persistent-dev` if `shared_dev_on_branch_push` | -| protected `workflow_dispatch` persistent-dev | the existing manual `persistent-dev` job (unchanged) | +| protected `workflow_dispatch` persistent-dev | the same `persistent-dev` delivery call, `manual: true` (Plan and Migration skipped): its `manual` job runs the engine in manual mode (or the legacy two-command contract), no automatic recovery | | 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 @@ -68,7 +68,7 @@ force-pushed back to the original commit in between (A → B → A) the tip look | 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 | +| Nesting depth | caller → `_cf-worker-v2.yml` → `_cf-worker-delivery.yml` / `-ci` / `-lifecycle` = 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 | diff --git a/scripts/cf-preview/test-jobs.py b/scripts/cf-preview/test-jobs.py index bdcfd4f..9594fbf 100644 --- a/scripts/cf-preview/test-jobs.py +++ b/scripts/cf-preview/test-jobs.py @@ -4,7 +4,7 @@ Executes the REAL steps of the ONE Migrate 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 -Plan, and the persistent-Dev job - including the +Plan, and the manual persistent-Dev dispatch (the `manual` job of the delivery workflow) - 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: @@ -30,7 +30,6 @@ 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" @@ -77,7 +76,7 @@ def desc(kind, d1="", owner="", app="app", scope="", phase="all", **over): return json.dumps(dict({"v": 1, "kind": kind, "target": TARGET_OF[kind], "environment": f"{app}/{_ENV_OF[kind]}", "deploy_group": g, "rollback_group": g if kind == "persistent-dev" else g + "-rollback", "d1_expected_id": d1, "d1_owner_kind": owner, "owns_migration": bool(d1 and owner == kind), "smoke_mode": "full", "phase": phase}, **over)) -INNER = ("deploy", "smoke", "rollback") +INNER = ("deploy", "smoke", "rollback", "manual") SKIPPED = {"result": "skipped", "outputs": {}} @@ -107,6 +106,8 @@ def call_workflow(job, c, ctl): w = {k: render(v, c) for k, v in job["with"].items()} dinputs = dict(DINPUT_DEFAULTS) dinputs.update(w) + if "manual" in dinputs: # type: boolean - GitHub coerces the rendered 'true' / 'false' string + dinputs["manual"] = str(dinputs["manual"]).lower() == "true" sec = {k: render(v, c) for k, v in job.get("secrets", {}).items()} kind = json.loads(w["descriptor"])["kind"] dc = Ctx({"github": c["github"], "inputs": dinputs, "needs": {}, "jobs": {}}, sec) @@ -403,9 +404,21 @@ def advertised(r): PD = dict(DEV_INPUTS, deployment_strategy="hybrid", persistent_dev_ref="refs/heads/dev", dispatch_target="persistent-dev", shared_dev_on_branch_push=False) -def manual(inputs=None, env=None, toml=DEV_TOML): +def manual(inputs=None, env=None, toml=DEV_TOML, gate=True): ws = make_workspace(toml) - 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 {}))) + # the REAL call: delivery_persistent_dev (Plan / Migration skipped for a dispatch) -> the `manual` job of the delivery workflow + needs = {"plan": SKIPPED, "migration": SKIPPED} + c = ctx("refs/heads/dev", dict(PD, **(inputs or {})), PROD_SECRETS, needs, event="workflow_dispatch") + pj = doc["jobs"]["delivery_persistent_dev"] + if gate and not ev(pj["if"], c): + return {"result": "skipped", "log": "", "summary": "", "ws": ws, "steps": []} + dinputs = dict(DINPUT_DEFAULTS, **{k: render(v, c) for k, v in pj["with"].items()}) + dinputs["manual"] = dinputs["manual"] == "true" + dc = Ctx({"github": c["github"], "inputs": dinputs, "needs": {}, "jobs": {}}, {k: render(v, c) for k, v in pj["secrets"].items()}) + for n in ("deploy", "smoke", "rollback"): + assert not ev(ddoc["jobs"][n]["if"], dc), f"{n} must not run for the manual dispatch" + assert ev(ddoc["jobs"]["manual"]["if"], dc) + r = run_job(ddoc, "manual", dc, ws, dict({"STUB_TIPS": SHA}, **(env or {}))) r["ws"] = ws return r @@ -951,7 +964,9 @@ def group_shared_dev(): 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") + check(r["result"] == "skipped" and not ran(r, "PUBLISH"), "manual dispatch from a ref other than persistent_dev_ref: the delivery call is skipped (nothing runs)") + r = manual(inputs={"persistent_dev_ref": "refs/heads/other"}, gate=False) + check(r["result"] == "failure" and not ran(r, "PUBLISH"), "engine contract: manual run from a ref other than persistent_dev_ref is refused by the engine too (defence in depth, even if the call gate were bypassed)") def group_chain_smoke_rollback(): diff --git a/scripts/cf-preview/test-workflow-contract.py b/scripts/cf-preview/test-workflow-contract.py index e02cee6..4a727bc 100755 --- a/scripts/cf-preview/test-workflow-contract.py +++ b/scripts/cf-preview/test-workflow-contract.py @@ -8,6 +8,7 @@ Environment protection rules, runner behaviour. See docs/cf-worker-v2-refactor.md "Verified vs not verified". """ import json +import os import pathlib import re import sys @@ -17,16 +18,15 @@ 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"] +ljobs = ldoc["jobs"] +mjob = djobs["manual"] # the manual persistent Dev dispatch lives in the delivery workflow (one `persistent-dev` delivery job) inputs_def = doc[True]["workflow_call"]["inputs"] fail = 0 @@ -104,25 +104,25 @@ def vres(**over): 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, Plan, ONE Migrate job, one Delivery CALL per kind (reusable workflow), Release + config-guard / lifecycle / persistent-dev handlers") +check(set(jobs) == {"ci", "plan", "migration"} | CALL_JOBS | {"release", "config-guard", "lifecycle"}, + "public job set: CI call, Plan, ONE Migrate job, one Delivery CALL per kind (reusable workflow; persistent-dev covers the automatic AND the manual-dispatch delivery), Release + config-guard / lifecycle handlers") 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)") +check(set(djobs) == {"deploy", "smoke", "rollback", "manual"}, "the internal delivery workflow has exactly: deploy, smoke, rollback, manual (ONE target per call; `manual` = the protected persistent Dev dispatch; no Preflight / Migration path)") +check(set(ljobs) == {"lifecycle"} and not os.path.exists(".github/workflows/_cf-worker-persistent-dev.yml"), "the event handler is one small workflow (Preview lifecycle); the separate manual persistent Dev workflow is gone") for gone in ("dev-deploy", "dev-verify", "dev-rollback", "test", "prepare", "migrate", "test-gate", "preview", "production", "verify", "revert", "deploy", "smoke", "rollback", "preflight", "gate") + tuple(VALIDATION) + tuple(f"{s_}_{KK(k)}" for s_ in ("preflight", "migration") for k in KINDS): check(gone not in jobs, f"obsolete / matrix / inlined job '{gone}' is gone from the public workflow (validation lives in _cf-worker-ci.yml; no matrix output merging, no Preflight stage, no per-kind Migration call)") check(sum(1 for j in jobs.values() if j.get("name") == "Migrate") == 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(sum(len(re.findall(r"^ (?:migration|preflight)[\w_-]*:", open(f).read(), re.M)) for f in (CWF, DWF, LWF)) == 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"] == "Plan" and jobs["migration"]["name"] == "Migrate", "CI, Validation Gate, Plan and Migrate are named jobs") # Displayed check = ` / ...`; consumers call this workflow from a job named `CD`, so names carry no repeated words. check({k: jobs["delivery_" + KK(k)]["name"] for k in KINDS} == {k: k for k in KINDS}, "check names: each Delivery call is named just its kind (production / native-branch / native-dev / persistent-dev)") -check([djobs[j]["name"] for j in ("deploy", "smoke", "rollback")] == ["Deploy", "Smoke Tests", "Revert on Verify Failure"], "check names: inner delivery jobs stay Deploy / Smoke Tests / Revert on Verify Failure") -check((jobs["config-guard"]["name"], jobs["lifecycle"]["name"], jobs["persistent-dev"]["name"], jobs["release"]["name"]) == ("Guard", "lifecycle", "persistent-dev", "Release"), "check names: Guard, lifecycle, persistent-dev, Release") -check((ljobs["lifecycle"]["name"], pjobs["persistent-dev"]["name"]) == ("ledger", "manual"), "check names: handler inner jobs (ledger, manual) never repeat their caller's name") +check([djobs[j]["name"] for j in ("deploy", "smoke", "rollback", "manual")] == ["Deploy", "Smoke Tests", "Revert on Verify Failure", "manual"], "check names: inner delivery jobs stay Deploy / Smoke Tests / Revert on Verify Failure, plus manual") +check((jobs["config-guard"]["name"], jobs["lifecycle"]["name"], jobs["release"]["name"]) == ("Guard", "lifecycle", "Release"), "check names: Guard, lifecycle, Release") +check(ljobs["lifecycle"]["name"] == "ledger", "check names: the handler inner job (ledger) never repeats its caller's name") _names = [j["name"] for j in jobs.values()] -check(sorted(n for n in set(_names) if _names.count(n) > 1) == ["persistent-dev"], "check names: public job names are unique except persistent-dev (auto Delivery kind vs manual dispatch handler, never on the same event)") -check(not any(re.search(r"Delivery \(|Preview lifecycle|Database Migration|Config guard|Persistent Dev \(Hybrid\)|Delivery plan", str(j.get("name", ""))) for d in (jobs, cjobs, djobs, ljobs, pjobs) for j in d.values()), "check names: none of the old verbose names remain in any v2 workflow") +check(sorted(n for n in set(_names) if _names.count(n) > 1) == [], "check names: every public job name is unique - the engine has exactly ONE `persistent-dev` job (automatic DAG delivery and manual dispatch)") +check(not any(re.search(r"Delivery \(|Preview lifecycle|Database Migration|Config guard|Persistent Dev \(Hybrid\)|Delivery plan", str(j.get("name", ""))) for d in (jobs, cjobs, djobs, ljobs) for j in d.values()), "check names: none of the old verbose names remain in any v2 workflow") check(jobs["ci"]["uses"] == "$/.github/workflows/_cf-worker-ci.yml", "CI is one STATIC `uses: $/.github/workflows/_cf-worker-ci.yml` call (this repository at the running commit)") check("strategy" not in jobs["ci"], "no matrix on the CI call (E2E variants are the only matrix, inside the CI workflow)") @@ -147,18 +147,19 @@ def needs_of(name, js=None): 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)") +check(not any("uses" in j for d_ in (djobs, cjobs, ljobs) 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"}, + DESC_EXPR = "${{ toJSON(fromJSON(needs.plan.outputs.descriptors)['" + k + "']) }}" if k != "persistent-dev" else "${{ needs.plan.outputs.descriptors && toJSON(fromJSON(needs.plan.outputs.descriptors)['persistent-dev']) || '{}' }}" # persistent-dev also serves the manual dispatch (no Plan) + check(w["descriptor"] == DESC_EXPR and not set(w) & {"target", "environment", "d1_expected_id", "d1_owner_kind", "deploy_group", "rollback_group"}, f"delivery_{KK(k)}: ONE target descriptor of the plan replaces the per-call target / environment / D1 / owner / concurrency-group copies") check(w["migration_result"] == "${{ needs.migration.result }}" and w["migration_json"] == "${{ needs.migration.outputs.migration_json }}" and not set(w) & {"kinds", "peers", "own_migration_result", "own_migration_json", "owner_migration_result", "owner_migration_json"}, 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}} +CALLED = {"ci": cjobs, "lifecycle": ljobs, **{c: djobs for c in CALL_JOBS}} for n, j in jobs.items(): if "uses" not in j: continue @@ -173,20 +174,21 @@ def needs_of(name, js=None): # ---------------------------------------------------------------- 2. one deployment implementation ENGINE_USE = "$/actions/cf-deploy" -engine_jobs = {n: [s for s in j.get("steps", []) if s.get("uses") == ENGINE_USE] for n, j in {**{f"d.{k}": v for k, v in djobs.items()}, **jobs, "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)") +engine_jobs = {n: [s for s in j.get("steps", []) if s.get("uses") == ENGINE_USE] for n, j in {**{f"d.{k}": v for k, v in djobs.items()}, **jobs}.items()} +check({n for n, s in engine_jobs.items() if s} == {"d.deploy", "d.smoke", "d.rollback", "d.manual", "migration"} and all(len(s) == 1 for n, s in engine_jobs.items() if s and n != "migration") and len(engine_jobs["migration"]) == 3, + "the four delivery-workflow jobs (deploy, smoke, rollback and the manual persistent Dev) each call the SAME delivery engine action exactly once; the Migration job calls it for the owner's migration and for each non-owner target's validation (no second deployment implementation)") 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)") +AUTO_JOBS = ("deploy", "smoke", "rollback") +check({n: eng[n]["with"]["stage"] for n in AUTO_JOBS} == {"deploy": "publish", "smoke": "verify", "rollback": "rollback"} and "stage" not in eng["manual"]["with"], "each automatic delivery job selects its engine stage (publish / verify / rollback); the manual job runs the engine's default one-job stage (all)") 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(eng[n]["with"]["target"] == "${{ fromJSON(inputs.descriptor).target }}" and eng[n]["with"]["kind"] == "${{ fromJSON(inputs.descriptor).kind }}" and eng[n]["with"]["descriptor"] == "${{ inputs.descriptor }}" for n in AUTO_JOBS), "the engine target comes from the call's validated descriptor, kind from the static input; the engine re-validates the descriptor") check(all(re.fullmatch(r"\$\{\{ fromJSON\(needs\.plan\.outputs\.descriptors\)\[(fromJSON\(needs\.plan\.outputs\.migration\)\.kind|'[a-z-]+')\]\.target \}\}", s["with"]["target"]) and "toJSON(fromJSON(needs.plan.outputs.descriptors)[" in s["with"]["descriptor"] for s in MIG_STEPS), "Migration job: every engine step takes its target and descriptor from the plan's validated descriptors (the owner's, or the named non-owner's)") -check(eng["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), +check(eng["manual"]["with"]["target"] == "dev" and eng["manual"]["with"]["mode"] == "manual" and eng["manual"]["with"]["inputs_json"] == "${{ inputs.inputs_json }}" and "descriptor" not in eng["manual"]["with"], "manual persistent Dev binds target=dev, mode=manual (no descriptor: Plan does not run for a dispatch)") +check(all(eng[n]["with"]["inputs_json"] == "${{ inputs.inputs_json }}" for n in AUTO_JOBS) and all(eng[n]["with"]["d1_expected_id"] == "${{ fromJSON(inputs.descriptor).d1_expected_id }}" for n in AUTO_JOBS) and all(s["with"]["inputs_json"] == "${{ toJSON(inputs) }}" and s["with"]["d1_expected_id"] for s in MIG_STEPS), "engine inputs are passed as one JSON data blob; every stage binds the plan's D1 id (preflight, migrate, publish, verify, rollback)") VERIFY_BLOB = "${{ inputs.pipeline_phase == 'verify' && toJSON(fromJSON(inputs.phase_state).inputs) || toJSON(inputs) }}" 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") @@ -359,8 +361,16 @@ def dout(kind, **kw): check(runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="o/r")) and not runs("lifecycle", scenario("pull_request", "refs/pull/1/merge", head_repo="fork/r")), "lifecycle: same-repo PR comments only") check(not runs("lifecycle", scenario("push", FEAT)), "lifecycle: not on push") dev_ok = dict(inputs={"dispatch_target": "persistent-dev", "deployment_strategy": "hybrid", "persistent_dev_ref": "refs/heads/dev"}) -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") +SKIP_PM = {"plan": {"result": "skipped", "outputs": {}}, "migration": {"result": "skipped", "outputs": {}}} # Plan / Migration never run for a dispatch +check(runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, **dev_ok)) and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/other", needs=SKIP_PM, **dev_ok)) + and not runs("delivery_persistent_dev", scenario("push", "refs/heads/dev", needs=SKIP_PM, **dev_ok)), "persistent Dev: the manual entry runs on the approved ref via dispatch only, never on push") +check(not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], deployment_strategy="production-only"))) + and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], persistent_dev_ref=""))) + and not runs("delivery_persistent_dev", scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, inputs=dict(dev_ok["inputs"], pipeline_phase="verify"))), "persistent Dev manual entry: hybrid strategy, a non-empty persistent_dev_ref and a non-verify phase are required") +pd_with = {k: render(v, scenario("workflow_dispatch", "refs/heads/dev", needs=SKIP_PM, **dev_ok)) for k, v in jobs["delivery_persistent_dev"]["with"].items() if k in ("manual", "descriptor")} +check(pd_with == {"manual": "true", "descriptor": "{}"}, "persistent Dev manual entry: the delivery call gets manual=true and an empty descriptor (no Plan ran)") +auto_with = {k: render(v, scenario("push", FEAT, needs={"plan": pl(("persistent_dev",), False), "migration": {"result": "skipped", "outputs": {}}})) for k, v in jobs["delivery_persistent_dev"]["with"].items() if k == "manual"} +check(auto_with == {"manual": "false"}, "persistent Dev automatic entry (push): manual=false") BAD = {"deployment_strategy": "production-only", "shared_dev_on_branch_push": True} check(runs("config-guard", scenario("push", MAIN, BAD, GREEN)) and runs("config-guard", scenario("push", FEAT, dict(BAD, shared_dev_on_branch_push=False, shared_dev_native_preview_on_branch_push=True), GREEN)), "Config guard: production-only + (persistent Dev | Native Dev) is reported") @@ -405,14 +415,14 @@ def dout(kind, **kw): 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: +for n in AUTO_JOBS: 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") +ptxt = yaml.dump(mjob) +check("secrets.CLOUDFLARE_API_TOKEN" in ptxt and "secrets.CLOUDFLARE_ACCOUNT_ID" in ptxt and "CLOUDFLARE_PREVIEW" not in ptxt and "PREVIEW_SECRETS" not in ptxt, "manual persistent Dev reuses account credentials, not Preview secrets") check(not any(k in j.get("env", {}) for j in list(jobs.values()) + list(djobs.values()) for k in ("TARGET", "BUILD_ENV", "SMOKE_ENV", "DEV_ENV", "EXPECTED_SHA", "RUN_SHA")), "no job exports TARGET / BUILD_ENV / DEV_ENV / EXPECTED_SHA ...: the delivery target is bound by the engine action input, not by environment") ENVEXPR = "${{ fromJSON(inputs.descriptor).environment }}" @@ -420,8 +430,8 @@ def dout(kind, **kw): check(all(render(ENVEXPR, Ctx({"inputs": {"descriptor": json.dumps(desc(k))}}, SECRETS)) == DESC_FIX[k][1] for k in KINDS), "descriptor environments are /prod | preview | native-dev | dev") check("environment" not in djobs["smoke"], "Smoke: no Environment (no protection prompt; it mutates nothing)") check(djobs["deploy"]["environment"]["url"] == "${{ steps.engine.outputs.url }}", "Deploy: the job shows the actual deployed URL (environment.url)") -check(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(mjob["environment"] == "${{ fromJSON(inputs.inputs_json).app_name }}/dev" and render(mjob["environment"], Ctx({"inputs": {"inputs_json": json.dumps({"app_name": "app"})}}, {})) == DESC_FIX["persistent-dev"][1], "manual persistent Dev runs in the /dev Environment (same as the automatic persistent-dev kind)") +allj = {**jobs, **{f"d.{k}": v for k, v in djobs.items()}, **{f"ci.{k}": v for k, v in cjobs.items()}, **{f"l.{k}": v for k, v in ljobs.items()}, } check(all(j.get("permissions", {}).get("contents", "read") == "read" for n, j in allj.items() if n != "release"), "no job except Release can write repository contents") check(jobs["migration"]["permissions"].get("deployments") == "read", "Migration can READ (not write) Deployments: the Preview identity guard reads the ledger owner") 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)") @@ -432,10 +442,10 @@ def dout(kind, **kw): check(not ({"secrets"} & set(cdoc[True]["workflow_call"])) and not ({"secrets"} & set(ldoc[True]["workflow_call"])) and "secrets" not in jobs["ci"] and "secrets" not in jobs["lifecycle"], "the CI and lifecycle calls pass NO secrets at all (credential-free by construction)") check("secrets.SMOKE_PASSWORD" not in yaml.dump(cjobs) and eng["smoke"]["with"]["smoke_password"] == "${{ secrets.SMOKE_PASSWORD }}" and "smoke_password" not in eng["deploy"]["with"] and not any("smoke_password" in x["with"] for x in MIG_STEPS), "smoke credentials reach only the engine actions that smoke (Smoke Tests, Rollback re-smoke, Dev)") -for n in ("deploy", "smoke", "rollback", "migration"): +for n in ("deploy", "smoke", "rollback", "manual", "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") +check(all(s["with"].get("persist-credentials") is False for s in mjob["steps"] if str(s.get("uses", "")).startswith("actions/checkout")), "manual persistent Dev: the checkout does not persist the GitHub token") # ---------------------------------------------------------------- 6. cross-repository reusable workflow + local composite action references USES_ALL = [(n, i, s_["uses"]) for n, j in allj.items() for i, s_ in enumerate(j.get("steps", [])) if "uses" in s_] + [(n, -1, j["uses"]) for n, j in allj.items() if "uses" in j] @@ -445,7 +455,7 @@ def dout(kind, **kw): 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")) +ALLTXT = "".join(open(f).read() for f in (WF, DWF, CWF, LWF, ".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)") @@ -464,7 +474,7 @@ def dout(kind, **kw): 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)]): +for n, called_doc in (("ci", cdoc), ("lifecycle", ldoc), *[(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") @@ -612,7 +622,7 @@ def js_group(scope, app): check(djobs["deploy"]["concurrency"]["group"] == "${{ fromJSON(inputs.descriptor).deploy_group }}" and djobs["rollback"]["concurrency"]["group"] == "${{ fromJSON(inputs.descriptor).rollback_group }}", "delivery jobs take their concurrency groups from the descriptor") -manual_group = lambda scope, app: render(pjobs["persistent-dev"]["concurrency"]["group"], scenario(inputs={"app_name": app, "d1_lock_scope": scope})) +manual_group = lambda scope, app: render(mjob["concurrency"]["group"], Ctx({"github": {"repository": "o/r"}, "inputs": {"inputs_json": json.dumps({"app_name": app, "d1_lock_scope": scope})}}, {})) for scope, app in (("shared-db", "blog"), ("", "blog"), ("", "wiki")): check(js_group(scope, app) == manual_group(scope, app), f"persistent-dev: the descriptor's Deploy/Rollback group equals the manual dispatch's group (scope={scope or '-'}, app={app}) - one serialisation domain per shared D1") check(js_group("", "blog") != js_group("", "wiki"), "without a scope the Dev group is keyed by app") @@ -620,7 +630,7 @@ def js_group(scope, app): 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(mjob["concurrency"]["group"] == "cfv2-dev-${{ github.repository }}-${{ fromJSON(inputs.inputs_json).d1_lock_scope != '' && fromJSON(inputs.inputs_json).d1_lock_scope || fromJSON(inputs.inputs_json).app_name }}" and mjob["concurrency"]["cancel-in-progress"] is False, "manual persistent Dev uses the same job-level group (automatic and manual Dev never overlap)") check(all(not g(r, {"d1_migrations": True}).startswith("cfv2-dev-") for r in (MAIN, FEAT)), "workflow-level groups can never equal the job-level Dev group") ND_ON = {"preview_on_branch_push": False, "shared_dev_native_preview_on_branch_push": True} check(g(FEAT, ND_ON) == "cfv2-shared-dev-app" == g("refs/heads/other", ND_ON), "group: Native Dev (fixed target `dev`) serializes ALL branches of the app on one group") @@ -643,13 +653,19 @@ def js_group(scope, app): check("delivery_persistent_dev" in OUTS["dev_url"]["value"] and "delivery_native_dev" in OUTS["native_dev_url"]["value"] and "delivery_native_dev" not in OUTS["dev_url"]["value"], "outputs: dev_* stay persistent Dev; native_dev_* are Native Dev") check(all("outputs.smoke_json" in OUTS[k]["value"] and ".handoff" not in OUTS[k]["value"] for k in ("deploy_verified_url", "deploy_verified", "native_dev_verified", "dev_verified_url")), "outputs: *_verified come from the Smoke result only (the only place a deployment becomes verified)") check(all(re.search(r"jobs\.plan\.outputs\.on_\w+ == 'true'", OUTS[k]["value"]) for k in ("deploy_url", "deploy_verified", "rollback_result")) and "jobs.migration.outputs.migration_status" in OUTS["migration_status"]["value"], "P1-A: every per-kind public output SELECTS its source by the plan (on_); migration_status is the ONE Migration job's result") -pd = 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", +pd = mjob +MANUAL_IF = "(!cancelled() && inputs.pipeline_phase != 'verify' && github.event_name == 'workflow_dispatch' && inputs.dispatch_target == 'persistent-dev' && inputs.deployment_strategy == 'hybrid' && inputs.persistent_dev_ref != '' && github.ref == inputs.persistent_dev_ref)" +check(jobs["delivery_persistent_dev"]["if"].endswith(" || " + MANUAL_IF) and mjob["if"] == "inputs.manual" and pd["environment"] == "${{ fromJSON(inputs.inputs_json).app_name }}/dev", "persistent-dev keeps the approved-ref gate and protected Environment") -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(mjob["permissions"] == {"contents": "read", "deployments": "write"}, "manual persistent Dev keeps its narrower permissions (contents read, deployments write; no pull-requests write)") +check(all("!inputs.manual" in djobs[n]["if"] for n in AUTO_JOBS), "automatic deploy / smoke / rollback never run for the manual dispatch") +check(all(not ev(djobs[n]["if"], Ctx({"inputs": {"phase": "all", "manual": True}, "needs": {"deploy": {"result": "success", "outputs": {}}, "smoke": {"result": "failure", "outputs": {"smoke_status": "failure"}}}}, {})) for n in AUTO_JOBS) + and ev(mjob["if"], Ctx({"inputs": {"manual": True}}, {})) and not ev(mjob["if"], Ctx({"inputs": {"manual": False}}, {})), "delivery: manual=true runs ONLY the manual job; manual=false never runs it") +check("recovery" not in yaml.dump(mjob).lower() and "rollback" not in yaml.dump(mjob).lower(), "manual persistent Dev has no automatic recovery step") +legacy = [x for x in pd["steps"] if x.get("if", "").startswith("fromJSON(inputs.inputs_json).dev_build_cmd == ''")] +engine_side = [x for x in pd["steps"] if x.get("if") == "fromJSON(inputs.inputs_json).dev_build_cmd != ''"] check(len(legacy) == 3 and len(engine_side) == 1 and all("gh.sh" not in x.get("run", "") for x in legacy), "persistent-dev: legacy (dev_build_cmd empty) and engine (set) paths are mutually exclusive; legacy never writes the known-good ledger") -check(next(x for x in pd["steps"] if x.get("name") == "Deploy persistent Dev")["env"]["CMD"] == "${{ inputs.dev_deploy_cmd }}", "persistent-dev manual dispatch (legacy contract) still runs dev_deploy_cmd") +check(next(x for x in pd["steps"] if x.get("name") == "Deploy persistent Dev")["env"]["CMD"] == "${{ fromJSON(inputs.inputs_json).dev_deploy_cmd }}", "persistent-dev manual dispatch (legacy contract) still runs dev_deploy_cmd") check("shared_dev_on_branch_push" not in ptxt, "persistent-dev does not depend on the automatic Shared Dev switch") check("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", "")) @@ -678,7 +694,7 @@ def js_group(scope, app): 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"]: +for job in ["ci", "migration", "config-guard", "lifecycle"]: 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(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")