From c527c787437e97df976fff729640e50e3f29299e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:00:53 +0800 Subject: [PATCH] fix(console): isolate reviewed frontend version publishing Co-Authored-By: Codex --- .../deploy-strategy-switch-console.yml | 230 +++++++++++++++++- tests/console_deploy_safety_validation.mjs | 186 +++++++++++++- 2 files changed, 402 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index 9a1eed1f..5521f299 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -28,8 +28,13 @@ on: required: false type: string default: "" + release_frontend_assets: + description: "Release only the fixed reviewed frontend map over the approved e2a backend. No secret/profile sync." + required: true + type: boolean + default: false rollback_to_pre_release: - description: "Restore only the verified pre-release c88234c2 version; never build or sync. Read-only mode takes priority." + description: "Restore only the verified pre-frontend 2beea000 version; never build or sync. Read-only mode takes priority." required: true type: boolean default: false @@ -200,7 +205,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && !inputs.read_only_preflight && !inputs.rollback_to_pre_release runs-on: ubuntu-latest environment: runtime-strategy-switch - timeout-minutes: 15 + timeout-minutes: 25 env: WORKER_DIR: web/strategy-switch-console CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -239,8 +244,22 @@ jobs: - name: Select reviewed historical main source env: RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} + RELEASE_FRONTEND_ASSETS: ${{ inputs.release_frontend_assets }} + SYNC_SECRETS: ${{ inputs.sync_secrets }} + SYNC_STRATEGY_PROFILES: ${{ inputs.sync_strategy_profiles }} + SKIP_BINANCE_RECEIVER_CHECKS: ${{ inputs.skip_binance_receiver_checks }} run: | set -euo pipefail + frontend_mode="${RELEASE_FRONTEND_ASSETS:-false}" + case "$frontend_mode" in true|false) ;; *) exit 2 ;; esac + if [ "$frontend_mode" = "true" ]; then + if [ "$GITHUB_REF" != "refs/heads/main" ] || [ "$RELEASE_SOURCE_SHA" != "e2aab576d6874feeaf87df3411411ad04ee02d4f" ] \ + || [ "$SYNC_SECRETS" != "false" ] || [ "$SYNC_STRATEGY_PROFILES" != "false" ] \ + || [ "$SKIP_BINANCE_RECEIVER_CHECKS" != "true" ]; then + echo "invalid_frontend_release_mode" >&2 + exit 2 + fi + fi # Empty input preserves the original workflow-ref deployment behavior. if [ -z "$RELEASE_SOURCE_SHA" ]; then exit 0; fi if [ "$GITHUB_REF" != "refs/heads/main" ] || [ "$RELEASE_SOURCE_SHA" != "e2aab576d6874feeaf87df3411411ad04ee02d4f" ]; then @@ -252,6 +271,16 @@ jobs: echo "console_control_source_mismatch" >&2 exit 2 fi + if [ "$frontend_mode" = "true" ]; then + frontend_sha="456853b6655e05bd18ede58cdab54bd0b02a4a34" + git cat-file -e "${frontend_sha}^{commit}" + git merge-base --is-ancestor "$frontend_sha" "$control_sha" + if [ "$(git rev-parse "${frontend_sha}^{tree}")" != "a98498a60753cadc7b010f2e9d9c5a5e34f812f4" ] \ + || [ "$(git rev-parse "${frontend_sha}:web/strategy-switch-console/v2_asset_map.js")" != "e5c9239bd1da99f0272464a23118c2784926d923" ]; then + echo "frontend_release_source_mismatch" >&2 + exit 2 + fi + fi git cat-file -e "${RELEASE_SOURCE_SHA}^{commit}" git merge-base --is-ancestor "$RELEASE_SOURCE_SHA" "$control_sha" expected_tree="968b323ca75ee48d0c92bfe0b7f4110c3d27df72" @@ -303,6 +332,35 @@ jobs: python3 python/scripts/build_platform_config.py python3 python/scripts/build_platform_config.py --check + - name: Overlay fixed reviewed frontend assets + if: inputs.release_frontend_assets + run: | + set -euo pipefail + backend="e2aab576d6874feeaf87df3411411ad04ee02d4f" + frontend="456853b6655e05bd18ede58cdab54bd0b02a4a34" + asset="web/strategy-switch-console/v2_asset_map.js" + blob="e5c9239bd1da99f0272464a23118c2784926d923" + if [ "$GITHUB_REF" != "refs/heads/main" ] || [ "$(git rev-parse HEAD)" != "$backend" ] \ + || [ "$(git rev-parse 'HEAD^{tree}')" != "968b323ca75ee48d0c92bfe0b7f4110c3d27df72" ] \ + || [ "$(git rev-parse "${frontend}^{tree}")" != "a98498a60753cadc7b010f2e9d9c5a5e34f812f4" ]; then + echo "frontend_overlay_source_mismatch" >&2 + exit 2 + fi + staged_asset="$(mktemp)" + trap 'rm -f "$staged_asset"' EXIT + git show "${frontend}:${asset}" > "$staged_asset" + if [ "$(git hash-object "$staged_asset")" != "$blob" ]; then + echo "frontend_overlay_blob_mismatch" >&2 + exit 2 + fi + cp "$staged_asset" "$asset" + if [ "$(git diff --name-only "$backend")" != "$asset" ] \ + || [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "frontend_overlay_extra_source_change" >&2 + exit 2 + fi + printf 'Frontend map: %s; backend source: %s\n' "$blob" "$backend" + - name: Validate Worker assets run: | set -euo pipefail @@ -311,6 +369,13 @@ jobs: node --check --input-type=module < web/strategy-switch-console/v2_asset_map.js node --check --input-type=module < web/strategy-switch-console/worker.js + - name: Validate fixed backend with reviewed frontend + if: inputs.release_frontend_assets + run: | + set -euo pipefail + node tests/console_v2_assets_validation.mjs + node tests/console_v2_worker_validation.mjs + - name: Validate optional Binance account-facts binding if: env.BINANCE_ACCOUNT_FACTS_BINDING_JSON != '' working-directory: web/strategy-switch-console @@ -330,6 +395,7 @@ jobs: - name: Prepare Wrangler config env: UX1_RESEARCH_JOB_TOKEN: ${{ secrets.UX1_RESEARCH_JOB_TOKEN }} + RELEASE_FRONTEND_ASSETS: ${{ inputs.release_frontend_assets }} run: | set -euo pipefail if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] && [ -z "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ]; then @@ -512,6 +578,13 @@ jobs: raise SystemExit("wrangler.toml.example must contain a [vars] section.") rendered_vars = "".join(f"{key} = {json.dumps(value)}\n" for key, value in ux1_vars.items()) text = text.replace(vars_header, vars_header + rendered_vars, 1) + if os.environ.get("RELEASE_FRONTEND_ASSETS") == "true": + # --keep-vars preserves remote values only when no local values + # override them. This release changes code/assets, never flags. + import re + text, count = re.subn(r"(?ms)^\[vars\]\n.*?(?=^\[|\Z)", "[vars]\n\n", text) + if count != 1: + raise SystemExit("frontend_release_vars_section_mismatch") target.write_text(text, encoding="utf-8") PY @@ -529,9 +602,153 @@ jobs: exit 2 fi + - name: Build frontend-only Worker bundle without publishing + if: inputs.release_frontend_assets + working-directory: web/strategy-switch-console + env: + WRANGLER_SEND_METRICS: "false" + run: | + set -euo pipefail + npx wrangler@4.106.0 deploy --dry-run --config wrangler.toml --outdir "$RUNNER_TEMP/console-frontend-bundle" + + - name: Publish fixed frontend-only Worker version + if: inputs.release_frontend_assets + run: | + node --input-type=module <<'JS' + import { readFile } from 'node:fs/promises'; + import { createHash } from 'node:crypto'; + const fail = () => { throw new Error('frontend_release_stopped'); }; + const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value) ? value : fail(); + const prior = '2beea000-cce5-43a5-9255-9724f7318e83'; + const priorDeployment = '6107ffd9-4c76-4bc5-be85-a12caf2af766'; + const canonical = value => JSON.stringify(value, function (_key, item) { + return item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item; + }); + let stage = 'configuration', httpStatus = null, uploadAttempted = false, deployAttempted = false, uploaded = null; + try { + const account = process.env.CLOUDFLARE_ACCOUNT_ID, token = process.env.CLOUDFLARE_API_TOKEN; + if (process.env.GITHUB_REF !== 'refs/heads/main' || !token || !/^[0-9a-f]{32}$/i.test(account ?? '')) fail(); + const base = `https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/qsl-strategy-switch-console`; + // Fixed host/Worker, bounded response, no pagination or retry. Remote configuration + // stays in process memory; never write it or include it in an error/receipt. + async function request(path, method = 'GET', body) { + const response = await fetch(base + path, { method, body, redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Accept-Encoding': 'identity', ...(typeof body === 'string' ? { 'Content-Type': 'application/json' } : {}) } }); + httpStatus = response.status; + if (!response.ok) { await response.body?.cancel(); fail(); } + if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') + || !['', 'identity'].includes(response.headers.get('content-encoding') ?? '')) fail(); + const length = response.headers.get('content-length'); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > 524288)) fail(); + const reader = response.body?.getReader(); if (!reader) fail(); + let size = 0, text = ''; const decoder = new TextDecoder('utf-8', { fatal: true }); + try { + for (;;) { + const { done, value } = await reader.read(); if (done) break; + size += value.byteLength; if (size > 524288) fail(); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { await reader.cancel(); } + if (length !== null && Number(length) !== size) fail(); + const result = JSON.parse(text); + if (result?.success !== true || (result.errors !== undefined && (!Array.isArray(result.errors) || result.errors.length))) fail(); + return result.result; + } + async function active(id, version) { + const value = await request('/deployments?page=1&per_page=10'), list = value?.deployments; + if (!Array.isArray(list) || !list.length || list.length > 10 || list[0]?.id !== id + || list[0]?.strategy !== 'percentage' || !Array.isArray(list[0]?.versions) || list[0].versions.length !== 1 + || list[0].versions[0]?.version_id !== version || list[0].versions[0]?.percentage !== 100) fail(); + } + async function latest(version) { + const value = await request('/versions?page=1&per_page=10'), list = value?.items; + if (!Array.isArray(list) || !list.length || list.length > 10 || list[0]?.id !== version) fail(); + } + function resources(version, id) { + if (version?.id !== id) fail(); + const value = version.resources, runtime = value?.script_runtime, script = value?.script; + if (!value || Object.keys(value).some(key => !['bindings', 'script', 'script_runtime'].includes(key)) + || !Array.isArray(value.bindings) || !value.bindings.length || value.bindings.length > 128 || !runtime || !script + || Object.keys(runtime).some(key => !['compatibility_date', 'compatibility_flags', 'usage_model', 'limits', 'migration_tag', 'exports'].includes(key)) + || Object.keys(script).some(key => !['etag', 'handlers', 'last_deployed_from', 'placement_mode'].includes(key)) + || typeof script.etag !== 'string' || !script.etag || script.etag.length > 256 + || !(script.handlers === null || (Array.isArray(script.handlers) && script.handlers.every(x => typeof x === 'string'))) + || !/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date ?? '') + || (runtime.compatibility_flags !== undefined && (!Array.isArray(runtime.compatibility_flags) || runtime.compatibility_flags.some(x => typeof x !== 'string'))) + || !['standard', 'bundled', 'unbound'].includes(runtime.usage_model)) fail(); + const names = new Set(); + for (const binding of value.bindings) { + if (!binding || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(binding.name ?? '') || names.has(binding.name) + || !['plain_text', 'json', 'secret_text', 'kv_namespace', 'durable_object_namespace'].includes(binding.type)) fail(); + if (binding.type === 'kv_namespace' && !/^[0-9a-f]{32}$/i.test(typeof binding.namespace_id === 'string' ? binding.namespace_id : '')) fail(); + if (binding.type === 'durable_object_namespace' && ( + !/^[0-9a-f]{32}$/i.test(typeof binding.namespace_id === 'string' ? binding.namespace_id : '') + || typeof binding.class_name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(binding.class_name) + || ['script_name', 'environment', 'dispatch_namespace'].some(key => binding[key] !== undefined && (typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 128)))) fail(); + if (binding.type === 'plain_text' && (typeof binding.text !== 'string' || binding.text.length > 131072)) fail(); + if (binding.type === 'json' && !Object.hasOwn(binding, 'json')) fail(); + if (binding.type === 'secret_text' && Object.keys(binding).some(key => !['name', 'type'].includes(key))) fail(); + names.add(binding.name); + } + // Existing resources only: no resource provisioning or DO migration request. + if (!value.bindings.some(b => b.name === 'STRATEGY_SWITCH_CONFIG' && b.type === 'kv_namespace') + || !value.bindings.some(b => b.name === 'STRATEGY_SWITCH_RUNTIME_INSTANCES' && b.type === 'durable_object_namespace')) fail(); + return value; + } + stage = 'pre_release_active'; await active(priorDeployment, prior); + stage = 'pre_release_configuration'; const old = resources(await request(`/versions/${prior}`), prior); + stage = 'bundle'; + const bundle = await readFile(`${process.env.RUNNER_TEMP}/console-frontend-bundle/worker.js`); + if (!bundle.byteLength || bundle.byteLength > 10485760) fail(); + // The bundler is offline (--dry-run). Inherit EVERY existing binding, including + // secrets, without supplying any value or trusting current Environment inputs. + // Script-version inherit resolves 'latest', not a historical ID: require the + // fixed version to be latest immediately before upload. This is NOT provider CAS. + const metadata = { main_module: 'worker.js', bindings: old.bindings.map(b => ({ type: 'inherit', name: b.name })), + compatibility_date: old.script_runtime.compatibility_date, usage_model: old.script_runtime.usage_model, + annotations: { 'workers/message': 'Reviewed frontend 456853b over fixed e2a backend; retained bindings' } }; + for (const key of ['compatibility_flags', 'limits']) if (old.script_runtime[key] !== undefined) metadata[key] = old.script_runtime[key]; + const form = new FormData(); form.set('metadata', JSON.stringify(metadata)); + form.set('worker.js', new File([bundle], 'worker.js', { type: 'application/javascript+module' })); + stage = 'pre_upload_latest'; await latest(prior); + stage = 'upload'; uploadAttempted = true; + uploaded = uuid((await request('/versions?bindings_inherit=strict', 'POST', form))?.id); + if (uploaded === prior) fail(); + stage = 'uploaded_configuration'; const next = resources(await request(`/versions/${uploaded}`), uploaded); + const comparable = value => ({ bindings: [...value.bindings].sort((a, b) => a.name.localeCompare(b.name)), + script_runtime: value.script_runtime, handlers: value.script.handlers, placement_mode: value.script.placement_mode ?? null }); + if (canonical(comparable(next)) !== canonical(comparable(old)) || next.script.etag === old.script.etag) fail(); + // Same-Worker workflow concurrency serializes our release controls. These fresh + // GETs detect observed external drift; Cloudflare offers no compare-and-swap + // precondition here. No automatic retry or force fallback on an unknown result. + stage = 'pre_deploy_active'; await active(priorDeployment, prior); + stage = 'pre_deploy_latest'; await latest(uploaded); + stage = 'activate'; deployAttempted = true; + const deployment = await request('/deployments?force=false', 'POST', JSON.stringify({ strategy: 'percentage', + versions: [{ version_id: uploaded, percentage: 100 }], annotations: { 'workers/message': 'Reviewed frontend only; fixed e2a backend and retained configuration' } })); + const deploymentId = uuid(deployment?.id); + if (deployment.strategy !== 'percentage' || !Array.isArray(deployment.versions) || deployment.versions.length !== 1 + || deployment.versions[0]?.version_id !== uploaded || deployment.versions[0]?.percentage !== 100) fail(); + stage = 'readback'; await active(deploymentId, uploaded); + console.log(JSON.stringify({ status: 'frontend_release_verified', prior_version: prior, version_id: uploaded, + deployment_id: deploymentId, percentage: 100, bindings_equal: true, runtime_equal: true, + bundle_sha256: createHash('sha256').update(bundle).digest('hex'), force: false, provider_cas: false })); + } catch { + console.error(JSON.stringify({ error: 'frontend_release_stopped', stage, http_status: httpStatus, + upload_attempted: uploadAttempted, deploy_attempted: deployAttempted, uploaded_version: uploaded, + outcome: deployAttempted ? 'UNKNOWN_REQUIRES_READBACK' : uploadAttempted ? 'UPLOAD_REQUIRES_READBACK_NOT_ACTIVATED' : 'NOT_ATTEMPTED', retry: false })); + process.exitCode = 1; + } + JS + - name: Deploy Worker + if: ${{ !inputs.release_frontend_assets }} working-directory: web/strategy-switch-console - run: npx wrangler@4.106.0 deploy --config wrangler.toml + run: | + set -euo pipefail + npx wrangler@4.106.0 deploy --config wrangler.toml # Secret writes require an explicit manual opt-in. - name: Sync UX1 research job token @@ -787,6 +1004,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} + RELEASE_FRONTEND_ASSETS: ${{ inputs.release_frontend_assets }} steps: - name: Setup Node.js uses: actions/setup-node@v6 @@ -799,7 +1017,7 @@ jobs: // One fixed deployment request. In particular, never retry with force: // Wrangler 4.106.0 rollback can implicitly do that after API error 10220. // https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/methods/create/ - const target = 'c88234c2-292a-40d4-b624-500f145e3f7d'; + const target = '2beea000-cce5-43a5-9255-9724f7318e83'; const fail = () => { throw new Error('console_restore_failed'); }; let stage = 'configuration'; let httpStatus = null; @@ -807,7 +1025,7 @@ jobs: try { const token = process.env.CLOUDFLARE_API_TOKEN; const account = process.env.CLOUDFLARE_ACCOUNT_ID; - if (process.env.GITHUB_REF !== 'refs/heads/main' || process.env.RELEASE_SOURCE_SHA + if (process.env.GITHUB_REF !== 'refs/heads/main' || process.env.RELEASE_SOURCE_SHA || process.env.RELEASE_FRONTEND_ASSETS === 'true' || !token || !/^[0-9a-f]{32}$/i.test(account ?? '')) fail(); stage = 'request'; requested = true; @@ -815,7 +1033,7 @@ jobs: method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ strategy: 'percentage', versions: [{ version_id: target, percentage: 100 }], - annotations: { 'workers/message': 'Restore verified pre-release c88234c2 baseline' } }), + annotations: { 'workers/message': 'Restore verified pre-frontend 2beea000 baseline' } }), }); httpStatus = Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null; stage = 'http_status'; diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index c544d049..954a88bf 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { readFileSync, mkdtempSync, existsSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, existsSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -7,7 +8,7 @@ import { spawnSync } from "node:child_process"; const workflow = readFileSync(new URL("../.github/workflows/deploy-strategy-switch-console.yml", import.meta.url), "utf8"); const triggers = workflow.slice(workflow.indexOf("\non:\n"), workflow.indexOf("\npermissions:")); assert.deepEqual([...triggers.matchAll(/^ (\w+):/gm)].map(match => match[1]), ["workflow_dispatch"], "merges must not deploy"); -for (const input of ["sync_secrets", "sync_strategy_profiles", "read_only_preflight", "skip_binance_receiver_checks", "rollback_to_pre_release"]) { +for (const input of ["sync_secrets", "sync_strategy_profiles", "read_only_preflight", "skip_binance_receiver_checks", "release_frontend_assets", "rollback_to_pre_release"]) { const block = triggers.match(new RegExp(` ${input}:\\n([\\s\\S]*?)(?=\\n \\w+:|$)`))?.[1]; assert.ok(block, input); assert.match(block, /type: boolean/); @@ -64,7 +65,7 @@ const binanceChecks = [ "Wait for optional Binance account-facts readiness stability", "Verify optional Binance account-facts receiver credentials", ]; -const skipGates = steps.filter(step => /inputs\.skip_binance_receiver_checks/.test(step)); +const skipGates = steps.filter(step => /^ if: .*inputs\.skip_binance_receiver_checks/m.test(step)); assert.deepEqual(skipGates.map(step => step.split("\n")[0]), binanceChecks, "only these two network checks may be skipped"); for (const step of skipGates) { const gate = step.match(/^ if: (.+)$/m)?.[1]; @@ -87,6 +88,10 @@ assert.deepEqual([...preflight.matchAll(/^ ([A-Z_]+):/gm)].map(match => mat // separately frozen by the release evidence and the required ancestry check. const approved = "e2aab576d6874feeaf87df3411411ad04ee02d4f"; const approvedTree = "968b323ca75ee48d0c92bfe0b7f4110c3d27df72"; +const frontend = "456853b6655e05bd18ede58cdab54bd0b02a4a34"; +const frontendTree = "a98498a60753cadc7b010f2e9d9c5a5e34f812f4"; +const frontendBlob = "e5c9239bd1da99f0272464a23118c2784926d923"; +const assetPath = "web/strategy-switch-console/v2_asset_map.js"; const control = "0041b6beb2c4c319b4605eb34f5a721d46bec799"; const selectionStep = steps.find(step => step.startsWith("Select reviewed historical main source\n")); assert.ok(selectionStep); @@ -99,8 +104,12 @@ const fakeGit = `git() { case "$*" in 'rev-parse HEAD') if [ -f "$SELECTED" ]; then printf '%s' "$FAKE_SELECTED_HEAD"; else printf '%s' "$FAKE_CONTROL_HEAD"; fi ;; 'cat-file -e ${approved}^{commit}') return "$FAKE_OBJECT_EXIT" ;; + 'cat-file -e ${frontend}^{commit}') return "$FAKE_FRONTEND_OBJECT_EXIT" ;; 'merge-base --is-ancestor ${approved} ${control}') return "$FAKE_ANCESTRY_EXIT" ;; + 'merge-base --is-ancestor ${frontend} ${control}') return "$FAKE_FRONTEND_ANCESTRY_EXIT" ;; 'rev-parse ${approved}^{tree}'|'rev-parse HEAD^{tree}') printf '%s' "$FAKE_TREE" ;; + 'rev-parse ${frontend}^{tree}') printf '%s' "$FAKE_FRONTEND_TREE" ;; + 'rev-parse ${frontend}:${assetPath}') printf '%s' "$FAKE_FRONTEND_BLOB" ;; '-c advice.detachedHead=false checkout --detach ${approved}') touch "$SELECTED" ;; *) return 91 ;; esac @@ -112,7 +121,9 @@ function sourceCase(delta, expected) { const trace = join(temp, "trace"), selected = join(temp, "selected"); const env = { PATH: process.env.PATH, GITHUB_REF: "refs/heads/main", GITHUB_SHA: control, RELEASE_SOURCE_SHA: approved, TRACE: trace, SELECTED: selected, FAKE_CONTROL_HEAD: control, - FAKE_SELECTED_HEAD: approved, FAKE_TREE: approvedTree, FAKE_OBJECT_EXIT: "0", FAKE_ANCESTRY_EXIT: "0", ...delta }; + FAKE_SELECTED_HEAD: approved, FAKE_TREE: approvedTree, FAKE_OBJECT_EXIT: "0", FAKE_ANCESTRY_EXIT: "0", + RELEASE_FRONTEND_ASSETS: "false", SYNC_SECRETS: "false", SYNC_STRATEGY_PROFILES: "false", SKIP_BINANCE_RECEIVER_CHECKS: "true", + FAKE_FRONTEND_OBJECT_EXIT: "0", FAKE_FRONTEND_ANCESTRY_EXIT: "0", FAKE_FRONTEND_TREE: frontendTree, FAKE_FRONTEND_BLOB: frontendBlob, ...delta }; const result = spawnSync("bash", ["-c", fakeGit + selectShell], { env, encoding: "utf8" }); assert.equal(result.status === 0, expected, result.stderr); assert.ok(!result.stdout.includes("PRIVATE_SOURCE_SENTINEL") && !result.stderr.includes("PRIVATE_SOURCE_SENTINEL")); @@ -134,6 +145,165 @@ for (const delta of [{ FAKE_CONTROL_HEAD: "b".repeat(40) }, { FAKE_OBJECT_EXIT: assert.equal(sourceCase(delta, false).selected, false, "object/control/ancestry/tree rejection precedes selection"); } assert.equal(sourceCase({ FAKE_SELECTED_HEAD: "b".repeat(40) }, false).selected, true, "verify the actual selected checkout too"); +assert.equal(sourceCase({ RELEASE_FRONTEND_ASSETS: "true" }, true).selected, true); +for (const delta of [{ RELEASE_SOURCE_SHA: "" }, { SYNC_SECRETS: "true" }, { SYNC_STRATEGY_PROFILES: "true" }, + { SKIP_BINANCE_RECEIVER_CHECKS: "false" }, { GITHUB_REF: "refs/heads/unapproved" }]) { + assert.deepEqual(sourceCase({ RELEASE_FRONTEND_ASSETS: "true", ...delta }, false).calls, [], "invalid frontend modes reject before Git/project code"); +} +assert.deepEqual(sourceCase({ RELEASE_FRONTEND_ASSETS: "true\n" }, false).calls, []); +for (const delta of [{ FAKE_FRONTEND_OBJECT_EXIT: "1" }, { FAKE_FRONTEND_ANCESTRY_EXIT: "1" }, + { FAKE_FRONTEND_TREE: "a".repeat(40) }, { FAKE_FRONTEND_BLOB: "a".repeat(40) }]) { + assert.equal(sourceCase({ RELEASE_FRONTEND_ASSETS: "true", ...delta }, false).selected, false); +} + +// Execute the actual overlay shell. Only Git metadata/working-diff boundaries +// are synthetic; hash-object validates the real reviewed map bytes. +const overlayStep = steps.find(step => step.startsWith("Overlay fixed reviewed frontend assets\n")); +assert.ok(overlayStep); +assert.match(overlayStep, /^ if: inputs\.release_frontend_assets$/m); +assert.ok(deploy.indexOf("Build config & assets") < deploy.indexOf("Overlay fixed reviewed frontend assets")); +assert.ok(deploy.indexOf("Overlay fixed reviewed frontend assets") < deploy.indexOf("Validate fixed backend with reviewed frontend")); +assert.match(deploy, /Validate fixed backend with reviewed frontend[\s\S]*?node tests\/console_v2_assets_validation\.mjs\n node tests\/console_v2_worker_validation\.mjs/); +const overlayShell = shellBody(overlayStep), reviewedMap = readFileSync(new URL("../" + assetPath, import.meta.url)); +const fakeOverlayGit = `git() { + case "$*" in + 'rev-parse HEAD') printf '%s' "$FAKE_HEAD" ;; + 'rev-parse HEAD^{tree}') printf '%s' "$FAKE_TREE" ;; + 'rev-parse ${frontend}^{tree}') printf '%s' "$FAKE_FRONTEND_TREE" ;; + 'show ${frontend}:${assetPath}') cat "$FROZEN_MAP" ;; + hash-object*) command git "$@" ;; + 'diff --name-only ${approved}') printf '%s' "$FAKE_DIFF" ;; + 'ls-files --others --exclude-standard') printf '%s' "$FAKE_UNTRACKED" ;; + *) return 91 ;; + esac +};\n`; +let overlayCases = 0; +function overlayCase(delta = {}, expected = true, bytes = reviewedMap) { + const temp = mkdtempSync(join(tmpdir(), "console-frontend-overlay-")); + try { + const dest = join(temp, assetPath), frozen = join(temp, "frozen-map"); + mkdirSync(join(temp, "web/strategy-switch-console"), { recursive: true }); + writeFileSync(dest, "old synthetic map"); writeFileSync(frozen, bytes); + const env = { PATH: process.env.PATH, GITHUB_REF: "refs/heads/main", FAKE_HEAD: approved, FAKE_TREE: approvedTree, + FAKE_FRONTEND_TREE: frontendTree, FAKE_DIFF: assetPath, FAKE_UNTRACKED: "", FROZEN_MAP: frozen, ...delta }; + const result = spawnSync("bash", ["-c", fakeOverlayGit + overlayShell], { cwd: temp, env, encoding: "utf8" }); + assert.equal(result.status === 0, expected, result.stderr); + if (expected) assert.deepEqual(readFileSync(dest), reviewedMap); + overlayCases++; + } finally { rmSync(temp, { recursive: true, force: true }); } +} +overlayCase(); +for (const delta of [{ GITHUB_REF: "refs/heads/unapproved" }, { FAKE_HEAD: frontend }, { FAKE_TREE: frontendTree }, + { FAKE_FRONTEND_TREE: approvedTree }, { FAKE_DIFF: assetPath + "\nweb/strategy-switch-console/worker.js" }, + { FAKE_DIFF: "" }, { FAKE_UNTRACKED: "unreviewed-runtime.js" }]) overlayCase(delta, false); +overlayCase({}, false, Buffer.concat([reviewedMap, Buffer.from("\n// corrupt synthetic input")])); + +// Run the actual config renderer with only synthetic configuration. In frontend +// mode no local vars may overwrite remote flags; all other TOML stays the same. +const prepareStep = steps.find(step => step.startsWith("Prepare Wrangler config\n")); +const preparePython = prepareStep.match(/python3 - <<'PY'\n([\s\S]*?)\n PY/)[1].split("\n").map(line => line.slice(10)).join("\n"); +const exampleConfig = readFileSync(new URL("../web/strategy-switch-console/wrangler.toml.example", import.meta.url), "utf8"); +let configCases = 0; +const renderedConfigs = []; +for (const frontendMode of ["false", "true"]) { + const temp = mkdtempSync(join(tmpdir(), "console-frontend-config-")); + try { + writeFileSync(join(temp, "wrangler.toml.example"), exampleConfig); + const result = spawnSync("python3", ["-c", preparePython], { env: { PATH: process.env.PATH, + WORKER_DIR: temp, RELEASE_FRONTEND_ASSETS: frontendMode, STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID: "a".repeat(32), + ACCOUNT_FACTS_READ_MODEL_ENABLED: "true", ACCOUNT_FACTS_SYNC_TOKEN: "SYNTHETIC_CONFIG_SENTINEL", RUNTIME_DAILY_READ_MODEL_ENABLED: "false" }, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const text = readFileSync(join(temp, "wrangler.toml"), "utf8"); + const parsed = spawnSync("python3", ["-c", "import json,sys,tomllib;print(json.dumps(tomllib.loads(sys.stdin.read())))"], { input: text, encoding: "utf8" }); + assert.equal(parsed.status, 0, parsed.stderr); + renderedConfigs.push(JSON.parse(parsed.stdout)); + if (frontendMode === "true") { + assert.match(text, /\[vars\]\n\n\[triggers\]/); + assert.doesNotMatch(text, /ACCOUNT_FACTS_READ_MODEL_ENABLED =/); + } else assert.match(text, /ACCOUNT_FACTS_READ_MODEL_ENABLED = "true"/); + assert.ok(!result.stdout.includes("SYNTHETIC_CONFIG_SENTINEL") && !result.stderr.includes("SYNTHETIC_CONFIG_SENTINEL")); + configCases++; + } finally { rmSync(temp, { recursive: true, force: true }); } +} +assert.deepEqual(renderedConfigs[1].vars, {}, "no locally rendered flag may override a retained remote var"); +const normalWithoutVars = { ...renderedConfigs[0] }, frontendWithoutVars = { ...renderedConfigs[1] }; +delete normalWithoutVars.vars; delete frontendWithoutVars.vars; +assert.deepEqual(frontendWithoutVars, normalWithoutVars, "namespace, DO migration, trigger and all other configuration stay unchanged"); +const publishStep = steps.find(s => s.startsWith("Publish fixed frontend-only Worker version\n")); +const bundleStep = steps.find(s => s.startsWith("Build frontend-only Worker bundle without publishing\n")); +const normalDeploy = steps.find(s => s.startsWith("Deploy Worker\n")); +assert.match(normalDeploy, /if: \$\{\{ !inputs\.release_frontend_assets \}\}/); +assert.match(normalDeploy, /npx wrangler@4\.106\.0 deploy --config wrangler\.toml\n/); +assert.match(bundleStep, /WRANGLER_SEND_METRICS: "false"/); +assert.match(bundleStep, /deploy --dry-run --config wrangler\.toml --outdir/); +for (const s of [bundleStep,publishStep]) assert.match(s, /^ if: inputs\.release_frontend_assets$/m); +assert.doesNotMatch(publishStep, /npx|triggers deploy|secret put|script-settings|\/schedules|\/routes/); +assert.match(workflow, /group: strategy-switch-console-deploy\n cancel-in-progress: false/); +const releaseScript = publishStep.match(/node --input-type=module <<'JS'\n([\s\S]*?)\n JS/)[1].split("\n").map(l=>l.slice(10)).filter(l=>!l.startsWith("import ")).join("\n"); +const runRelease = new (Object.getPrototypeOf(async function(){}).constructor)("process","fetch","console","readFile","createHash",releaseScript); +const priorVersion="2beea000-cce5-43a5-9255-9724f7318e83", priorDeployment="6107ffd9-4c76-4bc5-be85-a12caf2af766"; +const newVersion="00000000-0000-4000-8000-000000000002", newDeployment="00000000-0000-4000-8000-000000000003", privateRelease="PRIVATE_RELEASE_SENTINEL"; +const remoteBindings=[{name:"STRATEGY_SWITCH_CONFIG",type:"kv_namespace",namespace_id:"b".repeat(32)}, + {name:"STRATEGY_SWITCH_RUNTIME_INSTANCES",type:"durable_object_namespace",namespace_id:"c".repeat(32),class_name:"RuntimeInstances"}, + {name:"FLAG",type:"plain_text",text:privateRelease},{name:"DATA",type:"json",json:{private:privateRelease}},{name:"SECRET",type:"secret_text"}]; +const runtime={compatibility_date:"2026-06-08",compatibility_flags:[],usage_model:"standard",limits:{},migration_tag:"runtime-instances-v1",exports:{RuntimeInstances:{type:"durable-object",storage:"sqlite"}}}; +const resource=etag=>({bindings:structuredClone(remoteBindings),script_runtime:structuredClone(runtime),script:{etag,handlers:["fetch","scheduled"],last_deployed_from:"api"}}); +const wrap=result=>({success:true,errors:[],result}), dep=(id,v)=>({id,strategy:"percentage",versions:[{version_id:v,percentage:100}]}); +const responseSequence=()=>[wrap({deployments:[dep(priorDeployment,priorVersion)]}),wrap({id:priorVersion,resources:resource("old-code")}), + wrap({items:[{id:priorVersion}]}),wrap({id:newVersion}),wrap({id:newVersion,resources:resource("new-code")}), + wrap({deployments:[dep(priorDeployment,priorVersion)]}),wrap({items:[{id:newVersion}]}),wrap(dep(newDeployment,newVersion)),wrap({deployments:[dep(newDeployment,newVersion)]})]; +const apiSequence=[["/deployments?page=1&per_page=10","GET"],[`/versions/${priorVersion}`,"GET"],["/versions?page=1&per_page=10","GET"],["/versions?bindings_inherit=strict","POST"], + [`/versions/${newVersion}`,"GET"],["/deployments?page=1&per_page=10","GET"],["/versions?page=1&per_page=10","GET"],["/deployments?force=false","POST"],["/deployments?page=1&per_page=10","GET"]]; +let releaseCases=0; +async function releaseCase({mutate=()=>{},env={},failAt=-1,responseAt=-1,response,readError=false}={}, expected=true,count=9){ + const responses=responseSequence();mutate(responses);const calls=[],stdout=[],stderr=[]; + const process={env:{GITHUB_REF:"refs/heads/main",CLOUDFLARE_ACCOUNT_ID:"a".repeat(32),CLOUDFLARE_API_TOKEN:privateRelease,RUNNER_TEMP:"/synthetic",STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID:"d".repeat(32),...env},exitCode:0}; + await runRelease(process,async(url,options)=>{const i=calls.length;calls.push({url,options});if(i===failAt)throw Error(privateRelease);if(i===responseAt)return response; + assert.ok(istdout.push(x),error:x=>stderr.push(x)},async path=>{assert.equal(path,"/synthetic/console-frontend-bundle/worker.js");if(readError)throw Error(privateRelease);return Buffer.from("export default { fetch(){} }; // synthetic bundle");},createHash); + assert.equal(process.exitCode===0,expected,stderr.join("\n"));assert.equal(calls.length,count); + assert.ok(!JSON.stringify({stdout,stderr}).includes(privateRelease));assert.ok(!JSON.stringify({stdout,stderr}).includes("a".repeat(32))); + for(const [i,{url,options}] of calls.entries()){ + assert.equal(url,`https://api.cloudflare.com/client/v4/accounts/${"a".repeat(32)}/workers/scripts/qsl-strategy-switch-console`+apiSequence[i][0]); + assert.equal(options.headers["Accept-Encoding"],"identity");assert.equal(options.method,apiSequence[i][1]);assert.equal(options.redirect,"error");assert.ok(options.signal instanceof AbortSignal);assert.equal(options.headers.Authorization,`Bearer ${privateRelease}`); + if(i===3){assert.ok(options.body instanceof FormData);assert.deepEqual([...options.body.keys()],["metadata","worker.js"]);const m=JSON.parse(options.body.get("metadata")); + assert.deepEqual(m.bindings,remoteBindings.map(b=>({type:"inherit",name:b.name}))); + assert.deepEqual(Object.keys(m).sort(),["main_module","bindings","compatibility_date","compatibility_flags","usage_model","limits","annotations"].sort()); + assert.ok(!JSON.stringify(m).includes(privateRelease));assert.ok(!JSON.stringify(m).includes("d".repeat(32)),"Environment namespace must not rebind existing KV");assert.equal(options.headers["Content-Type"],undefined);} + if(i===7)assert.deepEqual(JSON.parse(options.body),{strategy:"percentage",versions:[{version_id:newVersion,percentage:100}],annotations:{"workers/message":"Reviewed frontend only; fixed e2a backend and retained configuration"}}); + } + if(expected){const r=JSON.parse(stdout[0]);assert.equal(r.version_id,newVersion);assert.equal(r.deployment_id,newDeployment);assert.equal(r.bindings_equal,true);assert.equal(r.runtime_equal,true);assert.equal(r.provider_cas,false);} + else{assert.equal(stdout.length,0);assert.equal(JSON.parse(stderr[0]).retry,false);} + releaseCases++;return {calls,stdout,stderr}; +} +await releaseCase();await releaseCase({env:{STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID:"e".repeat(32)}}); // H's namespace drift no longer affects inherited configuration +for(const env of [{GITHUB_REF:"refs/heads/unapproved"},{CLOUDFLARE_API_TOKEN:""},{CLOUDFLARE_ACCOUNT_ID:"bad"}])await releaseCase({env},false,0); +await releaseCase({readError:true},false,2); +for(let i=0;i<9;i++)await releaseCase({failAt:i},false,i+1); +for(const [at,change] of [ + [0,x=>{x.result.deployments[0].id=newDeployment;}],[1,x=>{x.result.id=newVersion;}],[1,x=>{x.result.resources.bindings.push(x.result.resources.bindings[0]);}], + [1,x=>{delete x.result.resources.bindings[0].namespace_id;}],[1,x=>{delete x.result.resources.bindings[1].class_name;}], + [1,x=>{x.result.resources.bindings[0].namespace_id={private:privateRelease};}], + [1,x=>{delete x.result.resources.bindings[2].text;}],[1,x=>{delete x.result.resources.bindings[3].json;}], + [1,x=>{x.result.resources.bindings[4].text=privateRelease;}], + [1,x=>{x.result.resources.script_runtime.unknown=privateRelease;}],[1,x=>{x.result.resources.bindings[0].type="service";}],[1,x=>{x.result.resources.assets={};}], + [2,x=>{x.result.items[0].id=newVersion;}],[3,x=>{x.result.id="untrusted";}], + [4,x=>{x.result.resources.bindings[0].namespace_id="e".repeat(32);}],[4,x=>{x.result.resources.bindings.pop();}],[4,x=>{x.result.resources.bindings[2].text="different";}], + [4,x=>{x.result.resources.script_runtime.compatibility_date="2026-06-09";}],[4,x=>{x.result.resources.script_runtime.limits={cpu_ms:1};}], + [4,x=>{x.result.resources.script_runtime.migration_tag="changed";}],[4,x=>{x.result.resources.script.handlers=["fetch"];}],[4,x=>{x.result.resources.script.etag="old-code";}], + [5,x=>{x.result.deployments[0].versions[0].version_id=newVersion;}],[6,x=>{x.result.items[0].id=priorVersion;}],[7,x=>{x.result.versions[0].percentage=99;}],[8,x=>{x.result.deployments[0].id=priorDeployment;}] +])await releaseCase({mutate:r=>change(r[at])},false,at+1); +for (const corrupt of [b=>{delete b.namespace_id;},b=>{b.namespace_id={private:privateRelease};}]) { + await releaseCase({mutate:r=>{for(const i of [1,4])corrupt(r[i].result.resources.bindings[0]);}},false,2); +} +await releaseCase({mutate:r=>{for(const i of [1,4])delete r[i].result.resources.bindings[1].class_name;}},false,2); +for(const [at,response] of [ + [0,new Response(privateRelease,{status:403})],[3,new Response(privateRelease,{status:500})],[7,new Response(JSON.stringify({success:false,errors:[{code:10220,message:privateRelease}]}),{status:400})], + [1,new Response("{",{headers:{"content-type":"application/json"}})],[1,new Response("x".repeat(524289),{headers:{"content-type":"application/json"}})], + [1,new Response(privateRelease,{headers:{"content-type":"text/html"}})],[1,new Response("{}",{headers:{"content-type":"application/json","content-encoding":"gzip"}})], + [1,new Response("{}",{headers:{"content-type":"application/json","content-length":"524289"}})],[1,new Response("{}",{headers:{"content-type":"application/json","content-length":"1"}})], + [1,new Response(new ReadableStream({pull(c){c.error(Error(privateRelease));}}),{headers:{"content-type":"application/json"}})] +])await releaseCase({responseAt:at,response},false,at+1); assert.match(rollback, /environment: runtime-strategy-switch/); assert.doesNotMatch(rollback, /actions\/checkout|npm ci|worker\.js|secret put|sync-strategy-profiles/); @@ -144,7 +314,7 @@ assert.ok(restoreScript); assert.doesNotMatch(rollback, /npx|wrangler.*rollback|force=true|RUNNER_TEMP/); const RestoreFunction = Object.getPrototypeOf(async function () {}).constructor; const runRestore = new RestoreFunction("process", "fetch", "console", restoreScript); -const restoreTarget = "c88234c2-292a-40d4-b624-500f145e3f7d"; +const restoreTarget = "2beea000-cce5-43a5-9255-9724f7318e83"; const restoreId = "00000000-0000-4000-8000-000000000001"; const restoreSentinel = "PRIVATE_RESTORE_SENTINEL_DO_NOT_OUTPUT"; const restoreBody = { success: true, errors: [], result: { id: restoreId, strategy: "percentage", @@ -173,7 +343,7 @@ async function restoreCase({ env = {}, status = 200, body = restoreBody, raw, he assert.ok(options.signal instanceof AbortSignal); assert.deepEqual(options.headers, { Authorization: `Bearer ${restoreSentinel}`, Accept: "application/json", "Content-Type": "application/json" }); assert.deepEqual(JSON.parse(options.body), { strategy: "percentage", versions: [{ version_id: restoreTarget, percentage: 100 }], - annotations: { "workers/message": "Restore verified pre-release c88234c2 baseline" } }); + annotations: { "workers/message": "Restore verified pre-frontend 2beea000 baseline" } }); } if (expected) assert.deepEqual(JSON.parse(stdout[0]), { status: "restore_response_verified", deployment_id: restoreId, version_id: restoreTarget, percentage: 100, force: false }); else { @@ -186,7 +356,7 @@ async function restoreCase({ env = {}, status = 200, body = restoreBody, raw, he } await restoreCase(); for (const env of [{ GITHUB_REF: "refs/heads/unapproved" }, { GITHUB_REF: "refs/tags/main" }, - { RELEASE_SOURCE_SHA: approved }, { CLOUDFLARE_API_TOKEN: "" }, { CLOUDFLARE_ACCOUNT_ID: "bad" }]) await restoreCase({ env }, false, 0); + { RELEASE_SOURCE_SHA: approved }, { RELEASE_FRONTEND_ASSETS: "true" }, { CLOUDFLARE_API_TOKEN: "" }, { CLOUDFLARE_ACCOUNT_ID: "bad" }]) await restoreCase({ env }, false, 0); // Provider's changed-secret rejection must stop, not trigger Wrangler's force fallback. await restoreCase({ status: 400, body: { success: false, errors: [{ code: 10220, message: restoreSentinel }] } }, false); for (const status of [302, 403, 429, 500]) await restoreCase({ status, raw: restoreSentinel }, false); @@ -342,4 +512,4 @@ const zeroTotal = await failure([{ success: true, result: { deployments: [{ ...d { version_id: versionId, percentage: 0 }, { version_id: deploymentId, percentage: 0 }, ] }] } }]); assert.equal(zeroTotal.checkpoint, "deployment_percentage_total", "all-zero traffic is still invalid"); -console.log(`console_deploy_safety_validation: PASS (manual-only; 16 original write gates; exclusive preflight/deploy/restore; 2 exact Binance gates; ${cases} offline reader cases; ${sourceCases} source cases; ${restoreCases} restore cases)`); +console.log(`console_deploy_safety_validation: PASS (manual-only; 16 original write gates; exclusive preflight/deploy/restore; 2 exact Binance gates; ${cases} offline reader cases; ${sourceCases} source cases; ${restoreCases} restore cases; ${overlayCases} overlay cases; ${configCases} config cases; ${releaseCases} isolated release cases)`);