发布可以跟上只多了文档的测试环境头

四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。

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

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 10:24:40 +08:00
co-authored by Grok (agent loop)
parent f4c53e7f9f
commit 00e6efdbfa
23 changed files with 1470 additions and 174 deletions
+3 -3
View File
@@ -140,7 +140,7 @@ It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operat
## Manual production deployment with Gitea Actions
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. The workflow requires an exact SHA shared by `main` and `staging`, an exact-SHA staging push gate and image manifest, the manual release gate, and matching public staging health. It deploys immutable registry digests and never builds application images on the production host.
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. `main` must be a fast-forward to the current staging head `H`. The deployed SHA is `R`, the latest successful staging push of `backend-quality-gate`, and `deploy/is-docs-only-range.sh R H` must exit 0 (exit 1 or 2 both refuse). Controller artifacts and image digests come from `R`. Public staging health must report `R`. The manual release gate may record `H` when `H` is docs-only ahead of `R`. Rollback still requires an explicit confirmation, a gate run, and ancestry inside reviewed `main` history. The workflow deploys immutable registry digests and never builds application images on the production host.
The `internal` verification mode is for pre-DNS checks on the new host. The `public` mode is for the same SHA after authoritative DNS and Caddy TLS converge. Neither mode imports data, applies pending migrations, or changes DNS.
@@ -281,13 +281,13 @@ Use this order for every staging revision:
5. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
6. After health verification, create the local encrypted backup described below.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA. For a forward release that SHA is `R` (the latest gated staging push that is docs-only behind the shared `main`/`staging` head), plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker still exits before app changes when a migration is pending. After the automatic pre-deploy migrate that should be rare. A failed automatic migration fails the quality gate and does not dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
### Production recovery point before schema migration
Use Gitea Actions → **Create Production Recovery Point** from the exact current `main` release SHA before every production schema migration. The manual workflow requires `main`, `staging`, public staging health, and the successful release gate to identify the same SHA. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled.
Use Gitea Actions → **Create Production Recovery Point** before every production schema migration. Dispatch it from current `main` (the staging head `H`) and set `deploy_sha` to `R`. `main` and `staging` must be the same SHA, `R` must be a docs-only ancestor of that SHA, public staging health must report `R`, and the release gate must have succeeded for `R` or for that same head. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled. The migration rehearsal has no Gitea button; the product runs `deploy/rehearse-production-migrations.sh` from the runbook.
The host script writes an AES-256-CBC/PBKDF2 encrypted custom-format PostgreSQL dump under `/opt/jyotisha-production/backups`, restores it into a uniquely named disposable database, validates non-sensitive row/table counts, removes only that disposable database, and writes a mode-`0600` verification manifest. Before acquiring the shared host lock it validates that `.state` and `backups` are real directories, then reuses the existing passwordless Docker boundary to run the already-loaded PostgreSQL image with no network, a read-only root filesystem, all capabilities dropped except `CHOWN`, and only the two directories plus the verified existing lock inode bind-mounted. That helper exposes the regular `mutation.lock` directly at `/mutation.lock`, restores that inode before changing its mode-`0700` parent directory, then restores the two directory mount points to the current `deploy` UID/GID. The direct file mount avoids depending on traversal through a parent left half-repaired by an earlier failed run, without adding `DAC_OVERRIDE`; it never recursively changes backup files or removes or replaces a lock inode. The workflow retrieves only the encrypted dump and verification metadata, verifies the SHA-256 digest, and uploads them as a 30-day Gitea Actions artifact with compression disabled. The artifact-backed `recovery_reference`, `recovery_created_at`, and `restore_verified=true` output are the inputs for **Migrate Production Database**. Do not use the attestation if backup, restore, retrieval, digest validation, ownership normalization, or artifact upload fails.
+329
View File
@@ -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"
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
# Resolve the production release SHA shared by the four production workflows.
#
# Validate mode (default): REQUESTED_SHA must be a successful staging push of
# backend-quality-gate, main and staging must be the same head H, and
# is-docs-only-range.sh REQUESTED_SHA H must exit 0.
# Discover mode (RESOLVE_MODE=discover): R is the newest such push whose range
# to H is docs-only.
# CHECK_RELEASE_GATE=true also requires a successful release-quality-gate
# dispatch whose head is R or H.
#
# stdout is three KEY=VALUE lines: release_sha, gate_run_id, head_sha.
# Exit 0 accepted, 1 refused, 2 when the Gitea API cannot be read.
# Tests set JYOTISHA_HTTP_FIXTURE_DIR and JYOTISHA_DOCS_ONLY_MODE=git.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
python="${JYOTISHA_PYTHON:-python3}"
mode="${RESOLVE_MODE:-validate}"
api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
repository="${GITEA_REPOSITORY:-root/Jyotisha}"
docs_mode="${JYOTISHA_DOCS_ONLY_MODE:-api}"
if [[ "$mode" != validate && "$mode" != discover ]]; then
echo "RESOLVE_MODE must be validate or discover" >&2
exit 2
fi
http_get() {
local url="$1" dest="$2"
if [ -n "${JYOTISHA_HTTP_FIXTURE_DIR:-}" ]; then
if ! URL="$url" DEST="$dest" FIXTURE_DIR="$JYOTISHA_HTTP_FIXTURE_DIR" "$python" - <<'PY'
import json, os, sys
from pathlib import Path
url = os.environ["URL"]
root = Path(os.environ["FIXTURE_DIR"])
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
for item in manifest:
if item["match"] in url:
status = int(item.get("status", 200))
if status != 200:
sys.stderr.write("fixture refused the Gitea request\n")
sys.exit(1)
Path(os.environ["DEST"]).write_bytes((root / item["file"]).read_bytes())
sys.exit(0)
sys.stderr.write("no fixture matched the Gitea request\n")
sys.exit(1)
PY
then
return 1
fi
return 0
fi
local -a auth=()
if [ -n "${GITEA_TOKEN:-}" ]; then
auth=(--header "Authorization: token ${GITEA_TOKEN}")
fi
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
"${auth[@]}" "$url" --output "$dest"; then
return 1
fi
}
fetch_json() {
local url="$1" dest
dest="$(mktemp)"
if ! http_get "$url" "$dest"; then
rm -f -- "$dest"
echo "Gitea API request failed" >&2
exit 2
fi
printf '%s' "$dest"
}
read_ref_sha() {
local branch="$1" dest sha
dest="$(fetch_json "$api_url/repos/$repository/git/refs/heads/$branch")"
if ! sha="$("$python" - "$branch" "$dest" <<'PY'
import json, re, sys
branch, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
ref = "refs/heads/" + branch
if not isinstance(payload, list) or len(payload) != 1 or payload[0].get("ref") != ref:
sys.exit(2)
sha = ((payload[0].get("object") or {}).get("sha") or "")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
sys.exit(2)
print(sha)
PY
)"; then
rm -f -- "$dest"
echo "Gitea ref for $branch is unreadable" >&2
exit 2
fi
rm -f -- "$dest"
printf '%s' "$sha"
}
gate_id_for() {
local sha="$1" dest gate_id
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&branch=staging&event=push&status=success&limit=100")"
set +e
gate_id="$("$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
matched = []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
):
matched.append(run)
if not matched:
sys.exit(1)
matched.sort(key=lambda run: int(run.get("id") or 0), reverse=True)
print(int(matched[0]["id"]))
PY
)"
local status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
if [ "$status" -ne 0 ]; then
echo "no successful exact-SHA production quality gate run found" >&2
exit 1
fi
printf '%s' "$gate_id"
}
require_docs_only() {
local base="$1" head="$2" status
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$base" "$head" >&2
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "release SHA is not a docs-only ancestor of the staging head" >&2
exit 1
fi
}
require_release_gate() {
local release="$1" head="$2" sha dest status found=false
for sha in "$release" "$head"; do
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&event=workflow_dispatch&status=success&limit=100")"
set +e
"$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("release-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("event") == "workflow_dispatch"
and run.get("conclusion") == "success"
):
sys.exit(0)
sys.exit(1)
PY
status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 0 ]; then
found=true
break
fi
if [ "$status" -eq 2 ]; then
echo "Gitea release gate list is unreadable" >&2
exit 2
fi
done
if [ "$found" != true ]; then
echo "no successful manual release quality gate found for the release SHA or current main" >&2
exit 1
fi
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
if [ "$main_head" != "$staging_head" ]; then
echo "main and staging must identify the same reviewed release" >&2
exit 1
fi
head_sha="$staging_head"
if [ "$mode" = validate ]; then
[[ "${REQUESTED_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || {
echo "deploy_sha must be a lowercase full commit SHA" >&2
exit 1
}
release_sha="$REQUESTED_SHA"
gate_run_id="$(gate_id_for "$release_sha")"
require_docs_only "$release_sha" "$head_sha"
else
list="$(fetch_json "$api_url/repos/$repository/actions/runs?branch=staging&event=push&status=success&limit=100")"
set +e
candidates="$("$python" - "$list" <<'PY'
import json, sys
try:
payload = json.loads(open(sys.argv[1], encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
best = {}
for run in payload.get("workflow_runs") or []:
workflow = str(run.get("path") or "").split("@", 1)[0]
sha = run.get("head_sha") or ""
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
and len(sha) == 40
):
run_id = int(run.get("id") or 0)
if sha not in best or run_id > best[sha]:
best[sha] = run_id
for sha, run_id in sorted(best.items(), key=lambda item: item[1], reverse=True):
print(f"{sha} {run_id}")
PY
)"
status=$?
set -e
rm -f -- "$list"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
release_sha=""
gate_run_id=""
while read -r candidate_sha candidate_id; do
[ -n "$candidate_sha" ] || continue
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$candidate_sha" "$head_sha" >&2
status=$?
set -e
if [ "$status" -eq 0 ]; then
release_sha="$candidate_sha"
gate_run_id="$candidate_id"
break
fi
done <<<"$candidates"
if [ -z "$release_sha" ]; then
echo "no docs-only staging quality gate can be released" >&2
exit 1
fi
fi
if [ "${CHECK_RELEASE_GATE:-}" = true ]; then
require_release_gate "$release_sha" "$head_sha"
fi
printf 'release_sha=%s\n' "$release_sha"
printf 'gate_run_id=%s\n' "$gate_run_id"
printf 'head_sha=%s\n' "$head_sha"
+5
View File
@@ -155,4 +155,9 @@ if [ "$personal_report_daily_limit_count" -ne 1 ] ||
exit 1
fi
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?IDENTITY_TEST_OTP=' "$ENV_FILE"; then
echo "IDENTITY_TEST_OTP is forbidden in production" >&2
exit 1
fi
echo "production environment selectors: valid"