From 32aa46b5731a32b9d7760738f51b983ab4dea041 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 14 Oct 2026 16:37:58 +0800 Subject: [PATCH] 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) --- .gitea/workflows/deploy-production.yml | 20 +- CHANGELOG.md | 2 +- deploy/rehearse-production-migrations.sh | 221 ++++++++++++++++-- docs/BUG_HISTORY.md | 12 +- .../production-migrations-20261014.md | 2 +- .../production-release-runbook-20261014.md | 27 ++- ...SS-release-path-production-fix-20261014.md | 35 +++ docs/tasks/README.md | 4 +- ...production-migration-rehearsal-20261014.md | 2 +- frontend/src/modules/identity/config.ts | 2 +- frontend/tests/identity-config.test.ts | 12 + .../tests/staging-backend-workflows.test.ts | 18 ++ tests/test_rehearse_production_migrations.py | 168 ++++++++++++- tests/test_resolve_release_revision.py | 23 +- 14 files changed, 481 insertions(+), 67 deletions(-) create mode 100644 docs/tasks/PROGRESS-release-path-production-fix-20261014.md diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml index 227e2d9f..25f09c09 100644 --- a/.gitea/workflows/deploy-production.yml +++ b/.gitea/workflows/deploy-production.yml @@ -122,21 +122,11 @@ jobs: ([$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" ' - [.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")" - fi - [[ "$controller_gate_run_id" =~ ^[0-9]+$ ]] + 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")" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7c7b7b..cac04799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging) -- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码。Skill 版本不变(BUG-1348、BUG-1349)。 +- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。 ## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging) diff --git a/deploy/rehearse-production-migrations.sh b/deploy/rehearse-production-migrations.sh index 83d65a2a..5b5884b5 100755 --- a/deploy/rehearse-production-migrations.sh +++ b/deploy/rehearse-production-migrations.sh @@ -3,6 +3,14 @@ # 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. Exit 4 means: after this rehearsal +# copy was restored and migrated, service_role still cannot SELECT +# public.chart_profiles. Grants come from the backup plus migrations, +# not from a live probe of the database that is serving production. +# If removing the rehearsal database fails, the script exits non-zero +# and names that database so it can be dropped by hand. set -euo pipefail set +x umask 077 @@ -21,10 +29,22 @@ database_name_from_url() { local rest="${1#postgresql://}" rest="${rest#*@}" local path="${rest#*/}" - path="${path%%\?*}" + 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" @@ -33,17 +53,22 @@ assert_rehearse_name() { 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="$(basename "$file")" + base="${file##*/}" if [ "$mode" = before ] && [[ ! "$base" < "$STOP_BEFORE" ]]; then continue fi - cp -p "$file" "$dest/$base" + selected+=("$file") done + if [ "${#selected[@]}" -gt 0 ]; then + cp -p "${selected[@]}" "$dest/" + fi done } @@ -106,6 +131,20 @@ if (kind === "count") { const count = result.rows[0]?.n ?? ""; if (!/^[0-9]+$/.test(count)) process.exit(2); process.stdout.write(count); +} else if (kind === "archived") { + const result = await client.query( + "select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chat_sessions' and column_name = 'archived_at') then (select count(*)::text from public.chat_sessions where archived_at is not null) else 'absent' end as n", + ); + const count = result.rows[0]?.n ?? ""; + if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2); + process.stdout.write(count); +} else if (kind === "self") { + const result = await client.query( + "select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chart_profiles' and column_name = 'role') then (select count(*)::text from public.chart_profiles where role = 'self') else 'absent' end as n", + ); + const count = result.rows[0]?.n ?? ""; + if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2); + process.stdout.write(count); } else if (kind === "smoke") { const checks = [ ["authenticated", "public.profiles"], @@ -139,7 +178,20 @@ PY drop_rehearse_database() { [ "${REHEARSAL_CREATED:-}" = 1 ] || return 0 REHEARSAL_CREATED=0 - run_node_sql drop >/dev/null || echo "rehearsal database could not be removed" >&2 + 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 @@ -150,21 +202,37 @@ 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}" - [ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || refuse "unexpected production path" + 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() { - drop_rehearse_database + 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 @@ -204,10 +272,12 @@ if [ "$local_mode" -eq 0 ]; then [ -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" - flock -n 9 || { - echo "another production mutation holds the host lock" >&2 - exit 75 - } + 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" @@ -217,33 +287,89 @@ if [ "$local_mode" -eq 0 ]; then 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 < <( - sudo -n docker ps -q \ + 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 < <( - sudo -n docker ps -q \ + 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="$(sudo -n docker inspect --format '{{.Image}}' "$web_container")" + 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="$(sudo -n docker inspect --format '{{range $name, $net := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' "$web_container" | head -n 1)" + 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" - sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \ + 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" | - sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \ + docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \ pg_restore -U "$POSTGRES_USER" -d "$database_name" \ - --no-owner --no-acl --exit-on-error + --no-owner --exit-on-error + set +o pipefail unset PRODUCTION_BACKUP_ENCRYPTION_KEY + docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \ + psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -f - <<'SQL' +DO $$ +DECLARE r record; +BEGIN + FOR r IN + SELECT n.nspname, c.relname, c.relkind + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND n.nspname NOT LIKE 'pg_%' + AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') + LOOP + IF r.relkind = 'S' THEN + EXECUTE format('ALTER SEQUENCE %I.%I OWNER TO schema_owner', r.nspname, r.relname); + ELSIF r.relkind = 'v' THEN + EXECUTE format('ALTER VIEW %I.%I OWNER TO schema_owner', r.nspname, r.relname); + ELSIF r.relkind = 'm' THEN + EXECUTE format('ALTER MATERIALIZED VIEW %I.%I OWNER TO schema_owner', r.nspname, r.relname); + ELSE + EXECUTE format('ALTER TABLE %I.%I OWNER TO schema_owner', r.nspname, r.relname); + END IF; + END LOOP; + FOR r IN + SELECT n.nspname, p.proname, pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_%' + LOOP + EXECUTE format('ALTER FUNCTION %I.%I(%s) OWNER TO schema_owner', r.nspname, r.proname, r.args); + END LOOP; + FOR r IN + SELECT n.nspname, t.typname + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_%' + AND t.typtype IN ('e', 'c', 'd') + LOOP + EXECUTE format('ALTER TYPE %I.%I OWNER TO schema_owner', r.nspname, r.typname); + END LOOP; + FOR r IN + SELECT n.nspname + FROM pg_namespace n + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public') + AND n.nspname NOT LIKE 'pg_%' + LOOP + EXECUTE format('ALTER SCHEMA %I OWNER TO schema_owner', r.nspname); + END LOOP; + EXECUTE 'ALTER SCHEMA public OWNER TO schema_owner'; +END +$$; +SQL # 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 @@ -257,12 +383,11 @@ print("postgresql://{user}:{password}@postgres:5432/{name}?options=-c%20role%3Ds )) PY )" - unset POSTGRES_PASSWORD export SCHEMA_DATABASE_URL run_batch() { local batch="$1" log="$2" status set +e - sudo -n docker run --rm --network "$web_network" \ + docker_cli run --rm --network "$web_network" \ -e SCHEMA_DATABASE_URL \ -e MIGRATIONS_DIRECTORY=/migrations \ -v "$batch:/migrations:ro" \ @@ -282,9 +407,47 @@ else } fi +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) + sql="select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chat_sessions' and column_name = 'archived_at') then (select count(*)::text from public.chat_sessions where archived_at is not null) else 'absent' end" + ;; + self) + sql="select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chart_profiles' and column_name = 'role') then (select count(*)::text from public.chart_profiles where role = 'self') else 'absent' end" + ;; + *) + 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" +} + +sql_smoke() { + if [ "$local_mode" -eq 1 ]; then + run_node_sql smoke + 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 1 as n, 'authenticated public.profiles ' || case when has_table_privilege('authenticated', 'public.profiles', 'SELECT') then 'true' else 'false' end as line union all select 2, 'authenticated public.chat_sessions ' || case when has_table_privilege('authenticated', 'public.chat_sessions', 'SELECT') then 'true' else 'false' end union all select 3, 'service_role public.chart_profiles ' || case when has_table_privilege('service_role', 'public.chart_profiles', 'SELECT') then 'true' else 'false' end) s order by n" +} + 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" set +e run_batch "$work_dir/before" "$work_dir/before.log" before_status=$? @@ -293,7 +456,7 @@ if [ "$local_mode" -eq 0 ]; then print_safe_log "$work_dir/before.log" fi [ "$before_status" -eq 0 ] || refuse "rehearsal migration failed" -other_rows="$(run_node_sql count)" || refuse "could not count other-person profiles before the stop file" +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 @@ -308,8 +471,18 @@ 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" smoke_file="$work_dir/smoke.txt" -if ! run_node_sql smoke >"$smoke_file"; then +if ! sql_smoke >"$smoke_file"; then refuse "rehearsal smoke could not check role privileges" fi cat "$smoke_file" @@ -320,10 +493,8 @@ if ! grep -q '^authenticated public.chat_sessions true$' "$smoke_file"; then refuse "web role cannot read chat sessions" fi if ! grep -q '^service_role public.chart_profiles true$' "$smoke_file"; then - echo "stop: service_role still cannot read chart_profiles (BUG-1063 grant is not in this batch); do not migrate production" >&2 + echo "stop: rehearsal copy still has no service_role SELECT on chart_profiles after migrations; do not migrate production" >&2 exit 4 fi echo "rehearsal_ok=1" -trap - EXIT HUP INT TERM -drop_rehearse_database -rm -rf -- "$work_dir" +exit 0 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a3b9801e..957bb6d2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36095,9 +36095,9 @@ - 现象:测试环境只要在最后一次代码提交后再推了文档,四个生产发布按钮全部拒绝。 - 触发条件:要发布的提交没有自己的代码门禁记录,或者它不等于测试环境当前的头。 - 根因:生产工作流仍要求「被发布的提交等于测试环境头,并且这个头自己有一次代码门禁」。文档提交不跑那道门禁。测试环境侧早就允许「最近一次门禁提交之后只前进文档」,生产侧没有跟上。 -- 修复:发布修订改成最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。四个生产工作流共用同一个判断。回滚仍要明确确认,并且目标必须在已审查的 main 历史里。 -- 验证:`tests/test_resolve_release_revision.py` 7 通过。工作流文本合同改去要求非回滚时「提交必须等于测试环境头」。生产还没实跑。 -- 防复发:上述测试锁住「中间改了代码就拒绝」「不是祖先就拒绝」「没有门禁运行就拒绝」「两个分支不一致就拒绝」「接口失败退出 2」。 +- 修复:发布修订改成最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。四个生产工作流共用同一个判断。回滚仍要明确确认,并且目标必须在已审查的 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,避免撞号。 @@ -36110,9 +36110,9 @@ - 现象:只要服务器配了 6 位测试验证码,登录就不区分是不是生产站。 - 触发条件:生产环境变量里出现测试验证码,包括写成空值。 - 根因:读取测试码时只检查位数,不看站点;生产环境校验脚本也没把它列为禁止项。 -- 修复:站点正好是生产域名时,配了测试码就拒绝启动。生产环境校验只要看见这个变量就失败。测试环境的行为不变。 -- 验证:身份配置测试覆盖生产站抛错、测试站仍返回验证码、未设置返回空。环境校验测试覆盖有值、空值都失败,没有这个变量则通过。 -- 防复发:上述两处测试。 +- 修复:站点正好是生产域名时,配了测试码就拒绝启动。比较用的是规范化之后的站点地址,末尾多一个斜杠也算生产站。生产环境校验只要看见这个变量就失败。测试环境的行为不变。 +- 验证:身份配置测试覆盖生产站抛错、网址末尾多一个斜杠仍抛错、测试站仍返回验证码、未设置返回空。本轮身份配置测试 9 通过、0 失败、0 取消。环境校验测试覆盖有值、空值都失败,没有这个变量则通过。 +- 防复发:上述两处测试,包括带斜杠的生产网址。 - 相关记录:BUG-1348。 - 复发自:无。 - 修复版本:分支 `codex/release-path-production-20261014`。编号在 BUG-1347 之后。 diff --git a/docs/operations/production-migrations-20261014.md b/docs/operations/production-migrations-20261014.md index 4a800985..33ebe84c 100644 --- a/docs/operations/production-migrations-20261014.md +++ b/docs/operations/production-migrations-20261014.md @@ -20,7 +20,7 @@ - `20260925010000`:自动校正标题会改成带结果。只看 NOTICE 的行数。 - `20260925030000`:他人档案里已采用或已确认的时间会退回已申报。演练在跑这个文件之前先数行,预期 0。不是 0 就停下,只把数字报给产品,不要继续。 -服务角色读人物表的授权不在这 63 个文件里(BUG-1063)。演练冒烟到这一项会停下。那笔授权进入这一批之前,不要点生产迁移。 +上面这 63 个文件里没有服务角色读人物表的授权(BUG-1063)。测试环境后来多了 `20261014010000_chart_profiles_service_role_select.sql`,它排在停止文件之后,会进演练的第二批。冒烟看的是跑完迁移之后的演练库:仍是 false 就退出码 4,不要点生产迁移。这不是去问正在服务的生产库。 ## 63 条 diff --git a/docs/operations/production-release-runbook-20261014.md b/docs/operations/production-release-runbook-20261014.md index 8799ffb5..a759bbc2 100644 --- a/docs/operations/production-release-runbook-20261014.md +++ b/docs/operations/production-release-runbook-20261014.md @@ -64,13 +64,13 @@ DEPLOY_PATH=/opt/jyotisha-production RECOVERY_DUMP=/opt/jyotisha-production/back 通过,要同时满足: - 输出有 `other_person_accepted_rows=0`。这是 `20260925030000` 之前的人数。不是 0 就停,只把这个数字发给产品,不要继续,也不要写出任何一行的内容。 -- 两条会改用户能看见的数据:归档会话回到列表,人物表里的本人镜像被删掉。行数变化要和 `docs/operations/production-migrations-20261014.md` 的产品需知一致。 -- 权限冒烟三行都是 `true`:网页角色能读账户资料、能读会话列表,服务角色能读人物表。 +- `archived_sessions_before` / `archived_sessions_after`:归档标记还在的会话数。迁移后应为 0。旧备份里还没有这一列时,前面那个数是 `absent`。 +- `self_chart_rows_before` / `self_chart_rows_after`:人物表里「本人」那一行。迁移后应为 0。还没有 `role` 列时,前面那个数是 `absent`。 +- `notice_lines`:两批迁移日志里 `notice` 的行数。标题修补和自动标题回填的数字在这些行里。生产上标题修补预期是 0;不是 0 就停,只报数字。 +- 权限冒烟三行都是 `true`:网页角色能读账户资料、能读会话列表,服务角色能读人物表。这是演练库里的授权(备份里原来的授权,加上这次迁移新给的),不是去问正在服务的那一个生产库。 - 最后一行是 `rehearsal_ok=1`。 -现在这一批里还没有「服务角色可读人物表」那一笔授权(BUG-1063,在另一条尚未合入的分支上)。冒烟因此会停,并退出码 4。这不是脚本坏了。在那一笔进入本次发布之前,不要点生产迁移。 - -失败时:脚本会删掉一次性库。生产库还没动。把退出码和它打印的那一句原因留下来。不要复制数据库口令或备份密钥。 +服务角色读人物表若仍是 `false`,脚本退出码 4,并停住。不要点生产迁移。演练库删不掉时,脚本不再只是警告:退出码不是 0,并写出「演练库未删除,请手动 DROP DATABASE rehearse_…」。生产库还没动。把退出码和它打印的那一句原因留下来。不要复制数据库口令或备份密钥。 ## 6. Migrate Production Database @@ -101,11 +101,18 @@ Gitea → Deploy production。Use workflow from:`main`。 接受整库还原到第 4 步那个恢复点。恢复点之后新写入的数据会丢掉。内测还没开放时,这个窗口里通常还没有新的用户数据。 -旧代码读不了新的表结构。所以不能只把程序退回去。两件事都要做: +旧代码读不了新的表结构。所以不能只把程序退回去。两件事都要做,都由产品负责人点,不要让执行代理代点。 -1. 用恢复点把数据库整库还原到发布前。 -2. 再点 Deploy production,`allow_rollback` 选 true,目标用发布前的生产版本 `7b620c7a`。 +1. 整库还原。没有单独按钮。第 4 步的 `recovery_reference` 斜杠后面就是备份文件名,例如 `production-pre-migration-20261014T120000Z.dump.enc`。文件在 `/opt/jyotisha-production/backups/` 下面。先 `sha256sum` 这个文件,和 `/opt/jyotisha-production/.state/recovery-baseline.env` 里的 `RECOVERY_SHA256` 对上,再继续。工具和创建恢复点时一样,都在 `deploy/run-production-recovery.sh` 里:`openssl` 解密,再在 postgres 容器里 `pg_restore`。这一步会清掉现在的生产库 `jyotisha` 再装回备份。先停 `api` 和 `web`,不要停 `postgres`。在 postgres 容器里删除并重建名为 `jyotisha` 的库(只动这一个),然后: -谁来点:产品负责人。不要让执行代理代点生产按钮。 +```text +openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in <备份文件> \ + | sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" \ + pg_restore -U "$POSTGRES_USER" -d jyotisha --no-owner --no-acl --exit-on-error +``` -还原之后再按核对清单看健康检查。版本号应回到 `7b620c7a`。 +口令和密钥只用主机上已有的环境文件,不要贴出来。还原后数这四样,要和备份旁边的 `*-restore-verify.json` 里 `checks` 一致:`identity.users`、`public.profiles`、`public.credit_transactions`、`public` 里的表数量。对不上就停,不要部署。 + +2. 再点 Deploy production,`allow_rollback` 选 true,目标用发布前的生产版本 `7b620c7a`。测试环境头如果只多了文档、自己没有门禁记录,回滚用的控制器仍然是最近一次门禁通过的修订,不再因为这个被拒绝。目标版本仍必须在已经审查过的 main 历史里。 + +还原并且部署成功之后,再按核对清单看健康检查。版本号应回到 `7b620c7a`。 diff --git a/docs/tasks/PROGRESS-release-path-production-fix-20261014.md b/docs/tasks/PROGRESS-release-path-production-fix-20261014.md new file mode 100644 index 00000000..8cb9fcfa --- /dev/null +++ b/docs/tasks/PROGRESS-release-path-production-fix-20261014.md @@ -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 仍表示演练库跑完后还是没有这项授权。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8fe210b6..9228eb3c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -334,8 +334,8 @@ | `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` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 第 1 次打回,修复中 | `codex/release-path-production-20261014`(BUG-1348、BUG-1349) | -| `TASK-release-path-production-fix-20261014.md` | — | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 修复中 | `codex/release-path-production-20261014` | +| `TASK-release-path-production-20261014.md` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 第 1 次打回已修,待验收 | `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 守卫规范化 | 待验收 | `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` 为准) | diff --git a/docs/testing/production-migration-rehearsal-20261014.md b/docs/testing/production-migration-rehearsal-20261014.md index 592e21ef..5f4b25cb 100644 --- a/docs/testing/production-migration-rehearsal-20261014.md +++ b/docs/testing/production-migration-rehearsal-20261014.md @@ -19,7 +19,7 @@ | 无日期证据改为已确认的行数 | | | 网页角色读账户资料 | true 才算过 | | 网页角色读会话列表 | true 才算过 | -| 服务角色读人物表 | true 才算过。false 时停止,不要点生产迁移(BUG-1063 的授权还没进这一批) | +| 服务角色读人物表 | true 才算过。false 时脚本退出码 4,停止,不要点生产迁移。这是演练库里的结果,不是正在服务的生产库 | | 最终一行 | `rehearsal_ok=1` 才算演练通过 | 停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。 diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts index 2c4a9cbd..a98f4b18 100644 --- a/frontend/src/modules/identity/config.ts +++ b/frontend/src/modules/identity/config.ts @@ -98,7 +98,7 @@ 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 (env.AUTH_USER_ORIGIN?.trim() === "https://jyotisha.chat") { + 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; diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts index d66e7b63..08865b27 100644 --- a/frontend/tests/identity-config.test.ts +++ b/frontend/tests/identity-config.test.ts @@ -97,3 +97,15 @@ test("production origin refuses a configured test OTP and staging still accepts 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/, + ); +}); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index cd02ed9d..b6a73ca8 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -1151,6 +1151,16 @@ test("production deploy is manual-only and consumes the accepted staging artifac 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,删掉开关测试仍绿 + // 新值: 正向部署必须带着这个开关调用解析脚本 + // 原因: 开关关掉后,正向路径不再要求跑过发布门禁 + assert.match(production, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/); + // 原值: 回滚用 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/); @@ -1264,6 +1274,10 @@ test("production recovery workflow creates a verified encrypted off-site artifac // 新值: 调用 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"/); @@ -1314,6 +1328,10 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL // 新值: 工作流调用 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"/); diff --git a/tests/test_rehearse_production_migrations.py b/tests/test_rehearse_production_migrations.py index c7573527..6577fa7d 100644 --- a/tests/test_rehearse_production_migrations.py +++ b/tests/test_rehearse_production_migrations.py @@ -3,13 +3,30 @@ 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" -GIT_BASH = Path(r"D:\Program Files\Git\bin\bash.exe") -BASH = str(GIT_BASH) if GIT_BASH.exists() else "bash" + + +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]: @@ -19,6 +36,7 @@ def run(env: dict[str, str]) -> subprocess.CompletedProcess[str]: [BASH, str(SCRIPT)], cwd=ROOT, env=full, + stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", @@ -63,3 +81,149 @@ def test_plan_keeps_the_stop_file_out_of_the_first_batch() -> None: 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 diff --git a/tests/test_resolve_release_revision.py b/tests/test_resolve_release_revision.py index b235a6bd..40f19547 100644 --- a/tests/test_resolve_release_revision.py +++ b/tests/test_resolve_release_revision.py @@ -4,15 +4,32 @@ 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 = ROOT / ".venv" / "Scripts" / "python.exe" -GIT_BASH = Path(r"D:\Program Files\Git\bin\bash.exe") -BASH = str(GIT_BASH) if GIT_BASH.exists() else "bash" +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: