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:
co-authored by
Grok (agent loop)
parent
0dc1d8fa52
commit
32aa46b573
@@ -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
@@ -2,7 +2,7 @@
|
||||
|
||||
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
|
||||
|
||||
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码。Skill 版本不变(BUG-1348、BUG-1349)。
|
||||
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
|
||||
|
||||
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
|
||||
|
||||
|
||||
@@ -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
@@ -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 仍表示演练库跑完后还是没有这项授权。
|
||||
@@ -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` 才算演练通过 |
|
||||
|
||||
停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user