发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。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
+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"
|
||||
|
||||
Reference in New Issue
Block a user