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)
This commit is contained in:
jesse-ux
2026-10-14 16:37:58 +08:00
co-authored by Grok (agent loop)
parent 0dc1d8fa52
commit 32aa46b573
14 changed files with 481 additions and 67 deletions
+5 -15
View File
@@ -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")"
+1 -1
View File
@@ -2,7 +2,7 @@
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码。Skill 版本不变(BUG-1348、BUG-1349)。
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
+196 -25
View File
@@ -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
+6 -6
View File
@@ -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 之后。
@@ -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 条
@@ -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" <postgres容器> \
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`。
@@ -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 仍表示演练库跑完后还是没有这项授权。
+2 -2
View File
@@ -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` 为准) |
@@ -19,7 +19,7 @@
| 无日期证据改为已确认的行数 | |
| 网页角色读账户资料 | true 才算过 |
| 网页角色读会话列表 | true 才算过 |
| 服务角色读人物表 | true 才算过。false 时停止,不要点生产迁移(BUG-1063 的授权还没进这一批) |
| 服务角色读人物表 | true 才算过。false 时脚本退出码 4,停止,不要点生产迁移。这是演练库里的结果,不是正在服务的生产库 |
| 最终一行 | `rehearsal_ok=1` 才算演练通过 |
停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。
+1 -1
View File
@@ -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;
+12
View File
@@ -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/,
);
});
@@ -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"/);
+166 -2
View File
@@ -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
+20 -3
View File
@@ -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: