feat: expose control plane research attention (#277) #157
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Strategy Switch Console | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - ".github/workflows/deploy-strategy-switch-console.yml" | |
| - "platform-config.json" | |
| - "python/scripts/build_config.py" | |
| - "python/scripts/sync_strategy_switch_page_asset.py" | |
| - "web/strategy-switch-console/**" | |
| workflow_dispatch: | |
| inputs: | |
| sync_strategy_profiles: | |
| description: "After deploy, write the bundled strategy profile catalog to KV." | |
| required: true | |
| type: boolean | |
| default: true | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: strategy-switch-console-deploy | |
| cancel-in-progress: false | |
| jobs: | |
| deploy: | |
| runs-on: ubuntu-latest | |
| environment: runtime-strategy-switch | |
| timeout-minutes: 15 | |
| env: | |
| WORKER_DIR: web/strategy-switch-console | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_WRANGLER_CONFIG_TOML: ${{ secrets.CLOUDFLARE_WRANGLER_CONFIG_TOML }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} | |
| STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID: ${{ secrets.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID || vars.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID }} | |
| STRATEGY_SWITCH_CONSOLE_URL: ${{ vars.STRATEGY_SWITCH_CONSOLE_URL }} | |
| STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }} | |
| CONTROL_PLANE_SYNC_TOKEN: ${{ secrets.CONTROL_PLANE_SYNC_TOKEN }} | |
| RESEARCH_TASK_SYNC_TOKEN: ${{ secrets.RESEARCH_TASK_SYNC_TOKEN }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22" | |
| - name: Build config & assets from platform-config.json | |
| run: | | |
| set -euo pipefail | |
| python3 python/scripts/build_platform_config.py | |
| python3 python/scripts/inject_platform_config.py | |
| python3 python/scripts/sync_strategy_switch_page_asset.py | |
| - name: Validate Worker assets | |
| run: | | |
| set -euo pipefail | |
| jq empty web/strategy-switch-console/strategy-profiles.example.json | |
| node --check --input-type=module < web/strategy-switch-console/page_asset.js | |
| node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js | |
| node --check --input-type=module < web/strategy-switch-console/worker.js | |
| - name: Prepare Wrangler config | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] && [ -z "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ]; then | |
| 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 | |
| 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" | |
| fi | |
| if [ -z "${STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID:-}" ]; then | |
| echo "STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID is required so deploy does not drop the STRATEGY_SWITCH_CONFIG binding." >&2 | |
| exit 2 | |
| fi | |
| python3 - <<'PY' | |
| import os | |
| from pathlib import Path | |
| root = Path(os.environ["WORKER_DIR"]) | |
| source = root / "wrangler.toml.example" | |
| target = root / "wrangler.toml" | |
| text = source.read_text(encoding="utf-8") | |
| account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip() | |
| if account_id and "\naccount_id" not in text: | |
| text = text.replace("\n[vars]\n", f"\naccount_id = \"{account_id}\"\n\n[vars]\n", 1) | |
| kv_id = os.environ["STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID"].strip() | |
| commented_kv = "\n".join([ | |
| "# [[kv_namespaces]]", | |
| "# binding = \"STRATEGY_SWITCH_CONFIG\"", | |
| "# id = \"replace-with-cloudflare-kv-namespace-id\"", | |
| "", | |
| ]) | |
| active_kv = "\n".join([ | |
| "[[kv_namespaces]]", | |
| "binding = \"STRATEGY_SWITCH_CONFIG\"", | |
| f"id = \"{kv_id}\"", | |
| "", | |
| ]) | |
| if commented_kv in text: | |
| text = text.replace(commented_kv, active_kv, 1) | |
| elif 'binding = "STRATEGY_SWITCH_CONFIG"' not in text: | |
| text = text.rstrip() + "\n\n" + active_kv | |
| target.write_text(text, encoding="utf-8") | |
| PY | |
| - name: Deploy Worker | |
| working-directory: web/strategy-switch-console | |
| run: npx wrangler@4.106.0 deploy --config wrangler.toml | |
| - name: Sync optional control-plane ingress token | |
| if: env.CONTROL_PLANE_SYNC_TOKEN != '' | |
| 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 research-task ingress token | |
| if: env.RESEARCH_TASK_SYNC_TOKEN != '' | |
| 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 bundled strategy profiles to KV | |
| if: github.event_name != 'workflow_dispatch' || inputs.sync_strategy_profiles | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${STRATEGY_SWITCH_CONSOLE_URL:-}" ]; then | |
| echo "STRATEGY_SWITCH_CONSOLE_URL is required to sync strategy profiles." >&2 | |
| exit 2 | |
| fi | |
| if [ -z "${STRATEGY_SWITCH_SYNC_TOKEN:-}" ]; then | |
| echo "STRATEGY_SWITCH_SYNC_TOKEN or RUNTIME_SETTINGS_GH_TOKEN is required to sync strategy profiles." >&2 | |
| exit 2 | |
| fi | |
| expected_profiles="$(mktemp)" | |
| sync_body="$(mktemp)" | |
| live_body="$(mktemp)" | |
| cleanup() { rm -f "$expected_profiles" "$sync_body" "$live_body"; } | |
| trap cleanup EXIT | |
| expected_count="$(python3 - "$expected_profiles" <<'PY' | |
| import json | |
| import sys | |
| from pathlib import Path | |
| profiles = json.loads(Path("web/strategy-switch-console/strategy-profiles.example.json").read_text()) | |
| if not isinstance(profiles, list): | |
| raise SystemExit("strategy-profiles.example.json must contain a JSON list") | |
| catalog_fields = ( | |
| "profile", | |
| "runtime_enabled", | |
| "lifecycle_stage", | |
| "can_switch_live", | |
| "allowed_execution_modes", | |
| "blocked_live_reason", | |
| ) | |
| def project(item): | |
| projected = {field: item.get(field) for field in catalog_fields} | |
| projected["lifecycle_stage"] = projected["lifecycle_stage"] or "" | |
| projected["allowed_execution_modes"] = projected["allowed_execution_modes"] or [] | |
| projected["blocked_live_reason"] = projected["blocked_live_reason"] or "" | |
| return projected | |
| expected_profiles = sorted((project(item) for item in profiles), key=lambda item: item["profile"]) | |
| Path(sys.argv[1]).write_text( | |
| json.dumps(expected_profiles, ensure_ascii=False, separators=(",", ":")), | |
| encoding="utf-8", | |
| ) | |
| print(len(expected_profiles)) | |
| PY | |
| )" | |
| for attempt in 1 2 3 4 5 6; do | |
| curl --fail --show-error --silent \ | |
| --request POST \ | |
| --header "Authorization: Bearer ${STRATEGY_SWITCH_SYNC_TOKEN}" \ | |
| --header "Content-Type: application/json" \ | |
| --header "Cache-Control: no-cache" \ | |
| --data '{"source":"github-actions"}' \ | |
| --output "$sync_body" \ | |
| "${STRATEGY_SWITCH_CONSOLE_URL%/}/api/internal/sync-strategy-profiles" | |
| python3 -m json.tool "$sync_body" | |
| curl --fail --show-error --silent \ | |
| --header "Cache-Control: no-cache" \ | |
| --header "Pragma: no-cache" \ | |
| --output "$live_body" \ | |
| "${STRATEGY_SWITCH_CONSOLE_URL%/}/api/strategy-profiles?catalog_readback=${GITHUB_SHA}-${attempt}-$(date +%s%N)" | |
| if live_digest="$(python3 - "$expected_profiles" "$live_body" <<'PY' | |
| import hashlib | |
| import json | |
| import sys | |
| expected_profiles = json.load(open(sys.argv[1], encoding="utf-8")) | |
| payload = json.load(open(sys.argv[2], encoding="utf-8")) | |
| profiles = payload.get("strategyProfiles") | |
| if not isinstance(profiles, list): | |
| raise SystemExit("/api/strategy-profiles did not return strategyProfiles list") | |
| catalog_fields = ( | |
| "profile", | |
| "runtime_enabled", | |
| "lifecycle_stage", | |
| "can_switch_live", | |
| "allowed_execution_modes", | |
| "blocked_live_reason", | |
| ) | |
| def project(item): | |
| projected = {field: item.get(field) for field in catalog_fields} | |
| projected["lifecycle_stage"] = projected["lifecycle_stage"] or "" | |
| projected["allowed_execution_modes"] = projected["allowed_execution_modes"] or [] | |
| projected["blocked_live_reason"] = projected["blocked_live_reason"] or "" | |
| return projected | |
| actual_profiles = sorted((project(item) for item in profiles), key=lambda item: item["profile"]) | |
| if actual_profiles != expected_profiles: | |
| expected_by_profile = {item["profile"]: item for item in expected_profiles} | |
| actual_by_profile = {item["profile"]: item for item in actual_profiles} | |
| changed_profiles = sorted( | |
| profile | |
| for profile in expected_by_profile.keys() | actual_by_profile.keys() | |
| if expected_by_profile.get(profile) != actual_by_profile.get(profile) | |
| ) | |
| print( | |
| "Live strategy profile catalog mismatch for: " + ", ".join(changed_profiles or ["unknown"]), | |
| file=sys.stderr, | |
| ) | |
| raise SystemExit(1) | |
| canonical = json.dumps(actual_profiles, ensure_ascii=False, separators=(",", ":")) | |
| print(hashlib.sha256(canonical.encode("utf-8")).hexdigest()) | |
| PY | |
| )"; then | |
| echo "Strategy profile KV sync verified with $expected_count profiles (catalog sha256: $live_digest)." | |
| break | |
| fi | |
| if [ "$attempt" = "6" ]; then | |
| echo "Live strategy profile catalog did not match the expected $expected_count-profile payload." >&2 | |
| exit 2 | |
| fi | |
| echo "Live strategy profile catalog is stale. Waiting for deployed Worker propagation..." >&2 | |
| sleep 5 | |
| done |