Compare commits

...
Author SHA1 Message Date
jesse-uxGrok (agent loop)Cursor
454951de27 fix(chart): keep the ascendant nakshatra the page already renders
The chart API rebuilt the ascendant without nakshatra, lord, and pada, so those cells stayed blank. Cache schema is now 7. Centered column headers line up with their cells, and the current dasha card keeps its tint without the left accent.

pytest 34 passed. tsc 0 errors. lint 0 errors and 123 existing warnings.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:46:37 +08:00
Jesse_ChenandClaude Opus 5.5 2b7112a601 docs(tasks): chart page ascendant row brief (agent loop, issue #53 round 2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 09:19:35 +08:00
Jesse_ChenandClaude Opus 5.5 10d0c4bba6 docs(tasks): answer effort switch fix brief (agent loop, issue #65, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 09:03:35 +08:00
Jesse_ChenandClaude Opus 5.5 9308382ef8 docs(tasks): api chart subprocess gate fix brief (agent loop, issue #64, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-15 08:45:34 +08:00
Jesse_ChenandClaude Opus 5.5 9274491681 docs(ops): prove the recovery backup decrypts before dropping production (issue #74)
The rollback restore dropped jyotisha before decrypting the backup; a bad
key or damaged file would leave the site on an empty database. Load the
host env file, check sha256, decrypt into pg_restore --list first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 21:28:47 +08:00
jesse-uxandGrok (agent loop) 40bc565a49 docs(testing): keep the host rehearsal sheet and record the local PostgreSQL 17 drill
Independent Staging Quality Gate / validate (push) Successful in 18m13s
Independent Staging Quality Gate / publish (push) Successful in 4m37s
Co-Authored-By: Grok (agent loop)
2026-10-14 21:02:19 +08:00
jesse-uxandGrok (agent loop) 09906cdccc fix(release): keep owners and grants in production recovery points
Co-Authored-By: Grok (agent loop)
2026-10-14 21:01:52 +08:00
jesse-uxandGrok (agent loop) d13250f40a merge origin/staging into the release-path branch
Bring in the second review brief for issue 74. Keep the existing progress links and add the fix2 task row.

Co-Authored-By: Grok (agent loop)
2026-10-14 20:28:40 +08:00
Jesse_ChenandClaude Opus 5.5 6b12e09436 docs(tasks): release path fix brief 2, recovery points keep owners and grants (agent loop, issue #74)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 20:08:16 +08:00
jesse-uxandGrok (agent loop) 32aa46b573 fix(release): close the first review of the production release path
Host rehearsal no longer needs Node on the production machine, and a failed cleanup now stops with the rehearsal database name. Docs-only staging heads no longer block rollback. Tests no longer hardcode Windows paths, and a trailing slash on the production site still refuses the test code.

pytest 12 passed; tsc exit 0; lint 0 errors; identity tests 9 passed, 0 cancelled. test:db was not run.

Co-Authored-By: Grok (agent loop)
2026-10-14 16:37:58 +08:00
jesse-uxandGrok (agent loop) 0dc1d8fa52 merge origin/staging into the release-path fix branch
Co-Authored-By: Grok (agent loop)
2026-10-14 15:54:45 +08:00
Jesse_ChenandClaude Opus 5.5 53b2f0008d docs(tasks): release path production fix brief (agent loop, issue #74, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 15:39:22 +08:00
Claude 3e20c125b8 test(api): capability audit no longer counts the deleted dead panel as app-visible (BUG-1342)
Independent Staging Quality Gate / validate (push) Successful in 18m9s
Independent Staging Quality Gate / publish (push) Successful in 3m56s
The audit scans frontend app/components source. Its only Case Validation
hit was evidence-audit-panel.tsx, never mounted and deleted in #63, so the
topic is truthfully gone and case_validator's UX level is thin. Product
owner chose option A on issue #63 (Claude fixes directly). Staging gate
run 1824 was red on this test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 15:27:53 +08:00
jesse-uxandGrok (agent loop) 00e6efdbfa 发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。

自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:24:40 +08:00
47 changed files with 2687 additions and 199 deletions
+10 -30
View File
@@ -40,43 +40,23 @@ jobs:
run: |
set -euo pipefail
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || { echo "dispatch recovery from the exact main release SHA" >&2; exit 1; }
[[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; }
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
[[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || {
echo "production recovery requires main and staging to equal deploy_sha" >&2
exit 1
}
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch recovery from current main" >&2; exit 1; }
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
"$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')"
[[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || {
echo "public staging has not accepted the requested SHA" >&2
exit 1
}
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$DEPLOY_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || {
echo "no successful exact-SHA manual release quality gate found" >&2
exit 1
}
echo "sha=$DEPLOY_SHA" >>"$GITHUB_OUTPUT"
- name: Checkout exact recovery controller
+79 -68
View File
@@ -66,79 +66,77 @@ jobs:
allow_rollback=true
fi
runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
if [[ "$allow_rollback" == false ]]; then
CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$REQUESTED_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
controller_sha="$release_sha"
controller_gate_run_id="$gate_run_id"
else
runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
controller_sha="$(read_ref_sha main)"
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
echo "stale production revision refused; use explicit manual rollback only when intended" >&2
exit 1
fi
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
(.commits // []) as $commits |
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
def reaches($sha; $seen):
if $sha == $base then true
elif ($seen | index($sha)) != null then false
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
(.total_commits | type) == "number" and
.total_commits == ($commits | length) and ($commits | length) > 0 and
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
fi
controller_gate_run_id="$gate_run_id"
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")"
controller_run="$(jq -cer --arg sha "$controller_sha" '
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$controller_runs")"
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
controller_sha="$(read_ref_sha main)"
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
if [[ "$REQUESTED_SHA" != "$controller_sha" ]]; then
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
(.commits // []) as $commits |
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
def reaches($sha; $seen):
if $sha == $base then true
elif ($seen | index($sha)) != null then false
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
(.total_commits | type) == "number" and
.total_commits == ($commits | length) and ($commits | length) > 0 and
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
fi
discover_output="$(RESOLVE_MODE=discover CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh)"
controller_sha="$(awk -F= '$1 == "release_sha" { print $2 }' <<<"$discover_output" | tail -n 1)"
controller_gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' <<<"$discover_output" | tail -n 1)"
[[ "$controller_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "no docs-only staging quality gate can be released" >&2; exit 1; }
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$REQUESTED_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; }
fi
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$REQUESTED_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; }
if [[ "$allow_rollback" == false ]]; then
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
@@ -338,8 +336,14 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/main") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
echo "main or staging advanced during deployment; refusing stale mutation" >&2
[[ "$current_main" == "$current_staging" ]] || {
echo "main and staging diverged during deployment; refusing stale mutation" >&2
exit 1
}
checker=artifacts/controller/extracted/deploy/is-docs-only-range.sh
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
exit 1
}
}
@@ -376,6 +380,13 @@ jobs:
forward_verified=true
fi
require_current_release_heads
if [[ "$ALLOW_ROLLBACK" == true ]]; then
recovery_format="$(ssh "${ssh_options[@]}" "$remote" "awk -F= '\$1 == \"RECOVERY_FORMAT\" { print \$2 }' '$DEPLOY_PATH/.state/recovery-baseline.env' || true")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
fi
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' PRODUCTION_URL='$PRODUCTION_URL' PRODUCTION_ADMIN_URL='$PRODUCTION_ADMIN_URL' VERIFICATION_MODE='$VERIFICATION_MODE' bash '$incoming/deploy/run-production-deploy.sh'"
require_current_release_heads
@@ -85,54 +85,18 @@ jobs:
PY
echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT restore_verified=true"
echo "WARNING: migration files run sequentially and are not atomic as a whole; recovery may be required after a partial migration." >&2
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
[[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || {
echo "production migration requires main and staging to equal deploy_sha" >&2
exit 1
}
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || {
echo "dispatch the production migration workflow from the exact main release SHA" >&2
exit 1
}
runs="$(curl --fail --silent --show-error \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || {
echo "no successful exact-SHA staging backend quality gate found" >&2
exit 1
}
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$DEPLOY_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || {
echo "no successful exact-SHA manual release quality gate found" >&2
exit 1
}
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging backend quality gate found" >&2; exit 1; }
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the production migration workflow from current main" >&2; exit 1; }
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
"$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')"
[[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || {
@@ -317,8 +281,14 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/main") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
echo "main or staging advanced during production migration; refusing stale mutation" >&2
[[ "$current_main" == "$current_staging" ]] || {
echo "main and staging diverged during production migration; refusing stale mutation" >&2
exit 1
}
checker=artifacts/staging-image/extracted/deploy/is-docs-only-range.sh
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
exit 1
}
}
@@ -356,6 +326,11 @@ jobs:
fi
require_current_release_heads
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
recovery_format="$(ssh "${ssh_options[@]}" "$remote" "awk -F= '\$1 == \"RECOVERY_FORMAT\" { print \$2 }' '$DEPLOY_PATH/.state/recovery-baseline.env' || true")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' FORWARD_REVISION_VERIFIED='$forward_verified' RECOVERY_REFERENCE='$RECOVERY_REFERENCE' RECOVERY_CREATED_AT='$RECOVERY_CREATED_AT' RESTORE_VERIFIED='$RESTORE_VERIFIED' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' bash '$incoming/deploy/run-production-migration.sh'"
require_current_release_heads
+18
View File
@@ -20,6 +20,24 @@ jobs:
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
- name: Record whether main is docs-only ahead of the last staging gate
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RESOLVE_MODE: discover
run: |
set -euo pipefail
bash deploy/resolve-release-revision.sh | tee "$RUNNER_TEMP/release-revision.env"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the release gate from current main" >&2; exit 1; }
{
echo "### Release revision"
echo "- head_sha: ${head_sha}"
echo "- release_sha: ${release_sha}"
echo "- H relative to R is docs-only (is-docs-only-range.sh exited 0)"
} >>"$GITHUB_STEP_SUMMARY"
- name: Prepare pinned Node tooling
run: |
set -euo pipefail
+3 -3
View File
@@ -45,10 +45,10 @@
1. 动手前 `git fetch origin --prune`,以远端 **`origin/staging`** 为基线。本地 `staging` / `main` 经常落后远端上百个提交,不得作为基线。
2. 在独立 worktree 开发:路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`(见 §3)。
3. 交付到 staging 用快进推送 `git push origin HEAD:staging`。这会触发 Gitea `backend-quality-gate`;它的 `paths:` 过滤与 `deploy/gated-paths.txt` 逐行一致。改动**全部**落在清单之外的纯文档推送(`docs/**`、根目录 `BLOCKED.md` / `CHANGELOG*.md` / `CONTEXT.md` / `AGENTS.md` / `CLAUDE.md` / `README.md` 等)不触发门禁、不发布镜像、不部署。鼓励把文档与同批代码合并一次推送,避免 staging head 与已部署 SHA 分离。
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**;其后若只有纯文档提交,`deploy/is-docs-only-range.sh <该 SHA> <staging head>` 必须退出 0,否则视为未部署。
5. 提升到 `main` **必须快进,不得 merge**。`deploy-production.yml` 强制 `main` 与 `staging` 指向同一 SHA,merge commit 会让生产部署失败。
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**。记这个提交为发布修订 `R`,记当前 staging head 为 `H`。其后若只有纯文档提交,`deploy/is-docs-only-range.sh R H` 必须退出 0,否则视为未部署。生产四个手动工作流发布的是 `R`,不是纯文档的 `H`;公网 health 的 gitCommit 仍必须等于 `R`。该脚本退出 1 或 2 都拒绝发布。
5. 提升到 `main` **必须快进到 `H`,不得 merge**。`main` 与 `staging` 必须是同一 SHA。生产镜像和控制器产物取 `R` 的门禁结果,不取纯文档的 `H`。merge commit 会让生产部署失败。
6. 推送后必须核对远端 SHA;远端验证失败时不得声称已交付。
7. 不得自行提升 `main`、不改 `.gitea/workflows/**`、不动 DNS——这三件事只由产品负责人触发。
7. 不得自行提升 `main`、不动 DNS——这两件事只由产品负责人触发。不改 `.gitea/workflows/**`,除非任务书的决策记录点名了具体文件。2026-10-14 的授权只覆盖 `deploy-production.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`release-quality-gate.yml` 的发布修订规则(`R` 加其后纯文档)。不改 staging 工作流,不改 `deploy/gated-paths.txt`。
## 3. 工作树与多会话
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging)
- 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
- 给人物档案里的其他人出报告时,后台只能按出报告要用的那些列读取,不能改档案(BUG-1063)。
+3 -3
View File
@@ -140,7 +140,7 @@ It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operat
## Manual production deployment with Gitea Actions
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. The workflow requires an exact SHA shared by `main` and `staging`, an exact-SHA staging push gate and image manifest, the manual release gate, and matching public staging health. It deploys immutable registry digests and never builds application images on the production host.
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. `main` must be a fast-forward to the current staging head `H`. The deployed SHA is `R`, the latest successful staging push of `backend-quality-gate`, and `deploy/is-docs-only-range.sh R H` must exit 0 (exit 1 or 2 both refuse). Controller artifacts and image digests come from `R`. Public staging health must report `R`. The manual release gate may record `H` when `H` is docs-only ahead of `R`. Rollback still requires an explicit confirmation, a gate run, and ancestry inside reviewed `main` history. The workflow deploys immutable registry digests and never builds application images on the production host.
The `internal` verification mode is for pre-DNS checks on the new host. The `public` mode is for the same SHA after authoritative DNS and Caddy TLS converge. Neither mode imports data, applies pending migrations, or changes DNS.
@@ -281,13 +281,13 @@ Use this order for every staging revision:
5. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
6. After health verification, create the local encrypted backup described below.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA. For a forward release that SHA is `R` (the latest gated staging push that is docs-only behind the shared `main`/`staging` head), plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker still exits before app changes when a migration is pending. After the automatic pre-deploy migrate that should be rare. A failed automatic migration fails the quality gate and does not dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
### Production recovery point before schema migration
Use Gitea Actions → **Create Production Recovery Point** from the exact current `main` release SHA before every production schema migration. The manual workflow requires `main`, `staging`, public staging health, and the successful release gate to identify the same SHA. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled.
Use Gitea Actions → **Create Production Recovery Point** before every production schema migration. Dispatch it from current `main` (the staging head `H`) and set `deploy_sha` to `R`. `main` and `staging` must be the same SHA, `R` must be a docs-only ancestor of that SHA, public staging health must report `R`, and the release gate must have succeeded for `R` or for that same head. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled. The migration rehearsal has no Gitea button; the product runs `deploy/rehearse-production-migrations.sh` from the runbook.
The host script writes an AES-256-CBC/PBKDF2 encrypted custom-format PostgreSQL dump under `/opt/jyotisha-production/backups`, restores it into a uniquely named disposable database, validates non-sensitive row/table counts, removes only that disposable database, and writes a mode-`0600` verification manifest. Before acquiring the shared host lock it validates that `.state` and `backups` are real directories, then reuses the existing passwordless Docker boundary to run the already-loaded PostgreSQL image with no network, a read-only root filesystem, all capabilities dropped except `CHOWN`, and only the two directories plus the verified existing lock inode bind-mounted. That helper exposes the regular `mutation.lock` directly at `/mutation.lock`, restores that inode before changing its mode-`0700` parent directory, then restores the two directory mount points to the current `deploy` UID/GID. The direct file mount avoids depending on traversal through a parent left half-repaired by an earlier failed run, without adding `DAC_OVERRIDE`; it never recursively changes backup files or removes or replaces a lock inode. The workflow retrieves only the encrypted dump and verification metadata, verifies the SHA-256 digest, and uploads them as a 30-day Gitea Actions artifact with compression disabled. The artifact-backed `recovery_reference`, `recovery_created_at`, and `restore_verified=true` output are the inputs for **Migrate Production Database**. Do not use the attestation if backup, restore, retrieval, digest validation, ownership normalization, or artifact upload fails.
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env bash
# Rehearse pending production migrations on a disposable database.
# The product runs this on the production host, or locally with
# JYOTISHA_REHEARSAL_LOCAL=1. There is no fifth Gitea workflow for it.
# Never restores into or migrates the database named jyotisha.
#
# Host mode runs every SQL statement inside the postgres container.
# It does not call node on the host. Restore keeps the owners and grants
# stored in the backup. Migrations then run as schema_owner.
# Exit 1: refused, including when a migration removed a table or column
# grant that was present on the restored copy.
# Exit 4: service_role still lacks column SELECT on public.chart_profiles
# for the columns named by BUG-1063. Do not migrate production.
# Exit 75: another production mutation holds the host lock.
# Exit 0 prints rehearsal_ok=1 only after those checks, and only when the
# rehearsal database was removed. A failed drop is non-zero and names
# that database so it can be dropped by hand.
set -euo pipefail
set +x
umask 077
export LC_ALL=C
STOP_BEFORE="20260925030000_chart_subject_trust_and_limit.sql"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
frontend_dir="$repo_root/frontend"
refuse() {
echo "$1" >&2
exit 1
}
database_name_from_url() {
local rest="${1#postgresql://}"
rest="${rest#*@}"
local path="${rest#*/}"
case "$path" in
*\?*)
refuse "rehearsal URL must not include query parameters"
;;
esac
printf '%s' "$path"
}
docker_cli() {
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" = 1 ]; then
docker "$@"
else
sudo -n docker "$@"
fi
}
assert_rehearse_name() {
local name="$1"
[[ "$name" != "jyotisha" ]] || refuse "refusing to touch the production database"
[[ "$name" =~ ^rehearse_[0-9a-z_]+$ ]] || refuse "rehearsal database name must match rehearse_<suffix>"
}
copy_batch() {
local dest="$1" mode="$2" dir file base
local -a selected=()
mkdir -p "$dest"
for dir in "$frontend_dir/db/migrations" "$frontend_dir/supabase/migrations"; do
[ -d "$dir" ] || continue
selected=()
for file in "$dir"/*.sql; do
[ -f "$file" ] || continue
base="${file##*/}"
if [ "$mode" = before ] && [[ ! "$base" < "$STOP_BEFORE" ]]; then
continue
fi
selected+=("$file")
done
if [ "${#selected[@]}" -gt 0 ]; then
cp -p "${selected[@]}" "$dest/"
fi
done
}
count_sql() {
find "$1" -maxdepth 1 -type f -name '*.sql' | wc -l | tr -d ' '
}
print_safe_log() {
local log="$1" line
while IFS= read -r line; do
case "$line" in
applied\ *.sql|already\ applied\ *.sql)
printf '%s\n' "$line"
;;
notice\ *)
if [[ "$line" =~ ^notice\ [A-Za-z0-9_.:=[:space:]-]+$ ]]; then
printf '%s\n' "$line"
else
echo "notice_redacted=1"
fi
;;
migration\ failed:*|migration\ file\ missing:*|migration\ checksum\ mismatch:*|duplicate\ migration\ filename:*|unable\ to\ read\ migrations\ directory|database\ migration\ failed)
printf '%s\n' "$line" >&2
;;
esac
done <"$log"
}
run_node_migrator() {
local batch="$1" log="$2" status migrate_url
migrate_url="${SCHEMA_DATABASE_URL}?options=-c%20role%3Dschema_owner"
set +e
(
cd "$frontend_dir"
SCHEMA_DATABASE_URL="$migrate_url" MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
) >"$log" 2>&1
status=$?
set -e
print_safe_log "$log"
[ "$status" -eq 0 ] || refuse "rehearsal migration failed"
}
run_node_sql() {
local kind="$1"
(
cd "$frontend_dir"
node --input-type=module - "$kind" <<'PY'
import pg from "pg";
const kind = process.argv[2];
const source = process.env.SCHEMA_DATABASE_URL;
if (!source || !source.startsWith("postgresql://")) process.exit(2);
const url = new URL(source);
const name = url.pathname.replace(/^\//, "").split("/")[0];
if (!/^rehearse_[0-9a-z_]+$/.test(name) || name === "jyotisha") process.exit(2);
const client = new pg.Client({ connectionString: source });
await client.connect();
if (kind === "count") {
const result = await client.query(
"select count(*)::text as n from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)",
);
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count)) process.exit(2);
process.stdout.write(count);
} else if (kind === "archived" || kind === "self") {
const spec = kind === "archived"
? { table: "chat_sessions", column: "archived_at", whereSql: "archived_at is not null" }
: { table: "chart_profiles", column: "role", whereSql: "role = 'self'" };
const exists = await client.query(
"select exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = $1 and column_name = $2) as present",
[spec.table, spec.column],
);
let count = "absent";
if (exists.rows[0]?.present === true) {
const result = await client.query(
`select count(*)::text as n from public.${spec.table} where ${spec.whereSql}`,
);
count = result.rows[0]?.n ?? "";
}
if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2);
process.stdout.write(count);
} else if (kind === "grants") {
const result = await client.query(`
select line from (
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line
from information_schema.role_table_grants
where table_schema not in ('pg_catalog', 'information_schema')
union all
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name
from information_schema.column_privileges
where table_schema not in ('pg_catalog', 'information_schema')
) s
order by 1
`);
for (const row of result.rows) process.stdout.write(`${row.line}\n`);
} else if (kind === "bug1063") {
const columns = [
"id", "user_id", "role", "name", "birth_date", "reported_birth_time",
"birth_time_source", "birth_time_period", "declared_window_start",
"declared_window_end", "uncertainty_before_minutes", "uncertainty_after_minutes",
"latitude", "longitude", "timezone_id", "timezone_offset", "birth_place_label",
"birth_place_type", "birth_place_provider", "birth_place_provider_id", "ayanamsa",
"active_birth_time", "active_birth_date", "active_birth_timezone_offset",
"birth_time_status", "gender",
];
const result = await client.query(
`select count(*)::text as n
from unnest($1::text[]) as cols(column_name)
where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')`,
[columns],
);
const missing = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(missing)) process.exit(2);
process.stdout.write(missing);
} else if (kind === "grant_create") {
await client.query(`grant connect, create on database ${name} to schema_owner`);
} else if (kind === "drop") {
await client.end();
const admin = new URL(source);
admin.pathname = "/postgres";
const dropper = new pg.Client({ connectionString: admin.toString() });
await dropper.connect();
await dropper.query(`drop database if exists ${name} with (force)`);
await dropper.end();
process.exit(0);
} else {
process.exit(2);
}
await client.end();
PY
)
}
drop_rehearse_database() {
[ "${REHEARSAL_CREATED:-}" = 1 ] || return 0
REHEARSAL_CREATED=0
local status=0
if [ "$local_mode" -eq 0 ]; then
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"${database_name}\" WITH (FORCE);" >/dev/null || status=$?
else
run_node_sql drop >/dev/null || status=$?
fi
unset POSTGRES_PASSWORD || true
return "$status"
}
say_cleanup_failed() {
echo "演练库未删除,请手动 DROP DATABASE ${database_name}" >&2
}
local_mode=0
if [ "${JYOTISHA_REHEARSAL_LOCAL:-}" = 1 ]; then
local_mode=1
fi
if [ "$local_mode" -eq 1 ]; then
: "${JYOTISHA_REHEARSAL_DATABASE_URL:?JYOTISHA_REHEARSAL_DATABASE_URL is required}"
[[ "$JYOTISHA_REHEARSAL_DATABASE_URL" == postgresql://* ]] || refuse "rehearsal URL must be a PostgreSQL URL"
case "$JYOTISHA_REHEARSAL_DATABASE_URL" in
*\?*) refuse "rehearsal URL must not include query parameters" ;;
esac
database_name="$(database_name_from_url "$JYOTISHA_REHEARSAL_DATABASE_URL")"
assert_rehearse_name "$database_name"
export SCHEMA_DATABASE_URL="$JYOTISHA_REHEARSAL_DATABASE_URL"
unset JYOTISHA_REHEARSAL_DATABASE_URL
else
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" = 1 ]; then
case "$DEPLOY_PATH" in
/opt/jyotisha-production|/opt/jyotisha-production/*)
refuse "fake host mode cannot use the production path"
;;
esac
else
[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || refuse "unexpected production path"
fi
database_name="rehearse_$(date -u +%Y%m%d%H%M%S)"
assert_rehearse_name "$database_name"
fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/jyotisha-rehearse.XXXXXX")"
cleanup_work() {
local status=$?
if ! drop_rehearse_database; then
say_cleanup_failed
status=1
fi
rm -rf -- "$work_dir"
exit "$status"
}
trap cleanup_work EXIT HUP INT TERM
copy_batch "$work_dir/before" before
copy_batch "$work_dir/all" all
before_count="$(count_sql "$work_dir/before")"
all_count="$(count_sql "$work_dir/all")"
if [ -e "$work_dir/before/$STOP_BEFORE" ]; then
refuse "stop file leaked into the first batch"
fi
[ -f "$work_dir/all/$STOP_BEFORE" ] || refuse "stop file is missing from the migration set"
if [ "${JYOTISHA_REHEARSAL_PLAN:-}" = 1 ]; then
trap - EXIT HUP INT TERM
rm -rf -- "$work_dir"
printf 'rehearsal_plan=1\n'
printf 'database_name=%s\n' "$database_name"
printf 'batch_before=%s\n' "$before_count"
printf 'batch_all=%s\n' "$all_count"
printf 'stop_before=%s\n' "$STOP_BEFORE"
printf 'stop_in_before=no\n'
exit 0
fi
if [ "$local_mode" -eq 0 ]; then
: "${RECOVERY_DUMP:?RECOVERY_DUMP is required}"
[ -f "$RECOVERY_DUMP" ] && [ ! -L "$RECOVERY_DUMP" ] || refuse "recovery dump is missing or unsafe"
case "$RECOVERY_DUMP" in
"$DEPLOY_PATH"/backups/*.dump.enc) ;;
*) refuse "recovery dump must be an encrypted file under the production backup directory" ;;
esac
environment_file="$DEPLOY_PATH/.env.production.database"
[ -f "$environment_file" ] && [ ! -L "$environment_file" ] || refuse "production database environment file is missing or unsafe"
state_directory="$DEPLOY_PATH/.state"
backup_directory="$DEPLOY_PATH/backups"
lock_file="$state_directory/mutation.lock"
[ -d "$state_directory" ] && [ ! -L "$state_directory" ] || refuse "production state directory is unsafe"
[ -d "$backup_directory" ] && [ ! -L "$backup_directory" ] || refuse "production backup directory is unsafe"
exec 9>"$lock_file"
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" != 1 ]; then
flock -n 9 || {
echo "another production mutation holds the host lock" >&2
exit 75
}
fi
set -a
# shellcheck disable=SC1090
. "$environment_file"
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
[ "$POSTGRES_DB" = "jyotisha" ] || refuse "unexpected production database name"
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || refuse "production backup disk usage must remain below 70 percent"
mapfile -t postgres_containers < <(
docker_cli ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=postgres'
)
[ "${#postgres_containers[@]}" -eq 1 ] || refuse "expected exactly one running production PostgreSQL container"
postgres_container="${postgres_containers[0]}"
mapfile -t web_containers < <(
docker_cli ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=web'
)
[ "${#web_containers[@]}" -eq 1 ] || refuse "expected exactly one running production web container"
web_container="${web_containers[0]}"
web_image="$(docker_cli inspect --format '{{.Image}}' "$web_container")"
[[ "$web_image" =~ ^sha256:[0-9a-f]{64}$ ]] || refuse "production web image identity is unsafe"
web_network="$(docker_cli inspect --format '{{range $name, $net := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' "$web_container" | head -n 1)"
[ -n "$web_network" ] || refuse "production web network is missing"
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE \"$database_name\";" >/dev/null
REHEARSAL_CREATED=1
set -o pipefail
openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$RECOVERY_DUMP" |
docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$database_name" \
--exit-on-error
set +o pipefail
unset PRODUCTION_BACKUP_ENCRYPTION_KEY
# The URL is captured only into the environment, never echoed.
SCHEMA_DATABASE_URL="$(python3 - "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$database_name" <<'PY'
import sys, urllib.parse
user, password, name = sys.argv[1:]
if name == "jyotisha" or not name.startswith("rehearse_"):
sys.exit(2)
print("postgresql://{user}:{password}@postgres:5432/{name}?options=-c%20role%3Dschema_owner".format(
user=urllib.parse.quote(user, safe=""),
password=urllib.parse.quote(password, safe=""),
name=name,
))
PY
)"
export SCHEMA_DATABASE_URL
run_batch() {
local batch="$1" log="$2" status
set +e
docker_cli run --rm --network "$web_network" \
-e SCHEMA_DATABASE_URL \
-e MIGRATIONS_DIRECTORY=/migrations \
-v "$batch:/migrations:ro" \
-v "$frontend_dir/scripts/db-migrate.mjs:/app/frontend/scripts/db-migrate.rehearse.mjs:ro" \
-w /app/frontend \
--entrypoint node \
"$web_image" scripts/db-migrate.rehearse.mjs >"$log" 2>&1
status=$?
set -e
return "$status"
}
else
REHEARSAL_CREATED=1
run_batch() {
run_node_migrator "$1" "$2"
return 0
}
fi
column_present() {
local table="$1" column="$2" present
present="$(docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = '${column}') then 'yes' else 'no' end")"
[ "$present" = "yes" ]
}
sql_scalar() {
local kind="$1" sql
if [ "$local_mode" -eq 1 ]; then
run_node_sql "$kind"
return
fi
case "$kind" in
count)
sql="select count(*)::text from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)"
;;
archived)
if ! column_present chat_sessions archived_at; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chat_sessions where archived_at is not null"
;;
self)
if ! column_present chart_profiles role; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chart_profiles where role = 'self'"
;;
*)
return 2
;;
esac
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c "$sql"
}
capture_grants() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql grants
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select line from (select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line from information_schema.role_table_grants where table_schema not in ('pg_catalog', 'information_schema') union all select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema not in ('pg_catalog', 'information_schema')) s order by 1"
}
chart_profiles_select_missing() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql bug1063
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select count(*)::text from (values ('id'),('user_id'),('role'),('name'),('birth_date'),('reported_birth_time'),('birth_time_source'),('birth_time_period'),('declared_window_start'),('declared_window_end'),('uncertainty_before_minutes'),('uncertainty_after_minutes'),('latitude'),('longitude'),('timezone_id'),('timezone_offset'),('birth_place_label'),('birth_place_type'),('birth_place_provider'),('birth_place_provider_id'),('ayanamsa'),('active_birth_time'),('active_birth_date'),('active_birth_timezone_offset'),('birth_time_status'),('gender')) as cols(column_name) where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')"
}
grant_migration_database() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql grant_create
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 \
-c "GRANT CONNECT, CREATE ON DATABASE \"${database_name}\" TO schema_owner;" >/dev/null
}
echo "rehearsal_database=$database_name"
echo "batch_before=$before_count"
echo "stop_before=$STOP_BEFORE"
archived_before="$(sql_scalar archived)" || refuse "could not count archived sessions before migrations"
self_before="$(sql_scalar self)" || refuse "could not count self chart rows before migrations"
echo "archived_sessions_before=$archived_before"
echo "self_chart_rows_before=$self_before"
grants_before="$work_dir/grants-before.txt"
capture_grants >"$grants_before" || refuse "could not read grants before migrations"
grant_migration_database || refuse "could not grant the migration role on the rehearsal database"
set +e
run_batch "$work_dir/before" "$work_dir/before.log"
before_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/before.log"
fi
[ "$before_status" -eq 0 ] || refuse "rehearsal migration failed"
other_rows="$(sql_scalar count)" || refuse "could not count other-person profiles before the stop file"
echo "other_person_accepted_rows=$other_rows"
if [ "$other_rows" != 0 ]; then
echo "stop: $STOP_BEFORE would change $other_rows other-person profiles; production was not changed" >&2
exit 1
fi
echo "batch_all=$all_count"
set +e
run_batch "$work_dir/all" "$work_dir/all.log"
all_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/all.log"
fi
[ "$all_status" -eq 0 ] || refuse "rehearsal migration failed"
archived_after="$(sql_scalar archived)" || refuse "could not count archived sessions after migrations"
self_after="$(sql_scalar self)" || refuse "could not count self chart rows after migrations"
echo "archived_sessions_after=$archived_after"
echo "self_chart_rows_after=$self_after"
notice_lines=0
for log in "$work_dir/before.log" "$work_dir/all.log"; do
[ -f "$log" ] || continue
notice_lines=$((notice_lines + $(grep -c '^notice ' "$log" || true)))
done
echo "notice_lines=$notice_lines"
grants_after="$work_dir/grants-after.txt"
capture_grants >"$grants_after" || refuse "could not read grants after migrations"
revoked="$(comm -23 <(sort "$grants_before") <(sort "$grants_after") | wc -l | tr -d '[:space:]')"
echo "revoked_grants=$revoked"
if [ "$revoked" != 0 ]; then
refuse "migration revoked existing grants"
fi
if ! missing="$(chart_profiles_select_missing | tr -d '[:space:]')"; then
refuse "could not check chart_profiles column grants"
fi
[[ "$missing" =~ ^[0-9]+$ ]] || refuse "could not check chart_profiles column grants"
echo "chart_profiles_service_role_select_missing=$missing"
if [ "$missing" != 0 ]; then
echo "stop: rehearsal copy still lacks service_role column SELECT on chart_profiles after migrations; do not migrate production" >&2
exit 4
fi
echo "rehearsal_ok=1"
exit 0
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
# Resolve the production release SHA shared by the four production workflows.
#
# Validate mode (default): REQUESTED_SHA must be a successful staging push of
# backend-quality-gate, main and staging must be the same head H, and
# is-docs-only-range.sh REQUESTED_SHA H must exit 0.
# Discover mode (RESOLVE_MODE=discover): R is the newest such push whose range
# to H is docs-only.
# CHECK_RELEASE_GATE=true also requires a successful release-quality-gate
# dispatch whose head is R or H.
#
# stdout is three KEY=VALUE lines: release_sha, gate_run_id, head_sha.
# Exit 0 accepted, 1 refused, 2 when the Gitea API cannot be read.
# Tests set JYOTISHA_HTTP_FIXTURE_DIR and JYOTISHA_DOCS_ONLY_MODE=git.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
python="${JYOTISHA_PYTHON:-python3}"
mode="${RESOLVE_MODE:-validate}"
api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
repository="${GITEA_REPOSITORY:-root/Jyotisha}"
docs_mode="${JYOTISHA_DOCS_ONLY_MODE:-api}"
if [[ "$mode" != validate && "$mode" != discover ]]; then
echo "RESOLVE_MODE must be validate or discover" >&2
exit 2
fi
http_get() {
local url="$1" dest="$2"
if [ -n "${JYOTISHA_HTTP_FIXTURE_DIR:-}" ]; then
if ! URL="$url" DEST="$dest" FIXTURE_DIR="$JYOTISHA_HTTP_FIXTURE_DIR" "$python" - <<'PY'
import json, os, sys
from pathlib import Path
url = os.environ["URL"]
root = Path(os.environ["FIXTURE_DIR"])
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
for item in manifest:
if item["match"] in url:
status = int(item.get("status", 200))
if status != 200:
sys.stderr.write("fixture refused the Gitea request\n")
sys.exit(1)
Path(os.environ["DEST"]).write_bytes((root / item["file"]).read_bytes())
sys.exit(0)
sys.stderr.write("no fixture matched the Gitea request\n")
sys.exit(1)
PY
then
return 1
fi
return 0
fi
local -a auth=()
if [ -n "${GITEA_TOKEN:-}" ]; then
auth=(--header "Authorization: token ${GITEA_TOKEN}")
fi
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
"${auth[@]}" "$url" --output "$dest"; then
return 1
fi
}
fetch_json() {
local url="$1" dest
dest="$(mktemp)"
if ! http_get "$url" "$dest"; then
rm -f -- "$dest"
echo "Gitea API request failed" >&2
exit 2
fi
printf '%s' "$dest"
}
read_ref_sha() {
local branch="$1" dest sha
dest="$(fetch_json "$api_url/repos/$repository/git/refs/heads/$branch")"
if ! sha="$("$python" - "$branch" "$dest" <<'PY'
import json, re, sys
branch, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
ref = "refs/heads/" + branch
if not isinstance(payload, list) or len(payload) != 1 or payload[0].get("ref") != ref:
sys.exit(2)
sha = ((payload[0].get("object") or {}).get("sha") or "")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
sys.exit(2)
print(sha)
PY
)"; then
rm -f -- "$dest"
echo "Gitea ref for $branch is unreadable" >&2
exit 2
fi
rm -f -- "$dest"
printf '%s' "$sha"
}
gate_id_for() {
local sha="$1" dest gate_id
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&branch=staging&event=push&status=success&limit=100")"
set +e
gate_id="$("$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
matched = []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
):
matched.append(run)
if not matched:
sys.exit(1)
matched.sort(key=lambda run: int(run.get("id") or 0), reverse=True)
print(int(matched[0]["id"]))
PY
)"
local status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
if [ "$status" -ne 0 ]; then
echo "no successful exact-SHA production quality gate run found" >&2
exit 1
fi
printf '%s' "$gate_id"
}
require_docs_only() {
local base="$1" head="$2" status
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$base" "$head" >&2
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "release SHA is not a docs-only ancestor of the staging head" >&2
exit 1
fi
}
require_release_gate() {
local release="$1" head="$2" sha dest status found=false
for sha in "$release" "$head"; do
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&event=workflow_dispatch&status=success&limit=100")"
set +e
"$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("release-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("event") == "workflow_dispatch"
and run.get("conclusion") == "success"
):
sys.exit(0)
sys.exit(1)
PY
status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 0 ]; then
found=true
break
fi
if [ "$status" -eq 2 ]; then
echo "Gitea release gate list is unreadable" >&2
exit 2
fi
done
if [ "$found" != true ]; then
echo "no successful manual release quality gate found for the release SHA or current main" >&2
exit 1
fi
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
if [ "$main_head" != "$staging_head" ]; then
echo "main and staging must identify the same reviewed release" >&2
exit 1
fi
head_sha="$staging_head"
if [ "$mode" = validate ]; then
[[ "${REQUESTED_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || {
echo "deploy_sha must be a lowercase full commit SHA" >&2
exit 1
}
release_sha="$REQUESTED_SHA"
gate_run_id="$(gate_id_for "$release_sha")"
require_docs_only "$release_sha" "$head_sha"
else
list="$(fetch_json "$api_url/repos/$repository/actions/runs?branch=staging&event=push&status=success&limit=100")"
set +e
candidates="$("$python" - "$list" <<'PY'
import json, sys
try:
payload = json.loads(open(sys.argv[1], encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
best = {}
for run in payload.get("workflow_runs") or []:
workflow = str(run.get("path") or "").split("@", 1)[0]
sha = run.get("head_sha") or ""
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
and len(sha) == 40
):
run_id = int(run.get("id") or 0)
if sha not in best or run_id > best[sha]:
best[sha] = run_id
for sha, run_id in sorted(best.items(), key=lambda item: item[1], reverse=True):
print(f"{sha} {run_id}")
PY
)"
status=$?
set -e
rm -f -- "$list"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
release_sha=""
gate_run_id=""
while read -r candidate_sha candidate_id; do
[ -n "$candidate_sha" ] || continue
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$candidate_sha" "$head_sha" >&2
status=$?
set -e
if [ "$status" -eq 0 ]; then
release_sha="$candidate_sha"
gate_run_id="$candidate_id"
break
fi
done <<<"$candidates"
if [ -z "$release_sha" ]; then
echo "no docs-only staging quality gate can be released" >&2
exit 1
fi
fi
if [ "${CHECK_RELEASE_GATE:-}" = true ]; then
require_release_gate "$release_sha" "$head_sha"
fi
printf 'release_sha=%s\n' "$release_sha"
printf 'gate_run_id=%s\n' "$gate_run_id"
printf 'head_sha=%s\n' "$head_sha"
+12
View File
@@ -34,6 +34,18 @@ if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then
echo "invalid rollback authorization" >&2
exit 1
fi
if [ "$ALLOW_ROLLBACK" = "true" ]; then
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
fi
if [ "$VERIFICATION_MODE" != "internal" ] && [ "$VERIFICATION_MODE" != "public" ]; then
echo "invalid production verification mode" >&2
exit 1
+10
View File
@@ -51,6 +51,16 @@ esac
echo "production recovery point must have restore_verified=true" >&2
exit 1
}
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_created_epoch="$(date -u -d "$RECOVERY_CREATED_AT" +%s 2>/dev/null)" || {
echo "invalid production recovery creation time" >&2
exit 1
+59 -5
View File
@@ -88,6 +88,48 @@ set -a
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
privilege_sql() {
case "$1" in
table_grants)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name from information_schema.role_table_grants where table_schema = 'public' order by 1"
;;
column_privileges)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema = 'public' order by 1"
;;
table_owners)
printf '%s' "select c.relname || '|' || pg_get_userbyid(c.relowner) from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r', 'p') order by 1"
;;
*)
echo "unknown privilege check" >&2
exit 1
;;
esac
}
privilege_snapshot() {
local database="$1" kind="$2" destination="$3"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database" -At -v ON_ERROR_STOP=1 \
-c "$(privilege_sql "$kind")" | sort >"$destination"
}
privileges_match() {
local kind="$1" source_file restore_file status=0
source_file="$(mktemp)"
restore_file="$(mktemp)"
if ! privilege_snapshot "$POSTGRES_DB" "$kind" "$source_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
if ! privilege_snapshot "$restore_database" "$kind" "$restore_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
cmp -s "$source_file" "$restore_file" || status=$?
rm -f -- "$source_file" "$restore_file"
return "$status"
}
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || {
echo "production backup disk usage must remain below 70 percent" >&2
@@ -111,7 +153,7 @@ cleanup() {
trap cleanup EXIT HUP INT TERM
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-acl |
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom |
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY >"$backup_partial"
[ -s "$backup_partial" ]
@@ -127,7 +169,7 @@ openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup_file" |
sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$restore_database" \
--no-owner --no-acl --exit-on-error
--exit-on-error
counts="$({
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
@@ -146,6 +188,17 @@ for value in "$identity_users" "$profiles" "$credit_transactions" "$public_table
}
done
table_grants_match=false
column_privileges_match=false
table_owners_match=false
if privileges_match table_grants; then table_grants_match=true; fi
if privileges_match column_privileges; then column_privileges_match=true; fi
if privileges_match table_owners; then table_owners_match=true; fi
if [ "$table_grants_match" != true ] || [ "$column_privileges_match" != true ] || [ "$table_owners_match" != true ]; then
echo "restore privilege verification failed" >&2
exit 1
fi
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE \"$restore_database\" WITH (FORCE);" >/dev/null
@@ -154,14 +207,15 @@ trap - EXIT HUP INT TERM
verify_basename="${backup_basename%.dump.enc}-restore-verify.json"
verify_file="$backup_directory/$verify_basename"
printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha256": "%s",\n "created_at": "%s",\n "verified_at": "%s",\n "restore_database_removed": true,\n "checks": {"identity_users": %s, "profiles": %s, "credit_transactions": %s, "public_tables": %s}\n}\n' \
printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha256": "%s",\n "created_at": "%s",\n "verified_at": "%s",\n "restore_database_removed": true,\n "checks": {"identity_users": %s, "profiles": %s, "credit_transactions": %s, "public_tables": %s, "table_grants_match": %s, "column_privileges_match": %s, "table_owners_match": %s}\n}\n' \
"$backup_basename" "$backup_sha256" "$created_at" "$verified_at" \
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" >"$verify_file"
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" \
"$table_grants_match" "$column_privileges_match" "$table_owners_match" >"$verify_file"
chmod 600 "$verify_file"
recovery_reference="gitea-actions-run-${RECOVERY_RUN_ID}/${backup_basename}"
state_tmp="$state_directory/recovery-baseline.env.tmp.$$"
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nVERIFY_FILE=%s\n' \
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nRECOVERY_FORMAT=2\nVERIFY_FILE=%s\n' \
"$recovery_reference" "$created_at" "$backup_sha256" "$verify_basename" >"$state_tmp"
chmod 600 "$state_tmp"
mv "$state_tmp" "$state_directory/recovery-baseline.env"
+5
View File
@@ -155,4 +155,9 @@ if [ "$personal_report_daily_limit_count" -ne 1 ] ||
exit 1
fi
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?IDENTITY_TEST_OTP=' "$ENV_FILE"; then
echo "IDENTITY_TEST_OTP is forbidden in production" >&2
exit 1
fi
echo "production environment selectors: valid"
+81
View File
@@ -16174,6 +16174,7 @@
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
- 复发自:无
@@ -34241,6 +34242,7 @@
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
- 复发自:无
@@ -36085,3 +36087,82 @@
- 相关记录:BUG-1247(首页卡片入口已删)。
- 复发自:无。
- 修复版本:分支 `codex/frontend-dead-code-20261013`,尚未合入 staging。
- 合入后回归(2026-10-14):staging 门禁 run 1824 红在 `tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources`。能力审计扫描前端页面与组件源码判断「界面可见」,唯一命中「案例校验」的是本单删掉的从未挂载的内部面板,删后该项不再可见、对应体验评级由 excellent 变 thin。两处期望按三栏改为如实结果(产品负责人在 issue #63 选 A,由 Claude 直接修)。验收时只跑了被改的两份 Python 测试、没跑全量,是验收侧漏检。防复发补充:删前端组件的单,验收必须跑 Python 全量(审计会扫前端源码)。
## BUG-1348 | 测试环境后面多了文档,生产就发不出去
- 状态:fixed-pending-verify(代码和合同测试已有;生产按钮还没点,不能标 resolved)。
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #74。产品 2026-10-14 授权改四个生产工作流。
- 现象:测试环境只要在最后一次代码提交后再推了文档,四个生产发布按钮全部拒绝。
- 触发条件:要发布的提交没有自己的代码门禁记录,或者它不等于测试环境当前的头。
- 根因:生产工作流仍要求「被发布的提交等于测试环境头,并且这个头自己有一次代码门禁」。文档提交不跑那道门禁。测试环境侧早就允许「最近一次门禁提交之后只前进文档」,生产侧没有跟上。
- 修复:发布修订改成最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。四个生产工作流共用同一个判断。回滚仍要明确确认,并且目标必须在已审查的 main 历史里。第 1 次打回后又补了三处:发布修订测试不再写死 Windows 路径;主机上的迁移演练改在容器里跑,删不掉演练库会以非零退出并写出库名;测试环境头只多了文档时,回滚的控制器改用同一条发布修订,不再要求这个头自己有门禁记录。
- 验证:`tests/test_resolve_release_revision.py` 7 通过。`tests/test_rehearse_production_migrations.py` 5 通过(含主机模式不调用主机 node、清理失败非零、连接串带查询参数被拒)。工作流文本合同锁住三个生产工作流的发布门禁开关,以及回滚走发现模式。生产还没实跑。
- 防复发:上述测试锁住「中间改了代码就拒绝」「不是祖先就拒绝」「没有门禁运行就拒绝」「两个分支不一致就拒绝」「接口失败退出 2」。演练测试锁住主机不调用本机 node、清理失败必须非零。回滚合同不再允许用测试环境头自己的门禁地址。
- 相关记录:无。
- 复发自:无。
- 修复版本:分支 `codex/release-path-production-20261014`。本树最大号原来是 BUG-1346。BUG-1347 已在合并进来的测试环境记录里,这里用 1348,避免撞号。
## BUG-1349 | 生产环境如果配了测试验证码,任何人都能用固定码登录
- 状态:fixed-pending-verify(代码和测试已有;生产环境文件还没在主机上复检,不能标 resolved)。
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #74。
- 现象:只要服务器配了 6 位测试验证码,登录就不区分是不是生产站。
- 触发条件:生产环境变量里出现测试验证码,包括写成空值。
- 根因:读取测试码时只检查位数,不看站点;生产环境校验脚本也没把它列为禁止项。
- 修复:站点正好是生产域名时,配了测试码就拒绝启动。比较用的是规范化之后的站点地址,末尾多一个斜杠也算生产站。生产环境校验只要看见这个变量就失败。测试环境的行为不变。
- 验证:身份配置测试覆盖生产站抛错、网址末尾多一个斜杠仍抛错、测试站仍返回验证码、未设置返回空。本轮身份配置测试 9 通过、0 失败、0 取消。环境校验测试覆盖有值、空值都失败,没有这个变量则通过。
- 防复发:上述两处测试,包括带斜杠的生产网址。
- 相关记录:BUG-1348。
- 复发自:无。
- 修复版本:分支 `codex/release-path-production-20261014`。编号在 BUG-1347 之后。
## BUG-1350 | 生产恢复点不含属主与权限,整库还原后网站账号无权限
- 状态:fixed-pending-verify(本机 PostgreSQL 17 演练已通过;生产按钮还没点,不能标 resolved)。
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #74 第 2 次验收未过。任务书 `docs/tasks/TASK-release-path-production-fix2-20261014.md`。产品选 A,授权改备份工具。
- 现象:按 8 月以来的恢复点整库还原后,网站用来查询的账号对表没有权限,站点请求会被拒绝。恢复点旁边的四个计数仍然对得上,看不出权限丢了。
- 触发条件:用现有生产恢复点工具做备份再还原。该工具导出时丢掉属主和授权。
- 根因:导出和还原核对都丢掉属主和授权,核对只数行。8 月的恢复点工具就是这样写的。这不是 BUG-203。那条是恢复点锁文件的所有权漂移,锁和这次的授权不是同一件事。
- 修复:导出只保留自定义格式。还原核对比较源库和还原库的表权限、列权限、表属主,三者不一致就不写恢复点记录。记录里的格式版本是 2。部署和迁移只有在允许回退、并且这份记录的版本不低于 2 时才继续,否则提示先做新的恢复点。演练不再改类型属主,改为比对迁移有没有收回原有授权,并检查服务角色对人物表各列的读取权。新的一次性库补上迁移角色的连接和建对象权限,因为整库导出带不走数据库本身的权限。
- 验证:本机 PostgreSQL 17 演练见 `docs/testing/production-migration-rehearsal-20261014.md`。权限三样一致,演练退出码 0,`rehearsal_ok=1`,演练库已删除,网站角色读到 1 行。生产按钮没点。
- 防复发:工作流文本合同锁住导出不再丢掉属主和授权,并锁住格式版本 2。演练测试锁住不再改类型属主、要比对收回的授权。
- 相关记录:BUG-203(锁文件,不是这一条)、BUG-1063(人物表列级读取权)、BUG-1348。
- 复发自:无。
- 修复版本:分支 `codex/release-path-production-20261014`。编号接在 BUG-1349 之后。
## BUG-1352 | 星盘页上升行的星宿三项被排盘接口丢掉
- 状态:resolved
- 首次发现:2026-10-03
- 最近更新:2026-10-15
- 影响面:星盘页本命盘和月亮盘的上升行(星宿、宿主、pada)
- 用户现象:这两行的星宿、宿主、pada、状态都是「—」,像没算出来。九曜那几行是有的。
- 触发条件:打开星盘页的本命盘或月亮盘。上升经度其实已经算好。
- 根因:引擎给上升写了星宿三项,排盘接口在重组结果时只留了星座和度数,把这三项丢了。月亮盘用的是同一份上升。上次修复只测了引擎,前端样例又是事后补上这三项的,所以接口这一层一直没被发现。
- 修复:接口重组上升时原样带上这三项;引擎没给时按同一套星宿算法补上。排盘缓存版本 6 改为 7,避免继续用没有这三项的旧结果。
- 验证:虚构盘(1990-06-15 12:00 北京)走排盘处理函数,上升和 `modules.chart.ascendant` 的三项与星宿算法一致,为 Uttara Phalguni、1、Sun。本命表和月亮盘表都渲染出这三项。
- 防复发:回归测试必须经过排盘处理函数,不能只测引擎,也不能手工往样例里补字段。
- 相关记录:BUG-1201、BUG-1314
- 复发自:BUG-1201(修复没有打通接口层)
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。任务书写明 BUG-1351 已被另一单占用,本单从 BUG-1352 起。
## BUG-1353 | 星盘表居中列的表头靠左,当前大运卡片左边多一条线
- 状态:resolved
- 首次发现:2026-10-15
- 最近更新:2026-10-15
- 影响面:星盘页行星表的表头,以及大运列表里当前这一段
- 用户现象:分盘「庙旺」的表头靠左、格子里的字居中,对不齐。当前大运卡片左边有一条竖线。
- 触发条件:打开星盘页的分盘表或大运列表。
- 根因:居中只加在内容格子上,表头没有。八列表的写法是冻结的,不能改标记。大运当前段用内阴影画了左侧强调线,设计说明里没写,也没有测试盯着。
- 修复:表头与内容一起居中。本命和月亮盘的八列表不改标记,用列选择器把速度、宿主、pada 的表头居中,宫位仍靠左。分盘的庙旺和同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。当前大运去掉左侧那条线,浅底和细边框留着。
- 验证:样式合同锁定八列表对应表头居中、宫位列仍靠左,以及当前大运规则里没有 box-shadow。分盘、行运、Bhava 的渲染测试锁定表头带居中标记。
- 防复发:上述样式合同和渲染测试。
- 相关记录:BUG-1076、BUG-1352
- 复发自:无
- 修复版本:分支 `codex/chart-ascendant-row-20261015`。
@@ -0,0 +1,91 @@
# 生产待上线迁移(2026-10-14)
对照 `origin/main` 与本分支基线,`frontend/supabase/migrations` 新增 63 个文件,没有修改或删除。`frontend/db/migrations` 在这之间是 0 个。迁移文件本身不改。
分级看的是去掉函数体之后还留在文件顶层的语句。`A` 是用户能直接看见的改写或删除。`B` 是范围有限的改写。`C` 是顶层还有收回权限。`D` 是只加表、列、函数或授权。
任务书初筛是 A 2、B 5、C 约 45、D 其余。复核后是 A 2、B 7、C 42、D 12。多出来的两条 B 是写在匿名代码块里的标题修补,机械筛会把它们看成没有顶层更新。
重复的时间戳只记录、不改名:`20260816010000` 有两个文件,`20260920010000` 有两个文件。仓库里另外还有 `20260806010000` 和 `frontend/db/migrations` 的 `20260807010000`,不在这 63 个里面。
## 产品需知
- `20260921010000`:以前归档的会话会全部回到列表。这是取消归档功能时已经定过的。
- `20260925020000`:人物表里「本人」那一行会删掉,本人资料只留在账户资料里。
- `20260816010000_accept_exact_family_birth_times`:家人整点时间会变成已采用。
- `20260822010000`:只知道时段的,会补上开始和结束钟点。
- `20260823010000`:没有日期的职业、外貌、胎记证据会变成已确认。
- `20260906030000`:时间完全未知且还没采用的校正,会先进入时段比较。
- `20260916020000`:校正标题修补。生产上预期是 0 行。NOTICE 不是 0 就停下,只报两个数字。
- `20260925010000`:自动校正标题会改成带结果。只看 NOTICE 的行数。
- `20260925030000`:他人档案里已采用或已确认的时间会退回已申报。演练在跑这个文件之前先数行,预期 0。不是 0 就停下,只把数字报给产品,不要继续。
上面这 63 个文件里没有服务角色读人物表的授权(BUG-1063)。测试环境后来多了 `20261014010000_chart_profiles_service_role_select.sql`,它排在停止文件之后,会进演练的第二批。冒烟看的是跑完迁移之后的演练库:仍是 false 就退出码 4,不要点生产迁移。这不是去问正在服务的生产库。
## 63 条
| 文件 | 级 | 作用 | 对生产用户 | 演练要看 |
| --- | --- | --- | --- | --- |
| `20260816010000_accept_exact_family_birth_times.sql` | B | 家人给出的整点时间,从「已申报」改成「已采用」。 | 只动还没做过校正、并且本来就是整点的家人时间。 | 迁移前后数 public.profiles 里被这条命中的行。 |
| `20260816010000_admin_redemption_admin_access.sql` | C | 兑换码管理改成普通后台能力,并收紧权限。 | 不改用户已有的点数或兑换记录。 | 迁移无报错;后台仍只有管理员能进。 |
| `20260817010000_billing_catalog_admin_runtime_read_policies.sql` | D | 给后台只读价格目录加策略。 | 用户看不到新入口,也不改已有订单。 | 迁移无报错。 |
| `20260818010000_admin_product_catalog_mutations.sql` | C | 后台改价格目录的权限收紧。 | 不改用户已购内容。 | 迁移无报错。 |
| `20260819010000_rectification_ingest_precision_plateau.sql` | C | 校正录入的精度判断收紧。 | 不改已填写的出生时间。 | 迁移无报错。 |
| `20260820010000_rectification_family_appearance_d12.sql` | C | 校正增加家人外貌相关判断,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820020000_rectification_d5_d7_precision.sql` | C | 校正增加两类分盘精度,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820030000_rectification_d3_family.sql` | C | 校正增加家人分盘,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820040000_rectification_occupation_horary.sql` | C | 校正增加职业问事,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260822010000_declared_birth_window_clocks.sql` | B | 把「只知道大概时段」回填成开始和结束钟点。 | 只填原来空白的时段列,不改已经写上的时间。 | 数 public.profiles 里时段来源且两列仍空的行,迁移后应变少。 |
| `20260823010000_rectification_occupation_dateless.sql` | B | 没有日期的职业、外貌、胎记证据可以记成已确认。 | 只改这三类证据的确认状态,不改出生时间。 | 数这三类证据在迁移前后的确认行数。 |
| `20260823020000_rectification_inference_choice_write.sql` | C | 校正选择题的写入权限收紧。 | 不改已有答案。 | 迁移无报错。 |
| `20260824010000_rectification_inference_transition_ledger.sql` | C | 校正推断过程改成只追加的记录,并收紧权限。 | 旧的推断结果仍在,不删。 | 迁移无报错。 |
| `20260824020000_rectification_choice_action.sql` | C | 校正点选和停止改成一次写完,并收紧权限。 | 不改已有对话。 | 迁移无报错。 |
| `20260824030000_rectification_turn_origin.sql` | C | 校正每句话记下是打字、点选、语音还是重试,并收紧权限。 | 不改已有对话正文。 | 迁移无报错。 |
| `20260825010000_rectification_evidence_kind_date_dedup.sql` | C | 同类又同日的校正证据去重规则收紧。 | 不删除用户原文。 | 迁移无报错。 |
| `20260826010000_rectification_inference_round_audit.sql` | C | 校正每一轮多记审计字段,并收紧权限。 | 不改已有结论。 | 迁移无报错。 |
| `20260826020000_rectification_choice_focus_identity.sql` | C | 校正选项卡用服务器保存的编号认身份,并收紧权限。 | 刚发出的卡不会因为编号对不上而被拒绝。 | 迁移无报错。 |
| `20260826030000_rectification_migration_ledger_health.sql` | C | 网页角色不能再直接读迁移账本,健康检查改走只返回文件名的函数。 | 用户界面不变。 | 迁移无报错。 |
| `20260827010000_rectification_candidate_profile_freshness.sql` | C | 校正候选和档案是否还新鲜的判断收紧。 | 不改已采用的时间。 | 迁移无报错。 |
| `20260827020000_rectification_vedastro_validation_retry.sql` | C | 校正外部核对失败后可以重试,并收紧权限。 | 不改已采用的时间。 | 迁移无报错。 |
| `20260830010000_chat_session_chart_profile_binding.sql` | D | 会话可以绑定到某一位已保存的人。 | 旧会话不改内容。 | 迁移无报错。 |
| `20260830020000_personal_report_sections.sql` | C | 个人报告按章节保存进度,并收紧权限。 | 已生成的报告正文还在。 | 迁移无报错。 |
| `20260831010000_feature_pricing.sql` | C | 功能价格表收紧权限。 | 不改用户余额。 | 迁移无报错。 |
| `20260831020000_feature_pricing_admin_runtime_read_policy.sql` | D | 后台可以只读功能价格。 | 用户侧不变。 | 迁移无报错。 |
| `20260901010000_append_consultation_question.sql` | C | 咨询提问的写入函数收紧。 | 不改已有对话。 | 迁移无报错。 |
| `20260901020000_chat_session_pin_archive.sql` | D | 会话增加置顶和归档用的列。 | 这一步还不把归档会话放回列表。 | 迁移无报错。 |
| `20260902010000_rectification_question_on_turn.sql` | C | 校正把问过的题和选中的项记在当轮上,并收紧权限。 | 刷新后还能看到刚才那道题。 | 迁移无报错。 |
| `20260902020000_rectification_tool_activity_timing.sql` | C | 校正工具耗时从已有记录推算,并收紧权限。 | 不新增用户能看到的个人资料。 | 迁移无报错。 |
| `20260903010000_profile_ayanamsa.sql` | D | 本人资料增加岁差设置。 | 不改已有星盘结论。 | 迁移无报错。 |
| `20260904010000_read_report_candidate_range.sql` | C | 报告只通过一个只读函数看校正窗口,表权限保持收回。 | 找不到时按未找到处理,不编一个窗口。 | 迁移无报错。 |
| `20260905010000_personal_report_longform_appendices.sql` | C | 长报告附录单独缓存,失败不影响报告本身,并收紧权限。 | 已完成的报告不会因为附录失败被改成失败。 | 迁移无报错。 |
| `20260906010000_chat_session_context_summary.sql` | D | 会话增加一段上下文摘要列。 | 旧对话正文不变。 | 迁移无报错。 |
| `20260906020000_adopted_credible_range.sql` | C | 采用校正结果时另存当时的可信窗口,并收紧权限。 | 原来的搜索起点不被改写。 | 迁移无报错。 |
| `20260906030000_rectification_block_scan_stage.sql` | B | 出生时间完全未知、而且尚未采用的校正,阶段改成先比较时段。 | 已经确认、关闭或已有具体时间的不改。 | 数 public.agentic_rectification_cases 里被改成 block_scan 的行。 |
| `20260907010000_personal_report_card_summary.sql` | D | 报告列表增加一段纯文字摘要。 | 不改报告正文。 | 迁移无报错。 |
| `20260907020000_rectification_declared_uncertainty.sql` | C | 校正可以按用户说的不确定范围收窄或放宽,并收紧权限。 | 不改已经采用的时间。 | 迁移无报错。 |
| `20260909010000_profile_ayanamsa_service_role_grants.sql` | D | 补岁差列给服务角色的读取权。 | 不改用户设置。 | 迁移无报错。 |
| `20260910010000_rectification_host_fallback_phase.sql` | C | 校正在模型没写出答案时记下宿主兜底,并收紧权限。 | 不改已有答案。 | 迁移无报错。 |
| `20260915010000_rectification_touch_chat_session.sql` | C | 校正有进展时,会话的更新时间跟着前进,并收紧权限。 | 只打开或改标题不会刷新这个时间。 | 迁移无报错。 |
| `20260916010000_consultation_session_capacity.sql` | C | 咨询字数上限只数用户看得到的正文,并收紧权限。 | 思考过程仍保存,但不占那一档额度。 | 迁移无报错。 |
| `20260916020000_rectification_session_title_repair.sql` | B | 校正会话标题里对不上的月日会按创建时间改回;更新时间只向前补。 | 生产停在 08-16 时标题是固定的「生时校正」,预期改 0 行。 | 只看两条 NOTICE 的数字。不是 0 就停下,不记录标题文字。 |
| `20260920010000_consultation_free_completion.sql` | C | 咨询完成时的免费收尾规则收紧。 | 不改已扣的点数。 | 迁移无报错。 |
| `20260920010000_rectification_receipt_result_identity.sql` | C | 校正回执改用最后一次完成结果的来源,并收紧权限。 | 不改已展示的结论形状。 | 迁移无报错。 |
| `20260920020000_adopted_birth_date.sql` | C | 增加采用后的日期和时区列,并收紧权限。 | 原来申报的日期不改。 | 迁移无报错。 |
| `20260920030000_rectification_dated_windows.sql` | C | 校正窗口可以带公历日期,并收紧权限。 | 已有结果和原始日期不改写。 | 迁移无报错。 |
| `20260921010000_retire_chat_session_archive.sql` | A | 所有已归档会话的归档标记清空,会话回到列表。 | 用户会重新看到以前归档的会话。列先留着,以后再删。 | 数 public.chat_sessions 里归档标记非空的行,迁移后应为 0。 |
| `20260925010000_rectification_session_title_result.sql` | B | 自动生成的校正标题改成带结果,并回填已有自动标题。 | 手改过的标题不动。 | 只看 NOTICE 的行数,不记录标题文字。 |
| `20260925020000_chart_subject_typed_columns.sql` | A | 他人档案改用单独的列;本人在人物表里的镜像行删除。 | 本人资料改由账户资料承载。他人档案还在。 | 数 role 为 self 的人物行,迁移后应为 0。不要打印行内容。 |
| `20260925030000_chart_subject_trust_and_limit.sql` | B | 他人档案里已采用或已确认的时间退回「已申报」,并清空采用列。 | 每人最多再保存 4 位他人。生产预期受影响行数是 0。 | 本文件之前先数命中行。不是 0 就停下,只报数字。 |
| `20260925040000_deleted_report_release.sql` | C | 已删除报告的释放规则收紧。 | 不把已删除报告重新公开。 | 迁移无报错。 |
| `20260926010000_rectification_telemetry.sql` | C | 校正交付时写一行不含身份的汇总,并收紧权限。 | 不保存出生时间或对话原文。 | 迁移无报错。 |
| `20260927010000_profile_gender.sql` | D | 账户和已保存的人增加可选性别。 | 不填也能继续用。 | 迁移无报错。 |
| `20260927020000_profile_adopted_birth_service_role_select.sql` | D | 补服务角色读取已采用日期列的权限。 | 不改已有日期。 | 迁移无报错。 |
| `20260928010000_profile_ayanamsa_six.sql` | D | 岁差选项从四种扩成六种。 | 原来的设置仍然有效。 | 迁移无报错。 |
| `20260930010000_user_consents.sql` | C | 增加用户协议和隐私政策的同意记录,并收紧权限。 | 只追加,不改写旧同意。 | 迁移无报错。 |
| `20260930020000_account_deletion_requests.sql` | C | 增加 7 天冷静期的注销申请,并收紧权限。 | 申请当时会退出全部登录。不立刻删资料。 | 迁移无报错。 |
| `20260930030000_user_feedback.sql` | C | 增加应用内反馈和回复评价的表,并收紧权限。 | 旧对话不改。 | 迁移无报错。 |
| `20260930040000_moderation_events.sql` | C | 增加审核命中记录,并收紧权限。 | 只记类别和规则编号,不记原文。 | 迁移无报错。 |
| `20260930050000_reply_quality_snapshots.sql` | C | 点了负向评价时保存那一轮的问题、回答和上下文,并收紧权限。 | 没有点评价的对话不写这份快照。 | 迁移无报错。演练输出不要带正文。 |
| `20261001010000_rectification_segment_adoption.sql` | C | 校正可以把一段时间里的某一分钟记为采用结果,并收紧权限。 | 这不是新的引擎候选,也不等于最终确认。 | 迁移无报错。 |
| `20261001020000_rectification_segment_checks.sql` | C | 校正时段结果的检查收紧。 | 不改已采用的时间。 | 迁移无报错。 |
| `20261002010000_rectification_dated_algorithm_generation.sql` | D | 校正记下算法版本,方便以后区分新旧打分。 | 不改已经交付的结果。 | 迁移无报错。 |
@@ -0,0 +1,158 @@
# 生产首发步骤(2026-10-14)
给产品负责人照着点。本单合入测试环境并部署完成之后再做。执行人不要把出生资料、姓名、邮箱、会话、密钥写进仓库或评论。
发布用两个提交:
- `H`:当时的测试环境分支头。`main` 要快进到这一个,不能合并出新提交。
- `R`:测试环境上最近一次「代码门禁成功」的提交。`H` 可以比 `R` 更靠前,但中间只能是文档。公网健康检查里的版本号必须等于 `R`。
四个按钮都在 Gitea Actions 里,要从 `main` 点。迁移演练没有第五个按钮。产品在生产主机上自己跑脚本,看下面第 5 步。
## 0. 前一天
冻结代码推送。文档可以继续推。确认测试环境健康检查的版本号仍是 `R`,并且从 `R` 到当前测试环境头没有代码改动。
## 1. 核对版本
打开 `https://staging.jyotisha.chat/api/health`。`deployment.gitCommit` 等于 `R`。
在仓库里执行 `deploy/is-docs-only-range.sh R <当前测试环境头>`。退出码 0 才继续。退出码 1 或 2 都停,不要发布。
## 2. 把 main 快进到测试环境头
用快进把 `main` 推到 `H`。不要做 merge。快进之后 `main` 和测试环境是同一个提交。
通过:Gitea 上两个分支指向同一个 40 位提交。
## 3. Release Quality Gate
Gitea → Jyotish Release Quality Gate → Use workflow from:`main`。不需要填发布提交。
通过:这一步成功,摘要里能看到 `release_sha`(这就是 `R`)和 `head_sha`(这就是 `H`),并写着相对 `R` 的后续改动只是文档。
失败:不要点后面的按钮。看失败那一行的原文,把原文交给验收,不要附带环境文件。
## 4. Create Production Recovery Point
Gitea → Create Production Recovery Point。Use workflow from:`main`。`deploy_sha` 填 `R`。
通过:成功结束,记下三样东西:`recovery_reference`、`recovery_created_at`、以及它已经写明还原验证通过。主机上的 `/opt/jyotisha-production/.state/recovery-baseline.env` 里必须有 `RECOVERY_FORMAT=2`。没有这一行,或者数字小于 2,都不要往下走:先再点一次创建恢复点。8 月以来不带权限的旧备份不能再拿来回退。加密备份在生产主机的备份目录里,不要打开、不要把密钥贴出来。
失败:停。不要迁移。
## 5. 在主机上演练迁移
没有单独的 Gitea 按钮。用部署账号在生产主机上跑。主机上如果还没有这份代码,只读检出 `R`,不要在主机上构建镜像。备份文件要自己填进 `RECOVERY_DUMP`,脚本不会猜哪一个是最新的。
先看计划,不连数据库:
```text
DEPLOY_PATH=/opt/jyotisha-production JYOTISHA_REHEARSAL_PLAN=1 bash deploy/rehearse-production-migrations.sh
```
通过:输出里 `stop_in_before=no`,并且一次性库的名字以 `rehearse_` 开头。
再正式跑。把刚才那个备份文件路径放进 `RECOVERY_DUMP`,文件必须在 `/opt/jyotisha-production/backups/` 下面,并且是 `.dump.enc`:
```text
DEPLOY_PATH=/opt/jyotisha-production RECOVERY_DUMP=/opt/jyotisha-production/backups/<刚才那个备份文件名> bash deploy/rehearse-production-migrations.sh
```
脚本只还原到一次性库,不会改名为 `jyotisha` 的生产库。新库带不走原来的建库权限,所以脚本只在这个一次性库上,把连接和建对象的权限交给迁移角色。生产库不动。磁盘使用率要低于 70%。另一项生产变更正在进行时会停。跑完会删掉一次性库。
通过,要同时满足:
- 输出有 `other_person_accepted_rows=0`。这是 `20260925030000` 之前的人数。不是 0 就停,只把这个数字发给产品,不要继续,也不要写出任何一行的内容。
- `archived_sessions_before` / `archived_sessions_after`:归档标记还在的会话数。迁移后应为 0。旧备份里还没有这一列时,前面那个数是 `absent`。
- `self_chart_rows_before` / `self_chart_rows_after`:人物表里「本人」那一行。迁移后应为 0。还没有 `role` 列时,前面那个数是 `absent`。
- `notice_lines`:两批迁移日志里 `notice` 的行数。标题修补和自动标题回填的数字在这些行里。生产上标题修补预期是 0;不是 0 就停,只报数字。
- `revoked_grants=0`。这是迁移前后授权的差:迁移不能收回备份里已经有的表权限或列权限。不是 0 就停,不要点生产迁移。
- `chart_profiles_service_role_select_missing=0`。这是服务角色对人物表每一列的读取权,缺几列这里就是几。不是 0 时脚本退出码是 4,并停住。不要点生产迁移。
- 最后一行是 `rehearsal_ok=1`,并且命令的退出码是 0。只看见这一行但退出码不是 0,不算通过。
演练结束后,在 postgres 容器里执行 `\l rehearse_*`,确认没有名字以 `rehearse_` 开头的库留下来。演练库删不掉时,脚本退出码不是 0,并写出「演练库未删除,请手动 DROP DATABASE rehearse_…」。只删它写出的那一个名字,不要删名为 `jyotisha` 的生产库。生产库还没动。把退出码和它打印的那一句原因留下来。不要复制数据库口令或备份密钥。
## 6. Migrate Production Database
只在第 5 步 `rehearsal_ok=1` 之后点。
Gitea → Migrate Production Database。Use workflow from:`main`。
- `deploy_sha`:`R`
- `recovery_reference`:第 4 步记下的引用
- `recovery_created_at`:第 4 步记下的时间,24 小时内
- `restore_verified`:true
通过:工作流成功,并且没有「恢复点超过 24 小时」或「版本对不上」的失败句。
失败:不要部署新代码。按下面的「失败后」整库还原。
## 7. Deploy production
Gitea → Deploy production。Use workflow from:`main`。
- `deploy_sha`:`R`
- `allow_rollback`:false
- `verification_mode`:public
通过:工作流成功。然后做 `docs/testing/production-first-release-20261014.md`。
## 失败后
接受整库还原到第 4 步那个恢复点。恢复点之后新写入的数据会丢掉。内测还没开放时,这个窗口里通常还没有新的用户数据。
旧代码读不了新的表结构。所以不能只把程序退回去。两件事都要做,都由产品负责人点,不要让执行代理代点。
1. 整库还原。没有单独按钮。只允许 `RECOVERY_FORMAT=2` 的恢复点。先看 `/opt/jyotisha-production/.state/recovery-baseline.env`:没有 `RECOVERY_FORMAT=2` 就停,回到第 4 步再点一次创建恢复点。8 月的旧备份不能用。第 4 步的 `recovery_reference` 斜杠后面就是备份文件名,例如 `production-pre-migration-20261014T120000Z.dump.enc`。文件在 `/opt/jyotisha-production/backups/` 下面。先 `sha256sum` 这个文件,和同一份 `recovery-baseline.env` 里的 `RECOVERY_SHA256` 对上,再继续。
**先证明备份能解开、能读,再动生产库。** 顺序不能反:先删库再发现备份解不开,网站就停在一个空库上。口令和密钥只用主机上已有的环境文件,不要贴出来,也不要 `echo`:
```text
cd /opt/jyotisha-production
set -a; . ./.env.production.database; set +a
backup=/opt/jyotisha-production/backups/<备份文件名>
sha256sum "$backup" # 必须等于 recovery-baseline.env 里的 RECOVERY_SHA256
postgres_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=postgres)
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
| sudo -n docker exec -i "$postgres_id" pg_restore --list >/dev/null && echo backup_readable=1
```
上面只读备份、不碰任何库。必须看到 `backup_readable=1`;没看到(解密失败、文件损坏)就停,不要往下做,问产品负责人。
看到之后,再停 `api` 和 `web`,不要停 `postgres`:
```text
api_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=api)
web_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=web)
sudo -n docker stop $api_id $web_id
postgres_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=postgres)
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE jyotisha WITH (FORCE);"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE jyotisha;"
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
| sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
pg_restore -U "$POSTGRES_USER" -d jyotisha --exit-on-error
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d jyotisha -v ON_ERROR_STOP=1 \
-c "GRANT CONNECT, CREATE ON DATABASE jyotisha TO schema_owner;"
```
整库导出带不走数据库本身的权限。上面这一句只补回迁移角色在这个库上的连接和建对象权限,和主机最初的引导一致。不要删掉空的 `public`,备份会改它的属主,先删会让还原停住。
还原后数这四样,要和备份旁边的 `*-restore-verify.json` 里 `checks` 一致:`identity.users`、`public.profiles`、`public.credit_transactions`、`public` 里的表数量。同一份 `checks` 里 `table_grants_match`、`column_privileges_match`、`table_owners_match` 必须都是 true。这三样是创建恢复点时,源库和还原库的表权限、列权限、表属主已经对过。对不上就停,不要部署。
然后再用网站账号实际读一行。没有登录身份时,行级安全会把行藏起来,看起来像没读到,但那不是权限不够。先把身份设成库里已有的一条账户,再切换角色。下面这条必须成功(退出码 0),并且能读到一行。权限不够会直接失败,失败就停,不要部署:
```text
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d jyotisha -v ON_ERROR_STOP=1 \
-c "select set_config('request.jwt.claim.sub', (select id::text from public.profiles limit 1), false); set role authenticated; select 1 from public.profiles limit 1;"
```
2. 再点 Deploy production,`allow_rollback` 选 true,目标用发布前的生产版本 `7b620c7a`。点之前先在 Gitea 的 Actions 运行列表里确认:这个版本有一次成功的手动发布门禁(Release Quality Gate)。查不到就停,问产品负责人,不要点。测试环境头如果只多了文档、自己没有门禁记录,回滚用的控制器仍然是最近一次门禁通过的修订,不再因为这个被拒绝。目标版本仍必须在已经审查过的 main 历史里。回滚如果提示恢复点格式低于 2,同样先回到第 4 步创建新恢复点,不要改用旧备份。
还原并且部署成功之后,再按核对清单看健康检查。版本号应回到 `7b620c7a`。
@@ -0,0 +1,33 @@
# PROGRESS · 星盘页上升行(issue #53 第二轮)
- 分支:`codex/chart-ascendant-row-20261015`
- 基线:`origin/staging` `2b7112a6010acbcc0fec809b6b27e2375293dd5c`
- 编号:本文件最大号是 BUG-1350。任务书说 BUG-1351 已被 #65 的分支占用,本单用 BUG-1352(上升星宿被接口丢掉,复发自 BUG-1201)和 BUG-1353(表头错位与大运左边线)。
## 做了什么
- `/api/chart` 重组上升时带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`。引擎已经有就原样带上;没有才按同一套星宿算法补。`cache_schema_version` 6 → 7。
- 上升行状态从「—」改为「不适用」。本命、月亮盘共用这一行。
- 八列表的标记没改。速度、宿主、pada 的表头用列选择器居中,宫位仍靠左。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。
- 当前大运去掉 `box-shadow`,浅底和细边框还在。
## 样例
`chart-view-golden.json` 里上升的三项本来就是 Uttara Phalguni、1、Sun,和这次接口真实返回一致,数值没有手改。`sourceCommand` 不再写「用 `_nakshatra_fields` 事后补上」,改为写明来自 `_compute_chart_sync`。
## 断言改动
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chart-varga-table.test.tsx` 上升行状态格 | `<td>—</td>` | `<td>不适用</td>` | 产品把横杠看成没数据 |
| `test_api_scratch_retention.py` 缓存版本 | `6` | `7` | 旧缓存没有星宿三项,必须换键 |
| 基线 html | 仍是改前的整张表 | 不改文件 | 测试用替换得到新表,原值留在基线里 |
## 自测
- `pytest tests/test_api_chart_ascendant_nakshatra.py tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py tests/test_api_scratch_retention.py -q`:34 通过,退出码 0。没有新增 `JyotishAPIHandler.__new__`。
- 前端:`chart-varga-table`、`chart-moon-transit`、`chart-bhava`、`chart-page-mobile-width-20260929`、`runtime-sources-no-test-imports`。月亮盘那条第一次用整页去配「上升」,配到了图上的标记;改成只看月亮盘表格后通过。
- `tsc --noEmit -p .`:0 个错误,退出码 0。
- `npm run lint`:0 个错误、123 条原有警告,退出码 0。
- `chart-page-view` 与 `chart-page-hook`:46 通过,退出码 0。
- 真机清单沿用任务书里已有的 `docs/testing/chart-ascendant-row-20261015.md`。
@@ -0,0 +1,47 @@
# PROGRESS · 发布通路改规则 + 生产首发(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- 基线:从 `origin/staging` 新建,开工 HEAD `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`。任务书里的 `48a2fadd` 是更早的记录;`74384be0` 是本基线的祖先。
- 开工时测试环境健康检查的版本号与这个 HEAD 相同。收尾时公开健康检查已变成 `cb8004a188afd51ec440a7678f91be801fbd3147`,本分支没有变基到那个提交。本轮没有改测试环境。
- 预检 `scripts/pre_work_check.py`:远端仓库可见,Python 可用。碎片扫描 90 秒超时,聚焦测试 45 秒超时。不能写成预检通过。
- 编号:本树 `docs/BUG_HISTORY.md` 最大号原是 BUG-1346。BUG-1347 在另一条还没合入的分支上,本单用 BUG-1348、BUG-1349,避免以后撞号。
## 做了什么
1. 四个生产工作流共用 `deploy/resolve-release-revision.sh`。发布修订是最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。回滚仍走原来的确认和祖先检查。
2. 生产环境拒绝测试验证码:环境校验看见这个变量就失败;运行时如果站点是生产域名并且配了验证码,就拒绝启动。
3. 63 个新增迁移的清单、演练脚本、首发步骤和发布后核对都写好了。迁移演练没有单独的 Gitea 按钮,这是决策记录只授权四个工作流之后的做法。
4. 没有点任何生产按钮,没有推 `staging` 或 `main`。
## 自测
| 命令 | 结果 |
| --- | --- |
| `tests/test_resolve_release_revision.py` | 7 通过 |
| `tests/test_rehearse_production_migrations.py` | 3 通过。拒绝非生产路径、拒绝生产库名、计划输出把停止文件留在第二批 |
| `tsc --noEmit -p frontend` | 0 错 |
| `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 四个生产工作流 | `js-yaml` 能解析 |
| 新脚本 `bash -n` | 通过(工作流测试里的语法检查那一条通过) |
| 生产发布、恢复点、生产迁移、手动发布门的文本合同 | 通过 |
| 生产站拒绝测试验证码的身份配置测试 | 通过 |
同一批前端测试文件里还有 8 条失败,0 条取消。它们是这台 Windows 机器的环境,不是把断言改弱:
- 环境文件校验要求 Unix 权限 `0600`。Git Bash 里 `chmod 600` 之后 `stat` 仍是 `644`,脚本在看到验证码检查之前就停了。
- 工作流 YAML 检查找到的 `python` 是 Windows 占位程序,退出码 9009。四个生产文件另用项目里的 `js-yaml` 解析通过。
- 两条同步测试缺 `rsync`。
- 一条回滚测试在临时目录上 `install` 改权限被拒绝。
- 纯文档范围脚本在这台机器上没有 `python3`,退出码 49。发布修订测试用仓库里的 Python 跑过了同一条判断。
## 迁移演练(本机一次性库)
空库,角色按主机引导脚本的名字建好,没有真实个人资料。`deploy/rehearse-production-migrations.sh` 在本地模式跑完:
- 停止文件之前 160 个迁移,全部应用后 174 个。
- `other_person_accepted_rows=0`
- 两条标题修补 NOTICE 都是 0
- 网页角色读账户资料、读会话列表:true
- 服务角色读人物表:false,退出码 4
这是预期停止。BUG-1063 的授权不在这 63 个文件里。演练库已删除。在那笔授权进入这一批之前,不要点生产迁移。主机上的正式演练仍由产品按 runbook 做,本机这次不能代替主机恢复点。
@@ -0,0 +1,35 @@
# PROGRESS · 发布通路第 1 次打回(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- 被打回的提交:`00e6efdbfa3d97b0aad764865321bc00fb1cee13`
- 已把当时的 `origin/staging` 合并进来,合并提交 `0dc1d8fa`。没有改写已经推过的历史,也没有变基。
- 没有点任何生产按钮,没有推 `staging` 或 `main`。
## 做了什么
1. 发布修订测试改为用当前 Python。Windows 上如果找到的是系统自带的 bash,就改用 Git Bash。Linux 不再去找写死的 `python.exe`。
2. 主机上的迁移演练:计数、冒烟、删库都在 postgres 容器里做;迁移仍在 web 容器里做。主机上不再调用本机 node。演练库删不掉时退出码不是 0,并写出「演练库未删除,请手动 DROP DATABASE rehearse_…」。本地连接串只要带 `?` 就拒绝。恢复演练库时保留备份里的授权,再把对象属主改成 `schema_owner`。退出码 4 的意思是:演练库跑完迁移后,服务角色仍然不能读人物表。不是去问正在服务的生产库。
3. 生产回滚的控制器改成和正向发布同一条规则:最近一次门禁通过的修订。要回滚的那个版本自己的门禁、必须落在已审查的 main 历史里,这两条没有放宽。
4. 三个生产工作流的合同测试锁住发布门禁开关。生产网址末尾多一个斜杠时,测试验证码仍然拒绝启动。
5. 恢复点工作流没有再改成用门禁产物里的路径清单。原因:改路径清单或纯文档判断脚本本身就会触发门禁,所以测试环境头上的那份和发布修订上的那份是同一份,除非正在发布的就是这两份文件。恢复点只给正在服务的库做快照,不跑迁移、不换镜像。真正改库和换镜像的两步,仍然用发布修订的产物再核对一次。
## 自测
| 命令 | 结果 |
| --- | --- |
| `pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` | 12 通过,退出码 0 |
| 两个脚本 `bash -n` | 通过 |
| `tsc --noEmit -p frontend` | 退出码 0 |
| `npm run lint --prefix frontend` | 0 error,123 条原有 warning |
| 身份配置测试 | 9 通过,0 失败,0 取消 |
| 工作流合同测试整文件 | 44 条:39 通过,5 失败,0 取消 |
| `npm run test:db` | 未跑。本轮没有改表 |
整文件里失败的 5 条都是这台 Windows 机器的环境,和上一轮进度里记的是同一类:`python` 占位退出码 9009、缺 `rsync` 两条、临时目录改权限被拒绝、纯文档判断脚本找不到 `python3` 退出码 49。生产部署、恢复点、生产迁移这三条合同测试是通过的,里面包含新加的发布门禁开关和回滚发现模式。部署脚本的语法检查也通过。
## 偏离
- 假主机测试跳过文件锁。这台机器上的 Git Bash 对 `flock -n` 会一直卡住。正式主机仍然要拿到锁,拿不到就退出 75。
- 复制迁移文件改成每个目录一次复制。选中的文件和以前相同。逐个启动复制在这台机器上要几十秒,正式 Linux 主机不受影响。
- `docs/BUG_HISTORY.md` 里本来就有两份标题。合并时保持测试环境那份全文,只在末尾补了 BUG-1348、BUG-1349。没有去重。
- 63 条清单表没有重写。表上方的说明改了一句:服务角色授权在后来的 `20261014010000` 文件里,会进第二批。退出码 4 仍表示演练库跑完后还是没有这项授权。
@@ -0,0 +1,41 @@
# PROGRESS · 发布通路修复单 2(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- issue:#74。先并入当时的 `origin/staging`(合并提交 `d13250f4`),没有改写已推历史。
- 产品选 A:恢复点带上属主和权限,并在本机真实 PostgreSQL 17 上演练。没有点生产按钮。
- 新记录:BUG-1350。不是 BUG-203 的复发。BUG-203 是恢复点锁文件的所有权。
## 做了什么
1. 恢复点导出只保留自定义格式。还原后比较表权限、列权限、表属主,不一致就不写恢复点记录。记录里写 `RECOVERY_FORMAT=2`。创建恢复点的按钮输出仍是原来的 7 行,那个工作流文件没有改。
2. 部署和迁移在允许回退时,读主机上的恢复点记录。版本低于 2 就拒绝,并提示先做新的恢复点。正向部署不要求这份文件。
3. 演练不再改类型属主。迁移前后比较授权,收回了原有授权就退出 1。人物表列级读取权缺了就退出 4。新的一次性库补上迁移角色的连接和建对象权限,因为整库导出带不走数据库本身的权限。
4. 手册的整库还原去掉丢掉属主和授权的参数。还原后补回迁移角色在这个库上的连接和建对象权限。读网站账号之前先设登录身份,否则行级安全会把行藏起来。
## 和任务书不完全一样的地方
- 空库必须留着 `public`。备份是去改它的属主,不是重新创建它。先删掉会停在「schema public 不存在」。
- 数归档会话时,列还不存在不能写在同一条查询里,数据库会在解析阶段就拒绝。先看列在不在,再计数。没有这一列时仍输出 `absent`。
- 本机演练用的是 fixture 里的 PostgreSQL 17,不是生产主机上的那一份备份。生产恢复脚本要求生产目录,不能在这台电脑上原样执行。导出参数、加密方式和还原核对是按脚本重放的。
## 自测
| 命令 | 结果 |
| --- | --- |
| `bash -n` 四个生产脚本 | 退出码 0 |
| `tests/test_rehearse_production_migrations.py` | 6 通过,退出码 0 |
| `tests/test_resolve_release_revision.py` 与上一份一起 | 13 通过,退出码 0(授权那条断言是在这之后加上的,演练测试又单独跑过) |
| `tsc --noEmit -p frontend` | 退出码 0 |
| `npm run lint --prefix frontend` | 退出码 0,0 error,123 条原有 warning |
| `frontend/tests/staging-backend-workflows.test.ts` | 44 条里 39 通过、5 失败、0 取消。失败是这台 Windows 机器的环境:python 退出码 9009、两条缺 rsync、临时目录改权限被拒绝、纯文档范围脚本退出码 49。生产部署、恢复点、生产迁移合同(第 32、34、35、36、37 条)通过 |
| 本机 PostgreSQL 17 演练 | 见 `docs/testing/production-migration-rehearsal-20261014.md`。权限三样一致,`rehearsal_ok=1`,退出码 0,演练库 0 个残留,网站角色读到 1 行 |
## 预检
`scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 退出码 1。远端仓库可见,适配器正常。碎片扫描 90 秒超时,聚焦测试 45 秒超时。这是这台电脑上预检超时的老问题,没有改台账,也不写成预检通过。
## 没做
- 没有点生产按钮,没有连生产主机。
- 没有推 `staging` 或 `main`。
- 没有改测试环境工作流、门禁路径清单、纯文档判断脚本。
+6 -1
View File
@@ -213,6 +213,7 @@
| --- | --- | --- | --- | --- |
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
| `TASK-chart-ascendant-row-20261015.md` | `PROGRESS-chart-ascendant-row-20261015.md` | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段(BUG-1352,复发自 BUG-1201);状态改「不适用」;居中列表头对齐;当前大运去掉左边线(BUG-1353) | 待验收 | `codex/chart-ascendant-row-20261015` |
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
@@ -334,7 +335,9 @@
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-beta-main-path-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 第 1 次打回后已修,待验收 | `codex/beta-main-path-20261014` |
| `TASK-beta-main-path-fix-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **#73 第 1 次打回**:`fca62fda` 内容通过;与 #63 删除的旧报告视图有代码冲突,接受删除即可 | 待验收 | `codex/beta-main-path-20261014` |
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
| `TASK-release-path-production-20261014.md` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 第 2 次打回后续修 | `codex/release-path-production-20261014`(BUG-1348、BUG-1349) |
| `TASK-release-path-production-fix-20261014.md` | `PROGRESS-release-path-production-fix-20261014.md` | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 已修,第 2 次仍未过 | `codex/release-path-production-20261014` |
| `TASK-release-path-production-fix2-20261014.md` | `PROGRESS-release-path-production-fix2-20261014.md` | **#74 修复单 2(产品选 A)**:恢复点要带属主与权限,旧格式不得回退;演练按备份里的授权比对,并在真实 PostgreSQL 17 上跑通一次 | 待验收 | `codex/release-path-production-20261014` |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — |
| `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) |
@@ -481,7 +484,9 @@
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | — | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待领取 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-answer-effort-ab-20261013.md` | — | 答题步推理强度开关 + A/B(需模型 key) | 待领取(排队:consult-prep-parallel) | `codex/answer-effort-ab-20261013` |
| `TASK-answer-effort-ab-fix-20261015.md` | — | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140) | 待领取 | `codex/answer-effort-ab-20261013` |
| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` |
| `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` |
| `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` |
@@ -0,0 +1,36 @@
# TASK · 答题推理强度开关修复单(issue #65,第 1 次打回)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-answer-effort-ab-20261013.md`
- 被验 SHA:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`(`codex/answer-effort-ab-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
## 1. 验收结论:开关逻辑通过,前端全量多出 1 条失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 开关默认关时请求体不变 | 通过 | `CONSULT_ANSWER_REASONING_EFFORT` 未设或为空时 `answerEffortOptions` 返回 `{}`;非法值只警告一次按未设处理 |
| 只在答题步加强度 | 通过 | 主循环第 0 步 `answerReady=false`;公开日历从第一步即答题 |
| 改既有断言 | 通过 | `consult-answer-clock-20261001.test.ts` 三栏齐全 |
| tsc / lint / Python 全量 | 通过 | Python 与 staging 逐条一致(一条 prashna 族测试并发下抖动,单跑通过) |
| **前端全量 `npm test`** | **未通过:新增 1 条** | `frontend/tests/runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」 |
**根因**:`frontend/scripts/research/answer-effort-probe.mts` 约 186 行 `new URL("../../tests/fixtures/answer-effort-switch-20261013.json", import.meta.url)`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,`next build` 会类型检查它们——推上 staging 后镜像构建会失败(BUG-1140;#58、#62 已因同一形状各打回一次)。
## 2. 修法要求
1. 把 `answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`(文件名不得以 `.test.ts` / `.test.tsx` 结尾),路径改相对;同步改 PROGRESS 与 `docs/testing/answer-effort-ab-20261013.md` 里的命令。
2. **交回前必须本机跑一次** `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`,fail 与 cancelled 为 0,贴进回报。
3. 业务代码不改;开关默认值不改。
## 3. 非阻断提醒(不要求本轮改,写进 PROGRESS 即可)
开关打开时,`prepareStep` 返回的 `providerOptions` 来自 `agentGenerationSettings(model, { thinking: "enabled", ... })`。确认它不会把基础流选项里原有的 `providerOptions`(思考预算、缓存等)整体覆盖掉;若会覆盖,在 BLOCKED.md 记下,等有模型 key 的 A/B 一并核对。
## 4. 验收标准
前端全量 `npm test` 失败清单与 staging 基线逐名相同;`git diff ab7f66be..<新 SHA> -- frontend/src` 为空。
## 5. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,37 @@
# TASK · 合盘与报告子进程修复单(issue #64,第 1 次打回)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-api-chart-subprocess-gate-20261013.md`
- 被验 SHA:`336286e428c7cb43cac71eae5286e112c0b500b7`(`codex/api-chart-subprocess-gate-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用本单编号
## 1. 验收结论:内容通过,Python 全量多出 1 条失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 合盘带 skip、两两并发 | 通过 | Claude 在 Linux 复跑 `scripts/research/api_chart_subprocess_ab.py`:串行 1.19 s / 4 个子进程 → 并发带 skip 0.015 s / 0 个,摘要逐字节一致 |
| 报告正文 | 通过 | 同脚本 `report_bytes_match: true`(本机装了 PyJHora,短路不触发,子进程数不变;生产不装,以 Grok 回报为准) |
| 进程内缓存 | 通过 | 只缓存「PyJHora 是否可导入」「外部解释器路径」「静态 oracle 文件校验(键含路径 + mtime + 大小)」,不含出生资料(BUG-721) |
| 主文件 | 通过 | 类方法数不变,行数减少 |
| 前端全量 / tsc / lint | 通过 | 失败清单与 staging 基线相同 |
| **Python 全量(与 staging 逐条比对)** | **未通过:新增 1 条** | `tests/test_supabase_user_data_contract.py::test_synastry_route_orchestrates_python_chart_and_ashtakoot`:`assert 'postPython("/api/chart", selfPayload)' in route` |
**根因**:这条 Python 合同按源码文本检查 `frontend/src/app/api/synastry/route.ts` 里的 `postPython("/api/chart", selfPayload)`。本单把它改成了 `Promise.all` 里的 `postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })`,合同没跟着改(AGENTS.md §7.8;Python 测试也会读前端源码,所以前端改动要 grep `tests/` 和 `frontend/tests/` 两处)。
另:本机复跑时 `api_chart_subprocess_ab.py` 会**覆盖仓库里的** `docs/testing/api-chart-subprocess-gate-ab-20261013.json`。研究脚本默认不应写已入库的记录文件:改为默认写到临时目录或 `artifacts/`,只有显式 `--out <路径>` 时才写指定文件。
## 2. 修法要求
1. 改 `tests/test_supabase_user_data_contract.py` 那条断言,按 §7.3 写「原值 / 新值 / 原因」三栏,**意图不放宽**:仍要锁住「合盘路由向 Python `/api/chart` 请求本人和对方两张盘」,新断言要同时锁住带 `skip_vedastro_main_entry_overview: true`;该测试里其它 token(如 ashtakoot 调用)不动。
2. `grep -rn "synastry/route\|postPython(\"/api/chart\"\|postPython(\"/api/varga_full\"" tests frontend/tests`,其它按源码文本断言的同样处理。
3. `api_chart_subprocess_ab.py` 默认不覆盖入库文件(见上)。
4. 业务代码不改。PROGRESS 末尾记一行。
## 3. 验收标准
Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量无新增;`git diff 336286e4..<新 SHA> -- frontend/src scripts/*.py` 只有第 3 条的脚本改动。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,102 @@
# TASK · 星盘页上升行无星宿、表头错位、大运卡左边线(issue #53 第二轮)
- 日期:2026-10-15
- 来源:issue #53,产品负责人 10-15 在 staging 星盘页反馈:D1 本命与月亮盘的上升行「星宿 / 宿主 / pada / 状态」全是「—」;分盘表「庙旺」表头与内容不对齐;大运卡左侧那条线要去掉
- 执行方:**Grok(Windows + Docker)**,分支 `codex/chart-ascendant-row-20261015`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时为 BUG-1350);**BUG-1351 已被 issue #65 的分支占用,本单从 BUG-1352 起**
## 1. 基线
`origin/staging` `10d0c4bba`。
## 2. 事故实证与根因
### A · 上升行星宿 / 宿主 / pada 为空(D1 与月亮盘同因)——BUG-1201 修复从未打通
- 引擎正确:`scripts/jyotish_engine.py::compute_chart_data`(约 12378 行)给上升加了 `**_nakshatra_fields(asc_deg)`;`scripts/domain_calculation_service.py::compute_chart`(约 242 行)原样传出。
- **`/api/chart` 把它们丢了**:`scripts/jyotish_api_server.py::_compute_chart_sync` 约 6660–6666 行重建 `result['ascendant']`,只留 `sign / sign_idx / degree / degree_in_sign / lon`;`result['modules']['chart']['ascendant']` 用的是同一个字典。该段来自 06-24 的 `494e3870`,BUG-1201 的 `ae18952e` 没碰它。虚构盘(1990-06-15 12:00 北京)实测 `_compute_chart_sync` 返回的上升没有任何星宿键。
- 前端映射没问题:`frontend/src/lib/chart-view-mapper.ts::ascendantRow`(约 390–409 行)读 `nakshatra / nakshatra_pada / nakshatra_lord`,缺失时按设计回落「—」。月亮盘复用同一个 `view.vedic.ascendant`(`chart-vedic-tab.tsx` 约 351–355 行)。
- **不是缓存问题**:服务器缓存 900 s、前端 5 分钟,`cache_schema_version` 已是 6;BUG-1201 那次把 4→5 也没用,因为接口输出根本没变。
- **为什么没拦住**:`tests/test_report_chart_blank_columns.py` 只调 `compute_chart_data`;前端 golden `frontend/tests/fixtures/chart-view-golden.json` 取自 `domain_calculation_service.compute_chart` 而不是接口真实响应,又手工补了 `_nakshatra_fields`——违反 AGENTS.md §7.4「合同 fixture 必须来自真实引擎响应」,所以丢字段的那一层从没被测到。
### B · 上升行「状态」写「—」
`chart-vedic-tab.tsx::AscendantRow` 约 99 行写死 `<td>—</td>`,注释与 `frontend/DESIGN.md` 约 1181 行写「上升没有尊贵,状态格写「—」」——这是当时的有意设计,不是回归。产品负责人这次把它算作「没数据」。
### C · 表头与内容不对齐
`frontend/src/app/globals.css` 约 5619–5623 行把 `.chart-page-planet-table` 的所有 `th / td` 设为左对齐;约 5658 行只把 `td.chart-page-planet-mid` 居中;对应的 `<th scope="col">` 没有这个类,于是表头靠左、内容居中。所有 `.chart-page-planet-table` 都有:D1 / 月亮盘的「速度、宿主、pada」,分盘的「庙旺、同位」,行运的「速度」,Bhava 的「换宫」。
### D · 大运卡左边线
`globals.css` 约 5774–5778 行 `.chart-page-dasha-period.is-current { …; box-shadow: inset 2px 0 0 var(--color-focus); }`(`--color-focus` 是陶土色),挂在当前大运上(默认展开的那一张,`chart-dasha-tab.tsx` 约 115 行)。来自 `1e3570b4`;`frontend/DESIGN.md`「大运 Tab」一节没写,也没有测试引用。
## 3. 决策记录
- 产品负责人在 issue #53 授权修复上面四项。
- **B(按推荐先定,产品不同意回一句即改)**:上升行「状态」改写「不适用」,与分盘表上升「庙旺」格(BUG-1099)、报告上升行(BUG-1314)同一口径。**这推翻 `frontend/DESIGN.md` 约 1181 行「状态格写「—」」**,DESIGN.md 同步改。
- **D(按推荐先定)**:只去掉左边那条线,当前大运的浅底与细边框保留,用来标「现在走的是这一段」。
- 不推翻 BUG-1201 的设计(上升行要有星宿 / 宿主 / pada),这次是把它真正打通;记为新 BUG「复发自 BUG-1201(修复未打通接口层)」,并在 BUG-1201 记录里补一行更正其验证结论。
## 4. 硬红线
1. `scripts/jyotish_api_server.py`:类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长(`tests/test_api_server_growth_contract.py`;注释里写这个词也算);只在 `_compute_chart_sync` 里补字段。
2. 不改引擎数值;不改 `_nakshatra_fields`、`compute_chart_data`。
3. 合同 fixture 必须来自**接口真实响应**(§7.4);不得手工补字段。
4. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep(§7.8,Python 测试也会读前端源码):
`grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden\|ascendant\b" frontend/tests tests | grep -v node_modules`
已知:`frontend/tests/chart-varga-table.test.tsx` 约 101–103 行断言上升行末尾 `<td>—</td>`;`frontend/tests/fixtures/chart-d1-table-baseline.html`;`chart-page-view.test.tsx`、`chart-page-hook.test.ts`。
5. `frontend/scripts/**`、`frontend/src/**` 不得从 `frontend/tests/**` 导入或读文件(BUG-1140,近期已 5 次打回)。交回前必须本机跑 `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`。
6. 不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/**`;不动数据库;不升级依赖。
## 5. 任务分解
### T1 · 接口带出上升的星宿字段(新 BUG,复发自 BUG-1201)
- `_compute_chart_sync` 重建 `result['ascendant']` 时,从 `ascendant_data` 带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`(引擎已有就原样带;没有就调 `_nakshatra_fields(lon)`);`cache_schema_version` 6 → 7。
- 验收:
- 新回归测试**经过 `/api/chart` 处理函数**(用现有测试里已有的调用方式,不得新增 `JyotishAPIHandler.__new__`):虚构盘的响应 `ascendant` 有三个字段,且等于 `_nakshatra_fields(lon)`;`modules.chart.ascendant` 同样有。
- 用这次的**接口真实响应**重新生成 `chart-view-golden.json` 的上升部分,`fixtureProvenance` 写明命令;删掉手工补字段的那一步。
- 前端:D1 与月亮盘上升行渲染出星宿、宿主、pada(测试断言具体值,不是只断言「不是 —」)。
### T2 · 上升行状态「不适用」
- `AscendantRow` 状态格改「不适用」;DESIGN.md 约 1181 行同步改;`chart-varga-table.test.tsx` 与 baseline html 按三栏改。
### T3 · 表头对齐
- 给居中列对应的 `<th scope="col">` 加 `chart-page-planet-mid`(或用列选择器),让表头与内容同为居中;涉及 D1 / 月亮盘、分盘、行运、Bhava 四类表。8 列表的 markup 若被 DESIGN.md 约 1195 行写成「冻结」,改用 CSS 选择器、不改 markup,并在 DESIGN.md 记一句。
- 验收:渲染测试断言每张表里居中列的 `th` 与 `td` 同为居中(类名或计算后的选择器);DESIGN.md 更新。
### T4 · 去掉大运卡左边线
- 删 `.chart-page-dasha-period.is-current` 的 `box-shadow` 那一行,浅底与边框保留;DESIGN.md「大运 Tab」一节加一句「当前大运用浅底和细边框标出,不画左侧强调线」。
- 验收:一条 CSS 合同测试断言 `.chart-page-dasha-period.is-current` 不含 `box-shadow`。
### T5 · 记录
- `docs/BUG_HISTORY.md`:新 BUG(上升星宿字段被 `/api/chart` 丢弃,复发自 BUG-1201,写清为何 golden 没拦住);BUG-1201 记录补一行更正;表头错位、左边线各一条或合为一条 UI 记录。
- `docs/tasks/PROGRESS-chart-ascendant-row-20261015.md`、`CHANGELOG.md`、`frontend/DESIGN.md`。
- 真机清单 `docs/testing/chart-ascendant-row-20261015.md`。
## 6. 让步顺序
T1 → T3 → T2 → T4。T1 不可让。
## 7. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | `python -m pytest tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py <新测试> -q`;前端 `tsc --noEmit`、`npm run lint`、改到 / 新增的前端测试单文件,**加** `tests/runtime-sources-no-test-imports.test.ts`(fail 与 cancelled 为 0)。前端全量与 Python 快速门不跑 |
| Claude 验收 | 上述 + Python 全量与基线逐条比对 + 快速门 + 前端全量 `npm test` + `next build`(`/` 仍 Static,首屏 gzip ±2%) |
| 不需要 | `test:db` |
## 8. 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/chart-ascendant-row-20261015 .worktrees/chart-ascendant-row-20261015 origin/staging
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # BUG-1351 已被 #65 占用
grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden" frontend/tests tests
```
@@ -0,0 +1,52 @@
# TASK · 发布通路修复单(issue #74,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-release-path-production-20261014.md`(产品负责人 2026-10-14 在 issue #74 再次确认授权修改四个生产工作流)
- 被验 SHA:`00e6efdbfa3d97b0aad764865321bc00fb1cee13`(`codex/release-path-production-20261014`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- 红线不变:仍只改原任务书授权的四个工作流;不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;不点任何生产按钮
## 1. 验收结论:发布规则本身成立,三处阻断首发
已确认成立(不需要改):正向路径要求 R 有精确 SHA 的 staging push 门禁、R 或 H 有手动发布门禁、main == staging、R..H 只含文档;变更前复核用的是 R 门禁产物里的 `is-docs-only-range.sh` 与 `gated-paths.txt`;staging 工作流零改动;`AGENTS.md` 只动了授权的 §2.4、§2.5、§2.7。
### B1 · Python 测试在 Linux 上全红(会让发布门禁红)
`tests/test_resolve_release_revision.py` 第 13–14 行写死 `PYTHON = ROOT/".venv"/"Scripts"/"python.exe"` 和 `D:\Program Files\Git\bin\bash.exe`。Claude 在 Linux 全量 pytest:该文件 7 条里 6 条失败(`python.exe: No such file or directory`)。`release-quality-gate.yml` 跑全树 pytest,runbook 第 3 步会红。
- 修:用 `sys.executable`;bash 用 `shutil.which("bash")`,Windows 再回落到 Git Bash。Linux 与 Windows 都要过。
### B2 · 主机模式的迁移演练跑不通,且会在生产库上留下一份生产数据副本
`deploy/rehearse-production-migrations.sh`:
- `run_node_sql`(约 88–137 行)在**主机**上跑 `node` + `import pg`,连 `@postgres:5432`(约 253 行)——这个主机名只在 Docker 网络里有;生产主机按规矩不装 node / `node_modules`、不构建。计数步骤失败退出 1,清理步骤(约 142 行)也用同一个函数,同样失败只打警告 → `rehearse_<时间戳>` 库连同恢复出来的生产数据留在生产 PostgreSQL 上,与 runbook「演练库会删掉」矛盾。
- 恢复用 `--no-owner --no-acl`,对象归 `POSTGRES_USER`,而迁移以 `role=schema_owner` 执行,`ALTER` 可能报 "must be owner";ACL 没恢复,第 316–325 行的权限检查看到的不是生产真实授权,「退出 4 = BUG-1063 授权缺失」不可靠。
- 修:
1. 主机模式下所有 SQL 都走 `docker compose exec` / `docker run` 进 postgres 或 web 容器执行(与第 271 行 `--entrypoint node` 一致),不依赖主机 node。
2. 清理放 `trap`,并且**清理失败要非零退出并大字提示「演练库未删除,请手动 DROP DATABASE rehearse_xxx」**,不得只打警告。
3. 恢复后先把对象属主改成 `schema_owner`(或以 restore 后的属主执行迁移),并说明权限检查的口径;若做不到「看到生产真实授权」,就把「退出 4」的含义在脚本头和 runbook 里改成如实描述。
4. 本地模式的连接串拒绝带 `?` 查询参数(`?database=` / `?host=` 能覆盖库名守卫)。
5. runbook 第 5 步列的通过标准(归档会话前后数、自镜像前后数、NOTICE 计数)脚本并不打印:要么脚本打印,要么 runbook 删掉,二者必须一致。
- 验收:新增或扩展 `tests/test_rehearse_production_migrations.py`:主机模式不调用主机 `node`(用假 `docker` 记录调用);清理失败时退出码非零;带查询参数的 URL 被拒。
### B3 · runbook 的失败回退走不通
- `deploy-production.yml` 回滚分支(约 126–137 行)仍要求 main 头 H 有一次 staging push 门禁成功记录;而本单要解决的正是「H 只多了文档、没有门禁记录」的情况——此时 `first` 返回 null、`jq -e` 失败,runbook 约 107 行依赖的回滚会被拒。
- 修:回滚分支对 controller 的门禁查找改用与正向相同的规则(`resolve-release-revision.sh` 解析出的 R),或在 H 无门禁时用 R 的门禁产物;回滚的「目标 SHA 必须在已审 main 历史里」等原有检查不放宽。加合同测试锁住。
- runbook 约 106 行「从恢复点整库恢复」没有给出工具和步骤:写清用哪个脚本 / 命令、需要哪个文件(`recovery_reference` 结尾就是备份文件名)、恢复后怎么核对。
## 2. 非阻断但这轮一并改
1. `frontend/tests/staging-backend-workflows.test.ts`:迁移与恢复两条用例把工作流里的门禁断言换成了检查脚本文本,**没有任何断言锁住三个工作流里的 `CHECK_RELEASE_GATE=true`**——删掉它会让正向路径失去发布门禁要求而测试仍绿。补上这三处断言(三栏写明)。
2. `frontend/src/modules/identity/config.ts` 约 101 行用原始字符串比 `AUTH_USER_ORIGIN`,`https://jyotisha.chat/`(带斜杠)会漏过「生产拒绝测试验证码」。改为与 `readOrigin` 规范化后的值比较,并加一条带斜杠的测试。
3. `resolve-release-revision.sh` 的正向判定用的是 H 自己检出里的 `gated-paths.txt`;虽然变更前有 R 产物复核兜底,但 recovery 工作流没有复核。请在 recovery 的判定里同样用 R 产物(或在 PROGRESS 写明为何恢复点可以接受)。
## 3. 验收标准
- Linux:`python3 -m pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` 全绿;Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量 `npm test` 无新增。
- `bash -n` 全部脚本通过。
- runbook 每一步与工作流 / 脚本输出逐条一致(Claude 逐条对照)。
- 仍未点任何生产按钮。
## 4. 让步顺序
B1、B2、B3 都不可让;第 2 节第 1、2 条不可让,第 3 条可写理由不改。
@@ -0,0 +1,76 @@
# TASK · 发布通路修复单 2:恢复点要带权限(issue #74,第 2 次未过后产品选 A)
- 日期:2026-10-14
- 前序:`TASK-release-path-production-20261014.md`、`TASK-release-path-production-fix-20261014.md`
- 被验 SHA:`32aa46b5731a32b9d7760738f51b983ab4dea041`(`codex/release-path-production-20261014`)
- 执行方:Grok(Windows + Docker),同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- **决策记录**:产品负责人 2026-10-14 在 issue #74 选 **A**——**授权扩大范围修改生产备份工具 `deploy/run-production-recovery.sh`**,让恢复点带上属主与权限,并要求一次在真实 PostgreSQL 17 上的端到端演练。原有授权(四个生产工作流)不变;仍不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;仍不点任何生产按钮。
## 1. 上一轮结论
已修好:B1(测试跨平台)、主机模式不依赖主机 node、清理失败非零退出并提示手动删库、库名守卫、本地 URL 拒绝查询参数、runbook 第 5 步与脚本输出一致、docs-only 头时回滚不再被拒且原有回滚保护不少、OTP 守卫按规范化 origin 比较、三处 `CHECK_RELEASE_GATE=true` 断言、recovery 用 H 的门禁路径清单的理由成立。
仍未通过,根子是同一个:
1. `deploy/run-production-recovery.sh` 第 114 行 `pg_dump ... --no-owner --no-acl`:**恢复点不含属主、不含任何 GRANT / 默认权限**。第 130 行的还原核对同样 `--no-owner --no-acl`,只数行数,所以「核对通过」看不出权限丢了。
2. 后果 A(回退):runbook 约 106–114 行照此整库还原后,网页层切到 `authenticated` / `service_role` 执行查询(`frontend/src/lib/db/local-postgres-client-core.ts` 约 377 行),对所有表无权限,整站请求被拒;四个计数却全部对得上,看不出来。之后以 `schema_owner` 跑迁移也会 "must be owner"。
3. 后果 B(演练):`deploy/rehearse-production-migrations.sh`
- 约 351–360 行改属主的 DO 块遍历 `pg_type` 里 `typtype IN ('e','c','d')`,`'c'` 包含每张表的行类型,PostgreSQL 对它报 "is a table's row type",`ON_ERROR_STOP` 直接中止;约 332–333 行改序列属主在序列先于所属表出现时也会被拒。
- 备份里没有 ACL,约 316 行还原后权限烟测看到的不是生产真实授权(`has_table_privilege` 不看列级授权),永远过不了,演练打不出 `rehearsal_ok=1`。
- 脚本头约 10 行、runbook 约 70 行说授权「来自备份里原来的授权 + 迁移」,与事实不符。
- 模拟 docker 的测试抓不到以上任何一条。
## 2. 任务
### T1 · 恢复点带属主与权限(`deploy/run-production-recovery.sh`)
- `pg_dump` 去掉 `--no-owner --no-acl`(保留 `--format=custom`)。角色是集群级对象,同一 PostgreSQL 实例内还原时 `schema_owner`、`authenticated`、`service_role` 等都已存在。
- 还原核对改为不带 `--no-owner --no-acl`,以 `POSTGRES_USER` 执行;在原有四个计数之外,**加权限核对**:对 `public` 下每张表比较源库与还原库的
`select grantee, privilege_type, table_name from information_schema.role_table_grants where table_schema='public'`
和列级 `information_schema.column_privileges`、以及 `pg_class.relowner` 的属主名,**三者必须完全相同**,不同即失败、不写 `recovery-baseline.env`。核对结果写进 `*-restore-verify.json` 的 `checks`(只写计数与是否一致,不写任何数据内容)。
- `RESTORE_VERIFIED=true` 只有在权限核对也通过时才写。
- 旧格式(8 月以来、不带权限)的恢复点**不得**再被当作可回退的恢复点:在 `recovery-baseline.env` 写一个格式版本号(如 `RECOVERY_FORMAT=2`),deploy / migrate 工作流读取恢复点时要求版本 ≥ 2,否则拒绝并提示先点 Create production recovery。合同测试锁住。
### T2 · 演练脚本按真实权限工作(`deploy/rehearse-production-migrations.sh`)
- 还原不再加 `--no-owner`(备份已带属主),**删掉**改属主的 DO 块与序列改属主循环;若仍需兜底,只能用 `ALTER TABLE ... OWNER`(对表)且不得遍历表的行类型。
- 权限烟测改为比较「迁移前还原库的授权」与「迁移后」——迁移不应收回任何已有授权;再检查本批迁移新增的授权确实存在(含 BUG-1063 的 `service_role` 对 `chart_profiles` 逐列 SELECT)。退出码含义如实写进脚本头与 runbook。
- runbook 第 5 步加一句:演练结束后执行 `\l rehearse_*` 确认没有残留库。
### T3 · runbook 的整库还原(`docs/operations/production-release-runbook-20261014.md`)
- 还原命令去掉 `--no-owner --no-acl`;写出删库重建的具体命令(只针对 `jyotisha`,`WITH (FORCE)` 前先停 `api`、`web`)。
- 还原后核对除四个计数外,加上与 `*-restore-verify.json` 里权限核对同口径的检查,**并实际以 `authenticated` 身份读一行**(如 `set role authenticated; select 1 from public.profiles limit 1;`)验证网站账号能读。
- 只允许使用 `RECOVERY_FORMAT=2` 的恢复点。
- 回退目标 `7b620c7a` 需要一次成功的手动发布门禁记录:runbook 写明首发前先确认 Gitea 里该记录仍在(runs 列表里能查到);查不到就先停,问产品负责人。
### T4 · 在真实 PostgreSQL 17 上端到端演练一次(必做,不得只跑模拟测试)
在 Grok 本机 Docker 里:
1. 用 `npm run test:db` 同一个 PG17 fixture 建库,按 `frontend/supabase/migrations/` 应用到生产当前版本对应的迁移(以 `docs/operations/production-migrations-20261014.md` 里「生产已有」的最后一条为止),写入少量**虚构**数据(每张关键表几行)。
2. 用新 `run-production-recovery.sh` 的同款 `pg_dump` 参数导出、加密(本地测试密钥),按新还原核对流程还原并比对权限——必须一致。
3. 用 `rehearse-production-migrations.sh` 本地模式对这份备份演练全部待上线迁移,必须打出 `rehearsal_ok=1`,且演练库被删除。
4. 再按 runbook T3 的整库还原步骤还原到一个新库,以 `authenticated` 读一行成功。
5. 全过程命令与输出摘要(只写计数、退出码、`rehearsal_ok`,不写数据内容与任何密钥)写进 `docs/testing/production-migration-rehearsal-20261014.md`。
### T5 · 小项
- `frontend/tests/staging-backend-workflows.test.ts` 约 1157 行:正向路径的 `CHECK_RELEASE_GATE=true` 正则会同时匹配回滚那一行,删掉正向那处仍会绿;把它锚在正向调用(例如带 `>>"$GITHUB_OUTPUT"` 的那一行)。
- 记录:`docs/BUG_HISTORY.md` 新增一条「生产恢复点不含属主与权限,整库还原后网站账号无权限」(状态写 fixed-pending-verify;复发 / 关联写清楚 8 月的恢复点工具),PROGRESS 记录本轮。
## 3. 硬红线
1. 不点任何生产按钮;不连任何生产主机;所有演练只用本机 Docker 和虚构数据。
2. 备份与核对文件里不得写入任何数据内容、密钥、口令。
3. 不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;生产工作流的改动只限 T1 要求的「恢复点格式版本」检查。
4. 改既有断言写三栏。
## 4. 验收
- Linux:`python3 -m pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` 全绿;Claude 全量 pytest、前端全量 `npm test` 无新增失败;`bash -n` 全部脚本通过。
- `docs/testing/production-migration-rehearsal-20261014.md` 有 T4 的真实 PG17 运行记录:权限比对一致、`rehearsal_ok=1`、演练库已删、`authenticated` 读一行成功。Claude 会核对记录与脚本输出格式一致;必要时要求附上脱敏后的完整输出。
- runbook 每一步与脚本、工作流逐条一致。
## 5. 让步顺序
无。T1–T4 都是首发前提。
@@ -0,0 +1,9 @@
# 真机清单 · 星盘页上升行、表头、大运卡(issue #53 第二轮)
部署到 staging 后,打开本人星盘页(手机和电脑各一次):
1. D1 本命表第一行「上升」:星宿、宿主、pada 都有值;状态写「不适用」。
2. 切到月亮盘:上升那一行同样有星宿、宿主、pada,状态「不适用」。
3. 切到任一分盘(如 D9):「庙旺」「同位」表头和下面的内容都居中对齐。
4. D1 表的「速度、宿主、pada」,行运表的「速度」,Bhava 表的「换宫」:表头和内容对齐。
5. 大运 Tab:当前那段大运没有左侧那条彩色竖线,仍有浅色底能看出是「现在这一段」。
@@ -0,0 +1,20 @@
# 生产首发后核对(2026-10-14)
产品发布完成后照着看。把「实际」一栏填成通过或没通过。不要把登录账号、页面正文、出生资料写进仓库。
`R` 是这次部署填的那个 40 位提交,也就是测试环境健康检查在发布前显示的版本。
| 核对 | 预期 | 实际 |
| --- | --- | --- |
| 打开 `https://jyotisha.chat/api/health` | 状态 200,`deployment.gitCommit` 等于 `R`,`apiGitCommit` 等于 `R` | |
| 同一份健康检查里的数据库 | `database.requiredMigrationsPresent` 为 true,`latestMigration` 与测试环境同一字段相同 | |
| 内部健康检查 | 状态 200,`swisseph_available` 为 true | |
| 打开 `/login` | 状态 200,页面数据里的 `testOtp` 是 null | |
| 未登录打开 `/api/account` | 状态 401 | |
| 用受控账号登录 | 能登录 | |
| 登录后建一张盘 | 盘能打开 | |
| 发一轮对话 | 能收到回复 | |
| 打开一份报告 | 报告页能打开 | |
| 打开 `/admin` | 只有管理员能进,普通账号不能 | |
任何一行没通过:不要继续开放内测。按 runbook 的「失败后」做整库还原,并把应用退回 `7b620c7a`。
@@ -0,0 +1,65 @@
# 生产迁移演练记录(2026-10-14)
## 本机 PostgreSQL 17
本机 Docker,镜像 `postgres:17-alpine`,和 `npm run test:db` 用的是同一个 fixture。没有连生产主机,没有点生产按钮。数据是虚构的,每种关键表一行。下面只记计数、退出码和是否一致。
1. 建库后套用生产当前版本:`frontend/db/migrations` 全部,加上 `frontend/supabase/migrations` 里文件名早于 `20260816010000` 的文件。Windows 上有 4 个同名文件,内容一致,只留一份。
2. 用恢复脚本同款方式导出:`pg_dump --format=custom`。再用 openssl `aes-256-cbc -salt -pbkdf2` 加密,然后解密。密钥没有写进仓库。
3. 新建空库,`pg_restore --exit-on-error`。不先删 `public`。用超级用户比较源库和还原库的表权限、列权限、表属主。
4. 再还原一份,跑 `deploy/rehearse-production-migrations.sh` 的本地模式。
5. 再按手册还原一份。先把登录身份设成库里已有的一条账户,再 `set role authenticated`,读 `public.profiles`。
| 项 | 值 |
| --- | --- |
| 基线迁移文件 | 111 |
| 基线迁移退出码 | 0 |
| 基线已应用 | 111 |
| 源库计数(身份账户 / 账户资料 / 点数流水 / 人物表 / public 表) | 1 / 1 / 1 / 1 / 76 |
| 加密退出码 | 0 |
| 解密退出码 | 0 |
| 表权限一致 | true(871 行) |
| 列权限一致 | true(6760 行) |
| 表属主一致 | true(75 行) |
| 演练第一批文件 | 160 |
| 演练全部文件 | 175 |
| 演练退出码 | 0 |
| archived_sessions_before | absent |
| archived_sessions_after | 0 |
| self_chart_rows_before | 0 |
| self_chart_rows_after | 0 |
| other_person_accepted_rows | 0 |
| notice_lines | 161 |
| revoked_grants | 0 |
| chart_profiles_service_role_select_missing | 0 |
| rehearsal_ok | 1 |
| 结束后名字以 rehearse_ 开头的库 | 0 |
| authenticated 读到的行 | 1 |
`notice_lines=161` 是这次空库演练里迁移提示的行数,不是生产上的标题修补人数。生产上仍按手册:标题修补不是 0 就停。
## 主机演练(产品填写)
产品在主机上跑完 `deploy/rehearse-production-migrations.sh` 后填写。只写行数和通过/停止。不要写姓名、出生时间、邮箱、会话正文、密钥或任何一行的内容。
| 项 | 结果 |
| --- | --- |
| 日期 | |
| 发布修订 R | |
| 恢复点引用 | |
| 一次性库名字(必须是 `rehearse_` 开头) | |
| `other_person_accepted_rows` | 预期 0 |
| 归档会话:迁移前非空行数 / 迁移后 | 迁移后应为 0 |
| 人物表里本人镜像:迁移前 / 迁移后 | 迁移后应为 0 |
| 标题修补两条 NOTICE | 生产上预期都是 0。不是 0 就停止 |
| 自动标题回填 NOTICE | 只写数字 |
| 未知时间改成时段比较的行数 | |
| 家人整点改为已采用的行数 | |
| 时段回填的行数 | |
| 无日期证据改为已确认的行数 | |
| 网页角色读账户资料 | true 才算过 |
| 网页角色读会话列表 | true 才算过 |
| 服务角色读人物表 | true 才算过。false 时脚本退出码 4,停止,不要点生产迁移。这是演练库里的结果,不是正在服务的生产库 |
| 最终一行 | `rehearsal_ok=1` 才算演练通过 |
停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。
+3 -3
View File
@@ -1178,7 +1178,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| pada | 1–4,居中 |
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「不适用」(2026-10-15,与分盘表、报告同一口径;不再写「—」)。数字列 `tabular-nums`,符号列与宿主列居中。
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
@@ -1192,7 +1192,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。
说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化:表头与内容同为居中。本命/月亮八列 markup 冻结,速度、宿主、pada 的表头用列选择器居中,不改 markup,也不把宫位那一列居中。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头带 `chart-page-planet-mid`。不能全局覆盖 `chart-page-planet-mid` 去改宫位列。
### 三套坐标系
@@ -1214,7 +1214,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。
### 西洋盘 Tab(2026-09-25)
+8 -2
View File
@@ -5655,11 +5655,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-planet-glyph.is-node,
.ephemeris-transit-glyph.is-node { color: var(--color-planet-node); }
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
.chart-page-planet-table th.chart-page-planet-mid,
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
/* The natal/Moon eight-column markup stays frozen; only its house column changes. */
/* The natal/Moon eight-column markup stays frozen; only its house column changes.
Speed, nakshatra lord and pada stay centered, including their headers. */
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) {
text-align: left;
}
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(3),
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(6),
.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(7) {
text-align: center;
}
/* Figures line up column-wise instead of drifting with glyph width. */
.chart-page-planet-num { font-variant-numeric: tabular-nums; }
.chart-page-boundary {
@@ -5774,7 +5781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-dasha-period.is-current {
background: var(--color-selected);
border-color: var(--color-border-strong);
box-shadow: inset 2px 0 0 var(--color-focus);
}
.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); }
.chart-page-dasha-period.is-zero span { margin-left: auto; }
@@ -79,7 +79,7 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
/**
* The ascendant is a chart point: nakshatra, lord and pada come from the
* engine (BUG-1201); it has no dignity, so the status cell stays empty.
* engine (BUG-1201); it has no dignity, so the status cell says 不适用.
* `house` is 1 except on the Moon chart, which counts from the Moon.
*/
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
@@ -96,7 +96,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow;
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
</td>
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada ?? "—"}</td>
<td>—</td>
<td>不适用</td>
</tr>
);
}
@@ -192,8 +192,8 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
<th scope="col">分盘星座</th>
<th scope="col">宫位</th>
<th scope="col">本命星座</th>
{columns.supportsDignity && <th scope="col">庙旺</th>}
{columns.supportsVargottama && <th scope="col">同位</th>}
{columns.supportsDignity && <th scope="col" className="chart-page-planet-mid">庙旺</th>}
{columns.supportsVargottama && <th scope="col" className="chart-page-planet-mid">同位</th>}
</tr>
</thead>
<tbody>
@@ -219,7 +219,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
<tr>
<th scope="col">行星</th>
<th scope="col">今天的位置</th>
<th scope="col">速度</th>
<th scope="col" className="chart-page-planet-mid">速度</th>
<th scope="col">落本命</th>
<th scope="col">本命同星</th>
</tr>
@@ -257,7 +257,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
<th scope="col">黄道经度</th>
<th scope="col">星座宫</th>
<th scope="col">Bhava 宫</th>
<th scope="col">换宫</th>
<th scope="col" className="chart-page-planet-mid">换宫</th>
</tr>
</thead>
<tbody>
+3
View File
@@ -98,6 +98,9 @@ function readTestOtp(env: IdentityEnvironment): string | null {
if (!/^\d{6}$/.test(value)) {
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
}
if (readOrigin(env, "AUTH_USER_ORIGIN") === "https://jyotisha.chat") {
throw new Error("IDENTITY_TEST_OTP is forbidden when AUTH_USER_ORIGIN is the production site");
}
return value;
}
+1
View File
@@ -90,6 +90,7 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses"
assert.match(markup, /<dt>交点<\/dt>/);
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
assert.match(markup, /<caption>Bhava · Sripati<\/caption>/);
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">换宫<\/th>/);
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/td>/g) ?? []).length, shifted.length);
+8 -1
View File
@@ -87,6 +87,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
const table = markup.slice(markup.indexOf("月亮盘"));
const ascendantRow = table.match(/<th scope="row">[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendantRow, /is-sun/);
assert.match(ascendantRow, />1</);
assert.match(ascendantRow, />不适用</);
});
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
@@ -125,7 +131,8 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
assert.match(markup, /<th scope="col" class="chart-page-planet-mid">速度<\/th>/);
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
assert.match(markup, /<dl class="chart-page-params-table">/);
assert.match(markup, /<dt>岁差<\/dt>/);
@@ -48,7 +48,21 @@ test("the Vedic waiting slot reserves the result legend band without changing th
test("only the house column in frozen natal and Moon tables overrides centering", () => {
assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
assert.match(cssDeclarations(".chart-page-planet-table th.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/);
for (const child of [3, 6, 7]) {
assert.match(
cssDeclarations(`.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(${child})`, globalStyles),
/text-align:\s*center/,
);
}
});
test("the current dasha card keeps its tint and drops the left accent line", () => {
const current = cssDeclarations(".chart-page-dasha-period.is-current", globalStyles);
assert.match(current, /background:\s*var\(--color-selected\)/);
assert.match(current, /border-color:\s*var\(--color-border-strong\)/);
assert.doesNotMatch(current, /box-shadow/);
});
test("wide chart-page content still scrolls inside its own box instead of widening the page", () => {
+8 -1
View File
@@ -99,8 +99,11 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
// 原值:上升行状态格 <td>—</td>
// 新值:<td>不适用</td>
// 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>—</td>';
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>不适用</td>';
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
assert.equal(table, baseline.replace(dashes, filled));
});
@@ -113,6 +116,8 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async (
const ascendant = row(render(view, "D1"), "上升");
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
assert.match(ascendant, />1</, "pada 1");
assert.match(ascendant, />不适用</);
// An older cached chart without the fields still renders, with dashes.
const chart = structuredClone(golden.chart);
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
@@ -136,6 +141,8 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
const markup = render(view, "D9");
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
assert.match(header, /<th scope="col" class="chart-page-planet-mid">庙旺<\/th>/);
assert.match(header, /<th scope="col" class="chart-page-planet-mid">同位<\/th>/);
assert.doesNotMatch(header, /星宿|宿主|pada/);
// Same wording as the D1 status column (BUG-1202), friendship levels included.
// 原值:同位行星单元格是「同位」,非同位是「—」
+1 -1
View File
@@ -1,5 +1,5 @@
{
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).",
"sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1202): varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions. 2026-10-15 (BUG-1352): chart.ascendant nakshatra, nakshatra_pada and nakshatra_lord are the fields returned by JyotishAPIHandler._compute_chart_sync for this birth (tests/test_api_chart_ascendant_nakshatra.py); they are not filled in by hand.",
"chart": {
"success": true,
"birth": {
+18 -7
View File
@@ -12,6 +12,9 @@ import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
import {
databaseHealthFromFilenames,
RECTIFICATION_CONTRACT_VERSION,
@@ -218,7 +221,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
try {
writeFileSync(envFile, "APP_ENV_FILE=../.env.staging\nOPENAI_API_KEY=legacy\nLLM_MODEL=legacy\n");
chmodSync(envFile, 0o600);
const first = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const first = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(first.status, 0, first.stderr);
const prepared = readFileSync(envFile, "utf8");
assert.match(prepared, /^APP_ENV_FILE=\.\.\/\.env\.staging$/m);
@@ -227,7 +230,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
assert.ok(key);
assert.equal(statSync(envFile).mode & 0o777, 0o600);
const second = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const second = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(second.status, 0, second.stderr);
const secondKey = readFileSync(envFile, "utf8").match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1];
assert.equal(secondKey, key);
@@ -267,7 +270,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
"PERSONAL_REPORT_DAILY_LIMIT=5",
];
const run = () =>
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
spawnSync(bash, [validator, envFile], { encoding: "utf8" });
const writeEnv = (lines: string[], mode = 0o600) => {
writeFileSync(envFile, `${lines.join("\n")}\n`);
chmodSync(envFile, mode);
@@ -515,20 +518,28 @@ test("production env validators accept only self-hosted production selectors and
try {
writeEnv(appEnv, appLines);
writeEnv(databaseEnv, databaseLines);
assert.equal(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
for (const forbidden of ["IDENTITY_TEST_OTP=123456", "IDENTITY_TEST_OTP="]) {
writeEnv(appEnv, [...appLines, forbidden]);
const rejected = spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" });
assert.notEqual(rejected.status, 0, forbidden);
assert.match(rejected.stderr, /IDENTITY_TEST_OTP is forbidden in production/);
}
writeEnv(appEnv, appLines);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
writeEnv(appEnv, appLines.map((line) =>
line.startsWith("ADMIN_USER_ORIGIN=")
? "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat"
: line,
));
assert.notEqual(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
writeEnv(databaseEnv, databaseLines.filter((line) =>
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
));
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
+32
View File
@@ -77,3 +77,35 @@ test("identity test OTP channel is opt-in and strictly six digits", () => {
/must be a 6-digit/,
);
});
test("production origin refuses a configured test OTP and staging still accepts one", () => {
assert.throws(
() => readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
IDENTITY_TEST_OTP: "123456",
}),
/forbidden when AUTH_USER_ORIGIN is the production site/,
);
const unset = readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
});
assert.equal(unset.provider === "self-hosted" ? unset.testOtp : "unexpected", null);
const staging = readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "123456" });
assert.equal(staging.provider === "self-hosted" ? staging.testOtp : null, "123456");
});
test("production origin with a trailing slash still refuses a test OTP", () => {
assert.throws(
() => readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat/",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
IDENTITY_TEST_OTP: "123456",
}),
/forbidden when AUTH_USER_ORIGIN is the production site/,
);
});
@@ -6,6 +6,9 @@ import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
const giteaQualityWorkflow = new URL(
"../../.gitea/workflows/backend-quality-gate.yml",
import.meta.url,
@@ -999,7 +1002,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
chmodSync(join(mockBin, "flock"), 0o755);
try {
const result = spawnSync("bash", [fileURLToPath(deployScript)], {
const result = spawnSync(bash, [fileURLToPath(deployScript)], {
encoding: "utf8",
env: {
...process.env,
@@ -1117,6 +1120,9 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
assert.match(workflow, /NODE_TOOL_IMAGE: node:22-bookworm-slim/);
assert.match(workflow, /node --version \| grep -Eq '\^v22\\\.'/);
assert.match(workflow, /docker compose version --short \| grep -Eq '\^v\?2\\\.'/);
assert.match(workflow, /RESOLVE_MODE: discover/);
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /H relative to R is docs-only/);
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
for (const dependency of [
@@ -1139,7 +1145,27 @@ test("production deploy is manual-only and consumes the accepted staging artifac
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
assert.match(production, /public staging has not accepted the requested SHA/);
assert.match(production, /current_main[\s\S]*DEPLOY_SHA[\s\S]*current_staging[\s\S]*DEPLOY_SHA/);
// 原值: current_main 与 current_staging 都必须等于 DEPLOY_SHA
// 新值: 两者必须相等,并且用门禁产物里的 is-docs-only-range.sh 确认相对 DEPLOY_SHA 仍是纯文档
// 原因: 产品 2026-10-14 允许 staging head 在最近一次门禁提交之后只前进文档
assert.match(production, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(production, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(production, /bash deploy\/resolve-release-revision\.sh/);
// 原值: /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/ 同时匹配回滚那一行
// 新值: 正向断言锚在 >>"$GITHUB_OUTPUT" 的那一次调用
// 原因: 删掉正向那一处时,回滚行仍能让旧正则通过
assert.match(production, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh >>"\$GITHUB_OUTPUT"/);
// 原值: 回滚部署不读恢复点格式
// 新值: allow_rollback 为 true 时,主机上的恢复点格式必须 >= 2
// 原因: 8 月以来不带权限的旧恢复点不能再用来回退
assert.match(production, /ALLOW_ROLLBACK" == true[\s\S]*recovery format is below 2; dispatch Create production recovery first/);
// 原值: 回滚用 main 头自己的 push 门禁,jq 取不到 first 就失败
// 新值: 回滚的控制器改用 RESOLVE_MODE=discover 解析出的 R;目标仍须在已审查的 main 历史里
// 原因: 测试环境头只多了文档时没有门禁记录,说明书里的回退会被拒绝
assert.match(production, /RESOLVE_MODE=discover CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(production, /rollback revision is not in reviewed main history/);
assert.doesNotMatch(production, /head_sha=\$controller_sha&branch=staging/);
assert.doesNotMatch(production, /stale production revision refused/);
assert.match(production, /verification_mode:/);
assert.match(production, /PRODUCTION_HOST/);
assert.match(production, /118\.194\.235\.34/);
@@ -1162,10 +1188,12 @@ test("deployment scripts pass shell syntax validation", () => {
productionSyncScript,
productionEnvValidator,
productionDatabaseEnvValidator,
new URL("../../deploy/resolve-release-revision.sh", import.meta.url),
new URL("../../deploy/rehearse-production-migrations.sh", import.meta.url),
]) {
const path = fileURLToPath(script);
chmodSync(path, 0o755);
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });
const result = spawnSync(bash, ["-n", path], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
}
});
@@ -1205,6 +1233,10 @@ test("production runner validates state and migrations before switching exact im
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
// 原值: 回滚部署不读恢复点格式
// 新值: ALLOW_ROLLBACK=true 时主机脚本也拒绝低于 2 的恢复点
// 原因: 工作流检查被绕过时,回退仍不能使用 8 月的旧恢复点
assert.match(runner, /\[ "\$ALLOW_ROLLBACK" = "true" \][\s\S]*recovery format is below 2; dispatch Create production recovery first/);
assert.match(runner, /sync-production-tree\.sh/);
assertOrder(runner, [
"validate-production-env.sh",
@@ -1246,8 +1278,17 @@ test("production recovery workflow creates a verified encrypted off-site artifac
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /permissions:\n\s+contents: read\n\s+actions: write/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
// 原值: main_head 与 staging_head 都必须等于 DEPLOY_SHA,且工作流正文里用 jq 找 release 门
// 新值: 调用 resolve-release-revision.sh,dispatch 的 GITEA_SHA 必须等于脚本给出的 head_sha
// 原因: 恢复点跟四个生产工作流共用同一条「最近门禁提交 + 其后纯文档」规则
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
// 原值: 恢复点工作流没有断言锁住 CHECK_RELEASE_GATE=true
// 新值: 恢复点必须带着这个开关调用解析脚本
// 原因: 与部署、迁移同一条发布门禁,删掉开关不能仍是绿的
assert.match(workflow, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /main_head == "\$DEPLOY_SHA" && "\$staging_head" == "\$DEPLOY_SHA"/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /git checkout --detach --force "\$DEPLOY_SHA"/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
@@ -1274,10 +1315,18 @@ test("production recovery workflow creates a verified encrypted off-site artifac
assert.doesNotMatch(runner, /rm -f[^\n]*mutation\.lock|chown -R/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /usage_percent < 70/);
assert.match(runner, /pg_dump[\s\S]*--format=custom --no-owner --no-acl/);
// 原值: pg_dump 与 pg_restore 都带 --no-owner --no-acl
// 新值: 只保留 --format=custom,还原核对也不再丢掉属主和权限
// 原因: 旧恢复点还原后网站账号对所有表都没有权限,四个计数却仍然对得上
assert.match(runner, /pg_dump[\s\S]*--format=custom/);
assert.doesNotMatch(runner, /--no-owner|--no-acl/);
assert.match(runner, /table_grants_match/);
assert.match(runner, /column_privileges_match/);
assert.match(runner, /table_owners_match/);
assert.match(runner, /RECOVERY_FORMAT=2/);
assert.match(runner, /openssl enc -aes-256-cbc -salt -pbkdf2/);
assert.match(runner, /openssl enc -d -aes-256-cbc -pbkdf2/);
assert.match(runner, /pg_restore[\s\S]*--no-owner --no-acl --exit-on-error/);
assert.match(runner, /pg_restore[\s\S]*--exit-on-error/);
assert.match(runner, /DROP DATABASE IF EXISTS[\s\S]*WITH \(FORCE\)/);
assert.match(runner, /restore_database_removed/);
assert.match(runner, /gitea-actions-run-\$\{RECOVERY_RUN_ID\}/);
@@ -1291,15 +1340,25 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
// 原值: main_head、staging_head、GITEA_SHA 都必须等于 DEPLOY_SHA,门禁 jq 写在工作流里
// 新值: 工作流调用 resolve-release-revision.sh;GITEA_SHA 对齐 head_sha,DEPLOY_SHA 对齐 release_sha
// 原因: 生产迁移与部署接受同一发布修订,不再要求被 dispatch 的 main 提交自己有 push 门禁
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
// 原值: 生产迁移工作流没有断言锁住 CHECK_RELEASE_GATE=true
// 新值: 生产迁移必须带着这个开关调用解析脚本
// 原因: 与部署、恢复点同一条发布门禁,删掉开关不能仍是绿的
assert.match(workflow, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /GITEA_SHA" == "\$DEPLOY_SHA"/);
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /backend-quality-gate\.yml/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /release-quality-gate\.yml/);
assert.match(workflow, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(workflow, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
@@ -1311,6 +1370,11 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /recovery_created_at:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /restore_verified:[\s\S]*required: true[\s\S]*default: false[\s\S]*type: boolean/);
assert.match(workflow, /\[\[ "\$RESTORE_VERIFIED" == "true" \]\]/);
// 原值: 生产迁移不读恢复点格式
// 新值: 读取 RECOVERY_FORMAT,低于 2 就拒绝并提示先创建恢复点
// 原因: 不带权限的旧恢复点不能再当作这次迁移的退路
assert.match(workflow, /RECOVERY_FORMAT/);
assert.match(workflow, /recovery format is below 2; dispatch Create production recovery first/);
assert.match(workflow, /%Y-%m-%dT%H:%M:%SZ/);
assert.match(workflow, /age > timedelta\(hours=24\)/);
assert.match(workflow, /migration files run sequentially and are not atomic as a whole/);
@@ -1334,6 +1398,8 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(runner, /date -u -d "\$RECOVERY_CREATED_AT" \+%s/);
assert.match(runner, /recovery_age_seconds <= 24 \* 60 \* 60/);
assert.match(runner, /production recovery point must have restore_verified=true/);
assert.match(runner, /RECOVERY_FORMAT/);
assert.match(runner, /recovery format is below 2; dispatch Create production recovery first/);
assert.match(runner, /migration files run sequentially and are not atomic as a whole/);
assert.match(runner, /migration_runner_database_url="postgresql:\/\/migration_runner:/);
assert.match(runner, /role%3Dschema_owner/);
@@ -1582,7 +1648,7 @@ test("is-docs-only-range.sh decides from local history and refuses non-ancestor
git("commit", "-q", "-m", message);
return git("rev-parse", "HEAD");
};
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
const run = (...args: string[]) => spawnSync(bash, [script, ...args], { cwd: root, env, encoding: "utf8" });
try {
git("init", "-q", "-b", "staging");
@@ -1700,7 +1766,7 @@ esac
] as const) {
const cleanupLog = join(root, "cleanup.log");
writeFileSync(cleanupLog, "");
const result = spawnSync("bash", ["-c", script], {
const result = spawnSync(bash, ["-c", script], {
cwd: root,
env: { ...process.env, PATH: `${root}:${process.env.PATH}`, TMPDIR: root,
NODE_TOOL_SOURCE_IMAGE: "test-image", NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com",
+14 -1
View File
@@ -2589,12 +2589,24 @@ def _vedastro_runtime_fingerprint() -> dict:
}
def _chart_ascendant_nakshatra(ascendant_data: dict, lon: float) -> dict:
"""Keep the engine's nakshatra fields when /api/chart rebuilds the ascendant."""
fields = {
key: ascendant_data[key]
for key in ('nakshatra', 'nakshatra_pada', 'nakshatra_lord')
if ascendant_data.get(key) not in (None, '')
}
if 'nakshatra' in fields:
return fields
return _load_local_module('jyotish_engine')._nakshatra_fields(lon)
def _build_api_chart_cache_payload(body: dict) -> dict:
import api_scratch_retention as retention
today = body.get('today') or body.get('current_date')
return {
'cache_schema_version': 6,
'cache_schema_version': 7,
'birth': retention.normalize_chart_cache_birth(body),
'calculation': {
'ayanamsa': _request_ayanamsa(body),
@@ -6663,6 +6675,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
'degree': round(asc_lon % 30, 2),
'degree_in_sign': round(asc_lon % 30, 2),
'lon': round(asc_lon, 4),
**_chart_ascendant_nakshatra(ascendant_data, asc_lon),
},
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
'dasha': {
@@ -0,0 +1,56 @@
"""The chart API must keep the ascendant nakshatra the engine already computed."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
TESTS = ROOT / "tests"
for path in (SCRIPTS, TESTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import jyotish_api_server # noqa: E402
from test_api_server_security import _handler # noqa: E402
BEIJING = {
"year": 1990,
"month": 6,
"day": 15,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 39.9042,
"lon": 116.4074,
"tz": 8,
"ayanamsa": "raman",
"node_mode": "mean",
"transit_date": "2026-10-15",
"skip_vedastro_main_entry_overview": True,
}
def test_api_chart_keeps_ascendant_nakshatra_from_the_engine(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
monkeypatch.setattr(
jyotish_api_server,
"_attach_vedastro_main_entry_overview",
lambda result, _birth: result,
)
engine = jyotish_api_server._load_local_module("jyotish_engine")
result = _handler()._compute_chart_sync(BEIJING)
lon = result["ascendant"]["lon"]
expected = engine._nakshatra_fields(lon)
for packet in (result["ascendant"], result["modules"]["chart"]["ascendant"]):
assert packet["nakshatra"] == expected["nakshatra"]
assert packet["nakshatra_pada"] == expected["nakshatra_pada"]
assert packet["nakshatra_lord"] == expected["nakshatra_lord"]
assert result["ascendant"]["nakshatra"] == "Uttara Phalguni"
assert result["ascendant"]["nakshatra_pada"] == 1
assert result["ascendant"]["nakshatra_lord"] == "Sun"
+4 -1
View File
@@ -139,7 +139,10 @@ def test_numeric_birth_spellings_share_one_chart_cache_key():
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
assert skipped not in keys
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
# 原值:cache_schema_version == 6
# 新值:7
# 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 7
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
+9 -2
View File
@@ -1632,9 +1632,12 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
'reports/[reportId]',
'terms',
]
# 原值: 期望集合含 'Case Validation'
# 新值: 去掉 'Case Validation'
# 原因: 唯一命中它的是从未挂载的内部面板 evidence-audit-panel.tsx(含 MEVG 字样),
# BUG-1342 删掉该死组件后,用户界面上本来就看不到案例校验;审计现在如实不再列出
assert set(audit['surfaces']['app_visible_topics']) == {
'Birth Rectification',
'Case Validation',
'Synastry 16-factor',
}
assert '/api/deep_varga_avastha' in audit['surfaces']['api_endpoints']
@@ -1666,7 +1669,11 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
assert all('ux_next_action' in row for row in ux['next_queue'])
ux_by_id = {row['id']: row for row in ux['rows']}
assert ux_by_id['birth_time_rectifier']['ux_level'] in {'excellent', 'usable'}
assert ux_by_id['case_validator']['ux_level'] == 'excellent'
# 原值: case_validator 的 ux_level == 'excellent'
# 新值: == 'thin'
# 原因: 同上,'excellent' 靠的是从未挂载的 evidence-audit-panel.tsx 被扫成「界面可见」;
# BUG-1342 删掉它后按真实界面评分,案例校验没有用户入口,评为 thin 是如实结果
assert ux_by_id['case_validator']['ux_level'] == 'thin'
assert ux_by_id['synastry_16factor']['ux_level'] == 'excellent'
for technique_id in [
'ashtakavarga_pav',
@@ -0,0 +1,240 @@
"""Safety checks for the disposable migration rehearsal. No production database."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "deploy" / "rehearse-production-migrations.sh"
def resolve_bash() -> str:
found = shutil.which("bash")
git_bash = Path(r"D:\Program Files\Git\bin\bash.exe")
if os.name == "nt" and git_bash.exists():
lowered = (found or "").lower()
if not found or "system32" in lowered or "windowsapps" in lowered:
return str(git_bash)
if found:
return found
if git_bash.exists():
return str(git_bash)
return "bash"
BASH = resolve_bash()
def run(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
full = os.environ.copy()
full.update(env)
return subprocess.run(
[BASH, str(SCRIPT)],
cwd=ROOT,
env=full,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def test_refuses_a_non_production_path() -> None:
completed = run({"DEPLOY_PATH": "/tmp/not-production"})
assert completed.returncode == 1
assert "unexpected production path" in completed.stderr
def test_refuses_the_production_database_name() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/jyotisha",
}
)
assert completed.returncode == 1
assert "refusing to touch the production database" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def test_plan_keeps_the_stop_file_out_of_the_first_batch() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_PLAN": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan",
}
)
assert completed.returncode == 0, completed.stderr
text = completed.stdout
assert "rehearsal_plan=1" in text
assert "database_name=rehearse_plan" in text
assert "stop_before=20260925030000_chart_subject_trust_and_limit.sql" in text
assert "stop_in_before=no" in text
values = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
assert int(values["batch_before"]) < int(values["batch_all"])
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def test_local_url_with_query_is_refused() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan?database=jyotisha",
}
)
assert completed.returncode == 1
assert "query parameters" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def bash_path(path: Path) -> str:
if os.name != "nt":
return str(path)
completed = subprocess.run(
[BASH, "-lc", 'cygpath -u "$1"', "cygpath", str(path)],
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
def test_host_mode_does_not_call_host_node_and_failed_cleanup_is_nonzero(tmp_path: Path) -> None:
bin_dir = tmp_path / "bin"
host = tmp_path / "host"
(host / ".state").mkdir(parents=True)
(host / "backups").mkdir()
dump = host / "backups" / "production-pre-migration-20261014T000000Z.dump.enc"
dump.write_bytes(b"not-a-real-dump")
(host / ".env.production.database").write_text(
"\n".join(
[
"POSTGRES_DB=jyotisha",
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=secret-value",
"PRODUCTION_BACKUP_ENCRYPTION_KEY=not-a-real-key",
"",
]
),
encoding="utf-8",
newline="\n",
)
docker_log = tmp_path / "docker.log"
node_log = tmp_path / "node.log"
bin_dir.mkdir()
(bin_dir / "docker").write_text(
"\n".join(
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "${DOCKER_LOG:?}"',
'case "$*" in',
" *exec\\ -i*|*-i\\ *) cat >/dev/null || true ;;",
"esac",
'joined="$*"',
'case "$joined" in',
' *ps\\ *) echo container1; exit 0 ;;',
"esac",
'case "$joined" in',
" *'{{.Image}}'*) echo sha256:0000000000000000000000000000000000000000000000000000000000000000; exit 0 ;;",
"esac",
'case "$joined" in',
" *inspect*) echo jyotisha-net; exit 0 ;;",
"esac",
'case "$joined" in',
" *DROP\\ DATABASE*) exit 1 ;;",
"esac",
'case "$joined" in',
" *has_table_privilege*)",
" printf '%s\\n' 'authenticated public.profiles true' 'authenticated public.chat_sessions true' 'service_role public.chart_profiles true'",
" exit 0",
" ;;",
"esac",
'case "$joined" in',
" *count*) echo 0; exit 0 ;;",
"esac",
"exit 0",
"",
]
),
encoding="utf-8",
newline="\n",
)
(bin_dir / "node").write_text(
"\n".join(
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "${NODE_LOG:?}"',
"exit 9",
"",
]
),
encoding="utf-8",
newline="\n",
)
(bin_dir / "openssl").write_text("#!/bin/bash\nexit 0\n", encoding="utf-8", newline="\n")
unix_bin = bash_path(bin_dir)
python_bin = bash_path(Path(sys.executable))
(bin_dir / "python3").write_text(
f"#!/bin/bash\nexec '{python_bin}' \"$@\"\n",
encoding="utf-8",
newline="\n",
)
subprocess.run(
[BASH, "-lc", f'chmod +x "{unix_bin}/docker" "{unix_bin}/node" "{unix_bin}/openssl" "{unix_bin}/python3"'],
check=True,
)
full = os.environ.copy()
full.update(
{
"JYOTISHA_REHEARSAL_FAKE_HOST": "1",
"DEPLOY_PATH": bash_path(host),
"RECOVERY_DUMP": bash_path(dump),
"DOCKER_LOG": bash_path(docker_log),
"NODE_LOG": bash_path(node_log),
}
)
completed = subprocess.run(
[
BASH,
"-lc",
'export PATH="$1:$PATH"; exec bash "$2"',
"bash",
unix_bin,
bash_path(SCRIPT),
],
cwd=ROOT,
env=full,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
assert completed.returncode != 0, completed.stdout + completed.stderr
assert "演练库未删除,请手动 DROP DATABASE rehearse_" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
assert not node_log.exists()
docker_text = docker_log.read_text(encoding="utf-8")
assert "--entrypoint node" in docker_text
assert "DROP DATABASE" in docker_text
def test_restore_keeps_backup_owners_and_compares_grants() -> None:
text = SCRIPT.read_text(encoding="utf-8")
assert "--no-owner" not in text
assert "ALTER TYPE" not in text
assert "typtype" not in text
assert "revoked_grants" in text
assert "chart_profiles_service_role_select_missing" in text
assert "role%3Dschema_owner" in text
assert "grant connect, create on database" in text
+270
View File
@@ -0,0 +1,270 @@
"""Resolver for the production release SHA. Gitea responses are local fixtures."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "deploy" / "resolve-release-revision.sh"
GATED = ROOT / "deploy" / "gated-paths.txt"
PYTHON = Path(sys.executable)
def resolve_bash() -> str:
found = shutil.which("bash")
git_bash = Path(r"D:\Program Files\Git\bin\bash.exe")
if os.name == "nt" and git_bash.exists():
lowered = (found or "").lower()
if not found or "system32" in lowered or "windowsapps" in lowered:
return str(git_bash)
if found:
return found
if git_bash.exists():
return str(git_bash)
return "bash"
BASH = resolve_bash()
def run_git(repo: Path, *args: str) -> str:
completed = subprocess.run(
["git", "-c", "user.email=dev@example.com", "-c", "user.name=Release Test", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return completed.stdout.strip()
def make_repo(tmp_path: Path) -> dict[str, str]:
repo = tmp_path / "repo"
repo.mkdir()
run_git(repo, "init")
frontend = repo / "frontend"
frontend.mkdir()
(frontend / "app.txt").write_text("gated\n", encoding="utf-8")
run_git(repo, "add", "frontend/app.txt")
run_git(repo, "commit", "-m", "gated")
first = run_git(repo, "rev-parse", "HEAD")
docs = repo / "docs"
docs.mkdir()
(docs / "note.md").write_text("docs only\n", encoding="utf-8")
run_git(repo, "add", "docs/note.md")
run_git(repo, "commit", "-m", "docs")
second = run_git(repo, "rev-parse", "HEAD")
(frontend / "more.txt").write_text("more gated\n", encoding="utf-8")
run_git(repo, "add", "frontend/more.txt")
run_git(repo, "commit", "-m", "more gated")
third = run_git(repo, "rev-parse", "HEAD")
return {"repo": str(repo), "r": first, "h": second, "later": third}
def ref_payload(branch: str, sha: str) -> list[dict]:
return [{"ref": f"refs/heads/{branch}", "object": {"sha": sha}}]
def gate_payload(sha: str, run_id: int = 42) -> dict:
return {
"workflow_runs": [
{
"id": run_id,
"path": ".gitea/workflows/backend-quality-gate.yml",
"head_sha": sha,
"head_branch": "staging",
"event": "push",
"conclusion": "success",
}
]
}
def write_fixtures(directory: Path, manifest: list[dict], files: dict[str, object]) -> None:
directory.mkdir()
for name, payload in files.items():
(directory / name).write_text(json.dumps(payload), encoding="utf-8")
(directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
def invoke(repo: str, fixture: Path, env_extra: dict[str, str]) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
# is-docs-only-range.sh calls python3. Windows Git bash has no python3,
# and CreateProcess would rather hit the WSL stub than a PATH python.
shim = fixture.parent / "python3-shim"
shim.mkdir(exist_ok=True)
launcher = shim / "python3"
launcher.write_text(
"#!/bin/bash\n" + f'exec "{PYTHON.as_posix()}" "$@"\n',
encoding="utf-8",
newline="\n",
)
env.update(
{
"JYOTISHA_HTTP_FIXTURE_DIR": str(fixture),
"JYOTISHA_DOCS_ONLY_MODE": "git",
"JYOTISHA_PYTHON": str(PYTHON),
"GATED_PATHS_FILE": str(GATED),
"PATH": str(shim) + os.pathsep + env.get("PATH", ""),
}
)
env.update(env_extra)
return subprocess.run(
[BASH, str(SCRIPT)],
cwd=repo,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
)
def outputs(completed: subprocess.CompletedProcess[str]) -> dict[str, str]:
return dict(line.split("=", 1) for line in completed.stdout.splitlines() if "=" in line)
def test_equal_heads_pass(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "equal"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": gate_payload(shas["r"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 0, completed.stderr
assert outputs(completed)["release_sha"] == shas["r"]
assert outputs(completed)["head_sha"] == shas["r"]
assert outputs(completed)["gate_run_id"] == "42"
def test_docs_only_advance_passes(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "docs"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["h"]),
"staging.json": ref_payload("staging", shas["h"]),
"gate.json": gate_payload(shas["r"], 7),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 0, completed.stderr
assert outputs(completed)["release_sha"] == shas["r"]
assert outputs(completed)["head_sha"] == shas["h"]
assert outputs(completed)["gate_run_id"] == "7"
def test_gated_advance_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "gated"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["later"]),
"staging.json": ref_payload("staging", shas["later"]),
"gate.json": gate_payload(shas["h"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["h"]})
assert completed.returncode == 1
assert "not a docs-only ancestor" in completed.stderr
def test_non_ancestor_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "diverge"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": gate_payload(shas["later"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["later"]})
assert completed.returncode == 1
assert "not a docs-only ancestor" in completed.stderr
def test_missing_gate_run_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "nogate"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": {"workflow_runs": []},
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 1
assert "no successful exact-SHA production quality gate run found" in completed.stderr
def test_divergent_main_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "split"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["h"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 1
assert "main and staging must identify the same reviewed release" in completed.stderr
def test_api_failure_exits_2(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "down"
write_fixtures(
fixture,
[{"match": "refs/heads/staging", "file": "unused.json", "status": 500}],
{"unused.json": {}},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 2
assert "Gitea API request failed" in completed.stderr