diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index cff9c485..08dcf7d1 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -3,6 +3,11 @@ name: Deploy Strategy Switch Console on: workflow_dispatch: inputs: + render_and_compare_only: + description: "Render the ordinary config and compare with live using GET only; never deploy or sync." + required: true + type: boolean + default: false sync_secrets: description: "After deploy, synchronize Worker secrets. Disable to retain deployed secrets." required: true @@ -93,7 +98,7 @@ concurrency: jobs: backend_only_release: if: >- - ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && + ${{ github.event_name == 'workflow_dispatch' && !inputs.render_and_compare_only && github.ref == 'refs/heads/main' && inputs.backend_only_release && !inputs.read_only_preflight && inputs.private_release_check == 'off' && !inputs.external_flow_enable && !inputs.check_explicit_version_inherit && !inputs.diagnose_restore_target && @@ -153,7 +158,7 @@ jobs: # uses only the event's main SHA and runs after the bounded metadata read. needs: preflight if: >- - ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && + ${{ github.event_name == 'workflow_dispatch' && !inputs.render_and_compare_only && github.ref == 'refs/heads/main' && inputs.read_only_preflight && inputs.private_release_check == 'off' && !inputs.external_flow_enable && !inputs.check_explicit_version_inherit && !inputs.diagnose_restore_target && @@ -221,7 +226,7 @@ jobs: [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] preflight: - if: github.event_name == 'workflow_dispatch' && (inputs.read_only_preflight || inputs.check_explicit_version_inherit) + if: github.event_name == 'workflow_dispatch' && !inputs.render_and_compare_only && (inputs.read_only_preflight || inputs.check_explicit_version_inherit) runs-on: ubuntu-latest environment: runtime-strategy-switch timeout-minutes: 5 @@ -653,7 +658,7 @@ jobs: deploy: - if: github.event_name == 'workflow_dispatch' && !inputs.backend_only_release && !inputs.read_only_preflight && !inputs.check_explicit_version_inherit && !inputs.rollback_to_pre_release && inputs.private_release_check == 'off' + if: github.event_name == 'workflow_dispatch' && (inputs.render_and_compare_only || (github.event_name == 'workflow_dispatch' && !inputs.backend_only_release && !inputs.read_only_preflight && !inputs.check_explicit_version_inherit && !inputs.rollback_to_pre_release && inputs.private_release_check == 'off')) runs-on: ubuntu-latest environment: runtime-strategy-switch timeout-minutes: 25 @@ -694,7 +699,29 @@ jobs: with: fetch-depth: 0 + - name: Validate render-only inputs + if: inputs.render_and_compare_only + env: + DISPATCH_INPUTS: ${{ toJSON(inputs) }} + run: | + python3 - <<'PYINPUT' + import json, os + inputs = json.loads(os.environ["DISPATCH_INPUTS"]) + disabled = ("sync_secrets", "sync_strategy_profiles", "external_flow_enable", + "read_only_preflight", "diagnose_restore_target", "check_explicit_version_inherit", + "release_frontend_assets", "backend_only_release", "rollback_to_pre_release") + if (os.environ["GITHUB_REF"] != "refs/heads/main" + or any(inputs.get(key) not in (False, "false") for key in disabled) + or inputs.get("source_sha", "") != "" + or inputs.get("private_release_check") != "off" + or inputs.get("ibkr_candidate_resume") != "off"): + raise SystemExit("render_only_incompatible_inputs") + PYINPUT + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$GITHUB_SHA" + - name: Select reviewed historical main source + if: ${{ !inputs.render_and_compare_only }} env: RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} RELEASE_FRONTEND_ASSETS: ${{ inputs.release_frontend_assets }} @@ -1062,11 +1089,13 @@ jobs: python-version: "3.12" - name: Setup Node.js + if: ${{ !inputs.render_and_compare_only }} uses: actions/setup-node@v6 with: node-version: "22" - name: Build new console and embedded assets + if: ${{ !inputs.render_and_compare_only }} env: CLOUDFLARE_CF_FETCH_ENABLED: "false" npm_config_update_notifier: "false" @@ -1088,13 +1117,14 @@ jobs: node tests/binance_receiver_deploy_acceptance_validation.mjs - name: Build config & assets from platform-config.json + if: ${{ !inputs.render_and_compare_only }} run: | set -euo pipefail 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 && inputs.source_sha != '20dad166be317dd0d57e492ed06e56dfb82b7aef' + if: inputs.release_frontend_assets && inputs.source_sha != '20dad166be317dd0d57e492ed06e56dfb82b7aef' && !inputs.render_and_compare_only run: | set -euo pipefail integrated="0dd2c0b3e73e890bc2c39ab0bae0eeb48c58470a" @@ -1257,6 +1287,7 @@ jobs: printf 'Frontend map: %s; backend source: %s\n' "$blob" "$backend" - name: Validate Worker assets + if: ${{ !inputs.render_and_compare_only }} run: | set -euo pipefail jq empty web/strategy-switch-console/strategy-profiles.example.json @@ -1265,7 +1296,7 @@ jobs: node --check --input-type=module < web/strategy-switch-console/worker.js - name: Validate fixed backend with reviewed frontend - if: inputs.release_frontend_assets + if: inputs.release_frontend_assets && !inputs.render_and_compare_only run: | set -euo pipefail node tests/console_v2_assets_validation.mjs @@ -1279,7 +1310,7 @@ jobs: node tests/native_daily_returns_validation.mjs - name: Validate optional Binance account-facts binding - if: env.BINANCE_ACCOUNT_FACTS_BINDING_JSON != '' + if: env.BINANCE_ACCOUNT_FACTS_BINDING_JSON != '' && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail @@ -1296,6 +1327,7 @@ jobs: - name: Prepare Wrangler config env: + RENDER_AND_COMPARE_ONLY: ${{ inputs.render_and_compare_only }} UX1_RESEARCH_JOB_TOKEN: ${{ secrets.UX1_RESEARCH_JOB_TOKEN }} RELEASE_FRONTEND_ASSETS: ${{ inputs.release_frontend_assets }} run: | @@ -1304,7 +1336,7 @@ jobs: echo "CLOUDFLARE_API_TOKEN or CLOUDFLARE_WRANGLER_CONFIG_TOML is required to deploy the strategy switch console." >&2 exit 2 fi - if [ -n "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ]; then + if [ -n "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ] && [ "${RENDER_AND_COMPARE_ONLY:-false}" != true ]; then mkdir -p "$HOME/.config/.wrangler/config" printf '%s' "$CLOUDFLARE_WRANGLER_CONFIG_TOML" > "$HOME/.config/.wrangler/config/default.toml" chmod 600 "$HOME/.config/.wrangler/config/default.toml" @@ -1507,8 +1539,16 @@ jobs: target.write_text(text, encoding="utf-8") PY + - name: Compare rendered config with live without publishing + if: inputs.render_and_compare_only + run: python3 scripts/console_config_compare.py + + - name: Remove render-only private config + if: always() && inputs.render_and_compare_only + run: rm -f web/strategy-switch-console/wrangler.toml + - name: Verify M0 research-ledger ingress token - if: github.event_name == 'workflow_dispatch' && inputs.sync_secrets + if: github.event_name == 'workflow_dispatch' && inputs.sync_secrets && !inputs.render_and_compare_only env: # Only the Worker deployment receives this copy. The M0 publisher # reads the same value from its separate main-only Environment, @@ -1522,7 +1562,7 @@ jobs: fi - name: Build reviewed website Worker bundle without publishing - if: inputs.release_frontend_assets + if: inputs.release_frontend_assets && !inputs.render_and_compare_only working-directory: web/strategy-switch-console env: WRANGLER_SEND_METRICS: "false" @@ -1531,7 +1571,7 @@ jobs: npx wrangler@4.106.0 deploy --dry-run --config wrangler.toml --outdir "$RUNNER_TEMP/console-frontend-bundle" - name: Publish fixed reviewed website Worker version - if: inputs.release_frontend_assets + if: inputs.release_frontend_assets && !inputs.render_and_compare_only env: RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} IBKR_CANDIDATE_RESUME: ${{ inputs.ibkr_candidate_resume }} @@ -1891,7 +1931,7 @@ jobs: JS - name: Deploy Worker - if: ${{ !inputs.release_frontend_assets }} + if: ${{ !inputs.release_frontend_assets && !inputs.render_and_compare_only }} working-directory: web/strategy-switch-console run: | set -euo pipefail @@ -1899,7 +1939,7 @@ jobs: # Secret writes require an explicit manual opt-in. - name: Sync UX1 research job token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.UX1_PREVIEW_MODE == 'github_actions') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.UX1_PREVIEW_MODE == 'github_actions') && !inputs.render_and_compare_only env: UX1_RESEARCH_JOB_TOKEN: ${{ secrets.UX1_RESEARCH_JOB_TOKEN }} working-directory: web/strategy-switch-console @@ -1908,91 +1948,91 @@ jobs: printf '%s' "$UX1_RESEARCH_JOB_TOKEN" | npx wrangler@4.106.0 secret put UX1_RESEARCH_JOB_TOKEN --config wrangler.toml - name: Sync optional control-plane ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.CONTROL_PLANE_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.CONTROL_PLANE_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$CONTROL_PLANE_SYNC_TOKEN" | npx wrangler@4.106.0 secret put CONTROL_PLANE_SYNC_TOKEN --config wrangler.toml - name: Sync optional AAB validation-only ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.AAB_VALIDATION_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.AAB_VALIDATION_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$AAB_VALIDATION_SYNC_TOKEN" | npx wrangler@4.106.0 secret put AAB_VALIDATION_SYNC_TOKEN --config wrangler.toml - name: Sync optional strategy-health ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.STRATEGY_HEALTH_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.STRATEGY_HEALTH_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$STRATEGY_HEALTH_SYNC_TOKEN" | npx wrangler@4.106.0 secret put STRATEGY_HEALTH_SYNC_TOKEN --config wrangler.toml - name: Sync optional research-task ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RESEARCH_TASK_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RESEARCH_TASK_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$RESEARCH_TASK_SYNC_TOKEN" | npx wrangler@4.106.0 secret put RESEARCH_TASK_SYNC_TOKEN --config wrangler.toml - name: Sync optional research-promotion ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RESEARCH_PROMOTION_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RESEARCH_PROMOTION_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$RESEARCH_PROMOTION_SYNC_TOKEN" | npx wrangler@4.106.0 secret put RESEARCH_PROMOTION_SYNC_TOKEN --config wrangler.toml - name: Sync optional execution-evidence ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.EXECUTION_EVIDENCE_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.EXECUTION_EVIDENCE_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$EXECUTION_EVIDENCE_SYNC_TOKEN" | npx wrangler@4.106.0 secret put EXECUTION_EVIDENCE_SYNC_TOKEN --config wrangler.toml - name: Sync optional reconciliation-recovery ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RECONCILIATION_RECOVERY_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RECONCILIATION_RECOVERY_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$RECONCILIATION_RECOVERY_SYNC_TOKEN" | npx wrangler@4.106.0 secret put RECONCILIATION_RECOVERY_SYNC_TOKEN --config wrangler.toml - name: Sync optional reconciliation-recovery controller token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RECONCILIATION_RECOVERY_CONTROLLER_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.RECONCILIATION_RECOVERY_CONTROLLER_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$RECONCILIATION_RECOVERY_CONTROLLER_TOKEN" | npx wrangler@4.106.0 secret put RECONCILIATION_RECOVERY_CONTROLLER_TOKEN --config wrangler.toml - name: Sync optional account-facts ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.ACCOUNT_FACTS_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.ACCOUNT_FACTS_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$ACCOUNT_FACTS_SYNC_TOKEN" | npx wrangler@4.106.0 secret put ACCOUNT_FACTS_SYNC_TOKEN --config wrangler.toml - name: Sync optional IBKR account-facts ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.IBKR_ACCOUNT_FACTS_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.IBKR_ACCOUNT_FACTS_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$IBKR_ACCOUNT_FACTS_SYNC_TOKEN" | npx wrangler@4.106.0 secret put IBKR_ACCOUNT_FACTS_SYNC_TOKEN --config wrangler.toml - name: Sync optional Schwab account-facts ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.SCHWAB_ACCOUNT_FACTS_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.SCHWAB_ACCOUNT_FACTS_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$SCHWAB_ACCOUNT_FACTS_SYNC_TOKEN" | npx wrangler@4.106.0 secret put SCHWAB_ACCOUNT_FACTS_SYNC_TOKEN --config wrangler.toml - name: Sync optional Firstrade account-facts ingress token - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.FIRSTRADE_ACCOUNT_FACTS_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.FIRSTRADE_ACCOUNT_FACTS_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail printf '%s' "$FIRSTRADE_ACCOUNT_FACTS_SYNC_TOKEN" | npx wrangler@4.106.0 secret put FIRSTRADE_ACCOUNT_FACTS_SYNC_TOKEN --config wrangler.toml - name: Sync optional Binance account-facts credentials - if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '') + if: (github.event_name == 'workflow_dispatch' && inputs.sync_secrets) && (env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '') && !inputs.render_and_compare_only working-directory: web/strategy-switch-console run: | set -euo pipefail @@ -2001,7 +2041,7 @@ jobs: printf '%s' "$BINANCE_ACCOUNT_FACTS_HISTORY_BINDINGS_JSON" | npx wrangler@4.106.0 secret put BINANCE_ACCOUNT_FACTS_HISTORY_BINDINGS_JSON --config wrangler.toml - name: Sync M0 research-ledger ingress token - if: github.event_name == 'workflow_dispatch' && inputs.sync_secrets + if: github.event_name == 'workflow_dispatch' && inputs.sync_secrets && !inputs.render_and_compare_only env: M0_RESEARCH_SYNC_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }} working-directory: web/strategy-switch-console @@ -2010,17 +2050,17 @@ jobs: printf '%s' "$M0_RESEARCH_SYNC_TOKEN" | npx wrangler@4.106.0 secret put M0_RESEARCH_SYNC_TOKEN --config wrangler.toml - name: Wait for optional Binance account-facts readiness stability - if: env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '' && !inputs.skip_binance_receiver_checks + if: env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '' && !inputs.skip_binance_receiver_checks && !inputs.render_and_compare_only env: BINANCE_ACCOUNT_FACTS_DEPLOYMENT_READINESS: "true" run: node scripts/verify_binance_account_facts_receiver.mjs - name: Verify optional Binance account-facts receiver credentials - if: env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '' && !inputs.skip_binance_receiver_checks + if: env.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN != '' && !inputs.skip_binance_receiver_checks && !inputs.render_and_compare_only run: node scripts/verify_binance_account_facts_receiver.mjs - name: Sync bundled strategy profiles to KV - if: github.event_name == 'workflow_dispatch' && inputs.sync_strategy_profiles + if: github.event_name == 'workflow_dispatch' && inputs.sync_strategy_profiles && !inputs.render_and_compare_only run: | set -euo pipefail if [ -z "${STRATEGY_SWITCH_CONSOLE_URL:-}" ]; then @@ -2143,7 +2183,7 @@ jobs: done rollback: - if: github.event_name == 'workflow_dispatch' && !inputs.backend_only_release && !inputs.read_only_preflight && !inputs.check_explicit_version_inherit && inputs.rollback_to_pre_release && !inputs.external_flow_enable && inputs.private_release_check == 'off' + if: github.event_name == 'workflow_dispatch' && !inputs.render_and_compare_only && !inputs.backend_only_release && !inputs.read_only_preflight && !inputs.check_explicit_version_inherit && inputs.rollback_to_pre_release && !inputs.external_flow_enable && inputs.private_release_check == 'off' runs-on: ubuntu-latest environment: runtime-strategy-switch timeout-minutes: 5 diff --git a/python/tests/test_console_config_compare.py b/python/tests/test_console_config_compare.py new file mode 100644 index 00000000..1e14466a --- /dev/null +++ b/python/tests/test_console_config_compare.py @@ -0,0 +1,244 @@ +"""Offline synthetic regression tests; no production credentials or requests.""" +import contextlib +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('console_config_compare', ROOT / 'scripts/console_config_compare.py') +compare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(compare) + + +def fixture(): + config = { + 'name': 'demo-console', 'account_id': 'a' * 32, 'main': 'worker.js', + 'compatibility_date': '2026-06-08', 'workers_dev': True, 'preview_urls': True, + 'vars': {'FEATURE': 'true'}, + 'kv_namespaces': [{'binding': 'CACHE', 'id': 'b' * 32}], + 'durable_objects': {'bindings': [{'name': 'STATE', 'class_name': 'State'}]}, + 'migrations': [{'tag': 'v1', 'new_sqlite_classes': ['State']}], + 'triggers': {'crons': ['*/5 * * * *']}, + } + bindings = [ + {'name': 'FEATURE', 'type': 'plain_text', 'text': 'true'}, + {'name': 'CACHE', 'type': 'kv_namespace', 'namespace_id': 'b' * 32}, + {'name': 'STATE', 'type': 'durable_object_namespace', 'class_name': 'State', 'namespace_id': 'c' * 32}, + {'name': 'PRIVATE_NAME_SENTINEL', 'type': 'secret_text'}, + ] + live = { + 'settings': {'bindings': bindings, 'compatibility_date': '2026-06-08', + 'compatibility_flags': [], 'observability': {'enabled': False}}, + 'version_bindings': copy.deepcopy(bindings), + 'runtime': {'compatibility_date': '2026-06-08', 'compatibility_flags': [], 'migration_tag': 'v1'}, + 'secrets': [{'name': 'PRIVATE_NAME_SENTINEL', 'type': 'secret_text'}], + 'namespaces': [{'id': 'c' * 32, 'class': 'State', 'script': 'demo-console', 'use_sqlite': True}], + 'subdomain': {'enabled': True, 'previews_enabled': True}, + 'schedules': {'schedules': [{'cron': '*/5 * * * *'}]}, + 'routes': [], 'domains': [], + } + return config, live + + +class ComparisonTests(unittest.TestCase): + def setUp(self): + self.config, self.live = fixture() + self.env = patch.dict(os.environ, {'CLOUDFLARE_ACCOUNT_ID': 'a' * 32}) + self.env.start() + self.addCleanup(self.env.stop) + + def rows(self): + return compare.compare(self.config, self.live) + + def assertBlocked(self): + self.assertTrue(any(row['status'] != 'match' for row in self.rows())) + + def mutate_bindings(self, mutation): + mutation(self.live['settings']['bindings']) + mutation(self.live['version_bindings']) + + def assertBindingCheckBlocked(self): + rows = self.rows() + self.assertEqual(next(row for row in rows if row['item'] == 'bindings.active_version')['status'], 'match') + self.assertTrue(any(row['status'] != 'match' and row['item'] != 'bindings.active_version' for row in rows), rows) + + def test_all_match_and_no_private_output(self): + rows = self.rows() + self.assertTrue(all(row['status'] == 'match' for row in rows), rows) + output = json.dumps(rows) + for private in ['PRIVATE_NAME_SENTINEL', 'a' * 32, 'b' * 32, 'c' * 32, 'demo-console']: + self.assertNotIn(private, output) + + def test_each_binding_value_change_or_removal_blocks(self): + for index, field in [(0, 'text'), (1, 'namespace_id'), (2, 'class_name'), (3, 'name')]: + with self.subTest(index=index): + _, self.live = fixture() + self.mutate_bindings(lambda bindings: bindings[index].__setitem__(field, 'PRIVATE_VALUE_SENTINEL')) + self.assertBindingCheckBlocked() + self.assertNotIn('PRIVATE_VALUE_SENTINEL', json.dumps(self.rows())) + _, self.live = fixture() + self.mutate_bindings(lambda bindings: bindings.pop(index)) + self.assertBindingCheckBlocked() + + def test_remote_only_binding_duplicate_and_secret_collision_block(self): + for binding in [ + {'name': 'EXTRA_PRIVATE', 'type': 'plain_text', 'text': 'PRIVATE_VALUE'}, + {'name': 'CACHE', 'type': 'kv_namespace', 'namespace_id': 'b' * 32}, + ]: + with self.subTest(binding=binding['type']): + _, self.live = fixture() + self.mutate_bindings(lambda bindings: bindings.append(dict(binding))) + self.assertBindingCheckBlocked() + self.assertNotIn('EXTRA_PRIVATE', json.dumps(self.rows())) + + def secret_collision(bindings): + bindings[0]['type'] = 'secret_text' + + _, self.live = fixture() + self.mutate_bindings(secret_collision) + self.assertBindingCheckBlocked() + + def test_declared_runtime_settings_require_active_version_values(self): + for key, value in [('usage_model', 'standard'), ('limits', {'cpu_ms': 10})]: + with self.subTest(key=key): + self.config, self.live = fixture() + self.config[key] = value + self.live['settings'][key] = value + rows = {row['item']: row for row in self.rows()} + self.assertEqual(rows['settings.' + key]['status'], 'match') + self.assertIn('runtime.' + key, rows) + self.assertEqual(rows['runtime.' + key]['reason'], 'missing_runtime_setting') + self.assertEqual(rows['runtime.' + key]['status'], 'unreadable') + self.live['runtime'][key] = copy.deepcopy(value) + self.assertTrue(all(row['status'] == 'match' for row in self.rows())) + self.live['runtime'][key] = 'PRIVATE_VALUE_SENTINEL' + self.assertBlocked() + self.assertNotIn('PRIVATE_VALUE_SENTINEL', json.dumps(self.rows())) + + def test_settings_cron_routes_and_inventory_gaps_block(self): + mutations = [ + lambda x: x['settings'].update(compatibility_date='2020-01-01'), + lambda x: x['settings'].update(observability={'enabled': True}), + lambda x: x['settings'].update(PRIVATE_KEY='PRIVATE_VALUE'), + lambda x: x['settings'].update(limits={'cpu_ms': 10}), + lambda x: x['settings'].pop('bindings'), + lambda x: x.pop('secrets'), + lambda x: x['runtime'].pop('migration_tag'), + lambda x: x['subdomain'].update(previews_enabled=False), + lambda x: x['schedules'].update(schedules=[{'cron': '* * * * *'}]), + lambda x: x.update(routes=[{'pattern': 'PRIVATE_ROUTE'}]), + lambda x: x.update(domains=[{'hostname': 'PRIVATE_DOMAIN'}]), + lambda x: x['namespaces'][0].update(use_sqlite=False), + lambda x: x['namespaces'][0].update(script='other-worker'), + lambda x: x['version_bindings'].pop(), + ] + for mutation in mutations: + _, self.live = fixture() + mutation(self.live) + self.assertBlocked() + self.assertNotIn('PRIVATE_', json.dumps(self.rows())) + + def test_unknown_local_configuration_is_not_ignored(self): + self.config['unsafe'] = {'metadata': {'PRIVATE_KEY': 'PRIVATE_VALUE'}} + self.assertBlocked() + + def test_omitted_preview_setting_retains_only_readable_live_boolean(self): + self.config.pop('preview_urls') + for value in (True, False): + self.live['subdomain']['previews_enabled'] = value + self.assertTrue(all(row['status'] == 'match' for row in self.rows())) + self.live['subdomain'].pop('previews_enabled') + self.assertBlocked() + self.live['subdomain']['previews_enabled'] = None + self.assertBlocked() + + def test_exports_are_compared_when_present(self): + self.live['runtime']['exports'] = {'State': {'type': 'durable-object', 'storage': 'sqlite'}, 'default': {'type': 'worker'}} + self.assertTrue(all(row['status'] == 'match' for row in self.rows())) + self.live['runtime']['exports']['State']['storage'] = 'legacy-kv' + self.assertBlocked() + + def test_observation_change_blocks(self): + after = copy.deepcopy(self.live) + after['schedules']['schedules'] = [] + with patch.object(compare, 'snapshot', side_effect=[self.live, after]): + rows = compare.run(self.config, object()) + self.assertEqual(rows[-1]['status'], 'mismatch') + + def test_http_reader_get_only_and_incomplete_inventory(self): + class Response: + status = 200 + def __enter__(self): return self + def __exit__(self, *args): pass + def read(self, limit): return json.dumps(self.body).encode() + class headers: + @staticmethod + def get_content_type(): return 'application/json' + response = Response() + response.body = {'success': True, 'result': []} + reader = compare.Reader('a' * 32, 'PRIVATE_TOKEN') + requests = [] + def opened(request, timeout): + requests.append(request) + return response + with patch.object(reader.opener, 'open', side_effect=opened): + self.assertEqual(reader.get('secrets', 'workers/scripts/demo/secrets'), []) + response.body['result_info'] = {'count': 1, 'total_count': 2, 'total_pages': 2} + with self.assertRaises(compare.Unreadable): + reader.get('secrets', 'workers/scripts/demo/secrets') + self.assertTrue(all(request.method == 'GET' and request.data is None for request in requests)) + + def test_exception_never_prints_secrets(self): + with patch.object(Path, 'read_text', side_effect=ValueError('PRIVATE_TOKEN')), contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(compare.main(), 1) + self.assertNotIn('PRIVATE_TOKEN', out.getvalue()) + self.assertFalse(json.loads(out.getvalue())['all_match']) + + +class WorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.text = (ROOT / '.github/workflows/deploy-strategy-switch-console.yml').read_text() + + def test_dry_run_skips_every_existing_write_and_all_other_jobs(self): + import re + deploy = self.text.split('\n deploy:\n')[1].split('\n rollback:\n')[0] + allowed = {'Checkout', 'Validate render-only inputs', 'Setup Python', 'Prepare Wrangler config', + 'Compare rendered config with live without publishing', 'Remove render-only private config'} + for step in re.split(r'\n - name: ', deploy)[1:]: + name = step.splitlines()[0] + if name not in allowed: + gate = re.search(r'^ if: (.+)$', step, re.M) + self.assertIsNotNone(gate, name) + self.assertIn('!inputs.render_and_compare_only', gate[1], name) + for name, job in re.findall(r'^ (\w+):\n(.*?)(?=^ \w+:\n|\Z)', self.text.split('\njobs:\n')[1], re.M | re.S): + if name != 'deploy': + self.assertIn('!inputs.render_and_compare_only', job.split('runs-on:')[0], name) + + def test_conflicting_inputs_are_rejected_before_renderer(self): + import re + code = re.search(r"python3 - <<'PYINPUT'\n(.*?)\n PYINPUT", self.text, re.S)[1] + code = '\n'.join(line[10:] for line in code.splitlines()) + defaults = dict.fromkeys(['sync_secrets', 'sync_strategy_profiles', 'external_flow_enable', + 'read_only_preflight', 'diagnose_restore_target', 'check_explicit_version_inherit', + 'release_frontend_assets', 'backend_only_release', 'rollback_to_pre_release'], False) + defaults.update(source_sha='', private_release_check='off', ibkr_candidate_resume='off') + for key, value in [(None, None)] + [(k, True) for k, v in defaults.items() if v is False] + [('source_sha', 'old'), ('private_release_check', 'baseline'), ('ibkr_candidate_resume', 'activate')]: + inputs = dict(defaults) + if key: inputs[key] = value + result = subprocess.run([sys.executable, '-c', code], env={'GITHUB_REF': 'refs/heads/main', 'DISPATCH_INPUTS': json.dumps(inputs)}, capture_output=True) + self.assertEqual(result.returncode == 0, key is None, key) + self.assertLess(self.text.index('Validate render-only inputs'), self.text.index('Prepare Wrangler config')) + self.assertIn('[ "${RENDER_AND_COMPARE_ONLY:-false}" != true ]', self.text) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/console_config_compare.py b/scripts/console_config_compare.py new file mode 100644 index 00000000..4485ee75 --- /dev/null +++ b/scripts/console_config_compare.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Compare the ordinary rendered Console config with live; GET only, no raw output. + +Deliberately supports the current template, not arbitrary Wrangler configuration. +Unknown config/settings/binding shapes fail closed instead of guessing defaults. +Secret *values* are never fetched: ordinary deploy retains them, and secret names +and types are checked against the independent secret inventory. +""" +import json +import os +from pathlib import Path +import re +import sys +import tomllib +import urllib.error +import urllib.request + +MISSING = object() +KNOWN_CONFIG = { + 'name', 'main', 'account_id', 'compatibility_date', 'compatibility_flags', + 'workers_dev', 'preview_urls', 'vars', 'kv_namespaces', 'durable_objects', + 'migrations', 'triggers', 'observability', 'limits', 'placement', 'logpush', + 'tail_consumers', 'tags', 'usage_model', +} +SETTING_KEYS = { + 'compatibility_date', 'compatibility_flags', 'usage_model', 'limits', + 'placement', 'logpush', 'observability', 'tail_consumers', 'tags', + 'cache_options', 'streaming_tail_consumers', +} + + +class Unreadable(Exception): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise Unreadable('redirect') + + +class Reader: + def __init__(self, account, token): + self.root = f'https://api.cloudflare.com/client/v4/accounts/{account}/' + self.token = token + self.stage = 'configuration' + self.status = None + self.opener = urllib.request.build_opener(NoRedirect()) + + def get(self, stage, path): + self.stage, self.status = stage, None + request = urllib.request.Request(self.root + path, method='GET', headers={ + 'Authorization': f'Bearer {self.token}', 'Accept': 'application/json', + 'Accept-Encoding': 'identity', + }) + try: + with self.opener.open(request, timeout=30) as response: + self.status = response.status + if response.headers.get_content_type() != 'application/json': + raise Unreadable('content_type') + raw = response.read(2 * 1024 * 1024 + 1) + if len(raw) > 2 * 1024 * 1024: + raise Unreadable('response_limit') + body = json.loads(raw) + except urllib.error.HTTPError as exc: + self.status = exc.code + raise Unreadable('http') from None + if not isinstance(body, dict) or body.get('success') is not True or body.get('errors') not in (None, []): + raise Unreadable('envelope') + # Never accept a truncated listing as a complete inventory. + info = body.get('result_info', {}) + if not isinstance(info, dict) or info.get('cursor') or info.get('cursors', {}).get('after'): + raise Unreadable('pagination') + if info.get('total_pages', 1) > 1 or info.get('total_count', 0) > info.get('count', 0): + raise Unreadable('pagination') + if 'result' not in body: + raise Unreadable('result') + return body['result'] + + +def active(deployments): + items = deployments.get('deployments') if isinstance(deployments, dict) else None + if not isinstance(items, list) or not items: + raise Unreadable('deployment') + entry = items[0] + versions = entry.get('versions') + if (entry.get('strategy') != 'percentage' or not entry.get('id') + or not isinstance(versions, list) or len(versions) != 1 + or versions[0].get('percentage') != 100 + or not re.fullmatch(r'[0-9a-f-]{36}', versions[0].get('version_id', ''))): + raise Unreadable('active_not_single') + return entry['id'], versions[0]['version_id'] + + +def snapshot(reader, name): + script = f'workers/scripts/{name}' + deployment = active(reader.get('deployment', script + '/deployments')) + version = reader.get('version', script + '/versions/' + deployment[1]) + if not isinstance(version, dict) or version.get('id') != deployment[1]: + raise Unreadable('version') + resources = version.get('resources') + if not isinstance(resources, dict) or not isinstance(resources.get('script_runtime'), dict): + raise Unreadable('runtime') + return { + 'deployment': deployment, + 'runtime': resources['script_runtime'], + 'version_bindings': resources.get('bindings'), + 'namespaces': reader.get('namespaces', 'workers/durable_objects/namespaces?per_page=1000'), + 'settings': reader.get('settings', script + '/settings'), + 'schedules': reader.get('schedules', script + '/schedules'), + 'subdomain': reader.get('subdomain', script + '/subdomain'), + 'secrets': reader.get('secrets', script + '/secrets'), + # Same routes endpoint used by pinned Wrangler's fetchWorkerConfig. + 'routes': reader.get('routes', f'workers/services/{name}/environments/production/routes?show_zonename=true'), + 'domains': reader.get('domains', f'workers/domains/records?page=0&per_page=100&service={name}&environment=production'), + } + + +def compare(config, live): + rows = [] + + def row(item, expected=MISSING, actual=MISSING, reason=None): + status = 'unreadable' if reason else ('match' if expected == actual else 'mismatch') + rows.append({'item': item, 'status': status, + 'rendered': 'absent' if expected is MISSING else '', + 'live': 'absent' if actual is MISSING else '', + **({'reason': reason} if reason else {})}) + + for index, _ in enumerate(sorted(set(config) - KNOWN_CONFIG)): + row(f'config.unsupported[{index}]', reason='unsupported_config_key') + # These identify the request target and build entrypoint, not deployed code. + row('target.account', config.get('account_id'), os.environ.get('CLOUDFLARE_ACCOUNT_ID')) + if config.get('main') != 'worker.js': + row('source.main', reason='unsupported_entrypoint') + settings, runtime = live.get('settings'), live.get('runtime') + if not isinstance(settings, dict) or not isinstance(runtime, dict): + row('settings', reason='missing_settings_or_runtime') + return rows + bindings = settings.get('bindings') + if not isinstance(bindings, list): + row('bindings', reason='missing_binding_inventory') + bindings = [] + desired = {} + for name, value in config.get('vars', {}).items(): + desired[name] = {'type': 'plain_text', 'name': name, 'text': value} if isinstance(value, str) else {'type': 'json', 'name': name, 'json': value} + for binding in config.get('kv_namespaces', []): + if set(binding) != {'binding', 'id'} or not re.fullmatch(r'[0-9a-f]{32}', binding.get('id', '')): + row('kv.config', reason='unsupported_kv_config') + desired[binding['binding']] = {'name': binding['binding'], 'type': 'kv_namespace', 'namespace_id': binding['id']} + do_config = config.get('durable_objects', {}) + if set(do_config) != {'bindings'}: + row('durable_objects.config', reason='unsupported_durable_objects_config') + for binding in do_config.get('bindings', []): + if set(binding) != {'name', 'class_name'}: + row('durable_objects.config', reason='unsupported_durable_object_binding') + desired[binding['name']] = dict(binding, type='durable_object_namespace') + row('bindings.active_version', bindings, live.get('version_bindings', MISSING), + 'missing_version_bindings' if not isinstance(live.get('version_bindings'), list) else None) + inventory = live.get('secrets') + secret_names = {} + if not isinstance(inventory, list): + row('secrets', reason='missing_secret_inventory') + else: + for index, secret in enumerate(inventory): + if (not isinstance(secret, dict) or set(secret) != {'name', 'type'} + or secret.get('type') != 'secret_text' or not isinstance(secret.get('name'), str) + or secret['name'] in secret_names): + row(f'secrets[{index}]', reason='unsupported_secret_shape') + else: + secret_names[secret['name']] = secret + seen = set() + for index, binding in enumerate(bindings): + name = binding.get('name') if isinstance(binding, dict) else None + # Only local names may appear in logs. Remote-only names may be private. + label = f'bindings.{name}' if name in desired else f'bindings.remote[{index}]' + if not isinstance(name, str) or name in seen: + row(label, reason='invalid_or_duplicate_binding') + continue + seen.add(name) + if binding.get('type') == 'secret_text' and name not in desired: + row(label + '.retained_secret', secret_names.pop(name, MISSING), binding) + continue + wanted = desired.get(name, MISSING) + if binding.get('type') == 'durable_object_namespace' and wanted is not MISSING: + # Local DO names resolve to an existing namespace. Verify its mapping + # separately; never pretend the opaque namespace id came from TOML. + actual = dict(binding) + namespace = actual.pop('namespace_id', None) + script_name = actual.pop('script_name', config['name']) + row(label + '.script_name', config['name'], script_name) + namespaces = live.get('namespaces') + matches = [entry for entry in namespaces if isinstance(entry, dict) and entry.get('id') == namespace] if isinstance(namespaces, list) else [] + if len(matches) != 1 or not isinstance(namespace, str) or not re.fullmatch(r'[0-9a-f]{32}', namespace): + row(label + '.namespace_id', reason='unreadable_namespace_identity') + else: + entry = matches[0] + row(label + '.namespace_class', wanted.get('class_name'), entry.get('class', MISSING)) + row(label + '.namespace_script', config['name'], entry.get('script', MISSING)) + row(label + '.namespace_sqlite', True, entry.get('use_sqlite', MISSING), + 'unreadable_storage_type' if 'use_sqlite' not in entry else None) + row(label, wanted, actual) + else: + row(label, wanted, binding) + for name in sorted(set(desired) - seen): + row('bindings.' + name, desired[name], MISSING) + for index, _ in enumerate(secret_names): + row(f'secrets.unmatched[{index}]', reason='secret_inventory_disagrees') + # Missing required fields are unknown, not empty defaults. + for key in ('compatibility_date', 'compatibility_flags'): + expected = config.get(key, [] if key == 'compatibility_flags' else MISSING) + row('settings.' + key, expected, settings.get(key, MISSING), + 'missing_setting' if key not in settings else None) + row('runtime.' + key, settings.get(key, MISSING), runtime.get(key, MISSING), + 'missing_runtime_setting' if key not in runtime else None) + # Known implicit upload behavior; other provider defaults are NOT guessed. + expected_settings = {'observability': {'enabled': False}} + for key in SETTING_KEYS - {'compatibility_date', 'compatibility_flags'}: + expected = config.get(key, expected_settings.get(key, MISSING)) + actual = settings.get(key, MISSING) + # Pinned ordinary deploy omits these settings when not configured: + # logpush/tail consumers are omitted from PATCH, usage_model is never + # uploaded. No secret sync or special release mode runs here. + if key in {'logpush', 'tail_consumers', 'usage_model'} and expected is MISSING and actual is not MISSING: + row('settings.' + key + '.retained', actual, actual) + elif expected is MISSING and actual is not MISSING: + row('settings.' + key, expected, actual, 'undeclared_effective_setting') + else: + row('settings.' + key, expected, actual) + for index, _ in enumerate(sorted(set(settings) - SETTING_KEYS - {'bindings'})): + row(f'settings.unsupported[{index}]', reason='unsupported_live_setting') + migrations = config.get('migrations', []) + if len(migrations) != 1 or set(migrations[0]) != {'tag', 'new_sqlite_classes'}: + row('migrations', reason='unsupported_migrations') + else: + row('migrations.tag', migrations[0]['tag'], runtime.get('migration_tag', MISSING), + 'missing_migration_tag' if 'migration_tag' not in runtime else None) + row('migrations.sqlite_classes', sorted(migrations[0]['new_sqlite_classes']), + sorted(b['class_name'] for b in do_config.get('bindings', []))) + if 'exports' in runtime: + exports = runtime['exports'] + expected_exports = {name: {'type': 'durable-object', 'storage': 'sqlite', 'state': 'created'} + for name in migrations[0]['new_sqlite_classes']} + expected_exports['default'] = {'type': 'worker', 'state': 'created'} + if isinstance(exports, dict) and all(isinstance(value, dict) for value in exports.values()): + normalized = {name: dict(value, state=value.get('state', 'created')) for name, value in exports.items()} + row('runtime.exports', expected_exports, normalized) + else: + row('runtime.exports', reason='unsupported_runtime_exports') + for key in sorted({'usage_model', 'limits'}): + if key in config and key not in runtime: + row('runtime.' + key, reason='missing_runtime_setting') + for key in sorted(set(runtime) - {'compatibility_date', 'compatibility_flags', 'migration_tag', 'exports'}): + label = key if key in SETTING_KEYS else 'unsupported' + row('runtime.' + label, settings.get(key, MISSING), runtime[key], + 'unmapped_runtime_setting' if key not in SETTING_KEYS or key not in settings else None) + subdomain = live.get('subdomain') + if not isinstance(subdomain, dict): + row('subdomain', reason='missing_subdomain') + else: + for key, local in [('enabled', 'workers_dev'), ('previews_enabled', 'preview_urls')]: + actual = subdomain.get(key, MISSING) + # Pinned Wrangler omits previews_enabled when preview_urls is not + # configured; the API retains an existing setting, even when it + # differs from workers_dev. Only a readable boolean may be retained. + retained = local == 'preview_urls' and local not in config + expected = actual if retained else config.get(local, MISSING) + row('subdomain.' + key + ('.retained' if retained else ''), expected, actual, + 'unreadable_subdomain_setting' if not isinstance(actual, bool) else None) + for index, _ in enumerate(set(subdomain) - {'enabled', 'previews_enabled'}): + row(f'subdomain.unsupported[{index}]', reason='unsupported_subdomain_setting') + schedules = live.get('schedules') + crons = schedules.get('schedules') if isinstance(schedules, dict) else None + if (not isinstance(crons, list) or any(not isinstance(c, dict) or not isinstance(c.get('cron'), str) + or set(c) - {'cron', 'created_on', 'modified_on'} for c in crons) + or set(schedules) != {'schedules'} or set(config.get('triggers', {})) != {'crons'}): + row('triggers.crons', reason='unreadable_schedules') + else: + row('triggers.crons', sorted(config['triggers']['crons']), sorted(c['cron'] for c in crons)) + for key in ('routes', 'domains'): + actual = live.get(key) + row(key, [], actual, 'unreadable_route_inventory' if not isinstance(actual, list) else None) + return rows + + +def run(config, reader): + before = snapshot(reader, config['name']) + rows = compare(config, before) + after = snapshot(reader, config['name']) + rows.append({'item': 'observation.stable', 'status': 'match' if before == after else 'mismatch'}) + return rows + + +def main(): + reader = None + try: + config = tomllib.loads(Path('web/strategy-switch-console/wrangler.toml').read_text()) + account, token = os.environ.get('CLOUDFLARE_ACCOUNT_ID', ''), os.environ.get('CLOUDFLARE_API_TOKEN', '') + if (not re.fullmatch(r'[0-9a-f]{32}', account) or not token + or not re.fullmatch(r'[a-z0-9-]+', config.get('name', '')) + or config.get('account_id') != account): + raise Unreadable('configuration') + reader = Reader(account, token) + rows = run(config, reader) + except Exception: + # Exception messages, request URLs, TOML parse errors and response bodies + # can contain private data. Only our fixed stage and HTTP status escape. + rows = [{'item': reader.stage if reader else 'configuration', 'status': 'unreadable', + 'reason': 'request_or_schema_unreadable', 'http_status': reader.status if reader else None}] + passed = bool(rows) and all(row['status'] == 'match' for row in rows) + report = {'read_only': True, 'writes': 0, 'all_match': passed, 'items': rows} + print(json.dumps(report, ensure_ascii=True, indent=2)) + return 0 if passed else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index c9d741de..d0ecd195 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -63,6 +63,7 @@ for (const step of writers) { for (const optedIn of [false, true]) { const expression = gate.replaceAll("github.event_name", JSON.stringify(event)) .replaceAll(`inputs.${input}`, String(optedIn)) + .replaceAll("inputs.render_and_compare_only", "false") .replace(/env\.[A-Z0-9_]+/g, "'github_actions'"); assert.match(expression, /^[\w\s'"=!&|().-]+$/, "only the known gate grammar is evaluated"); assert.equal(Function(`return (${expression})`)(), event === "workflow_dispatch" && optedIn, `${name}: ${event}, ${input}=${optedIn}`); @@ -130,6 +131,7 @@ assert.deepEqual([...preflight.matchAll(/^ ([A-Z_]+):/gm)].map(match => mat const schemaStep = steps.find(s => s.startsWith("Read fixed frontend baseline schema categories\n")); assert.ok(schemaStep && preflight.includes("Read fixed frontend baseline schema categories")); assert.match(schemaStep, /^ if: inputs\.release_frontend_assets$/m); +assert.equal(evaluate(preflightGate, { read_only_preflight: true, release_frontend_assets: true, render_and_compare_only: true }), false, "render-only skips the schema diagnostic's entire preflight job"); const schemaScript = schemaStep.match(/node --input-type=module <<'JS'\n([\s\S]*?)\n JS/)[1].split("\n").map(l=>l.slice(10)).join("\n"); const runSchema = new (Object.getPrototypeOf(async function(){}).constructor)("process","fetch","console",schemaScript); let schemaCases = 0; @@ -410,7 +412,7 @@ for (const delta of [ // 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 && inputs\.source_sha != '20dad166be317dd0d57e492ed06e56dfb82b7aef'$/m); +assert.match(overlayStep, /^ if: inputs\.release_frontend_assets && inputs\.source_sha != '20dad166be317dd0d57e492ed06e56dfb82b7aef' && !inputs\.render_and_compare_only$/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/); @@ -565,11 +567,11 @@ assert.deepEqual(frontendWithoutVars, normalWithoutVars, "namespace, DO migratio const publishStep = steps.find(s => s.startsWith("Publish fixed reviewed website Worker version\n")); const bundleStep = steps.find(s => s.startsWith("Build reviewed website 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, /if: \$\{\{ !inputs\.release_frontend_assets && !inputs\.render_and_compare_only \}\}/); 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); +for (const s of [bundleStep,publishStep]) assert.match(s, /^ if: inputs\.release_frontend_assets && !inputs\.render_and_compare_only$/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"); diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index 75af6adc..5a8afe4a 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -300,6 +300,43 @@ Store the client ID and client secret in Worker secrets. ## Deploy With Wrangler +The manual deploy workflow's default-off `render_and_compare_only` input uses +its ordinary `Prepare Wrangler config` step and the same GitHub Environment. +Comparison requires both `CLOUDFLARE_API_TOKEN` and an explicit +`CLOUDFLARE_ACCOUNT_ID` in that Environment. `CLOUDFLARE_WRANGLER_CONFIG_TOML` +alone or an omitted account ID is insufficient; validation fails before the +first GET. +Use current `main`, an empty `source_sha`, `private_release_check=off`, +`ibkr_candidate_resume=off`, and disable all sync, release, rollback and other +preflight inputs. This mode skips builds, Wrangler commands, credential-file +installation, receiver checks and every write step, even with conflicting +inputs (conflicts fail validation). It only renders the private TOML, performs +Cloudflare GETs, prints a value-redacted per-item comparison and removes the +rendered file. Neither raw API responses nor config files are uploaded. + +The comparison checks settings, active-version bindings, variables, KV IDs, +Durable Object namespace identity/storage, migration tag, cron, subdomain, +routes, custom domains and secret name/type inventory. Secret values remain +unreadable and are retained by ordinary deployment; they are never compared +against GitHub secret values. Remote-only binding names are also redacted. +Unknown fields, unsupported template shapes, incomplete inventories, read errors, +multiple active versions or changed observations produce a nonzero exit code. +Undeclared provider settings are reported as unknown unless the pinned Wrangler +version has a verified preservation rule; absence is not an invented default. +An omitted `preview_urls` retains the readable live boolean, rather than copying +`workers_dev`; an unreadable preview setting still blocks the comparison. +The local `main` filename is a build input, not a deployed code equality check. +Two matching observations are not an atomic lock or permission to deploy. After +a clean result, any separately authorized standard deployment must use the same +reviewed source and unchanged Environment inputs; a mismatch or unreadable item +requires stopping, not changing production to make the comparison pass. + +References: [Cloudflare configuration source of truth](https://developers.cloudflare.com/workers/wrangler/configuration/#source-of-truth), +[script settings API](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/script_and_version_settings/methods/get/), +[version API](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/versions/methods/get/), +[Durable Object namespaces API](https://developers.cloudflare.com/api/resources/durable_objects/subresources/namespaces/methods/list/), +and [Version URL settings](https://developers.cloudflare.com/workers/versions-and-deployments/version-urls/). + The manual **Diagnose Active Console Module** workflow performs a read-only inspection of the single 100%-active Worker version. Supply the full reviewed main commit SHA; the workflow checks the checkout and current remote main before