Skip to content

Commit 17d957a

Browse files
committed
Automate strategy switch console deploy
1 parent 10945b2 commit 17d957a

6 files changed

Lines changed: 261 additions & 2 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: Deploy Strategy Switch Console
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- ".github/workflows/deploy-strategy-switch-console.yml"
8+
- "scripts/sync_strategy_switch_page_asset.py"
9+
- "web/strategy-switch-console/**"
10+
workflow_dispatch:
11+
inputs:
12+
sync_strategy_profiles:
13+
description: "After deploy, write the bundled strategy profile catalog to KV."
14+
required: true
15+
type: boolean
16+
default: true
17+
18+
permissions:
19+
contents: read
20+
21+
concurrency:
22+
group: strategy-switch-console-deploy
23+
cancel-in-progress: false
24+
25+
jobs:
26+
deploy:
27+
runs-on: ubuntu-latest
28+
environment: runtime-strategy-switch
29+
timeout-minutes: 15
30+
env:
31+
WORKER_DIR: web/strategy-switch-console
32+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
33+
CLOUDFLARE_WRANGLER_CONFIG_TOML: ${{ secrets.CLOUDFLARE_WRANGLER_CONFIG_TOML }}
34+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }}
35+
STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID: ${{ secrets.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID || vars.STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID }}
36+
STRATEGY_SWITCH_CONSOLE_URL: ${{ vars.STRATEGY_SWITCH_CONSOLE_URL }}
37+
STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }}
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v6
41+
42+
- name: Setup Python
43+
uses: actions/setup-python@v6
44+
with:
45+
python-version: "3.12"
46+
47+
- name: Setup Node.js
48+
uses: actions/setup-node@v6
49+
with:
50+
node-version: "22"
51+
52+
- name: Regenerate bundled assets
53+
run: |
54+
set -euo pipefail
55+
python3 scripts/sync_strategy_switch_page_asset.py
56+
git diff --exit-code -- web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js
57+
58+
- name: Validate Worker assets
59+
run: |
60+
set -euo pipefail
61+
jq empty web/strategy-switch-console/strategy-profiles.example.json
62+
node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs
63+
sed -n '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs
64+
node --check --input-type=module < web/strategy-switch-console/page_asset.js
65+
node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js
66+
node --check --input-type=module < web/strategy-switch-console/worker.js
67+
68+
- name: Prepare Wrangler config
69+
run: |
70+
set -euo pipefail
71+
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] && [ -z "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ]; then
72+
echo "CLOUDFLARE_API_TOKEN or CLOUDFLARE_WRANGLER_CONFIG_TOML is required to deploy the strategy switch console." >&2
73+
exit 2
74+
fi
75+
if [ -n "${CLOUDFLARE_WRANGLER_CONFIG_TOML:-}" ]; then
76+
mkdir -p "$HOME/.config/.wrangler/config"
77+
printf '%s' "$CLOUDFLARE_WRANGLER_CONFIG_TOML" > "$HOME/.config/.wrangler/config/default.toml"
78+
chmod 600 "$HOME/.config/.wrangler/config/default.toml"
79+
fi
80+
if [ -z "${STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID:-}" ]; then
81+
echo "STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID is required so deploy does not drop the STRATEGY_SWITCH_CONFIG binding." >&2
82+
exit 2
83+
fi
84+
python3 - <<'PY'
85+
import os
86+
from pathlib import Path
87+
88+
root = Path(os.environ["WORKER_DIR"])
89+
source = root / "wrangler.toml.example"
90+
target = root / "wrangler.toml"
91+
text = source.read_text(encoding="utf-8")
92+
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
93+
if account_id and "\naccount_id" not in text:
94+
text = text.replace("\n[vars]\n", f"\naccount_id = \"{account_id}\"\n\n[vars]\n", 1)
95+
96+
kv_id = os.environ["STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID"].strip()
97+
commented_kv = "\n".join([
98+
"# [[kv_namespaces]]",
99+
"# binding = \"STRATEGY_SWITCH_CONFIG\"",
100+
"# id = \"replace-with-cloudflare-kv-namespace-id\"",
101+
"",
102+
])
103+
active_kv = "\n".join([
104+
"[[kv_namespaces]]",
105+
"binding = \"STRATEGY_SWITCH_CONFIG\"",
106+
f"id = \"{kv_id}\"",
107+
"",
108+
])
109+
if commented_kv in text:
110+
text = text.replace(commented_kv, active_kv, 1)
111+
elif 'binding = "STRATEGY_SWITCH_CONFIG"' not in text:
112+
text = text.rstrip() + "\n\n" + active_kv
113+
target.write_text(text, encoding="utf-8")
114+
PY
115+
116+
- name: Deploy Worker
117+
working-directory: web/strategy-switch-console
118+
run: npx wrangler@latest deploy --config wrangler.toml
119+
120+
- name: Sync bundled strategy profiles to KV
121+
if: github.event_name != 'workflow_dispatch' || inputs.sync_strategy_profiles
122+
run: |
123+
set -euo pipefail
124+
if [ -z "${STRATEGY_SWITCH_CONSOLE_URL:-}" ]; then
125+
echo "STRATEGY_SWITCH_CONSOLE_URL is required to sync strategy profiles." >&2
126+
exit 2
127+
fi
128+
if [ -z "${STRATEGY_SWITCH_SYNC_TOKEN:-}" ]; then
129+
echo "STRATEGY_SWITCH_SYNC_TOKEN or RUNTIME_SETTINGS_GH_TOKEN is required to sync strategy profiles." >&2
130+
exit 2
131+
fi
132+
curl --fail --show-error --silent \
133+
--request POST \
134+
--header "Authorization: Bearer ${STRATEGY_SWITCH_SYNC_TOKEN}" \
135+
--header "Content-Type: application/json" \
136+
--data '{"source":"github-actions"}' \
137+
"${STRATEGY_SWITCH_CONSOLE_URL%/}/api/internal/sync-strategy-profiles" \
138+
| python3 -m json.tool

tests/strategy_switch_worker_validation.mjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ const unauthorizedSyncResponse = await worker.fetch(
186186
assert.equal(unauthorizedSyncResponse.status, 401);
187187
assert.match((await unauthorizedSyncResponse.json()).error, /internal sync token is invalid/);
188188

189+
const unauthorizedProfileSyncResponse = await worker.fetch(
190+
new Request("https://switch.example/api/internal/sync-strategy-profiles", {
191+
method: "POST",
192+
headers: { "Content-Type": "application/json" },
193+
body: "{}",
194+
}),
195+
{ STRATEGY_SWITCH_SYNC_TOKEN: "test-sync-token" },
196+
);
197+
assert.equal(unauthorizedProfileSyncResponse.status, 401);
198+
assert.match((await unauthorizedProfileSyncResponse.json()).error, /internal sync token is invalid/);
199+
189200
assert.equal(
190201
await __test.withTimeout(new Promise(() => {}), 1, "fallback"),
191202
"fallback",
@@ -347,6 +358,48 @@ assert.deepEqual(kvUnboundSyncBody.account_options_sync, {
347358
skipped: true,
348359
});
349360

361+
const kvUnboundProfileSyncResponse = await worker.fetch(
362+
new Request("https://switch.example/api/internal/sync-strategy-profiles", {
363+
method: "POST",
364+
headers: {
365+
Authorization: "Bearer test-sync-token",
366+
"Content-Type": "application/json",
367+
},
368+
body: "{}",
369+
}),
370+
{ STRATEGY_SWITCH_SYNC_TOKEN: "test-sync-token" },
371+
);
372+
assert.equal(kvUnboundProfileSyncResponse.status, 200);
373+
const kvUnboundProfileSyncBody = await kvUnboundProfileSyncResponse.json();
374+
assert.equal(kvUnboundProfileSyncBody.ok, true);
375+
assert.equal(kvUnboundProfileSyncBody.strategy_profiles_sync.reason, "kv_not_bound");
376+
assert.equal(kvUnboundProfileSyncBody.strategy_profiles_sync.skipped, true);
377+
378+
const profileKvWrites = new Map();
379+
const profileSyncResponse = await worker.fetch(
380+
new Request("https://switch.example/api/internal/sync-strategy-profiles", {
381+
method: "POST",
382+
headers: {
383+
Authorization: "Bearer test-sync-token",
384+
"Content-Type": "application/json",
385+
},
386+
body: "{}",
387+
}),
388+
{
389+
STRATEGY_SWITCH_SYNC_TOKEN: "test-sync-token",
390+
STRATEGY_SWITCH_CONFIG: {
391+
get: async (key) => (key === "strategy_profiles" ? JSON.stringify([{ profile: "stale" }]) : null),
392+
put: async (key, value) => profileKvWrites.set(key, value),
393+
},
394+
},
395+
);
396+
assert.equal(profileSyncResponse.status, 200);
397+
const profileSyncBody = await profileSyncResponse.json();
398+
assert.equal(profileSyncBody.ok, true);
399+
assert.equal(profileSyncBody.strategy_profiles_sync.synced, true);
400+
assert.equal(profileSyncBody.strategy_profiles_sync.changed, true);
401+
assert.ok(JSON.parse(profileKvWrites.get("strategy_profiles")).some((item) => item.profile === "ibit_smart_dca"));
402+
350403
const normalizedReservedCashInputs = __test.normalizeSwitchInputs({
351404
platform: "ibkr",
352405
target_name: "ibkr-primary",

tests/test_runtime_settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ def test_manual_switch_account_default_sync_is_warning_only(self):
156156
self.assertIn("::warning::", workflow)
157157
self.assertIn("raise SystemExit(0)", workflow)
158158

159+
def test_strategy_switch_console_deploy_workflow_syncs_bundled_profiles(self):
160+
workflow = (ROOT / ".github" / "workflows" / "deploy-strategy-switch-console.yml").read_text(
161+
encoding="utf-8"
162+
)
163+
164+
self.assertIn("environment: runtime-strategy-switch", workflow)
165+
self.assertIn("npx wrangler@latest deploy --config wrangler.toml", workflow)
166+
self.assertIn("/api/internal/sync-strategy-profiles", workflow)
167+
self.assertIn("STRATEGY_SWITCH_CONSOLE_URL", workflow)
168+
self.assertIn("STRATEGY_SWITCH_SYNC_TOKEN", workflow)
169+
self.assertIn("CLOUDFLARE_WRANGLER_CONFIG_TOML", workflow)
170+
self.assertIn("STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID", workflow)
171+
self.assertIn("scripts/sync_strategy_switch_page_asset.py", workflow)
172+
159173
def test_plugin_mount_schema_version_must_be_non_empty_string(self):
160174
_, target = self.load_target("examples/targets/schwab/live.example.json")
161175
target["plugin_mounts"][0]["expected_schema_version"] = ""

web/strategy-switch-console/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ When adding or renaming a strategy profile:
133133
- Set `domain` on each strategy profile. Current values are `us_equity` and `hk_equity`.
134134
- Set each affected account's `default_strategy_profile` and `supported_domains` in `account-options.example.json` and the deployed KV account config.
135135
- Use `["us_equity", "hk_equity"]` for LongBridge and IBKR accounts unless you intentionally want to narrow a specific account.
136-
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
136+
- The main-branch deploy workflow updates the deployed KV `strategy_profiles` key from `strategy-profiles.example.json` after deploying the Worker. For manual deploys, call `/api/internal/sync-strategy-profiles` with the Worker sync token.
137137
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
138138
- Let `manual-strategy-switch.yml` manage platform plugin mounts. It writes an empty `*_STRATEGY_PLUGIN_MOUNTS_JSON` payload for strategies without plugin mounts, so old strategy plugin config is cleared instead of lingering.
139139
- Use lower-case ids with letters, numbers, dot, underscore, dash, or equals only. Do not encode account names or secrets in profile ids.
@@ -181,6 +181,8 @@ wrangler kv namespace create STRATEGY_SWITCH_CONFIG
181181

182182
Add the returned namespace id to `wrangler.toml`.
183183

184+
For GitHub Actions auto-deploy, configure `STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID`, `STRATEGY_SWITCH_CONSOLE_URL`, `STRATEGY_SWITCH_SYNC_TOKEN`, and either `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_WRANGLER_CONFIG_TOML` in the `runtime-strategy-switch` environment (or reuse `RUNTIME_SETTINGS_GH_TOKEN` only if it matches the Worker sync secret). `CLOUDFLARE_ACCOUNT_ID` is optional when Wrangler can infer it from the token. The workflow deploys the Worker and then syncs the bundled strategy profile catalog into KV so the website is not left with stale profile/plugin metadata.
185+
184186
Deploy:
185187

186188
```bash

web/strategy-switch-console/README.zh-CN.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校
140140
- 给每个策略 profile 设置 `domain`。当前支持 `us_equity``hk_equity`
141141
-`account-options.example.json` 和已部署的 KV 账号配置里更新对应账号的 `default_strategy_profile``supported_domains`
142142
- LongBridge 和 IBKR 账号默认写 `["us_equity", "hk_equity"]`,除非你明确要把某个账号限制成单市场。
143-
-`strategy-profiles.example.json` 更新已部署 KV 的 `strategy_profiles` key。
143+
- main 分支部署 workflow 会在 Worker 部署后,`strategy-profiles.example.json` 自动更新已部署 KV 的 `strategy_profiles` key。手动部署时,可用 Worker 同步 token 调用 `/api/internal/sync-strategy-profiles`
144144
- 确认平台仓库当前的 `RUNTIME_TARGET_JSON.strategy_profile` 或账号级 `CLOUD_RUN_SERVICE_TARGETS_JSON` 使用同一个 id。
145145
-`manual-strategy-switch.yml` 统一管理平台 plugin mounts。策略不需要插件时,它会写入空的 `*_STRATEGY_PLUGIN_MOUNTS_JSON`,清掉旧策略留下的插件配置。
146146
- profile id 只使用小写字母、数字、点、下划线、短横线或等号。不要把账号名、密码、token、密钥信息写进 profile id。
@@ -188,6 +188,8 @@ wrangler kv namespace create STRATEGY_SWITCH_CONFIG
188188

189189
然后把返回的 namespace id 加到 `wrangler.toml`
190190

191+
GitHub Actions 自动部署需要在 `runtime-strategy-switch` environment 配置 `STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID``STRATEGY_SWITCH_CONSOLE_URL``STRATEGY_SWITCH_SYNC_TOKEN`,以及 `CLOUDFLARE_API_TOKEN``CLOUDFLARE_WRANGLER_CONFIG_TOML` 二选一(只有当 `RUNTIME_SETTINGS_GH_TOKEN` 与 Worker 同步密钥相同时才复用它)。如果 Wrangler 能从 token 推断账号,`CLOUDFLARE_ACCOUNT_ID` 可不配。workflow 会先部署 Worker,再把内置策略 profile 目录同步到 KV,避免网站继续使用旧的 profile/plugin 元数据。
192+
191193
部署:
192194

193195
```bash

web/strategy-switch-console/worker.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ export default {
9696
if (url.pathname === "/api/internal/sync-account-default" && request.method === "POST") {
9797
return await syncAccountDefaultResponse(request, env);
9898
}
99+
if (url.pathname === "/api/internal/sync-strategy-profiles" && request.method === "POST") {
100+
return await syncStrategyProfilesResponse(request, env);
101+
}
99102
if (url.pathname === "/api/logout" && request.method === "POST") return logout(request);
100103
if (url.pathname === "/api/switch" && request.method === "POST") return await dispatchSwitch(request, env);
101104
return html(PAGE_HTML);
@@ -848,6 +851,52 @@ function requireInternalSyncToken(request, env) {
848851
if (token !== expected) throw new HttpError("internal sync token is invalid", 401);
849852
}
850853

854+
async function syncStrategyProfilesResponse(request, env) {
855+
requireInternalSyncToken(request, env);
856+
const result = await syncStrategyProfilesConfig(env, { login: "github-actions" });
857+
const kvSyncSkipped = result.reason === "kv_not_bound";
858+
const strategyProfilesSync = kvSyncSkipped ? { ...result, skipped: true } : result;
859+
return json(
860+
{
861+
ok: result.synced || kvSyncSkipped,
862+
strategy_profiles_sync: strategyProfilesSync,
863+
strategy_profiles_count: result.count,
864+
},
865+
result.synced || kvSyncSkipped ? 200 : 500,
866+
);
867+
}
868+
869+
async function syncStrategyProfilesConfig(env, session) {
870+
const profiles = normalizeStrategyProfilesPayload(DEFAULT_STRATEGY_PROFILES, "DEFAULT_STRATEGY_PROFILES");
871+
if (!hasConfigStore(env)) return { synced: false, reason: "kv_not_bound", count: profiles.length };
872+
let changed = true;
873+
try {
874+
const current = await readConfigJson(env, STRATEGY_PROFILES_KEY);
875+
if (current) {
876+
const normalizedCurrent = normalizeStrategyProfilesPayload(current, STRATEGY_PROFILES_KEY);
877+
changed = JSON.stringify(normalizedCurrent) !== JSON.stringify(profiles);
878+
}
879+
} catch {
880+
changed = true;
881+
}
882+
let auditLogged = false;
883+
if (changed) {
884+
await writeConfigJson(env, STRATEGY_PROFILES_KEY, profiles);
885+
try {
886+
await appendAuditLog(env, {
887+
ts: new Date().toISOString(),
888+
login: session?.login || "",
889+
action: "sync_strategy_profiles",
890+
count: profiles.length,
891+
});
892+
auditLogged = true;
893+
} catch {
894+
auditLogged = false;
895+
}
896+
}
897+
return { synced: true, changed, count: profiles.length, audit_logged: auditLogged };
898+
}
899+
851900
function updateAccountOptionsDefaultStrategy(accountOptions, inputs) {
852901
const options = normalizeAccountOptionsPayload(accountOptions || {}, ACCOUNT_OPTIONS_KEY);
853902
const platformOptions = options[inputs.platform] || [];
@@ -2222,6 +2271,7 @@ export const __test = {
22222271
requireSameOrigin,
22232272
responseHeaders,
22242273
fetchWithTimeout,
2274+
syncDefaultStrategyProfiles: syncStrategyProfilesConfig,
22252275
syncDefaultStrategyForAccount,
22262276
supportedDomainsForAccount,
22272277
updateAccountOptionsDefaultStrategy,

0 commit comments

Comments
 (0)