发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。 自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
f4c53e7f9f
commit
00e6efdbfa
@@ -40,43 +40,23 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
|
||||
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || { echo "dispatch recovery from the exact main release SHA" >&2; exit 1; }
|
||||
[[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; }
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
main_head="$(read_ref_sha main)"
|
||||
[[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || {
|
||||
echo "production recovery requires main and staging to equal deploy_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin "$GITEA_SHA"
|
||||
git checkout --detach --force "$GITEA_SHA"
|
||||
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
|
||||
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
|
||||
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch recovery from current main" >&2; exit 1; }
|
||||
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
|
||||
"$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || {
|
||||
echo "public staging has not accepted the requested SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")"
|
||||
jq -e --arg sha "$DEPLOY_SHA" '
|
||||
any(.workflow_runs[]?;
|
||||
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
|
||||
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
|
||||
)
|
||||
' <<<"$release_runs" >/dev/null || {
|
||||
echo "no successful exact-SHA manual release quality gate found" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$DEPLOY_SHA" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout exact recovery controller
|
||||
|
||||
@@ -66,79 +66,87 @@ jobs:
|
||||
allow_rollback=true
|
||||
fi
|
||||
|
||||
runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
|
||||
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
|
||||
[.workflow_runs[] | select(
|
||||
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
|
||||
.head_sha == $sha and .head_branch == "staging" and
|
||||
.event == "push" and .conclusion == "success"
|
||||
)] | sort_by(.id) | reverse | first
|
||||
' <<<"$runs")"
|
||||
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
|
||||
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin "$GITEA_SHA"
|
||||
git checkout --detach --force "$GITEA_SHA"
|
||||
if [[ "$allow_rollback" == false ]]; then
|
||||
CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
|
||||
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
[[ "$release_sha" == "$REQUESTED_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
|
||||
controller_sha="$release_sha"
|
||||
controller_gate_run_id="$gate_run_id"
|
||||
else
|
||||
runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
controller_sha="$(read_ref_sha main)"
|
||||
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
|
||||
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
|
||||
echo "stale production revision refused; use explicit manual rollback only when intended" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
|
||||
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
controller_gate_run_id="$gate_run_id"
|
||||
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
|
||||
controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")"
|
||||
controller_run="$(jq -cer --arg sha "$controller_sha" '
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
|
||||
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
|
||||
[.workflow_runs[] | select(
|
||||
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
|
||||
.head_sha == $sha and .head_branch == "staging" and
|
||||
.event == "push" and .conclusion == "success"
|
||||
)] | sort_by(.id) | reverse | first
|
||||
' <<<"$controller_runs")"
|
||||
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
|
||||
' <<<"$runs")"
|
||||
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
controller_sha="$(read_ref_sha main)"
|
||||
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
|
||||
if [[ "$REQUESTED_SHA" != "$controller_sha" ]]; then
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
|
||||
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
|
||||
fi
|
||||
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]+$ ]]
|
||||
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
|
||||
jq -e --arg sha "$REQUESTED_SHA" '
|
||||
any(.workflow_runs[]?;
|
||||
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
|
||||
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
|
||||
)
|
||||
' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; }
|
||||
fi
|
||||
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
|
||||
|
||||
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
|
||||
jq -e --arg sha "$REQUESTED_SHA" '
|
||||
any(.workflow_runs[]?;
|
||||
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
|
||||
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
|
||||
)
|
||||
' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; }
|
||||
|
||||
if [[ "$allow_rollback" == false ]]; then
|
||||
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
|
||||
@@ -338,8 +346,14 @@ jobs:
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/main") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
|
||||
echo "main or staging advanced during deployment; refusing stale mutation" >&2
|
||||
[[ "$current_main" == "$current_staging" ]] || {
|
||||
echo "main and staging diverged during deployment; refusing stale mutation" >&2
|
||||
exit 1
|
||||
}
|
||||
checker=artifacts/controller/extracted/deploy/is-docs-only-range.sh
|
||||
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
|
||||
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
|
||||
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,54 +85,18 @@ jobs:
|
||||
PY
|
||||
echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT restore_verified=true"
|
||||
echo "WARNING: migration files run sequentially and are not atomic as a whole; recovery may be required after a partial migration." >&2
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
main_head="$(read_ref_sha main)"
|
||||
[[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || {
|
||||
echo "production migration requires main and staging to equal deploy_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || {
|
||||
echo "dispatch the production migration workflow from the exact main release SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
runs="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
|
||||
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
|
||||
[.workflow_runs[] | select(
|
||||
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
|
||||
.head_sha == $sha and .head_branch == "staging" and
|
||||
.event == "push" and .conclusion == "success"
|
||||
)] | sort_by(.id) | reverse | first
|
||||
' <<<"$runs")"
|
||||
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || {
|
||||
echo "no successful exact-SHA staging backend quality gate found" >&2
|
||||
exit 1
|
||||
}
|
||||
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")"
|
||||
jq -e --arg sha "$DEPLOY_SHA" '
|
||||
any(.workflow_runs[]?;
|
||||
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
|
||||
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
|
||||
)
|
||||
' <<<"$release_runs" >/dev/null || {
|
||||
echo "no successful exact-SHA manual release quality gate found" >&2
|
||||
exit 1
|
||||
}
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin "$GITEA_SHA"
|
||||
git checkout --detach --force "$GITEA_SHA"
|
||||
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
|
||||
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging backend quality gate found" >&2; exit 1; }
|
||||
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the production migration workflow from current main" >&2; exit 1; }
|
||||
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
|
||||
"$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || {
|
||||
@@ -317,8 +281,14 @@ jobs:
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/main") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
|
||||
echo "main or staging advanced during production migration; refusing stale mutation" >&2
|
||||
[[ "$current_main" == "$current_staging" ]] || {
|
||||
echo "main and staging diverged during production migration; refusing stale mutation" >&2
|
||||
exit 1
|
||||
}
|
||||
checker=artifacts/staging-image/extracted/deploy/is-docs-only-range.sh
|
||||
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
|
||||
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
|
||||
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,24 @@ jobs:
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin "$GITEA_SHA"
|
||||
git checkout --detach --force "$GITEA_SHA"
|
||||
- name: Record whether main is docs-only ahead of the last staging gate
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RESOLVE_MODE: discover
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash deploy/resolve-release-revision.sh | tee "$RUNNER_TEMP/release-revision.env"
|
||||
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
|
||||
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
|
||||
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the release gate from current main" >&2; exit 1; }
|
||||
{
|
||||
echo "### Release revision"
|
||||
echo "- head_sha: ${head_sha}"
|
||||
echo "- release_sha: ${release_sha}"
|
||||
echo "- H relative to R is docs-only (is-docs-only-range.sh exited 0)"
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Prepare pinned Node tooling
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
1. 动手前 `git fetch origin --prune`,以远端 **`origin/staging`** 为基线。本地 `staging` / `main` 经常落后远端上百个提交,不得作为基线。
|
||||
2. 在独立 worktree 开发:路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`(见 §3)。
|
||||
3. 交付到 staging 用快进推送 `git push origin HEAD:staging`。这会触发 Gitea `backend-quality-gate`;它的 `paths:` 过滤与 `deploy/gated-paths.txt` 逐行一致。改动**全部**落在清单之外的纯文档推送(`docs/**`、根目录 `BLOCKED.md` / `CHANGELOG*.md` / `CONTEXT.md` / `AGENTS.md` / `CLAUDE.md` / `README.md` 等)不触发门禁、不发布镜像、不部署。鼓励把文档与同批代码合并一次推送,避免 staging head 与已部署 SHA 分离。
|
||||
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**;其后若只有纯文档提交,`deploy/is-docs-only-range.sh <该 SHA> <staging head>` 必须退出 0,否则视为未部署。
|
||||
5. 提升到 `main` **必须快进,不得 merge**。`deploy-production.yml` 强制 `main` 与 `staging` 指向同一 SHA,merge commit 会让生产部署失败。
|
||||
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**。记这个提交为发布修订 `R`,记当前 staging head 为 `H`。其后若只有纯文档提交,`deploy/is-docs-only-range.sh R H` 必须退出 0,否则视为未部署。生产四个手动工作流发布的是 `R`,不是纯文档的 `H`;公网 health 的 gitCommit 仍必须等于 `R`。该脚本退出 1 或 2 都拒绝发布。
|
||||
5. 提升到 `main` **必须快进到 `H`,不得 merge**。`main` 与 `staging` 必须是同一 SHA。生产镜像和控制器产物取 `R` 的门禁结果,不取纯文档的 `H`。merge commit 会让生产部署失败。
|
||||
6. 推送后必须核对远端 SHA;远端验证失败时不得声称已交付。
|
||||
7. 不得自行提升 `main`、不改 `.gitea/workflows/**`、不动 DNS——这三件事只由产品负责人触发。
|
||||
7. 不得自行提升 `main`、不动 DNS——这两件事只由产品负责人触发。不改 `.gitea/workflows/**`,除非任务书的决策记录点名了具体文件。2026-10-14 的授权只覆盖 `deploy-production.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`release-quality-gate.yml` 的发布修订规则(`R` 加其后纯文档)。不改 staging 工作流,不改 `deploy/gated-paths.txt`。
|
||||
|
||||
## 3. 工作树与多会话
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
|
||||
|
||||
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码。Skill 版本不变(BUG-1348、BUG-1349)。
|
||||
|
||||
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
|
||||
|
||||
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
|
||||
|
||||
+3
-3
@@ -140,7 +140,7 @@ It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operat
|
||||
|
||||
## Manual production deployment with Gitea Actions
|
||||
|
||||
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. The workflow requires an exact SHA shared by `main` and `staging`, an exact-SHA staging push gate and image manifest, the manual release gate, and matching public staging health. It deploys immutable registry digests and never builds application images on the production host.
|
||||
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. `main` must be a fast-forward to the current staging head `H`. The deployed SHA is `R`, the latest successful staging push of `backend-quality-gate`, and `deploy/is-docs-only-range.sh R H` must exit 0 (exit 1 or 2 both refuse). Controller artifacts and image digests come from `R`. Public staging health must report `R`. The manual release gate may record `H` when `H` is docs-only ahead of `R`. Rollback still requires an explicit confirmation, a gate run, and ancestry inside reviewed `main` history. The workflow deploys immutable registry digests and never builds application images on the production host.
|
||||
|
||||
The `internal` verification mode is for pre-DNS checks on the new host. The `public` mode is for the same SHA after authoritative DNS and Caddy TLS converge. Neither mode imports data, applies pending migrations, or changes DNS.
|
||||
|
||||
@@ -281,13 +281,13 @@ Use this order for every staging revision:
|
||||
5. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
|
||||
6. After health verification, create the local encrypted backup described below.
|
||||
|
||||
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
|
||||
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA. For a forward release that SHA is `R` (the latest gated staging push that is docs-only behind the shared `main`/`staging` head), plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
|
||||
|
||||
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker still exits before app changes when a migration is pending. After the automatic pre-deploy migrate that should be rare. A failed automatic migration fails the quality gate and does not dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
|
||||
|
||||
### Production recovery point before schema migration
|
||||
|
||||
Use Gitea Actions → **Create Production Recovery Point** from the exact current `main` release SHA before every production schema migration. The manual workflow requires `main`, `staging`, public staging health, and the successful release gate to identify the same SHA. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled.
|
||||
Use Gitea Actions → **Create Production Recovery Point** before every production schema migration. Dispatch it from current `main` (the staging head `H`) and set `deploy_sha` to `R`. `main` and `staging` must be the same SHA, `R` must be a docs-only ancestor of that SHA, public staging health must report `R`, and the release gate must have succeeded for `R` or for that same head. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled. The migration rehearsal has no Gitea button; the product runs `deploy/rehearse-production-migrations.sh` from the runbook.
|
||||
|
||||
The host script writes an AES-256-CBC/PBKDF2 encrypted custom-format PostgreSQL dump under `/opt/jyotisha-production/backups`, restores it into a uniquely named disposable database, validates non-sensitive row/table counts, removes only that disposable database, and writes a mode-`0600` verification manifest. Before acquiring the shared host lock it validates that `.state` and `backups` are real directories, then reuses the existing passwordless Docker boundary to run the already-loaded PostgreSQL image with no network, a read-only root filesystem, all capabilities dropped except `CHOWN`, and only the two directories plus the verified existing lock inode bind-mounted. That helper exposes the regular `mutation.lock` directly at `/mutation.lock`, restores that inode before changing its mode-`0700` parent directory, then restores the two directory mount points to the current `deploy` UID/GID. The direct file mount avoids depending on traversal through a parent left half-repaired by an earlier failed run, without adding `DAC_OVERRIDE`; it never recursively changes backup files or removes or replaces a lock inode. The workflow retrieves only the encrypted dump and verification metadata, verifies the SHA-256 digest, and uploads them as a 30-day Gitea Actions artifact with compression disabled. The artifact-backed `recovery_reference`, `recovery_created_at`, and `restore_verified=true` output are the inputs for **Migrate Production Database**. Do not use the attestation if backup, restore, retrieval, digest validation, ownership normalization, or artifact upload fails.
|
||||
|
||||
|
||||
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rehearse pending production migrations on a disposable database.
|
||||
# The product runs this on the production host, or locally with
|
||||
# JYOTISHA_REHEARSAL_LOCAL=1. There is no fifth Gitea workflow for it.
|
||||
# Never restores into or migrates the database named jyotisha.
|
||||
set -euo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
export LC_ALL=C
|
||||
|
||||
STOP_BEFORE="20260925030000_chart_subject_trust_and_limit.sql"
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
frontend_dir="$repo_root/frontend"
|
||||
|
||||
refuse() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
database_name_from_url() {
|
||||
local rest="${1#postgresql://}"
|
||||
rest="${rest#*@}"
|
||||
local path="${rest#*/}"
|
||||
path="${path%%\?*}"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
assert_rehearse_name() {
|
||||
local name="$1"
|
||||
[[ "$name" != "jyotisha" ]] || refuse "refusing to touch the production database"
|
||||
[[ "$name" =~ ^rehearse_[0-9a-z_]+$ ]] || refuse "rehearsal database name must match rehearse_<suffix>"
|
||||
}
|
||||
|
||||
copy_batch() {
|
||||
local dest="$1" mode="$2" dir file base
|
||||
mkdir -p "$dest"
|
||||
for dir in "$frontend_dir/db/migrations" "$frontend_dir/supabase/migrations"; do
|
||||
[ -d "$dir" ] || continue
|
||||
for file in "$dir"/*.sql; do
|
||||
[ -f "$file" ] || continue
|
||||
base="$(basename "$file")"
|
||||
if [ "$mode" = before ] && [[ ! "$base" < "$STOP_BEFORE" ]]; then
|
||||
continue
|
||||
fi
|
||||
cp -p "$file" "$dest/$base"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
count_sql() {
|
||||
find "$1" -maxdepth 1 -type f -name '*.sql' | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
print_safe_log() {
|
||||
local log="$1" line
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
applied\ *.sql|already\ applied\ *.sql)
|
||||
printf '%s\n' "$line"
|
||||
;;
|
||||
notice\ *)
|
||||
if [[ "$line" =~ ^notice\ [A-Za-z0-9_.:=[:space:]-]+$ ]]; then
|
||||
printf '%s\n' "$line"
|
||||
else
|
||||
echo "notice_redacted=1"
|
||||
fi
|
||||
;;
|
||||
migration\ failed:*|migration\ file\ missing:*|migration\ checksum\ mismatch:*|duplicate\ migration\ filename:*|unable\ to\ read\ migrations\ directory|database\ migration\ failed)
|
||||
printf '%s\n' "$line" >&2
|
||||
;;
|
||||
esac
|
||||
done <"$log"
|
||||
}
|
||||
|
||||
run_node_migrator() {
|
||||
local batch="$1" log="$2" status
|
||||
set +e
|
||||
(
|
||||
cd "$frontend_dir"
|
||||
MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
|
||||
) >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
print_safe_log "$log"
|
||||
[ "$status" -eq 0 ] || refuse "rehearsal migration failed"
|
||||
}
|
||||
|
||||
run_node_sql() {
|
||||
local kind="$1"
|
||||
(
|
||||
cd "$frontend_dir"
|
||||
node --input-type=module - "$kind" <<'PY'
|
||||
import pg from "pg";
|
||||
const kind = process.argv[2];
|
||||
const source = process.env.SCHEMA_DATABASE_URL;
|
||||
if (!source || !source.startsWith("postgresql://")) process.exit(2);
|
||||
const url = new URL(source);
|
||||
const name = url.pathname.replace(/^\//, "").split("/")[0];
|
||||
if (!/^rehearse_[0-9a-z_]+$/.test(name) || name === "jyotisha") process.exit(2);
|
||||
const client = new pg.Client({ connectionString: source });
|
||||
await client.connect();
|
||||
if (kind === "count") {
|
||||
const result = await client.query(
|
||||
"select count(*)::text as n from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)",
|
||||
);
|
||||
const count = result.rows[0]?.n ?? "";
|
||||
if (!/^[0-9]+$/.test(count)) process.exit(2);
|
||||
process.stdout.write(count);
|
||||
} else if (kind === "smoke") {
|
||||
const checks = [
|
||||
["authenticated", "public.profiles"],
|
||||
["authenticated", "public.chat_sessions"],
|
||||
["service_role", "public.chart_profiles"],
|
||||
];
|
||||
for (const [role, table] of checks) {
|
||||
const result = await client.query(
|
||||
"select has_table_privilege($1, $2, 'SELECT') as ok",
|
||||
[role, table],
|
||||
);
|
||||
process.stdout.write(`${role} ${table} ${result.rows[0].ok}\n`);
|
||||
}
|
||||
} else if (kind === "drop") {
|
||||
await client.end();
|
||||
const admin = new URL(source);
|
||||
admin.pathname = "/postgres";
|
||||
const dropper = new pg.Client({ connectionString: admin.toString() });
|
||||
await dropper.connect();
|
||||
await dropper.query(`drop database if exists ${name} with (force)`);
|
||||
await dropper.end();
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(2);
|
||||
}
|
||||
await client.end();
|
||||
PY
|
||||
)
|
||||
}
|
||||
|
||||
drop_rehearse_database() {
|
||||
[ "${REHEARSAL_CREATED:-}" = 1 ] || return 0
|
||||
REHEARSAL_CREATED=0
|
||||
run_node_sql drop >/dev/null || echo "rehearsal database could not be removed" >&2
|
||||
}
|
||||
|
||||
local_mode=0
|
||||
if [ "${JYOTISHA_REHEARSAL_LOCAL:-}" = 1 ]; then
|
||||
local_mode=1
|
||||
fi
|
||||
|
||||
if [ "$local_mode" -eq 1 ]; then
|
||||
: "${JYOTISHA_REHEARSAL_DATABASE_URL:?JYOTISHA_REHEARSAL_DATABASE_URL is required}"
|
||||
[[ "$JYOTISHA_REHEARSAL_DATABASE_URL" == postgresql://* ]] || refuse "rehearsal URL must be a PostgreSQL URL"
|
||||
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"
|
||||
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
|
||||
rm -rf -- "$work_dir"
|
||||
}
|
||||
trap cleanup_work EXIT HUP INT TERM
|
||||
|
||||
copy_batch "$work_dir/before" before
|
||||
copy_batch "$work_dir/all" all
|
||||
before_count="$(count_sql "$work_dir/before")"
|
||||
all_count="$(count_sql "$work_dir/all")"
|
||||
if [ -e "$work_dir/before/$STOP_BEFORE" ]; then
|
||||
refuse "stop file leaked into the first batch"
|
||||
fi
|
||||
[ -f "$work_dir/all/$STOP_BEFORE" ] || refuse "stop file is missing from the migration set"
|
||||
|
||||
if [ "${JYOTISHA_REHEARSAL_PLAN:-}" = 1 ]; then
|
||||
trap - EXIT HUP INT TERM
|
||||
rm -rf -- "$work_dir"
|
||||
printf 'rehearsal_plan=1\n'
|
||||
printf 'database_name=%s\n' "$database_name"
|
||||
printf 'batch_before=%s\n' "$before_count"
|
||||
printf 'batch_all=%s\n' "$all_count"
|
||||
printf 'stop_before=%s\n' "$STOP_BEFORE"
|
||||
printf 'stop_in_before=no\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$local_mode" -eq 0 ]; then
|
||||
: "${RECOVERY_DUMP:?RECOVERY_DUMP is required}"
|
||||
[ -f "$RECOVERY_DUMP" ] && [ ! -L "$RECOVERY_DUMP" ] || refuse "recovery dump is missing or unsafe"
|
||||
case "$RECOVERY_DUMP" in
|
||||
"$DEPLOY_PATH"/backups/*.dump.enc) ;;
|
||||
*) refuse "recovery dump must be an encrypted file under the production backup directory" ;;
|
||||
esac
|
||||
environment_file="$DEPLOY_PATH/.env.production.database"
|
||||
[ -f "$environment_file" ] && [ ! -L "$environment_file" ] || refuse "production database environment file is missing or unsafe"
|
||||
state_directory="$DEPLOY_PATH/.state"
|
||||
backup_directory="$DEPLOY_PATH/backups"
|
||||
lock_file="$state_directory/mutation.lock"
|
||||
[ -d "$state_directory" ] && [ ! -L "$state_directory" ] || refuse "production state directory is unsafe"
|
||||
[ -d "$backup_directory" ] && [ ! -L "$backup_directory" ] || refuse "production backup directory is unsafe"
|
||||
exec 9>"$lock_file"
|
||||
flock -n 9 || {
|
||||
echo "another production mutation holds the host lock" >&2
|
||||
exit 75
|
||||
}
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$environment_file"
|
||||
set +a
|
||||
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
|
||||
[ "$POSTGRES_DB" = "jyotisha" ] || refuse "unexpected production database name"
|
||||
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
|
||||
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || refuse "production backup disk usage must remain below 70 percent"
|
||||
mapfile -t postgres_containers < <(
|
||||
sudo -n docker 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 \
|
||||
--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" =~ ^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)"
|
||||
[ -n "$web_network" ] || refuse "production web network is missing"
|
||||
sudo -n docker 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
|
||||
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" \
|
||||
pg_restore -U "$POSTGRES_USER" -d "$database_name" \
|
||||
--no-owner --no-acl --exit-on-error
|
||||
unset PRODUCTION_BACKUP_ENCRYPTION_KEY
|
||||
# The URL is captured only into the environment, never echoed.
|
||||
SCHEMA_DATABASE_URL="$(python3 - "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$database_name" <<'PY'
|
||||
import sys, urllib.parse
|
||||
user, password, name = sys.argv[1:]
|
||||
if name == "jyotisha" or not name.startswith("rehearse_"):
|
||||
sys.exit(2)
|
||||
print("postgresql://{user}:{password}@postgres:5432/{name}?options=-c%20role%3Dschema_owner".format(
|
||||
user=urllib.parse.quote(user, safe=""),
|
||||
password=urllib.parse.quote(password, safe=""),
|
||||
name=name,
|
||||
))
|
||||
PY
|
||||
)"
|
||||
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" \
|
||||
-e SCHEMA_DATABASE_URL \
|
||||
-e MIGRATIONS_DIRECTORY=/migrations \
|
||||
-v "$batch:/migrations:ro" \
|
||||
-v "$frontend_dir/scripts/db-migrate.mjs:/app/frontend/scripts/db-migrate.rehearse.mjs:ro" \
|
||||
-w /app/frontend \
|
||||
--entrypoint node \
|
||||
"$web_image" scripts/db-migrate.rehearse.mjs >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
return "$status"
|
||||
}
|
||||
else
|
||||
REHEARSAL_CREATED=1
|
||||
run_batch() {
|
||||
run_node_migrator "$1" "$2"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
|
||||
echo "rehearsal_database=$database_name"
|
||||
echo "batch_before=$before_count"
|
||||
echo "stop_before=$STOP_BEFORE"
|
||||
set +e
|
||||
run_batch "$work_dir/before" "$work_dir/before.log"
|
||||
before_status=$?
|
||||
set -e
|
||||
if [ "$local_mode" -eq 0 ]; then
|
||||
print_safe_log "$work_dir/before.log"
|
||||
fi
|
||||
[ "$before_status" -eq 0 ] || refuse "rehearsal migration failed"
|
||||
other_rows="$(run_node_sql count)" || refuse "could not count other-person profiles before the stop file"
|
||||
echo "other_person_accepted_rows=$other_rows"
|
||||
if [ "$other_rows" != 0 ]; then
|
||||
echo "stop: $STOP_BEFORE would change $other_rows other-person profiles; production was not changed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "batch_all=$all_count"
|
||||
set +e
|
||||
run_batch "$work_dir/all" "$work_dir/all.log"
|
||||
all_status=$?
|
||||
set -e
|
||||
if [ "$local_mode" -eq 0 ]; then
|
||||
print_safe_log "$work_dir/all.log"
|
||||
fi
|
||||
[ "$all_status" -eq 0 ] || refuse "rehearsal migration failed"
|
||||
smoke_file="$work_dir/smoke.txt"
|
||||
if ! run_node_sql smoke >"$smoke_file"; then
|
||||
refuse "rehearsal smoke could not check role privileges"
|
||||
fi
|
||||
cat "$smoke_file"
|
||||
if ! grep -q '^authenticated public.profiles true$' "$smoke_file"; then
|
||||
refuse "web role cannot read profiles"
|
||||
fi
|
||||
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
|
||||
exit 4
|
||||
fi
|
||||
echo "rehearsal_ok=1"
|
||||
trap - EXIT HUP INT TERM
|
||||
drop_rehearse_database
|
||||
rm -rf -- "$work_dir"
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve the production release SHA shared by the four production workflows.
|
||||
#
|
||||
# Validate mode (default): REQUESTED_SHA must be a successful staging push of
|
||||
# backend-quality-gate, main and staging must be the same head H, and
|
||||
# is-docs-only-range.sh REQUESTED_SHA H must exit 0.
|
||||
# Discover mode (RESOLVE_MODE=discover): R is the newest such push whose range
|
||||
# to H is docs-only.
|
||||
# CHECK_RELEASE_GATE=true also requires a successful release-quality-gate
|
||||
# dispatch whose head is R or H.
|
||||
#
|
||||
# stdout is three KEY=VALUE lines: release_sha, gate_run_id, head_sha.
|
||||
# Exit 0 accepted, 1 refused, 2 when the Gitea API cannot be read.
|
||||
# Tests set JYOTISHA_HTTP_FIXTURE_DIR and JYOTISHA_DOCS_ONLY_MODE=git.
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
python="${JYOTISHA_PYTHON:-python3}"
|
||||
mode="${RESOLVE_MODE:-validate}"
|
||||
api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
|
||||
repository="${GITEA_REPOSITORY:-root/Jyotisha}"
|
||||
docs_mode="${JYOTISHA_DOCS_ONLY_MODE:-api}"
|
||||
|
||||
if [[ "$mode" != validate && "$mode" != discover ]]; then
|
||||
echo "RESOLVE_MODE must be validate or discover" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
http_get() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -n "${JYOTISHA_HTTP_FIXTURE_DIR:-}" ]; then
|
||||
if ! URL="$url" DEST="$dest" FIXTURE_DIR="$JYOTISHA_HTTP_FIXTURE_DIR" "$python" - <<'PY'
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
url = os.environ["URL"]
|
||||
root = Path(os.environ["FIXTURE_DIR"])
|
||||
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||
for item in manifest:
|
||||
if item["match"] in url:
|
||||
status = int(item.get("status", 200))
|
||||
if status != 200:
|
||||
sys.stderr.write("fixture refused the Gitea request\n")
|
||||
sys.exit(1)
|
||||
Path(os.environ["DEST"]).write_bytes((root / item["file"]).read_bytes())
|
||||
sys.exit(0)
|
||||
sys.stderr.write("no fixture matched the Gitea request\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
local -a auth=()
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
auth=(--header "Authorization: token ${GITEA_TOKEN}")
|
||||
fi
|
||||
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
"${auth[@]}" "$url" --output "$dest"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_json() {
|
||||
local url="$1" dest
|
||||
dest="$(mktemp)"
|
||||
if ! http_get "$url" "$dest"; then
|
||||
rm -f -- "$dest"
|
||||
echo "Gitea API request failed" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s' "$dest"
|
||||
}
|
||||
|
||||
read_ref_sha() {
|
||||
local branch="$1" dest sha
|
||||
dest="$(fetch_json "$api_url/repos/$repository/git/refs/heads/$branch")"
|
||||
if ! sha="$("$python" - "$branch" "$dest" <<'PY'
|
||||
import json, re, sys
|
||||
branch, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
payload = json.loads(open(path, encoding="utf-8").read())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(2)
|
||||
ref = "refs/heads/" + branch
|
||||
if not isinstance(payload, list) or len(payload) != 1 or payload[0].get("ref") != ref:
|
||||
sys.exit(2)
|
||||
sha = ((payload[0].get("object") or {}).get("sha") or "")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
||||
sys.exit(2)
|
||||
print(sha)
|
||||
PY
|
||||
)"; then
|
||||
rm -f -- "$dest"
|
||||
echo "Gitea ref for $branch is unreadable" >&2
|
||||
exit 2
|
||||
fi
|
||||
rm -f -- "$dest"
|
||||
printf '%s' "$sha"
|
||||
}
|
||||
|
||||
gate_id_for() {
|
||||
local sha="$1" dest gate_id
|
||||
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&branch=staging&event=push&status=success&limit=100")"
|
||||
set +e
|
||||
gate_id="$("$python" - "$sha" "$dest" <<'PY'
|
||||
import json, sys
|
||||
sha, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
payload = json.loads(open(path, encoding="utf-8").read())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(2)
|
||||
runs = payload.get("workflow_runs") or []
|
||||
matched = []
|
||||
for run in runs:
|
||||
workflow = str(run.get("path") or "").split("@", 1)[0]
|
||||
if (
|
||||
workflow.endswith("backend-quality-gate.yml")
|
||||
and run.get("head_sha") == sha
|
||||
and run.get("head_branch") == "staging"
|
||||
and run.get("event") == "push"
|
||||
and run.get("conclusion") == "success"
|
||||
):
|
||||
matched.append(run)
|
||||
if not matched:
|
||||
sys.exit(1)
|
||||
matched.sort(key=lambda run: int(run.get("id") or 0), reverse=True)
|
||||
print(int(matched[0]["id"]))
|
||||
PY
|
||||
)"
|
||||
local status=$?
|
||||
set -e
|
||||
rm -f -- "$dest"
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo "Gitea gate run list is unreadable" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "no successful exact-SHA production quality gate run found" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$gate_id"
|
||||
}
|
||||
|
||||
require_docs_only() {
|
||||
local base="$1" head="$2" status
|
||||
set +e
|
||||
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$base" "$head" >&2
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "release SHA is not a docs-only ancestor of the staging head" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_release_gate() {
|
||||
local release="$1" head="$2" sha dest status found=false
|
||||
for sha in "$release" "$head"; do
|
||||
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&event=workflow_dispatch&status=success&limit=100")"
|
||||
set +e
|
||||
"$python" - "$sha" "$dest" <<'PY'
|
||||
import json, sys
|
||||
sha, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
payload = json.loads(open(path, encoding="utf-8").read())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(2)
|
||||
runs = payload.get("workflow_runs") or []
|
||||
for run in runs:
|
||||
workflow = str(run.get("path") or "").split("@", 1)[0]
|
||||
if (
|
||||
workflow.endswith("release-quality-gate.yml")
|
||||
and run.get("head_sha") == sha
|
||||
and run.get("event") == "workflow_dispatch"
|
||||
and run.get("conclusion") == "success"
|
||||
):
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
PY
|
||||
status=$?
|
||||
set -e
|
||||
rm -f -- "$dest"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo "Gitea release gate list is unreadable" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
if [ "$found" != true ]; then
|
||||
echo "no successful manual release quality gate found for the release SHA or current main" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
main_head="$(read_ref_sha main)"
|
||||
if [ "$main_head" != "$staging_head" ]; then
|
||||
echo "main and staging must identify the same reviewed release" >&2
|
||||
exit 1
|
||||
fi
|
||||
head_sha="$staging_head"
|
||||
|
||||
if [ "$mode" = validate ]; then
|
||||
[[ "${REQUESTED_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "deploy_sha must be a lowercase full commit SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
release_sha="$REQUESTED_SHA"
|
||||
gate_run_id="$(gate_id_for "$release_sha")"
|
||||
require_docs_only "$release_sha" "$head_sha"
|
||||
else
|
||||
list="$(fetch_json "$api_url/repos/$repository/actions/runs?branch=staging&event=push&status=success&limit=100")"
|
||||
set +e
|
||||
candidates="$("$python" - "$list" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
payload = json.loads(open(sys.argv[1], encoding="utf-8").read())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(2)
|
||||
best = {}
|
||||
for run in payload.get("workflow_runs") or []:
|
||||
workflow = str(run.get("path") or "").split("@", 1)[0]
|
||||
sha = run.get("head_sha") or ""
|
||||
if (
|
||||
workflow.endswith("backend-quality-gate.yml")
|
||||
and run.get("head_branch") == "staging"
|
||||
and run.get("event") == "push"
|
||||
and run.get("conclusion") == "success"
|
||||
and len(sha) == 40
|
||||
):
|
||||
run_id = int(run.get("id") or 0)
|
||||
if sha not in best or run_id > best[sha]:
|
||||
best[sha] = run_id
|
||||
for sha, run_id in sorted(best.items(), key=lambda item: item[1], reverse=True):
|
||||
print(f"{sha} {run_id}")
|
||||
PY
|
||||
)"
|
||||
status=$?
|
||||
set -e
|
||||
rm -f -- "$list"
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo "Gitea gate run list is unreadable" >&2
|
||||
exit 2
|
||||
fi
|
||||
release_sha=""
|
||||
gate_run_id=""
|
||||
while read -r candidate_sha candidate_id; do
|
||||
[ -n "$candidate_sha" ] || continue
|
||||
set +e
|
||||
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$candidate_sha" "$head_sha" >&2
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$status" -eq 0 ]; then
|
||||
release_sha="$candidate_sha"
|
||||
gate_run_id="$candidate_id"
|
||||
break
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [ -z "$release_sha" ]; then
|
||||
echo "no docs-only staging quality gate can be released" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${CHECK_RELEASE_GATE:-}" = true ]; then
|
||||
require_release_gate "$release_sha" "$head_sha"
|
||||
fi
|
||||
|
||||
printf 'release_sha=%s\n' "$release_sha"
|
||||
printf 'gate_run_id=%s\n' "$gate_run_id"
|
||||
printf 'head_sha=%s\n' "$head_sha"
|
||||
@@ -155,4 +155,9 @@ if [ "$personal_report_daily_limit_count" -ne 1 ] ||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?IDENTITY_TEST_OTP=' "$ENV_FILE"; then
|
||||
echo "IDENTITY_TEST_OTP is forbidden in production" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "production environment selectors: valid"
|
||||
|
||||
@@ -17930,3 +17930,33 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1348 | 测试环境后面多了文档,生产就发不出去
|
||||
|
||||
- 状态:fixed-pending-verify(代码和合同测试已有;生产按钮还没点,不能标 resolved)。
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #74。产品 2026-10-14 授权改四个生产工作流。
|
||||
- 现象:测试环境只要在最后一次代码提交后再推了文档,四个生产发布按钮全部拒绝。
|
||||
- 触发条件:要发布的提交没有自己的代码门禁记录,或者它不等于测试环境当前的头。
|
||||
- 根因:生产工作流仍要求「被发布的提交等于测试环境头,并且这个头自己有一次代码门禁」。文档提交不跑那道门禁。测试环境侧早就允许「最近一次门禁提交之后只前进文档」,生产侧没有跟上。
|
||||
- 修复:发布修订改成最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。四个生产工作流共用同一个判断。回滚仍要明确确认,并且目标必须在已审查的 main 历史里。
|
||||
- 验证:`tests/test_resolve_release_revision.py` 7 通过。工作流文本合同改去要求非回滚时「提交必须等于测试环境头」。生产还没实跑。
|
||||
- 防复发:上述测试锁住「中间改了代码就拒绝」「不是祖先就拒绝」「没有门禁运行就拒绝」「两个分支不一致就拒绝」「接口失败退出 2」。
|
||||
- 相关记录:无。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/release-path-production-20261014`。本树最大号原来是 BUG-1346。BUG-1347 在另一条尚未合入的分支上,所以这里用 1348,避免两条分支以后撞号。
|
||||
|
||||
## BUG-1349 | 生产环境如果配了测试验证码,任何人都能用固定码登录
|
||||
|
||||
- 状态:fixed-pending-verify(代码和测试已有;生产环境文件还没在主机上复检,不能标 resolved)。
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #74。
|
||||
- 现象:只要服务器配了 6 位测试验证码,登录就不区分是不是生产站。
|
||||
- 触发条件:生产环境变量里出现测试验证码,包括写成空值。
|
||||
- 根因:读取测试码时只检查位数,不看站点;生产环境校验脚本也没把它列为禁止项。
|
||||
- 修复:站点正好是生产域名时,配了测试码就拒绝启动。生产环境校验只要看见这个变量就失败。测试环境的行为不变。
|
||||
- 验证:身份配置测试覆盖生产站抛错、测试站仍返回验证码、未设置返回空。环境校验测试覆盖有值、空值都失败,没有这个变量则通过。
|
||||
- 防复发:上述两处测试。
|
||||
- 相关记录:BUG-1348。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/release-path-production-20261014`。编号跳过 BUG-1347,原因见 BUG-1348。
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 生产待上线迁移(2026-10-14)
|
||||
|
||||
对照 `origin/main` 与本分支基线,`frontend/supabase/migrations` 新增 63 个文件,没有修改或删除。`frontend/db/migrations` 在这之间是 0 个。迁移文件本身不改。
|
||||
|
||||
分级看的是去掉函数体之后还留在文件顶层的语句。`A` 是用户能直接看见的改写或删除。`B` 是范围有限的改写。`C` 是顶层还有收回权限。`D` 是只加表、列、函数或授权。
|
||||
|
||||
任务书初筛是 A 2、B 5、C 约 45、D 其余。复核后是 A 2、B 7、C 42、D 12。多出来的两条 B 是写在匿名代码块里的标题修补,机械筛会把它们看成没有顶层更新。
|
||||
|
||||
重复的时间戳只记录、不改名:`20260816010000` 有两个文件,`20260920010000` 有两个文件。仓库里另外还有 `20260806010000` 和 `frontend/db/migrations` 的 `20260807010000`,不在这 63 个里面。
|
||||
|
||||
## 产品需知
|
||||
|
||||
- `20260921010000`:以前归档的会话会全部回到列表。这是取消归档功能时已经定过的。
|
||||
- `20260925020000`:人物表里「本人」那一行会删掉,本人资料只留在账户资料里。
|
||||
- `20260816010000_accept_exact_family_birth_times`:家人整点时间会变成已采用。
|
||||
- `20260822010000`:只知道时段的,会补上开始和结束钟点。
|
||||
- `20260823010000`:没有日期的职业、外貌、胎记证据会变成已确认。
|
||||
- `20260906030000`:时间完全未知且还没采用的校正,会先进入时段比较。
|
||||
- `20260916020000`:校正标题修补。生产上预期是 0 行。NOTICE 不是 0 就停下,只报两个数字。
|
||||
- `20260925010000`:自动校正标题会改成带结果。只看 NOTICE 的行数。
|
||||
- `20260925030000`:他人档案里已采用或已确认的时间会退回已申报。演练在跑这个文件之前先数行,预期 0。不是 0 就停下,只把数字报给产品,不要继续。
|
||||
|
||||
服务角色读人物表的授权不在这 63 个文件里(BUG-1063)。演练冒烟到这一项会停下。那笔授权进入这一批之前,不要点生产迁移。
|
||||
|
||||
## 63 条
|
||||
|
||||
| 文件 | 级 | 作用 | 对生产用户 | 演练要看 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `20260816010000_accept_exact_family_birth_times.sql` | B | 家人给出的整点时间,从「已申报」改成「已采用」。 | 只动还没做过校正、并且本来就是整点的家人时间。 | 迁移前后数 public.profiles 里被这条命中的行。 |
|
||||
| `20260816010000_admin_redemption_admin_access.sql` | C | 兑换码管理改成普通后台能力,并收紧权限。 | 不改用户已有的点数或兑换记录。 | 迁移无报错;后台仍只有管理员能进。 |
|
||||
| `20260817010000_billing_catalog_admin_runtime_read_policies.sql` | D | 给后台只读价格目录加策略。 | 用户看不到新入口,也不改已有订单。 | 迁移无报错。 |
|
||||
| `20260818010000_admin_product_catalog_mutations.sql` | C | 后台改价格目录的权限收紧。 | 不改用户已购内容。 | 迁移无报错。 |
|
||||
| `20260819010000_rectification_ingest_precision_plateau.sql` | C | 校正录入的精度判断收紧。 | 不改已填写的出生时间。 | 迁移无报错。 |
|
||||
| `20260820010000_rectification_family_appearance_d12.sql` | C | 校正增加家人外貌相关判断,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
|
||||
| `20260820020000_rectification_d5_d7_precision.sql` | C | 校正增加两类分盘精度,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
|
||||
| `20260820030000_rectification_d3_family.sql` | C | 校正增加家人分盘,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
|
||||
| `20260820040000_rectification_occupation_horary.sql` | C | 校正增加职业问事,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
|
||||
| `20260822010000_declared_birth_window_clocks.sql` | B | 把「只知道大概时段」回填成开始和结束钟点。 | 只填原来空白的时段列,不改已经写上的时间。 | 数 public.profiles 里时段来源且两列仍空的行,迁移后应变少。 |
|
||||
| `20260823010000_rectification_occupation_dateless.sql` | B | 没有日期的职业、外貌、胎记证据可以记成已确认。 | 只改这三类证据的确认状态,不改出生时间。 | 数这三类证据在迁移前后的确认行数。 |
|
||||
| `20260823020000_rectification_inference_choice_write.sql` | C | 校正选择题的写入权限收紧。 | 不改已有答案。 | 迁移无报错。 |
|
||||
| `20260824010000_rectification_inference_transition_ledger.sql` | C | 校正推断过程改成只追加的记录,并收紧权限。 | 旧的推断结果仍在,不删。 | 迁移无报错。 |
|
||||
| `20260824020000_rectification_choice_action.sql` | C | 校正点选和停止改成一次写完,并收紧权限。 | 不改已有对话。 | 迁移无报错。 |
|
||||
| `20260824030000_rectification_turn_origin.sql` | C | 校正每句话记下是打字、点选、语音还是重试,并收紧权限。 | 不改已有对话正文。 | 迁移无报错。 |
|
||||
| `20260825010000_rectification_evidence_kind_date_dedup.sql` | C | 同类又同日的校正证据去重规则收紧。 | 不删除用户原文。 | 迁移无报错。 |
|
||||
| `20260826010000_rectification_inference_round_audit.sql` | C | 校正每一轮多记审计字段,并收紧权限。 | 不改已有结论。 | 迁移无报错。 |
|
||||
| `20260826020000_rectification_choice_focus_identity.sql` | C | 校正选项卡用服务器保存的编号认身份,并收紧权限。 | 刚发出的卡不会因为编号对不上而被拒绝。 | 迁移无报错。 |
|
||||
| `20260826030000_rectification_migration_ledger_health.sql` | C | 网页角色不能再直接读迁移账本,健康检查改走只返回文件名的函数。 | 用户界面不变。 | 迁移无报错。 |
|
||||
| `20260827010000_rectification_candidate_profile_freshness.sql` | C | 校正候选和档案是否还新鲜的判断收紧。 | 不改已采用的时间。 | 迁移无报错。 |
|
||||
| `20260827020000_rectification_vedastro_validation_retry.sql` | C | 校正外部核对失败后可以重试,并收紧权限。 | 不改已采用的时间。 | 迁移无报错。 |
|
||||
| `20260830010000_chat_session_chart_profile_binding.sql` | D | 会话可以绑定到某一位已保存的人。 | 旧会话不改内容。 | 迁移无报错。 |
|
||||
| `20260830020000_personal_report_sections.sql` | C | 个人报告按章节保存进度,并收紧权限。 | 已生成的报告正文还在。 | 迁移无报错。 |
|
||||
| `20260831010000_feature_pricing.sql` | C | 功能价格表收紧权限。 | 不改用户余额。 | 迁移无报错。 |
|
||||
| `20260831020000_feature_pricing_admin_runtime_read_policy.sql` | D | 后台可以只读功能价格。 | 用户侧不变。 | 迁移无报错。 |
|
||||
| `20260901010000_append_consultation_question.sql` | C | 咨询提问的写入函数收紧。 | 不改已有对话。 | 迁移无报错。 |
|
||||
| `20260901020000_chat_session_pin_archive.sql` | D | 会话增加置顶和归档用的列。 | 这一步还不把归档会话放回列表。 | 迁移无报错。 |
|
||||
| `20260902010000_rectification_question_on_turn.sql` | C | 校正把问过的题和选中的项记在当轮上,并收紧权限。 | 刷新后还能看到刚才那道题。 | 迁移无报错。 |
|
||||
| `20260902020000_rectification_tool_activity_timing.sql` | C | 校正工具耗时从已有记录推算,并收紧权限。 | 不新增用户能看到的个人资料。 | 迁移无报错。 |
|
||||
| `20260903010000_profile_ayanamsa.sql` | D | 本人资料增加岁差设置。 | 不改已有星盘结论。 | 迁移无报错。 |
|
||||
| `20260904010000_read_report_candidate_range.sql` | C | 报告只通过一个只读函数看校正窗口,表权限保持收回。 | 找不到时按未找到处理,不编一个窗口。 | 迁移无报错。 |
|
||||
| `20260905010000_personal_report_longform_appendices.sql` | C | 长报告附录单独缓存,失败不影响报告本身,并收紧权限。 | 已完成的报告不会因为附录失败被改成失败。 | 迁移无报错。 |
|
||||
| `20260906010000_chat_session_context_summary.sql` | D | 会话增加一段上下文摘要列。 | 旧对话正文不变。 | 迁移无报错。 |
|
||||
| `20260906020000_adopted_credible_range.sql` | C | 采用校正结果时另存当时的可信窗口,并收紧权限。 | 原来的搜索起点不被改写。 | 迁移无报错。 |
|
||||
| `20260906030000_rectification_block_scan_stage.sql` | B | 出生时间完全未知、而且尚未采用的校正,阶段改成先比较时段。 | 已经确认、关闭或已有具体时间的不改。 | 数 public.agentic_rectification_cases 里被改成 block_scan 的行。 |
|
||||
| `20260907010000_personal_report_card_summary.sql` | D | 报告列表增加一段纯文字摘要。 | 不改报告正文。 | 迁移无报错。 |
|
||||
| `20260907020000_rectification_declared_uncertainty.sql` | C | 校正可以按用户说的不确定范围收窄或放宽,并收紧权限。 | 不改已经采用的时间。 | 迁移无报错。 |
|
||||
| `20260909010000_profile_ayanamsa_service_role_grants.sql` | D | 补岁差列给服务角色的读取权。 | 不改用户设置。 | 迁移无报错。 |
|
||||
| `20260910010000_rectification_host_fallback_phase.sql` | C | 校正在模型没写出答案时记下宿主兜底,并收紧权限。 | 不改已有答案。 | 迁移无报错。 |
|
||||
| `20260915010000_rectification_touch_chat_session.sql` | C | 校正有进展时,会话的更新时间跟着前进,并收紧权限。 | 只打开或改标题不会刷新这个时间。 | 迁移无报错。 |
|
||||
| `20260916010000_consultation_session_capacity.sql` | C | 咨询字数上限只数用户看得到的正文,并收紧权限。 | 思考过程仍保存,但不占那一档额度。 | 迁移无报错。 |
|
||||
| `20260916020000_rectification_session_title_repair.sql` | B | 校正会话标题里对不上的月日会按创建时间改回;更新时间只向前补。 | 生产停在 08-16 时标题是固定的「生时校正」,预期改 0 行。 | 只看两条 NOTICE 的数字。不是 0 就停下,不记录标题文字。 |
|
||||
| `20260920010000_consultation_free_completion.sql` | C | 咨询完成时的免费收尾规则收紧。 | 不改已扣的点数。 | 迁移无报错。 |
|
||||
| `20260920010000_rectification_receipt_result_identity.sql` | C | 校正回执改用最后一次完成结果的来源,并收紧权限。 | 不改已展示的结论形状。 | 迁移无报错。 |
|
||||
| `20260920020000_adopted_birth_date.sql` | C | 增加采用后的日期和时区列,并收紧权限。 | 原来申报的日期不改。 | 迁移无报错。 |
|
||||
| `20260920030000_rectification_dated_windows.sql` | C | 校正窗口可以带公历日期,并收紧权限。 | 已有结果和原始日期不改写。 | 迁移无报错。 |
|
||||
| `20260921010000_retire_chat_session_archive.sql` | A | 所有已归档会话的归档标记清空,会话回到列表。 | 用户会重新看到以前归档的会话。列先留着,以后再删。 | 数 public.chat_sessions 里归档标记非空的行,迁移后应为 0。 |
|
||||
| `20260925010000_rectification_session_title_result.sql` | B | 自动生成的校正标题改成带结果,并回填已有自动标题。 | 手改过的标题不动。 | 只看 NOTICE 的行数,不记录标题文字。 |
|
||||
| `20260925020000_chart_subject_typed_columns.sql` | A | 他人档案改用单独的列;本人在人物表里的镜像行删除。 | 本人资料改由账户资料承载。他人档案还在。 | 数 role 为 self 的人物行,迁移后应为 0。不要打印行内容。 |
|
||||
| `20260925030000_chart_subject_trust_and_limit.sql` | B | 他人档案里已采用或已确认的时间退回「已申报」,并清空采用列。 | 每人最多再保存 4 位他人。生产预期受影响行数是 0。 | 本文件之前先数命中行。不是 0 就停下,只报数字。 |
|
||||
| `20260925040000_deleted_report_release.sql` | C | 已删除报告的释放规则收紧。 | 不把已删除报告重新公开。 | 迁移无报错。 |
|
||||
| `20260926010000_rectification_telemetry.sql` | C | 校正交付时写一行不含身份的汇总,并收紧权限。 | 不保存出生时间或对话原文。 | 迁移无报错。 |
|
||||
| `20260927010000_profile_gender.sql` | D | 账户和已保存的人增加可选性别。 | 不填也能继续用。 | 迁移无报错。 |
|
||||
| `20260927020000_profile_adopted_birth_service_role_select.sql` | D | 补服务角色读取已采用日期列的权限。 | 不改已有日期。 | 迁移无报错。 |
|
||||
| `20260928010000_profile_ayanamsa_six.sql` | D | 岁差选项从四种扩成六种。 | 原来的设置仍然有效。 | 迁移无报错。 |
|
||||
| `20260930010000_user_consents.sql` | C | 增加用户协议和隐私政策的同意记录,并收紧权限。 | 只追加,不改写旧同意。 | 迁移无报错。 |
|
||||
| `20260930020000_account_deletion_requests.sql` | C | 增加 7 天冷静期的注销申请,并收紧权限。 | 申请当时会退出全部登录。不立刻删资料。 | 迁移无报错。 |
|
||||
| `20260930030000_user_feedback.sql` | C | 增加应用内反馈和回复评价的表,并收紧权限。 | 旧对话不改。 | 迁移无报错。 |
|
||||
| `20260930040000_moderation_events.sql` | C | 增加审核命中记录,并收紧权限。 | 只记类别和规则编号,不记原文。 | 迁移无报错。 |
|
||||
| `20260930050000_reply_quality_snapshots.sql` | C | 点了负向评价时保存那一轮的问题、回答和上下文,并收紧权限。 | 没有点评价的对话不写这份快照。 | 迁移无报错。演练输出不要带正文。 |
|
||||
| `20261001010000_rectification_segment_adoption.sql` | C | 校正可以把一段时间里的某一分钟记为采用结果,并收紧权限。 | 这不是新的引擎候选,也不等于最终确认。 | 迁移无报错。 |
|
||||
| `20261001020000_rectification_segment_checks.sql` | C | 校正时段结果的检查收紧。 | 不改已采用的时间。 | 迁移无报错。 |
|
||||
| `20261002010000_rectification_dated_algorithm_generation.sql` | D | 校正记下算法版本,方便以后区分新旧打分。 | 不改已经交付的结果。 | 迁移无报错。 |
|
||||
@@ -0,0 +1,111 @@
|
||||
# 生产首发步骤(2026-10-14)
|
||||
|
||||
给产品负责人照着点。本单合入测试环境并部署完成之后再做。执行人不要把出生资料、姓名、邮箱、会话、密钥写进仓库或评论。
|
||||
|
||||
发布用两个提交:
|
||||
|
||||
- `H`:当时的测试环境分支头。`main` 要快进到这一个,不能合并出新提交。
|
||||
- `R`:测试环境上最近一次「代码门禁成功」的提交。`H` 可以比 `R` 更靠前,但中间只能是文档。公网健康检查里的版本号必须等于 `R`。
|
||||
|
||||
四个按钮都在 Gitea Actions 里,要从 `main` 点。迁移演练没有第五个按钮。产品在生产主机上自己跑脚本,看下面第 5 步。
|
||||
|
||||
## 0. 前一天
|
||||
|
||||
冻结代码推送。文档可以继续推。确认测试环境健康检查的版本号仍是 `R`,并且从 `R` 到当前测试环境头没有代码改动。
|
||||
|
||||
## 1. 核对版本
|
||||
|
||||
打开 `https://staging.jyotisha.chat/api/health`。`deployment.gitCommit` 等于 `R`。
|
||||
|
||||
在仓库里执行 `deploy/is-docs-only-range.sh R <当前测试环境头>`。退出码 0 才继续。退出码 1 或 2 都停,不要发布。
|
||||
|
||||
## 2. 把 main 快进到测试环境头
|
||||
|
||||
用快进把 `main` 推到 `H`。不要做 merge。快进之后 `main` 和测试环境是同一个提交。
|
||||
|
||||
通过:Gitea 上两个分支指向同一个 40 位提交。
|
||||
|
||||
## 3. Release Quality Gate
|
||||
|
||||
Gitea → Jyotish Release Quality Gate → Use workflow from:`main`。不需要填发布提交。
|
||||
|
||||
通过:这一步成功,摘要里能看到 `release_sha`(这就是 `R`)和 `head_sha`(这就是 `H`),并写着相对 `R` 的后续改动只是文档。
|
||||
|
||||
失败:不要点后面的按钮。看失败那一行的原文,把原文交给验收,不要附带环境文件。
|
||||
|
||||
## 4. Create Production Recovery Point
|
||||
|
||||
Gitea → Create Production Recovery Point。Use workflow from:`main`。`deploy_sha` 填 `R`。
|
||||
|
||||
通过:成功结束,记下三样东西:`recovery_reference`、`recovery_created_at`、以及它已经写明还原验证通过。加密备份在生产主机的备份目录里,不要打开、不要把密钥贴出来。
|
||||
|
||||
失败:停。不要迁移。
|
||||
|
||||
## 5. 在主机上演练迁移
|
||||
|
||||
没有单独的 Gitea 按钮。用部署账号在生产主机上跑。主机上如果还没有这份代码,只读检出 `R`,不要在主机上构建镜像。备份文件要自己填进 `RECOVERY_DUMP`,脚本不会猜哪一个是最新的。
|
||||
|
||||
先看计划,不连数据库:
|
||||
|
||||
```text
|
||||
DEPLOY_PATH=/opt/jyotisha-production JYOTISHA_REHEARSAL_PLAN=1 bash deploy/rehearse-production-migrations.sh
|
||||
```
|
||||
|
||||
通过:输出里 `stop_in_before=no`,并且一次性库的名字以 `rehearse_` 开头。
|
||||
|
||||
再正式跑。把刚才那个备份文件路径放进 `RECOVERY_DUMP`,文件必须在 `/opt/jyotisha-production/backups/` 下面,并且是 `.dump.enc`:
|
||||
|
||||
```text
|
||||
DEPLOY_PATH=/opt/jyotisha-production RECOVERY_DUMP=/opt/jyotisha-production/backups/<刚才那个备份文件名> bash deploy/rehearse-production-migrations.sh
|
||||
```
|
||||
|
||||
脚本只还原到一次性库,不会改名为 `jyotisha` 的生产库。磁盘使用率要低于 70%。另一项生产变更正在进行时会停。跑完会删掉一次性库。
|
||||
|
||||
通过,要同时满足:
|
||||
|
||||
- 输出有 `other_person_accepted_rows=0`。这是 `20260925030000` 之前的人数。不是 0 就停,只把这个数字发给产品,不要继续,也不要写出任何一行的内容。
|
||||
- 两条会改用户能看见的数据:归档会话回到列表,人物表里的本人镜像被删掉。行数变化要和 `docs/operations/production-migrations-20261014.md` 的产品需知一致。
|
||||
- 权限冒烟三行都是 `true`:网页角色能读账户资料、能读会话列表,服务角色能读人物表。
|
||||
- 最后一行是 `rehearsal_ok=1`。
|
||||
|
||||
现在这一批里还没有「服务角色可读人物表」那一笔授权(BUG-1063,在另一条尚未合入的分支上)。冒烟因此会停,并退出码 4。这不是脚本坏了。在那一笔进入本次发布之前,不要点生产迁移。
|
||||
|
||||
失败时:脚本会删掉一次性库。生产库还没动。把退出码和它打印的那一句原因留下来。不要复制数据库口令或备份密钥。
|
||||
|
||||
## 6. Migrate Production Database
|
||||
|
||||
只在第 5 步 `rehearsal_ok=1` 之后点。
|
||||
|
||||
Gitea → Migrate Production Database。Use workflow from:`main`。
|
||||
|
||||
- `deploy_sha`:`R`
|
||||
- `recovery_reference`:第 4 步记下的引用
|
||||
- `recovery_created_at`:第 4 步记下的时间,24 小时内
|
||||
- `restore_verified`:true
|
||||
|
||||
通过:工作流成功,并且没有「恢复点超过 24 小时」或「版本对不上」的失败句。
|
||||
|
||||
失败:不要部署新代码。按下面的「失败后」整库还原。
|
||||
|
||||
## 7. Deploy production
|
||||
|
||||
Gitea → Deploy production。Use workflow from:`main`。
|
||||
|
||||
- `deploy_sha`:`R`
|
||||
- `allow_rollback`:false
|
||||
- `verification_mode`:public
|
||||
|
||||
通过:工作流成功。然后做 `docs/testing/production-first-release-20261014.md`。
|
||||
|
||||
## 失败后
|
||||
|
||||
接受整库还原到第 4 步那个恢复点。恢复点之后新写入的数据会丢掉。内测还没开放时,这个窗口里通常还没有新的用户数据。
|
||||
|
||||
旧代码读不了新的表结构。所以不能只把程序退回去。两件事都要做:
|
||||
|
||||
1. 用恢复点把数据库整库还原到发布前。
|
||||
2. 再点 Deploy production,`allow_rollback` 选 true,目标用发布前的生产版本 `7b620c7a`。
|
||||
|
||||
谁来点:产品负责人。不要让执行代理代点生产按钮。
|
||||
|
||||
还原之后再按核对清单看健康检查。版本号应回到 `7b620c7a`。
|
||||
@@ -0,0 +1,47 @@
|
||||
# PROGRESS · 发布通路改规则 + 生产首发(2026-10-14)
|
||||
|
||||
- 分支:`codex/release-path-production-20261014`
|
||||
- 基线:从 `origin/staging` 新建,开工 HEAD `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`。任务书里的 `48a2fadd` 是更早的记录;`74384be0` 是本基线的祖先。
|
||||
- 开工时测试环境健康检查的版本号与这个 HEAD 相同。收尾时公开健康检查已变成 `cb8004a188afd51ec440a7678f91be801fbd3147`,本分支没有变基到那个提交。本轮没有改测试环境。
|
||||
- 预检 `scripts/pre_work_check.py`:远端仓库可见,Python 可用。碎片扫描 90 秒超时,聚焦测试 45 秒超时。不能写成预检通过。
|
||||
- 编号:本树 `docs/BUG_HISTORY.md` 最大号原是 BUG-1346。BUG-1347 在另一条还没合入的分支上,本单用 BUG-1348、BUG-1349,避免以后撞号。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 四个生产工作流共用 `deploy/resolve-release-revision.sh`。发布修订是最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。回滚仍走原来的确认和祖先检查。
|
||||
2. 生产环境拒绝测试验证码:环境校验看见这个变量就失败;运行时如果站点是生产域名并且配了验证码,就拒绝启动。
|
||||
3. 63 个新增迁移的清单、演练脚本、首发步骤和发布后核对都写好了。迁移演练没有单独的 Gitea 按钮,这是决策记录只授权四个工作流之后的做法。
|
||||
4. 没有点任何生产按钮,没有推 `staging` 或 `main`。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_resolve_release_revision.py` | 7 通过 |
|
||||
| `tests/test_rehearse_production_migrations.py` | 3 通过。拒绝非生产路径、拒绝生产库名、计划输出把停止文件留在第二批 |
|
||||
| `tsc --noEmit -p frontend` | 0 错 |
|
||||
| `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
|
||||
| 四个生产工作流 | `js-yaml` 能解析 |
|
||||
| 新脚本 `bash -n` | 通过(工作流测试里的语法检查那一条通过) |
|
||||
| 生产发布、恢复点、生产迁移、手动发布门的文本合同 | 通过 |
|
||||
| 生产站拒绝测试验证码的身份配置测试 | 通过 |
|
||||
|
||||
同一批前端测试文件里还有 8 条失败,0 条取消。它们是这台 Windows 机器的环境,不是把断言改弱:
|
||||
|
||||
- 环境文件校验要求 Unix 权限 `0600`。Git Bash 里 `chmod 600` 之后 `stat` 仍是 `644`,脚本在看到验证码检查之前就停了。
|
||||
- 工作流 YAML 检查找到的 `python` 是 Windows 占位程序,退出码 9009。四个生产文件另用项目里的 `js-yaml` 解析通过。
|
||||
- 两条同步测试缺 `rsync`。
|
||||
- 一条回滚测试在临时目录上 `install` 改权限被拒绝。
|
||||
- 纯文档范围脚本在这台机器上没有 `python3`,退出码 49。发布修订测试用仓库里的 Python 跑过了同一条判断。
|
||||
|
||||
## 迁移演练(本机一次性库)
|
||||
|
||||
空库,角色按主机引导脚本的名字建好,没有真实个人资料。`deploy/rehearse-production-migrations.sh` 在本地模式跑完:
|
||||
|
||||
- 停止文件之前 160 个迁移,全部应用后 174 个。
|
||||
- `other_person_accepted_rows=0`
|
||||
- 两条标题修补 NOTICE 都是 0
|
||||
- 网页角色读账户资料、读会话列表:true
|
||||
- 服务角色读人物表:false,退出码 4
|
||||
|
||||
这是预期停止。BUG-1063 的授权不在这 63 个文件里。演练库已删除。在那笔授权进入这一批之前,不要点生产迁移。主机上的正式演练仍由产品按 runbook 做,本机这次不能代替主机恢复点。
|
||||
@@ -333,7 +333,7 @@
|
||||
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
|
||||
| `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` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
|
||||
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
|
||||
| `TASK-release-path-production-20261014.md` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待验收 | `codex/release-path-production-20261014`(BUG-1348、BUG-1349;BUG-1347 在另一条未合入分支) |
|
||||
| `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` 为准) |
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 生产首发后核对(2026-10-14)
|
||||
|
||||
产品发布完成后照着看。把「实际」一栏填成通过或没通过。不要把登录账号、页面正文、出生资料写进仓库。
|
||||
|
||||
`R` 是这次部署填的那个 40 位提交,也就是测试环境健康检查在发布前显示的版本。
|
||||
|
||||
| 核对 | 预期 | 实际 |
|
||||
| --- | --- | --- |
|
||||
| 打开 `https://jyotisha.chat/api/health` | 状态 200,`deployment.gitCommit` 等于 `R`,`apiGitCommit` 等于 `R` | |
|
||||
| 同一份健康检查里的数据库 | `database.requiredMigrationsPresent` 为 true,`latestMigration` 与测试环境同一字段相同 | |
|
||||
| 内部健康检查 | 状态 200,`swisseph_available` 为 true | |
|
||||
| 打开 `/login` | 状态 200,页面数据里的 `testOtp` 是 null | |
|
||||
| 未登录打开 `/api/account` | 状态 401 | |
|
||||
| 用受控账号登录 | 能登录 | |
|
||||
| 登录后建一张盘 | 盘能打开 | |
|
||||
| 发一轮对话 | 能收到回复 | |
|
||||
| 打开一份报告 | 报告页能打开 | |
|
||||
| 打开 `/admin` | 只有管理员能进,普通账号不能 | |
|
||||
|
||||
任何一行没通过:不要继续开放内测。按 runbook 的「失败后」做整库还原,并把应用退回 `7b620c7a`。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 生产迁移演练报告(模板)
|
||||
|
||||
产品在主机上跑完 `deploy/rehearse-production-migrations.sh` 后填写。只写行数和通过/停止。不要写姓名、出生时间、邮箱、会话正文、密钥或任何一行的内容。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 日期 | |
|
||||
| 发布修订 R | |
|
||||
| 恢复点引用 | |
|
||||
| 一次性库名字(必须是 `rehearse_` 开头) | |
|
||||
| `other_person_accepted_rows` | 预期 0 |
|
||||
| 归档会话:迁移前非空行数 / 迁移后 | 迁移后应为 0 |
|
||||
| 人物表里本人镜像:迁移前 / 迁移后 | 迁移后应为 0 |
|
||||
| 标题修补两条 NOTICE | 生产上预期都是 0。不是 0 就停止 |
|
||||
| 自动标题回填 NOTICE | 只写数字 |
|
||||
| 未知时间改成时段比较的行数 | |
|
||||
| 家人整点改为已采用的行数 | |
|
||||
| 时段回填的行数 | |
|
||||
| 无日期证据改为已确认的行数 | |
|
||||
| 网页角色读账户资料 | true 才算过 |
|
||||
| 网页角色读会话列表 | true 才算过 |
|
||||
| 服务角色读人物表 | true 才算过。false 时停止,不要点生产迁移(BUG-1063 的授权还没进这一批) |
|
||||
| 最终一行 | `rehearsal_ok=1` 才算演练通过 |
|
||||
|
||||
停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。
|
||||
@@ -98,6 +98,9 @@ function readTestOtp(env: IdentityEnvironment): string | null {
|
||||
if (!/^\d{6}$/.test(value)) {
|
||||
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
|
||||
}
|
||||
if (env.AUTH_USER_ORIGIN?.trim() === "https://jyotisha.chat") {
|
||||
throw new Error("IDENTITY_TEST_OTP is forbidden when AUTH_USER_ORIGIN is the production site");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
|
||||
const bash = process.env.JYOTISHA_BASH || "bash";
|
||||
import {
|
||||
databaseHealthFromFilenames,
|
||||
RECTIFICATION_CONTRACT_VERSION,
|
||||
@@ -218,7 +221,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
|
||||
try {
|
||||
writeFileSync(envFile, "APP_ENV_FILE=../.env.staging\nOPENAI_API_KEY=legacy\nLLM_MODEL=legacy\n");
|
||||
chmodSync(envFile, 0o600);
|
||||
const first = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
|
||||
const first = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
const prepared = readFileSync(envFile, "utf8");
|
||||
assert.match(prepared, /^APP_ENV_FILE=\.\.\/\.env\.staging$/m);
|
||||
@@ -227,7 +230,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
|
||||
assert.ok(key);
|
||||
assert.equal(statSync(envFile).mode & 0o777, 0o600);
|
||||
|
||||
const second = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
|
||||
const second = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
const secondKey = readFileSync(envFile, "utf8").match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1];
|
||||
assert.equal(secondKey, key);
|
||||
@@ -267,7 +270,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
|
||||
"PERSONAL_REPORT_DAILY_LIMIT=5",
|
||||
];
|
||||
const run = () =>
|
||||
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
|
||||
spawnSync(bash, [validator, envFile], { encoding: "utf8" });
|
||||
const writeEnv = (lines: string[], mode = 0o600) => {
|
||||
writeFileSync(envFile, `${lines.join("\n")}\n`);
|
||||
chmodSync(envFile, mode);
|
||||
@@ -515,20 +518,28 @@ test("production env validators accept only self-hosted production selectors and
|
||||
try {
|
||||
writeEnv(appEnv, appLines);
|
||||
writeEnv(databaseEnv, databaseLines);
|
||||
assert.equal(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
|
||||
assert.equal(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
|
||||
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
|
||||
for (const forbidden of ["IDENTITY_TEST_OTP=123456", "IDENTITY_TEST_OTP="]) {
|
||||
writeEnv(appEnv, [...appLines, forbidden]);
|
||||
const rejected = spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" });
|
||||
assert.notEqual(rejected.status, 0, forbidden);
|
||||
assert.match(rejected.stderr, /IDENTITY_TEST_OTP is forbidden in production/);
|
||||
}
|
||||
writeEnv(appEnv, appLines);
|
||||
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
|
||||
assert.equal(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
|
||||
|
||||
writeEnv(appEnv, appLines.map((line) =>
|
||||
line.startsWith("ADMIN_USER_ORIGIN=")
|
||||
? "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat"
|
||||
: line,
|
||||
));
|
||||
assert.notEqual(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
|
||||
assert.notEqual(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
|
||||
|
||||
writeEnv(databaseEnv, databaseLines.filter((line) =>
|
||||
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
|
||||
));
|
||||
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
|
||||
assert.notEqual(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -77,3 +77,23 @@ test("identity test OTP channel is opt-in and strictly six digits", () => {
|
||||
/must be a 6-digit/,
|
||||
);
|
||||
});
|
||||
|
||||
test("production origin refuses a configured test OTP and staging still accepts one", () => {
|
||||
assert.throws(
|
||||
() => readIdentityConfig({
|
||||
...selfHostedEnvironment,
|
||||
AUTH_USER_ORIGIN: "https://jyotisha.chat",
|
||||
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
|
||||
IDENTITY_TEST_OTP: "123456",
|
||||
}),
|
||||
/forbidden when AUTH_USER_ORIGIN is the production site/,
|
||||
);
|
||||
const unset = readIdentityConfig({
|
||||
...selfHostedEnvironment,
|
||||
AUTH_USER_ORIGIN: "https://jyotisha.chat",
|
||||
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
|
||||
});
|
||||
assert.equal(unset.provider === "self-hosted" ? unset.testOtp : "unexpected", null);
|
||||
const staging = readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "123456" });
|
||||
assert.equal(staging.provider === "self-hosted" ? staging.testOtp : null, "123456");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import { spawnSync } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
|
||||
const bash = process.env.JYOTISHA_BASH || "bash";
|
||||
|
||||
const giteaQualityWorkflow = new URL(
|
||||
"../../.gitea/workflows/backend-quality-gate.yml",
|
||||
import.meta.url,
|
||||
@@ -999,7 +1002,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
|
||||
chmodSync(join(mockBin, "flock"), 0o755);
|
||||
|
||||
try {
|
||||
const result = spawnSync("bash", [fileURLToPath(deployScript)], {
|
||||
const result = spawnSync(bash, [fileURLToPath(deployScript)], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1117,6 +1120,9 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
|
||||
assert.match(workflow, /NODE_TOOL_IMAGE: node:22-bookworm-slim/);
|
||||
assert.match(workflow, /node --version \| grep -Eq '\^v22\\\.'/);
|
||||
assert.match(workflow, /docker compose version --short \| grep -Eq '\^v\?2\\\.'/);
|
||||
assert.match(workflow, /RESOLVE_MODE: discover/);
|
||||
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
|
||||
assert.match(workflow, /H relative to R is docs-only/);
|
||||
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
|
||||
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
|
||||
for (const dependency of [
|
||||
@@ -1139,7 +1145,13 @@ test("production deploy is manual-only and consumes the accepted staging artifac
|
||||
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
|
||||
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
|
||||
assert.match(production, /public staging has not accepted the requested SHA/);
|
||||
assert.match(production, /current_main[\s\S]*DEPLOY_SHA[\s\S]*current_staging[\s\S]*DEPLOY_SHA/);
|
||||
// 原值: current_main 与 current_staging 都必须等于 DEPLOY_SHA
|
||||
// 新值: 两者必须相等,并且用门禁产物里的 is-docs-only-range.sh 确认相对 DEPLOY_SHA 仍是纯文档
|
||||
// 原因: 产品 2026-10-14 允许 staging head 在最近一次门禁提交之后只前进文档
|
||||
assert.match(production, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
|
||||
assert.match(production, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
|
||||
assert.match(production, /bash deploy\/resolve-release-revision\.sh/);
|
||||
assert.doesNotMatch(production, /stale production revision refused/);
|
||||
assert.match(production, /verification_mode:/);
|
||||
assert.match(production, /PRODUCTION_HOST/);
|
||||
assert.match(production, /118\.194\.235\.34/);
|
||||
@@ -1162,10 +1174,12 @@ test("deployment scripts pass shell syntax validation", () => {
|
||||
productionSyncScript,
|
||||
productionEnvValidator,
|
||||
productionDatabaseEnvValidator,
|
||||
new URL("../../deploy/resolve-release-revision.sh", import.meta.url),
|
||||
new URL("../../deploy/rehearse-production-migrations.sh", import.meta.url),
|
||||
]) {
|
||||
const path = fileURLToPath(script);
|
||||
chmodSync(path, 0o755);
|
||||
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });
|
||||
const result = spawnSync(bash, ["-n", path], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
});
|
||||
@@ -1246,8 +1260,13 @@ test("production recovery workflow creates a verified encrypted off-site artifac
|
||||
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
|
||||
assert.match(workflow, /permissions:\n\s+contents: read\n\s+actions: write/);
|
||||
assert.match(workflow, /group: production-mutation/);
|
||||
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
|
||||
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
|
||||
// 原值: main_head 与 staging_head 都必须等于 DEPLOY_SHA,且工作流正文里用 jq 找 release 门
|
||||
// 新值: 调用 resolve-release-revision.sh,dispatch 的 GITEA_SHA 必须等于脚本给出的 head_sha
|
||||
// 原因: 恢复点跟四个生产工作流共用同一条「最近门禁提交 + 其后纯文档」规则
|
||||
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
|
||||
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
|
||||
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
|
||||
assert.doesNotMatch(workflow, /main_head == "\$DEPLOY_SHA" && "\$staging_head" == "\$DEPLOY_SHA"/);
|
||||
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
|
||||
assert.match(workflow, /git checkout --detach --force "\$DEPLOY_SHA"/);
|
||||
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
|
||||
@@ -1291,15 +1310,21 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
|
||||
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
|
||||
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
|
||||
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
|
||||
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
|
||||
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
|
||||
// 原值: main_head、staging_head、GITEA_SHA 都必须等于 DEPLOY_SHA,门禁 jq 写在工作流里
|
||||
// 新值: 工作流调用 resolve-release-revision.sh;GITEA_SHA 对齐 head_sha,DEPLOY_SHA 对齐 release_sha
|
||||
// 原因: 生产迁移与部署接受同一发布修订,不再要求被 dispatch 的 main 提交自己有 push 门禁
|
||||
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
|
||||
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
|
||||
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
|
||||
assert.doesNotMatch(workflow, /GITEA_SHA" == "\$DEPLOY_SHA"/);
|
||||
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
|
||||
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
|
||||
assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
|
||||
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
|
||||
assert.match(workflow, /branch=staging&event=push&status=success/);
|
||||
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
|
||||
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
|
||||
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /backend-quality-gate\.yml/);
|
||||
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /release-quality-gate\.yml/);
|
||||
assert.match(workflow, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
|
||||
assert.match(workflow, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
|
||||
assert.match(workflow, /group: production-mutation/);
|
||||
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
|
||||
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
|
||||
@@ -1582,7 +1607,7 @@ test("is-docs-only-range.sh decides from local history and refuses non-ancestor
|
||||
git("commit", "-q", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
|
||||
const run = (...args: string[]) => spawnSync(bash, [script, ...args], { cwd: root, env, encoding: "utf8" });
|
||||
|
||||
try {
|
||||
git("init", "-q", "-b", "staging");
|
||||
@@ -1700,7 +1725,7 @@ esac
|
||||
] as const) {
|
||||
const cleanupLog = join(root, "cleanup.log");
|
||||
writeFileSync(cleanupLog, "");
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
const result = spawnSync(bash, ["-c", script], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PATH: `${root}:${process.env.PATH}`, TMPDIR: root,
|
||||
NODE_TOOL_SOURCE_IMAGE: "test-image", NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Safety checks for the disposable migration rehearsal. No production database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
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 run(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
full = os.environ.copy()
|
||||
full.update(env)
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT)],
|
||||
cwd=ROOT,
|
||||
env=full,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def test_refuses_a_non_production_path() -> None:
|
||||
completed = run({"DEPLOY_PATH": "/tmp/not-production"})
|
||||
assert completed.returncode == 1
|
||||
assert "unexpected production path" in completed.stderr
|
||||
|
||||
|
||||
def test_refuses_the_production_database_name() -> None:
|
||||
completed = run(
|
||||
{
|
||||
"JYOTISHA_REHEARSAL_LOCAL": "1",
|
||||
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/jyotisha",
|
||||
}
|
||||
)
|
||||
assert completed.returncode == 1
|
||||
assert "refusing to touch the production database" in completed.stderr
|
||||
assert "secret-value" not in completed.stdout
|
||||
assert "secret-value" not in completed.stderr
|
||||
|
||||
|
||||
def test_plan_keeps_the_stop_file_out_of_the_first_batch() -> None:
|
||||
completed = run(
|
||||
{
|
||||
"JYOTISHA_REHEARSAL_LOCAL": "1",
|
||||
"JYOTISHA_REHEARSAL_PLAN": "1",
|
||||
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan",
|
||||
}
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
text = completed.stdout
|
||||
assert "rehearsal_plan=1" in text
|
||||
assert "database_name=rehearse_plan" in text
|
||||
assert "stop_before=20260925030000_chart_subject_trust_and_limit.sql" in text
|
||||
assert "stop_in_before=no" in text
|
||||
values = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
|
||||
assert int(values["batch_before"]) < int(values["batch_all"])
|
||||
assert "secret-value" not in completed.stdout
|
||||
assert "secret-value" not in completed.stderr
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Resolver for the production release SHA. Gitea responses are local fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
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"
|
||||
|
||||
|
||||
def run_git(repo: Path, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-c", "user.email=dev@example.com", "-c", "user.name=Release Test", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def make_repo(tmp_path: Path) -> dict[str, str]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
run_git(repo, "init")
|
||||
frontend = repo / "frontend"
|
||||
frontend.mkdir()
|
||||
(frontend / "app.txt").write_text("gated\n", encoding="utf-8")
|
||||
run_git(repo, "add", "frontend/app.txt")
|
||||
run_git(repo, "commit", "-m", "gated")
|
||||
first = run_git(repo, "rev-parse", "HEAD")
|
||||
docs = repo / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "note.md").write_text("docs only\n", encoding="utf-8")
|
||||
run_git(repo, "add", "docs/note.md")
|
||||
run_git(repo, "commit", "-m", "docs")
|
||||
second = run_git(repo, "rev-parse", "HEAD")
|
||||
(frontend / "more.txt").write_text("more gated\n", encoding="utf-8")
|
||||
run_git(repo, "add", "frontend/more.txt")
|
||||
run_git(repo, "commit", "-m", "more gated")
|
||||
third = run_git(repo, "rev-parse", "HEAD")
|
||||
return {"repo": str(repo), "r": first, "h": second, "later": third}
|
||||
|
||||
|
||||
def ref_payload(branch: str, sha: str) -> list[dict]:
|
||||
return [{"ref": f"refs/heads/{branch}", "object": {"sha": sha}}]
|
||||
|
||||
|
||||
def gate_payload(sha: str, run_id: int = 42) -> dict:
|
||||
return {
|
||||
"workflow_runs": [
|
||||
{
|
||||
"id": run_id,
|
||||
"path": ".gitea/workflows/backend-quality-gate.yml",
|
||||
"head_sha": sha,
|
||||
"head_branch": "staging",
|
||||
"event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def write_fixtures(directory: Path, manifest: list[dict], files: dict[str, object]) -> None:
|
||||
directory.mkdir()
|
||||
for name, payload in files.items():
|
||||
(directory / name).write_text(json.dumps(payload), encoding="utf-8")
|
||||
(directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
|
||||
def invoke(repo: str, fixture: Path, env_extra: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
# is-docs-only-range.sh calls python3. Windows Git bash has no python3,
|
||||
# and CreateProcess would rather hit the WSL stub than a PATH python.
|
||||
shim = fixture.parent / "python3-shim"
|
||||
shim.mkdir(exist_ok=True)
|
||||
launcher = shim / "python3"
|
||||
launcher.write_text(
|
||||
"#!/bin/bash\n" + f'exec "{PYTHON.as_posix()}" "$@"\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
env.update(
|
||||
{
|
||||
"JYOTISHA_HTTP_FIXTURE_DIR": str(fixture),
|
||||
"JYOTISHA_DOCS_ONLY_MODE": "git",
|
||||
"JYOTISHA_PYTHON": str(PYTHON),
|
||||
"GATED_PATHS_FILE": str(GATED),
|
||||
"PATH": str(shim) + os.pathsep + env.get("PATH", ""),
|
||||
}
|
||||
)
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT)],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def outputs(completed: subprocess.CompletedProcess[str]) -> dict[str, str]:
|
||||
return dict(line.split("=", 1) for line in completed.stdout.splitlines() if "=" in line)
|
||||
|
||||
|
||||
def test_equal_heads_pass(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "equal"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": gate_payload(shas["r"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert outputs(completed)["release_sha"] == shas["r"]
|
||||
assert outputs(completed)["head_sha"] == shas["r"]
|
||||
assert outputs(completed)["gate_run_id"] == "42"
|
||||
|
||||
|
||||
def test_docs_only_advance_passes(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "docs"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["h"]),
|
||||
"staging.json": ref_payload("staging", shas["h"]),
|
||||
"gate.json": gate_payload(shas["r"], 7),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert outputs(completed)["release_sha"] == shas["r"]
|
||||
assert outputs(completed)["head_sha"] == shas["h"]
|
||||
assert outputs(completed)["gate_run_id"] == "7"
|
||||
|
||||
|
||||
def test_gated_advance_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "gated"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["later"]),
|
||||
"staging.json": ref_payload("staging", shas["later"]),
|
||||
"gate.json": gate_payload(shas["h"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["h"]})
|
||||
assert completed.returncode == 1
|
||||
assert "not a docs-only ancestor" in completed.stderr
|
||||
|
||||
|
||||
def test_non_ancestor_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "diverge"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": gate_payload(shas["later"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["later"]})
|
||||
assert completed.returncode == 1
|
||||
assert "not a docs-only ancestor" in completed.stderr
|
||||
|
||||
|
||||
def test_missing_gate_run_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "nogate"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": {"workflow_runs": []},
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 1
|
||||
assert "no successful exact-SHA production quality gate run found" in completed.stderr
|
||||
|
||||
|
||||
def test_divergent_main_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "split"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["h"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 1
|
||||
assert "main and staging must identify the same reviewed release" in completed.stderr
|
||||
|
||||
|
||||
def test_api_failure_exits_2(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "down"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[{"match": "refs/heads/staging", "file": "unused.json", "status": 500}],
|
||||
{"unused.json": {}},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 2
|
||||
assert "Gitea API request failed" in completed.stderr
|
||||
Reference in New Issue
Block a user