fix: require cloud chart sync and deployed revision
This commit is contained in:
@@ -44,6 +44,8 @@ jobs:
|
|||||||
frontend/supabase/migrations/20260718050000_profiles_service_role_upsert_grants.sql \
|
frontend/supabase/migrations/20260718050000_profiles_service_role_upsert_grants.sql \
|
||||||
frontend/supabase/migrations/20260718070000_profiles_service_role_upsert_id.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/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/"
|
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/tmp/profile-migrations/"
|
||||||
|
|
||||||
- name: Apply profile migrations using VPS database URL
|
- 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/20260718020000_profiles_service_role_upsert_grants.sql \
|
||||||
tmp/profile-migrations/20260718050000_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/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
|
do
|
||||||
echo "applying $(basename "$SQL_FILE")"
|
echo "applying $(basename "$SQL_FILE")"
|
||||||
cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \
|
cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \
|
||||||
|
|||||||
@@ -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"
|
"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
|
- name: Verify production
|
||||||
|
env:
|
||||||
|
DEPLOY_GIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
run: |
|
run: |
|
||||||
curl --fail --silent --show-error --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null
|
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"
|
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" \
|
ssh -i ~/.ssh/jyotisha-production -p "$DEPLOY_PORT" \
|
||||||
-o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
|
-o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
|||||||
+17
-13
@@ -802,12 +802,8 @@ export default function Home() {
|
|||||||
setSynastryHistory(readSynastryHistory(accountId));
|
setSynastryHistory(readSynastryHistory(accountId));
|
||||||
void fetchCloudChartLibrary()
|
void fetchCloudChartLibrary()
|
||||||
.then((cloudLibrary) => {
|
.then((cloudLibrary) => {
|
||||||
setChartLibrary((current) => {
|
setChartLibrary(() => {
|
||||||
const otherById = new Map([
|
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
|
||||||
...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);
|
|
||||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -1451,8 +1447,10 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
record = await saveCloudChartProfile(record);
|
record = await saveCloudChartProfile(record);
|
||||||
} catch {
|
} catch (caught) {
|
||||||
// Keep local chart library usable when cloud sync is unavailable.
|
setProfileNotice("");
|
||||||
|
setAccountError(friendlyError(caught instanceof Error ? caught.message : "云端星盘保存失败,请稍后重试。"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setChartLibrary((current) => {
|
setChartLibrary((current) => {
|
||||||
const next = [...upsertSelfChart(current, profile), record];
|
const next = [...upsertSelfChart(current, profile), record];
|
||||||
@@ -1461,19 +1459,25 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
setOtherProfileDraft(emptyProfile);
|
setOtherProfileDraft(emptyProfile);
|
||||||
setAccountError("");
|
setAccountError("");
|
||||||
setProfileNotice("已添加到星盘库。");
|
setProfileNotice("已保存到云端星盘库。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOtherChart(recordId: string) {
|
async function deleteOtherChart(recordId: string) {
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
void deleteCloudChartProfile(recordId).catch(() => {
|
try {
|
||||||
// Local deletion should not be blocked by temporary cloud sync failures.
|
await deleteCloudChartProfile(recordId);
|
||||||
});
|
} catch (caught) {
|
||||||
|
setProfileNotice("");
|
||||||
|
setAccountError(friendlyError(caught instanceof Error ? caught.message : "云端星盘删除失败,请稍后重试。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setChartLibrary((current) => {
|
setChartLibrary((current) => {
|
||||||
const next = current.filter((record) => record.id !== recordId || record.role === "self");
|
const next = current.filter((record) => record.id !== recordId || record.role === "self");
|
||||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setAccountError("");
|
||||||
|
setProfileNotice("已从云端星盘库删除。");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeDefaultChart(record: ChartLibraryRecord) {
|
async function makeDefaultChart(record: ChartLibraryRecord) {
|
||||||
|
|||||||
@@ -9,3 +9,18 @@ test("other chart saves do not require the owner's rectification state", () => {
|
|||||||
assert.match(source, /if \(missingOtherProfileStep\(nextProfile\)\)/);
|
assert.match(source, /if \(missingOtherProfileStep\(nextProfile\)\)/);
|
||||||
assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(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"\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ test("production deployment passes the tested revision into the web runtime", ()
|
|||||||
assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
|
||||||
assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.event\.workflow_run\.head_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, /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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def fetch(url: str, timeout: float) -> tuple[int, str, float]:
|
|||||||
raise RuntimeError(str(error.reason)) from error
|
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("/")
|
base = base_url.rstrip("/")
|
||||||
checks: list[dict] = []
|
checks: list[dict] = []
|
||||||
|
|
||||||
@@ -41,11 +41,16 @@ def check(base_url: str, timeout: float) -> dict:
|
|||||||
checks.append(
|
checks.append(
|
||||||
{
|
{
|
||||||
"name": "health",
|
"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,
|
"status": status,
|
||||||
"latency_ms": round(elapsed * 1000),
|
"latency_ms": round(elapsed * 1000),
|
||||||
"health_status": health.get("status"),
|
"health_status": health.get("status"),
|
||||||
"checks": sorted((health.get("checks") or {}).keys()),
|
"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 = argparse.ArgumentParser()
|
||||||
parser.add_argument("--base-url", default="https://jyotisha.chat")
|
parser.add_argument("--base-url", default="https://jyotisha.chat")
|
||||||
parser.add_argument("--timeout", type=float, default=8.0)
|
parser.add_argument("--timeout", type=float, default=8.0)
|
||||||
|
parser.add_argument("--expected-git-sha")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
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.
|
except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure.
|
||||||
report = {"base_url": args.base_url, "ok": False, "error": str(error)}
|
report = {"base_url": args.base_url, "ok": False, "error": str(error)}
|
||||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
|||||||
@@ -26,3 +26,15 @@ def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None:
|
|||||||
|
|
||||||
assert report["ok"] is True
|
assert report["ok"] is True
|
||||||
assert report["checks"][1]["health_status"] == "blocked"
|
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"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def test_profile_migration_workflow_is_manual_and_uses_vps_env_without_printing_
|
|||||||
assert "cat \"$SQL_FILE\" |" in text
|
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")
|
text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "20260718010000_recover_missing_profile_rows.sql" in text
|
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 "20260718050000_profiles_service_role_upsert_grants.sql" in text
|
||||||
assert "20260718070000_profiles_service_role_upsert_id.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 "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
|
||||||
|
|||||||
Reference in New Issue
Block a user