diff --git a/.github/workflows/apply-supabase-profile-migrations.yml b/.github/workflows/apply-supabase-profile-migrations.yml index a63a15a9..108fa767 100644 --- a/.github/workflows/apply-supabase-profile-migrations.yml +++ b/.github/workflows/apply-supabase-profile-migrations.yml @@ -44,6 +44,8 @@ jobs: frontend/supabase/migrations/20260718050000_profiles_service_role_upsert_grants.sql \ frontend/supabase/migrations/20260718070000_profiles_service_role_upsert_id.sql \ frontend/supabase/migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \ + frontend/supabase/migrations/20260718100000_repair_missing_chart_profiles.sql \ + frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/tmp/profile-migrations/" - name: Apply profile migrations using VPS database URL @@ -70,7 +72,9 @@ jobs: tmp/profile-migrations/20260718020000_profiles_service_role_upsert_grants.sql \ tmp/profile-migrations/20260718050000_profiles_service_role_upsert_grants.sql \ tmp/profile-migrations/20260718070000_profiles_service_role_upsert_id.sql \ - tmp/profile-migrations/20260718080000_profiles_service_role_account_upsert_selects.sql + tmp/profile-migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \ + tmp/profile-migrations/20260718100000_repair_missing_chart_profiles.sql \ + tmp/profile-migrations/20260718103000_profile_birth_time_declaration_grants.sql do echo "applying $(basename "$SQL_FILE")" cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index bab8cc83..90f45de8 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -64,9 +64,18 @@ jobs: "cd '$DEPLOY_PATH' && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - name: Verify production + env: + DEPLOY_GIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | curl --fail --silent --show-error --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null test "$(curl --silent --output /dev/null --write-out '%{http_code}' https://jyotisha.chat/api/account)" = "401" + deployed_sha="" + for attempt in $(seq 1 24); do + deployed_sha="$(curl --fail --silent --show-error https://jyotisha.chat/api/health | python3 -c 'import json, sys; print(json.load(sys.stdin).get("deployment", {}).get("gitCommit", ""))')" || deployed_sha="" + [ "$deployed_sha" = "$DEPLOY_GIT_SHA" ] && break + sleep 5 + done + test "$deployed_sha" = "$DEPLOY_GIT_SHA" || { echo "Production revision did not converge: expected $DEPLOY_GIT_SHA, got ${deployed_sha:-empty}" >&2; exit 1; } ssh -i ~/.ssh/jyotisha-production -p "$DEPLOY_PORT" \ -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \ "$DEPLOY_USER@$DEPLOY_HOST" \ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c87b054c..902491eb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -802,12 +802,8 @@ export default function Home() { setSynastryHistory(readSynastryHistory(accountId)); void fetchCloudChartLibrary() .then((cloudLibrary) => { - setChartLibrary((current) => { - const otherById = new Map([ - ...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const), - ...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const), - ]); - const next = upsertSelfChart([...otherById.values()], profile); + setChartLibrary(() => { + const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); @@ -1451,8 +1447,10 @@ export default function Home() { }; try { record = await saveCloudChartProfile(record); - } catch { - // Keep local chart library usable when cloud sync is unavailable. + } catch (caught) { + setProfileNotice(""); + setAccountError(friendlyError(caught instanceof Error ? caught.message : "云端星盘保存失败,请稍后重试。")); + return; } setChartLibrary((current) => { const next = [...upsertSelfChart(current, profile), record]; @@ -1461,19 +1459,25 @@ export default function Home() { }); setOtherProfileDraft(emptyProfile); setAccountError(""); - setProfileNotice("已添加到星盘库。"); + setProfileNotice("已保存到云端星盘库。"); } - function deleteOtherChart(recordId: string) { + async function deleteOtherChart(recordId: string) { if (!accountId) return; - void deleteCloudChartProfile(recordId).catch(() => { - // Local deletion should not be blocked by temporary cloud sync failures. - }); + try { + await deleteCloudChartProfile(recordId); + } catch (caught) { + setProfileNotice(""); + setAccountError(friendlyError(caught instanceof Error ? caught.message : "云端星盘删除失败,请稍后重试。")); + return; + } setChartLibrary((current) => { const next = current.filter((record) => record.id !== recordId || record.role === "self"); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); + setAccountError(""); + setProfileNotice("已从云端星盘库删除。"); } async function makeDefaultChart(record: ChartLibraryRecord) { diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index dbd8023f..0f0130fb 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -9,3 +9,18 @@ test("other chart saves do not require the owner's rectification state", () => { assert.match(source, /if \(missingOtherProfileStep\(nextProfile\)\)/); assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(nextProfile\)/); }); + +test("other chart mutations acknowledge only confirmed cloud writes", () => { + assert.match(source, /await saveCloudChartProfile\(record\);[\s\S]{0,500}已保存到云端星盘库/); + assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}catch\s*\{[\s\S]{0,500}已添加到星盘库/); + assert.match(source, /async function deleteOtherChart[\s\S]{0,500}await deleteCloudChartProfile\(recordId\)/); + assert.doesNotMatch(source, /deleteOtherChart[\s\S]{0,500}void deleteCloudChartProfile/); +}); + +test("a successful cloud read replaces stale local other charts", () => { + assert.match( + source, + /fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profile\)/, + ); + assert.doesNotMatch(source, /fetchCloudChartLibrary\(\)[\s\S]{0,800}new Map\(\[[\s\S]{0,500}current\.filter\(\(record\) => record\.role === "other"\)/); +}); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 734f1d51..eb912aa8 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -18,4 +18,7 @@ test("production deployment passes the tested revision into the web runtime", () assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/); assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.event\.workflow_run\.head_sha \|\| github\.sha \}\}/); assert.match(workflow, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/); + assert.match(workflow, /get\("deployment", \{\}\)\.get\("gitCommit"/); + assert.match(workflow, /DEPLOY_GIT_SHA/); + assert.match(workflow, /Production revision did not converge/); }); diff --git a/scripts/production_smoke.py b/scripts/production_smoke.py index 2442fa75..17deea6e 100644 --- a/scripts/production_smoke.py +++ b/scripts/production_smoke.py @@ -22,7 +22,7 @@ def fetch(url: str, timeout: float) -> tuple[int, str, float]: raise RuntimeError(str(error.reason)) from error -def check(base_url: str, timeout: float) -> dict: +def check(base_url: str, timeout: float, expected_git_sha: str | None = None) -> dict: base = base_url.rstrip("/") checks: list[dict] = [] @@ -41,11 +41,16 @@ def check(base_url: str, timeout: float) -> dict: checks.append( { "name": "health", - "ok": status in {200, 503} and health.get("status") in {"ok", "degraded", "blocked"}, + "ok": ( + status in {200, 503} + and health.get("status") in {"ok", "degraded", "blocked"} + and (not expected_git_sha or health.get("deployment", {}).get("gitCommit") == expected_git_sha) + ), "status": status, "latency_ms": round(elapsed * 1000), "health_status": health.get("status"), "checks": sorted((health.get("checks") or {}).keys()), + "deployment_git_commit": health.get("deployment", {}).get("gitCommit"), } ) @@ -60,9 +65,10 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--base-url", default="https://jyotisha.chat") parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("--expected-git-sha") args = parser.parse_args() try: - report = check(args.base_url, args.timeout) + report = check(args.base_url, args.timeout, args.expected_git_sha) except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure. report = {"base_url": args.base_url, "ok": False, "error": str(error)} print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/tests/test_production_health_smoke.py b/tests/test_production_health_smoke.py index 6e02ff1e..b3b210d7 100644 --- a/tests/test_production_health_smoke.py +++ b/tests/test_production_health_smoke.py @@ -26,3 +26,15 @@ def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None: assert report["ok"] is True assert report["checks"][1]["health_status"] == "blocked" + + +def test_production_smoke_requires_the_expected_deployment_sha(monkeypatch) -> None: + def fake_fetch(url: str, timeout: float) -> tuple[int, str, float]: + if url.endswith("/api/health"): + return 200, '{"status":"ok","deployment":{"gitCommit":"old-sha"}}', 0.01 + return 200, "Jyotisha", 0.01 + + monkeypatch.setattr("scripts.production_smoke.fetch", fake_fetch) + report = check("https://example.invalid", 1.0, expected_git_sha="new-sha") + assert report["ok"] is False + assert report["checks"][1]["deployment_git_commit"] == "old-sha" diff --git a/tests/test_supabase_profile_migration_workflow.py b/tests/test_supabase_profile_migration_workflow.py index 622c8d60..f951cc80 100644 --- a/tests/test_supabase_profile_migration_workflow.py +++ b/tests/test_supabase_profile_migration_workflow.py @@ -17,7 +17,7 @@ def test_profile_migration_workflow_is_manual_and_uses_vps_env_without_printing_ assert "cat \"$SQL_FILE\" |" in text -def test_profile_migration_workflow_targets_only_account_profile_migrations() -> None: +def test_profile_migration_workflow_includes_chart_library_and_birth_time_profile_migrations() -> None: text = WORKFLOW.read_text(encoding="utf-8") assert "20260718010000_recover_missing_profile_rows.sql" in text @@ -25,3 +25,5 @@ def test_profile_migration_workflow_targets_only_account_profile_migrations() -> assert "20260718050000_profiles_service_role_upsert_grants.sql" in text assert "20260718070000_profiles_service_role_upsert_id.sql" in text assert "20260718080000_profiles_service_role_account_upsert_selects.sql" in text + assert "20260718100000_repair_missing_chart_profiles.sql" in text + assert "20260718103000_profile_birth_time_declaration_grants.sql" in text