ops: add production PostgreSQL migration tooling

This commit is contained in:
Jesse_Chen
2026-08-09 10:47:17 +08:00
parent 04c4bd3ead
commit f53e2d09ed
11 changed files with 2165 additions and 7 deletions
@@ -6,6 +6,7 @@ on:
- '.gitea/workflows/backend-quality-gate.yml' - '.gitea/workflows/backend-quality-gate.yml'
- '.gitea/workflows/deploy-staging.yml' - '.gitea/workflows/deploy-staging.yml'
- '.gitea/workflows/migrate-staging-database.yml' - '.gitea/workflows/migrate-staging-database.yml'
- '.gitea/workflows/migrate-production-database.yml'
- 'deploy/**' - 'deploy/**'
- 'frontend/**' - 'frontend/**'
- 'jyotish_vedic/**' - 'jyotish_vedic/**'
@@ -0,0 +1,363 @@
name: Migrate Production Database (manual only)
on:
workflow_dispatch:
inputs:
deploy_sha:
description: Full current production release SHA to migrate
required: true
type: string
recovery_reference:
description: Backup or PITR recovery reference; multiple migration files are not atomic as a set
required: true
type: string
recovery_created_at:
description: Recovery point creation time in UTC, exactly YYYY-MM-DDTHH:MM:SSZ and no more than 24 hours old
required: true
type: string
restore_verified:
description: Confirm that this recovery point has passed a restore verification
required: true
default: false
type: boolean
permissions:
contents: read
actions: read
concurrency:
group: production-mutation
cancel-in-progress: false
queue: max
jobs:
migrate:
runs-on: manman-linux
timeout-minutes: 20
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com
IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha
DEPLOY_HOST: ${{ vars.PRODUCTION_HOST }}
DEPLOY_PORT: ${{ vars.PRODUCTION_PORT }}
DEPLOY_USER: ${{ vars.PRODUCTION_USER }}
DEPLOY_PATH: ${{ vars.PRODUCTION_PATH }}
STAGING_URL: ${{ vars.STAGING_URL }}
PRODUCTION_KNOWN_HOSTS: ${{ vars.PRODUCTION_KNOWN_HOSTS }}
steps:
- name: Validate current production revision and successful gates
id: revision
env:
DEPLOY_SHA: ${{ inputs.deploy_sha }}
RECOVERY_REFERENCE: ${{ inputs.recovery_reference }}
RECOVERY_CREATED_AT: ${{ inputs.recovery_created_at }}
RESTORE_VERIFIED: ${{ inputs.restore_verified }}
run: |
set -euo pipefail
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
[[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; }
[[ "$RECOVERY_REFERENCE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$ ]] || {
echo "recovery_reference must be 1-200 safe reference characters" >&2
exit 1
}
[[ "$RECOVERY_CREATED_AT" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || {
echo "recovery_created_at must be UTC in YYYY-MM-DDTHH:MM:SSZ format" >&2
exit 1
}
[[ "$RESTORE_VERIFIED" == "true" ]] || {
echo "restore_verified=true is required for a production schema migration" >&2
exit 1
}
python3 - "$RECOVERY_CREATED_AT" <<'PY'
from datetime import datetime, timedelta, timezone
import sys
try:
created_at = datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError as error:
raise SystemExit(f"invalid recovery_created_at: {error}")
age = datetime.now(timezone.utc) - created_at
if age < timedelta(0) or age > timedelta(hours=24):
raise SystemExit("recovery_created_at must be no more than 24 hours old and not in the future")
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
}
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
}
{
echo "sha=$DEPLOY_SHA"
echo "gate_run_id=$gate_run_id"
echo "recovery_reference=$RECOVERY_REFERENCE"
echo "recovery_created_at=$RECOVERY_CREATED_AT"
echo "restore_verified=true"
} >>"$GITHUB_OUTPUT"
- name: Prepare pinned Node tooling
env:
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
NODE_TOOL_IMAGE: node:22-bookworm-slim
run: |
set -euo pipefail
if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then
for attempt in 1 2 3; do
if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2
exit 1
fi
sleep $((attempt * 15))
done
fi
docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE"
docker image inspect "$NODE_TOOL_IMAGE" >/dev/null
tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")"
cat > "$tool_dir/node" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
workdir="$(pwd -P)"
exec docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$workdir:$workdir" \
--workdir "$workdir" \
--env HOME=/tmp \
node:22-bookworm-slim "${0##*/}" "$@"
EOF
chmod 0755 "$tool_dir/node"
ln -s node "$tool_dir/npm"
test -n "${GITHUB_PATH:-}"
printf '%s\n' "$tool_dir" >> "$GITHUB_PATH"
export PATH="$tool_dir:$PATH"
node --version
npm --version
- name: Download gate-produced migration manifest
env:
GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
artifact_prefix="staging-image-manifest-$DEPLOY_SHA-"
artifacts="$(curl --fail --silent --show-error \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?limit=100")"
selected_artifact="$(jq -cer --arg prefix "$artifact_prefix" '
[(.artifacts // [])[]
| select(.expired == false and (.name | startswith($prefix)))
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
| select(.attempt != null and .attempt >= 1)
] | sort_by(.attempt, .id) | reverse | first
' <<<"$artifacts")"
artifact_name="$(jq -er '.name' <<<"$selected_artifact")"
artifact_id="$(jq -er '.id' <<<"$selected_artifact")"
artifact_attempt="${artifact_name#"$artifact_prefix"}"
[[ "$artifact_name" == "$artifact_prefix"* ]]
[[ "$artifact_attempt" =~ ^[1-9][0-9]*$ ]]
[[ "$artifact_id" =~ ^[0-9]+$ ]]
install -d -m 700 artifacts/staging-image
curl --fail --silent --show-error --location \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
python3 - "${RUNNER_TEMP}/staging-image-manifest.zip" artifacts/staging-image <<'PY'
import pathlib, stat, sys, zipfile
archive = pathlib.Path(sys.argv[1])
destination = pathlib.Path(sys.argv[2])
allowed = {"manifest.env", "controller.tar"}
with zipfile.ZipFile(archive) as bundle:
entries = bundle.infolist()
names = [entry.filename for entry in entries]
if len(names) != len(set(names)) or set(names) != allowed:
raise SystemExit("invalid production migration artifact bundle")
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
raise SystemExit("production migration artifact bundle is too large")
for entry in entries:
path = pathlib.PurePosixPath(entry.filename)
mode = entry.external_attr >> 16
if path.is_absolute() or ".." in path.parts or path.name != entry.filename:
raise SystemExit("unsafe production migration artifact path")
if mode and not stat.S_ISREG(mode):
raise SystemExit("unsafe production migration artifact type")
target = destination / entry.filename
with bundle.open(entry) as source, target.open("xb") as output:
output.write(source.read())
PY
[[ -f artifacts/staging-image/manifest.env ]]
[[ -f artifacts/staging-image/controller.tar ]]
- name: Validate gate-attested controller and digest-pinned migration image
id: image
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
manifest=artifacts/staging-image/manifest.env
controller_tar=artifacts/staging-image/controller.tar
[[ "$(wc -l < "$manifest" | tr -d ' ')" == 4 ]]
manifest_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$manifest")"
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$manifest")"
[[ "$manifest_sha" == "$DEPLOY_SHA" && "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]]
printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status
python3 - "$controller_tar" <<'PY'
import pathlib, sys, tarfile
archive = pathlib.Path(sys.argv[1])
required = {"deploy/run-production-migration.sh", "frontend/scripts/staging-image-manifest.mjs"}
with tarfile.open(archive, "r:") as bundle:
members = bundle.getmembers()
names = [member.name for member in members]
if len(names) != len(set(names)) or not required.issubset(names):
raise SystemExit("invalid production migration controller bundle")
if sum(member.size for member in members) > 2 * 1024 * 1024:
raise SystemExit("production migration controller bundle is too large")
for member in members:
path = pathlib.PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()):
raise SystemExit("unsafe production migration controller bundle")
PY
install -d -m 700 artifacts/staging-image/extracted
tar -xf "$controller_tar" -C artifacts/staging-image/extracted
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
"$manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
- name: Apply production schema migration under pinned SSH identity
env:
SSH_PRIVATE_KEY_BASE64: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
WEB_IMAGE: ${{ steps.image.outputs.web_image }}
RECOVERY_REFERENCE: ${{ steps.revision.outputs.recovery_reference }}
RECOVERY_CREATED_AT: ${{ steps.revision.outputs.recovery_created_at }}
RESTORE_VERIFIED: ${{ steps.revision.outputs.restore_verified }}
run: |
set -euo pipefail
[[ "$DEPLOY_HOST" == "118.194.235.34" ]]
[[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] && (( DEPLOY_PORT <= 65535 ))
[[ "$DEPLOY_USER" == "deploy" ]]
[[ "$DEPLOY_PATH" == "/opt/jyotisha-production" ]]
test -n "$PRODUCTION_KNOWN_HOSTS"
ssh_root="${RUNNER_TEMP}/production-migration-ssh"
key_path="$ssh_root/id_ed25519"
known_hosts_path="$ssh_root/known_hosts"
incoming=""
install -m 700 -d "$ssh_root"
test -n "$SSH_PRIVATE_KEY_BASE64"
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path"
printf '%s\n' "$PRODUCTION_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path"
chmod 600 "$key_path" "$known_hosts_path"
ssh-keygen -y -f "$key_path" >/dev/null
ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
remote="$DEPLOY_USER@$DEPLOY_HOST"
require_current_release_heads() {
current_staging="$(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/staging" |
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/staging") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
current_main="$(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/main" |
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
exit 1
}
}
cleanup() {
if [[ -n "$incoming" ]]; then
ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n rm -rf -- '$incoming'" >/dev/null 2>&1 || true
fi
rm -rf -- "$ssh_root"
}
trap cleanup EXIT
incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-production-migration.XXXXXXXXXX")"
[[ "$incoming" == /tmp/jyotisha-production-migration.* ]]
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/staging-image/controller.tar "$remote:$incoming/controller.tar"
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'"
previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-production' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")"
[[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
forward_verified=false
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_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/$previous_sha...$DEPLOY_SHA")"
jq -e --arg base "$previous_sha" --arg head "$DEPLOY_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 "production migration rollback or divergence refused" >&2; exit 1; }
forward_verified=true
fi
require_current_release_heads
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' FORWARD_REVISION_VERIFIED='$forward_verified' RECOVERY_REFERENCE='$RECOVERY_REFERENCE' RECOVERY_CREATED_AT='$RECOVERY_CREATED_AT' RESTORE_VERIFIED='$RESTORE_VERIFIED' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' bash '$incoming/deploy/run-production-migration.sh'"
require_current_release_heads
- name: Operator action
run: echo 'Schema migration complete. Production ETL and application deployment remain separate manual operations.'
+2
View File
@@ -81,6 +81,8 @@ SELECT format(
SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader' SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader'
) \gexec ) \gexec
GRANT schema_owner TO migration_runner;
SELECT format( SELECT format(
'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner', 'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner',
:'database_name' :'database_name'
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
set +x
required=(
INCOMING_PATH DEPLOY_PATH WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA
RECOVERY_REFERENCE RECOVERY_CREATED_AT RESTORE_VERIFIED DOCKER_CONFIG
)
case "${DOCKER_BIN:-docker}" in
docker) docker_command=(docker) ;;
"sudo -n docker") docker_command=(sudo -n docker --config "$DOCKER_CONFIG") ;;
*) echo "unsafe production Docker command" >&2; exit 1 ;;
esac
for key in "${required[@]}"; do
if [ -z "${!key:-}" ]; then
echo "required production migration input is missing: $key" >&2
exit 1
fi
done
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "unsafe production migration revision" >&2
exit 1
}
image_pattern='^crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com/copse/jyotisha@sha256:[0-9a-f]{64}$'
[[ "$WEB_IMAGE" =~ $image_pattern ]] || {
echo "unsafe production migration image" >&2
exit 1
}
case "$INCOMING_PATH" in
/tmp/jyotisha-production-migration.*) ;;
*) echo "unsafe incoming production migration path" >&2; exit 1 ;;
esac
[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || {
echo "unsafe production deployment path" >&2
exit 1
}
[ "$DOCKER_CONFIG" = "$INCOMING_PATH/.docker" ] || {
echo "unsafe production Docker configuration path" >&2
exit 1
}
[[ "$RECOVERY_REFERENCE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$ ]] || {
echo "unsafe production recovery reference" >&2
exit 1
}
[[ "$RECOVERY_CREATED_AT" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || {
echo "unsafe production recovery creation time" >&2
exit 1
}
[ "$RESTORE_VERIFIED" = "true" ] || {
echo "production recovery point must have restore_verified=true" >&2
exit 1
}
recovery_created_epoch="$(date -u -d "$RECOVERY_CREATED_AT" +%s 2>/dev/null)" || {
echo "invalid production recovery creation time" >&2
exit 1
}
recovery_now_epoch="$(date -u +%s)"
recovery_age_seconds=$((recovery_now_epoch - recovery_created_epoch))
(( recovery_age_seconds >= 0 && recovery_age_seconds <= 24 * 60 * 60 )) || {
echo "production recovery point must be no more than 24 hours old and not in the future" >&2
exit 1
}
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
state_directory="$DEPLOY_PATH/.state"
install -d -m 700 "$state_directory"
exec 9>"$state_directory/mutation.lock"
flock -n 9 || {
echo "another production mutation holds the host lock" >&2
exit 75
}
current_sha="not-deployed"
if [ -f "$state_directory/deployed-revision" ]; then
current_sha="$(<"$state_directory/deployed-revision")"
else
existing_web="$("${docker_command[@]}" ps -aq \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=web' | head -n 1)"
if [ -n "$existing_web" ]; then
discovered_sha="$("${docker_command[@]}" inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \
"$existing_web" | sed -n 's/^GITHUB_SHA=//p' | head -n 1)"
if [ -n "$discovered_sha" ]; then current_sha="$discovered_sha"; fi
fi
fi
if [ "$current_sha" != "not-deployed" ] && [[ ! "$current_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "invalid deployed production revision state" >&2
exit 1
fi
[ "$current_sha" = "$EXPECTED_PREVIOUS_SHA" ] || {
echo "production revision changed while this migration was waiting" >&2
exit 1
}
[ "$current_sha" = "not-deployed" ] ||
[ "$current_sha" = "$DEPLOY_SHA" ] ||
[ "${FORWARD_REVISION_VERIFIED:-false}" = "true" ] || {
echo "forward production revision was not verified" >&2
exit 1
}
bash "$INCOMING_PATH/deploy/sync-production-tree.sh" \
"$INCOMING_PATH" "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
EXPECTED_PRODUCTION_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")"
[[ "$EXPECTED_PRODUCTION_ENV_OWNER_UID" =~ ^[0-9]+$ ]] || {
echo "production deployment owner is invalid" >&2
exit 1
}
export EXPECTED_PRODUCTION_ENV_OWNER_UID
bash deploy/validate-production-env.sh .env.production
bash deploy/validate-production-database-env.sh .env.production.database
export DATABASE_ENV_FILE='../.env.production.database'
compose=("${docker_command[@]}" compose -p jyotisha-production -f deploy/docker-compose.postgres.yml)
"${docker_command[@]}" pull "$WEB_IMAGE"
"${compose[@]}" config --quiet
"${compose[@]}" up -d --no-build --pull never --wait postgres
membership="$("${compose[@]}" exec -T postgres psql -v ON_ERROR_STOP=1 -U postgres -d jyotisha -Atc \
"select pg_has_role('migration_runner', 'schema_owner', 'member')")"
[ "$membership" = "t" ] || {
echo "migration_runner must be allowed to SET ROLE schema_owner before production migration" >&2
exit 1
}
environment_value() {
local key="$1"
local value
value="$(sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" .env.production.database)"
case "$value" in
\"*\") value="${value:1:${#value}-2}" ;;
\'*\') value="${value:1:${#value}-2}" ;;
esac
printf '%s' "$value"
}
percent_encode() {
local value="$1"
local encoded=""
local character hex index
LC_ALL=C
for ((index = 0; index < ${#value}; index += 1)); do
character="${value:index:1}"
case "$character" in
[a-zA-Z0-9.~_-]) encoded+="$character" ;;
*)
printf -v hex '%%%02X' "'$character"
encoded+="$hex"
;;
esac
done
printf '%s' "$encoded"
}
migration_runner_password="$(environment_value MIGRATION_RUNNER_PASSWORD)"
[ -n "$migration_runner_password" ] || {
echo "production migration runner password is missing" >&2
exit 1
}
encoded_migration_runner_password="$(percent_encode "$migration_runner_password")"
unset migration_runner_password
migration_runner_database_url="postgresql://migration_runner:${encoded_migration_runner_password}@postgres:5432/jyotisha?options=-c%20role%3Dschema_owner"
unset encoded_migration_runner_password
migration_environment="$(mktemp "$state_directory/production-migration-env.XXXXXXXXXX")"
cleanup_migration_environment() {
rm -f -- "$migration_environment"
}
trap cleanup_migration_environment EXIT
chmod 600 "$migration_environment"
printf 'SCHEMA_DATABASE_URL=%s\n' "$migration_runner_database_url" >"$migration_environment"
unset migration_runner_database_url
set +e
DATABASE_ENV_FILE="$migration_environment" \
"${compose[@]}" --profile migration-check run --rm migration-checker
precheck_status=$?
set -e
if [ "$precheck_status" -ne 0 ] && [ "$precheck_status" -ne 3 ]; then
echo "production migration precheck failed safely" >&2
exit "$precheck_status"
fi
DATABASE_ENV_FILE="$migration_environment" \
"${compose[@]}" --profile migration run --rm migrator
set +e
DATABASE_ENV_FILE="$migration_environment" \
"${compose[@]}" --profile migration-check run --rm migration-checker
postcheck_status=$?
set -e
if [ "$postcheck_status" -ne 0 ]; then
echo "production migration postcheck did not converge" >&2
exit "$postcheck_status"
fi
echo "production schema migration verified for $DEPLOY_SHA"
@@ -13,7 +13,7 @@ This is not a volume copy. A full Supabase dump must not be restored over the ta
## Release invariants ## Release invariants
The production workflow is manual-only and accepts a full lowercase 40-character `deploy_sha`. A normal deployment proceeds only when all of the following identify that exact SHA: The production deployment and schema-migration workflows are manual-only and accept a full lowercase 40-character `deploy_sha`. A normal production mutation proceeds only when all of the following identify that exact SHA:
1. current `main`; 1. current `main`;
2. current `staging`; 2. current `staging`;
@@ -21,7 +21,7 @@ The production workflow is manual-only and accepts a full lowercase 40-character
4. a successful manually triggered `Jyotish Release Quality Gate`; 4. a successful manually triggered `Jyotish Release Quality Gate`;
5. the public staging `/api/health` deployment identity. 5. the public staging `/api/health` deployment identity.
The workflow consumes the exact API and Web image digests recorded by the staging gate. It does not build on the 2-core/4-GB production host, import user data, run schema migrations, or change DNS. The deployment workflow consumes the exact API and Web image digests recorded by the staging gate. It does not build on the 2-core/4-GB production host, import user data, run schema migrations, or change DNS. The separate `Migrate Production Database` workflow uses the same gate-attested Web image only to run the schema checker/migrator/checker sequence; it does not run ETL, deploy the application, or change DNS. Both workflows share the `production-mutation` lock.
## Required Gitea configuration ## Required Gitea configuration
@@ -80,7 +80,13 @@ Use distinct production credentials for PostgreSQL roles, Better Auth, Resend, b
## Database migration engineering gate ## Database migration engineering gate
Before production cutover, implement and review `frontend/scripts/migrate-supabase-production.mjs` with `--preflight`, `--apply`, and `--verify` modes. Until that tool and its fixtures pass, production data cutover is blocked. Before importing data, dispatch Gitea Actions → `Migrate Production Database` for the exact accepted release SHA. The workflow requires `main == staging == deploy_sha`, the same successful staging backend gate, the same manual release gate, and the public staging `/api/health` identity for that SHA. It also requires a non-sensitive recovery reference, its exact UTC creation time, and `restore_verified=true`; the recovery point must be no more than 24 hours old and must already have passed a restore verification. It verifies the current production revision, obtains the gate-attested immutable Web image, runs the schema checker, applies only pending application schema migrations, and requires the checker to converge afterward.
Schema migration files are committed sequentially and are not one atomic transaction as a set. If a later file or post-check fails, earlier files may remain applied; stop, preserve evidence, and restore from the attested recovery point when repair-in-place is not explicitly reviewed. Do not assume a failed workflow means the database is unchanged.
The production database must already grant `migration_runner` membership in `schema_owner`; `deploy/postgres/001-bootstrap-roles.sh` grants only that migration role the ability to `SET ROLE schema_owner`. Identity, app, service, admin, and backup runtime roles must not receive this membership. The workflow checks the membership and refuses to add it itself.
The reviewed data-transfer entry point is `frontend/scripts/migrate-supabase-production.mjs`. It has separate `--preflight`, `--apply`, and `--verify` modes; the application deployment workflow never runs it automatically. Production cutover remains blocked until the exact production snapshot has completed an isolated rehearsal and final verification.
The tool must: The tool must:
@@ -96,6 +102,24 @@ The tool must:
- emit only counts, state aggregates, and normalized SHA-256 manifests—not email addresses, birth data, tokens, or connection strings; - emit only counts, state aggregates, and normalized SHA-256 manifests—not email addresses, birth data, tokens, or connection strings;
- run all target writes in a transaction and roll back on failure. - run all target writes in a transaction and roll back on failure.
The operator supplies these values only on the trusted migration host; do not store the database URLs or encryption keys in Gitea:
- `SUPABASE_SOURCE_DATABASE_URL`: the consistent read-only Supabase snapshot/source URL;
- `PRODUCTION_TARGET_DATABASE_URL`: the PostgreSQL 17 target URL using the migration role;
- `PRODUCTION_OWNER_USER_ID`: the UUID of the designated active source administrator;
- `PRODUCTION_CIPHERTEXT_MODE=preserve|exclude`; preserve additionally requires `PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED=true`.
Run each phase separately and retain its redacted JSON manifest:
```bash
cd frontend
node scripts/migrate-supabase-production.mjs --preflight
node scripts/migrate-supabase-production.mjs --apply
node scripts/migrate-supabase-production.mjs --verify
```
`--apply` is intentionally one-shot: it refuses a populated target. If apply fails, discard or restore the isolated target, correct the cause, and rerun from an empty migrated schema rather than improvising a partial resume.
Do not import platform schemas, source roles/grants, Supabase migration ledgers, sessions, refresh tokens, or provider tokens. Do not use a full-database `pg_restore` against the target. Do not import platform schemas, source roles/grants, Supabase migration ledgers, sessions, refresh tokens, or provider tokens. Do not use a full-database `pg_restore` against the target.
Because migrated users have no portable password/session, all sessions are invalidated and users sign in again through email OTP. Administrators re-enrol MFA. Because migrated users have no portable password/session, all sessions are invalidated and users sign in again through email OTP. Administrators re-enrol MFA.
@@ -104,7 +128,7 @@ Because migrated users have no portable password/session, all sessions are inval
Complete at least one isolated full-data rehearsal before scheduling the final window: Complete at least one isolated full-data rehearsal before scheduling the final window:
1. Apply all target schema migrations to an empty rehearsal database. 1. Apply all target schema migrations to an empty rehearsal database using the same schema migrator path as `Migrate Production Database`.
2. Run migration preflight, apply, post-import reconciliation, and verify. 2. Run migration preflight, apply, post-import reconciliation, and verify.
3. Verify source/target row counts, primary-key set hashes, normalized row hashes, credit totals, payment state totals, subscriptions, reports, consultations, and rectification records. 3. Verify source/target row counts, primary-key set hashes, normalized row hashes, credit totals, payment state totals, subscriptions, reports, consultations, and rectification records.
4. Verify one Owner exists, every active admin has a target role, and database roles remain isolated. 4. Verify one Owner exists, every active admin has a target role, and database roles remain isolated.
@@ -135,6 +159,8 @@ Both `jyotisha.chat` and `admin.jyotisha.chat` are required. The application rej
- Confirm the exact release SHA is deployed and accepted on staging. - Confirm the exact release SHA is deployed and accepted on staging.
- Run the manual release quality gate for that SHA. - Run the manual release quality gate for that SHA.
- Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts. - Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts.
- Create and restore-verify a production recovery point no more than 24 hours before the schema migration; record its non-sensitive reference and UTC creation time.
- Dispatch `Migrate Production Database` for the accepted SHA with that recovery attestation and confirm its post-check reports no pending schema migrations.
- Record pending payment orders and long-running jobs; choose an explicit disposition for each. - Record pending payment orders and long-running jobs; choose an explicit disposition for each.
- Dispatch `Deploy production` with `verification_mode=internal` only after target schema/data preparation. This verifies the new host without depending on public DNS. - Dispatch `Deploy production` with `verification_mode=internal` only after target schema/data preparation. This verifies the new host without depending on public DNS.
@@ -189,7 +215,9 @@ Normal release:
1. Merge the reviewed `staging` release into `main` so both heads are the same SHA. 1. Merge the reviewed `staging` release into `main` so both heads are the same SHA.
2. Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA. 2. Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA.
3. Open Gitea Actions → `Deploy production`. 3. Open Gitea Actions → `Migrate Production Database`; enter the exact 40-character SHA, the no-more-than-24-hour-old recovery reference and UTC creation time, and confirm `restore_verified=true`. Wait for the post-migration checker to converge. Do not use this workflow for Supabase ETL.
4. Enter the exact 40-character SHA, leave `allow_rollback=false`, and choose `internal` or `public` for the current cutover phase. 4. Run the trusted-host ETL phases and retain the redacted reconciliation manifests.
5. Open Gitea Actions → `Deploy production`.
6. Enter the same exact SHA, leave `allow_rollback=false`, and choose `internal` or `public` for the current cutover phase.
Application rollback accepts only an explicitly authorized, previously gate-attested SHA in reviewed `main` history. Database migrations and imported data are not rolled back by the application workflow. Application rollback accepts only an explicitly authorized, previously gate-attested SHA in reviewed `main` history. Database migrations and imported data are not rolled back by the application workflow.
+6
View File
@@ -251,3 +251,9 @@ Prevention: keep evidence-request, life-event, private-candidate, public-recap,
After accumulated historical evidence produced a very narrow winning segment, that segment could contain fewer than two linked samples or discriminating divisional themes. Packet construction treated this valid “not enough distinction yet” state as a dependency failure, so a later answer returned 503 even though scoring and the astrology service were healthy. After accumulated historical evidence produced a very narrow winning segment, that segment could contain fewer than two linked samples or discriminating divisional themes. Packet construction treated this valid “not enough distinction yet” state as a dependency failure, so a later answer returned 503 even though scoring and the astrology service were healthy.
Prevention: classify insufficient candidate-range discrimination explicitly; when a newly narrowed segment cannot support the technical evidence contract, retain the prior candidate range, preserve scored evidence, clear the unconfirmed result, and continue conversational collection. Prevention: classify insufficient candidate-range discrimination explicitly; when a newly narrowed segment cannot support the technical evidence contract, retain the prior candidate range, preserve scored evidence, clear the unconfirmed result, and continue conversational collection.
## ERR-102 | Gitea returned HTTP 502 during final remote synchronization check | active 2026-08-09
Two final `git fetch origin --prune` attempts against the configured primary Gitea remote failed before ref exchange with HTTP `502`. The last locally verified refs remain available, but this run cannot prove that they are still current and must not claim a completed remote synchronization or push.
Prevention: retry fetch and `git ls-remote` before any push or release action, compare the full `main`, `staging`, and migration-branch SHAs, and stop if Gitea remains unavailable. Do not substitute cached refs, the GitHub mirror, or a successful local commit for current Gitea synchronization evidence.
File diff suppressed because it is too large Load Diff
+18
View File
@@ -56,6 +56,24 @@ test("database roles have no cluster privileges", () => {
`), `),
"t", "t",
); );
assert.equal(
fixture.psql(`
select role_name || ':' || case when pg_has_role(role_name, 'schema_owner', 'MEMBER') then 'true' else 'false' end
from unnest(array[
'migration_runner', 'identity_runtime', 'app_runtime',
'service_runtime', 'admin_runtime', 'backup_reader'
]) role_name
order by role_name
`),
[
"admin_runtime:f",
"app_runtime:f",
"backup_reader:f",
"identity_runtime:f",
"migration_runner:true",
"service_runtime:f",
].join("\n"),
);
assert.equal( assert.equal(
fixture.psql(` fixture.psql(`
select coalesce(string_agg(privilege_type, ',' order by privilege_type), '') select coalesce(string_agg(privilege_type, ',' order by privilege_type), '')
+89
View File
@@ -437,3 +437,92 @@ test("production API probes health rapidly while a replacement container starts"
assert.match(compose, /healthcheck:[\s\S]*start_period:\s*30s[\s\S]*start_interval:\s*1s/); assert.match(compose, /healthcheck:[\s\S]*start_period:\s*30s[\s\S]*start_interval:\s*1s/);
}); });
test("self-hosted production Caddy isolates user and admin hosts", () => {
const caddy = readFileSync(
new URL("../../deploy/Caddyfile.production.selfhosted", import.meta.url),
"utf8",
);
assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/jyotisha\.chat\}/);
assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/);
assert.match(caddy, /respond @adminPaths "Not found" 404/);
assert.match(caddy, /^https:\/\/admin\.jyotisha\.chat \{$/m);
assert.equal((caddy.match(/reverse_proxy web:3000/g) ?? []).length, 2);
assert.match(caddy, /@root path \/\n\s+redir @root \/admin 308/);
assert.doesNotMatch(caddy, /staging\.jyotisha\.chat|:443 \{/);
});
test("production env validators accept only self-hosted production selectors and role URLs", () => {
const appValidator = fileURLToPath(
new URL("../../deploy/validate-production-env.sh", import.meta.url),
);
const databaseValidator = fileURLToPath(
new URL("../../deploy/validate-production-database-env.sh", import.meta.url),
);
const root = mkdtempSync(join(tmpdir(), "jyotisha-production-env-"));
const appEnv = join(root, ".env.production");
const databaseEnv = join(root, ".env.production.database");
const appLines = [
"APP_ENV_FILE=../.env.production",
"CADDYFILE_PATH=./Caddyfile.production.selfhosted",
"SITE_ADDRESS=https://jyotisha.chat",
"AUTH_PROVIDER=self-hosted",
"SELF_HOSTED_IDENTITY_ENABLED=true",
"AUTH_USER_ORIGIN=https://jyotisha.chat",
"ADMIN_USER_ORIGIN=https://admin.jyotisha.chat",
"IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha",
"APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha",
"SERVICE_DATABASE_URL=postgresql://service_runtime:service-runtime-test-password@postgres:5432/jyotisha",
"ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha",
"BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long",
"RESEND_API_KEY=re_test_key_that_must_not_be_printed",
"RESEND_FROM_EMAIL=Jyotisha Production <login@jyotisha.chat>",
"ADMIN_EMAILS=admin@example.com",
"EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
"EPAY_CHAT_ENABLED=false",
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes",
"PERSONAL_REPORT_ENABLED=true",
"PERSONAL_REPORT_DAILY_LIMIT=5",
];
const databaseLines = [
"POSTGRES_DB=jyotisha",
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres-password",
"SCHEMA_OWNER_PASSWORD=schema-owner-password",
"IDENTITY_RUNTIME_PASSWORD=identity-runtime-password",
"APP_RUNTIME_PASSWORD=app-runtime-password",
"SERVICE_RUNTIME_PASSWORD=service-runtime-password",
"ADMIN_RUNTIME_PASSWORD=admin-runtime-password",
"MIGRATION_RUNNER_PASSWORD=migration-runner-password",
"BACKUP_READER_PASSWORD=backup-reader-password",
"PRODUCTION_BACKUP_ENCRYPTION_KEY=backup-encryption-key",
"SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-password@postgres:5432/jyotisha",
];
const writeEnv = (path: string, lines: string[]) => {
writeFileSync(path, `${lines.join("\n")}\n`);
chmodSync(path, 0o600);
};
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);
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);
writeEnv(databaseEnv, databaseLines.filter((line) =>
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
));
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
SafeProductionMigrationError,
assertActiveAdminUsers,
assertTargetEmpty,
normalizeAuthUser,
normalizeAuthUsers,
parseMode,
readConfiguration,
readSchema,
rowsSha256,
transferPlan,
} from "../scripts/migrate-supabase-production.mjs";
const scriptPath = fileURLToPath(
new URL("../scripts/migrate-supabase-production.mjs", import.meta.url),
);
function table(
columns: Array<{
name: string;
nullable?: boolean;
defaultValue?: string | null;
generated?: boolean;
identity?: boolean;
identityGeneration?: string | null;
}>,
primaryKey = ["id"],
foreignKeys: Array<{
columns: string[];
refSchema: string;
refTable: string;
refColumns: string[];
}> = [],
) {
return {
columns: columns.map((column) => ({
nullable: false,
defaultValue: null,
generated: false,
identity: false,
identityGeneration: null,
...column,
})),
primaryKey,
foreignKeys,
};
}
test("CLI requires one explicit migration mode", () => {
assert.equal(parseMode(["--preflight"]), "preflight");
assert.equal(parseMode(["--apply"]), "apply");
assert.equal(parseMode(["--verify"]), "verify");
assert.throws(() => parseMode([]), SafeProductionMigrationError);
assert.throws(() => parseMode(["--apply", "--verify"]), SafeProductionMigrationError);
});
test("configuration requires an explicit Owner and ciphertext decision", () => {
const base = {
SUPABASE_SOURCE_DATABASE_URL: "postgresql://source.invalid/jyotisha",
PRODUCTION_TARGET_DATABASE_URL: "postgresql://target.invalid/jyotisha",
PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001",
};
assert.equal(
readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "exclude" }).ciphertextMode,
"exclude",
);
assert.throws(
() => readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "preserve" }),
/confirmed production encryption keys/,
);
assert.equal(
readConfiguration({
...base,
PRODUCTION_CIPHERTEXT_MODE: "preserve",
PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED: "true",
}).ciphertextMode,
"preserve",
);
});
test("Supabase identity transform preserves UUID and ban state without credentials", () => {
const source = {
id: "018F4E6D-7A11-7000-8000-000000000001",
email: " Person@Example.com ",
raw_user_meta_data: { full_name: "Person One", avatar_url: "https://example.invalid/a.png" },
email_confirmed_at: new Date("2026-07-01T00:00:00Z"),
banned_until: new Date("2027-01-01T00:00:00Z"),
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-07-02T00:00:00Z"),
encrypted_password: "must-not-migrate",
refresh_token: "must-not-migrate",
mfa_secret: "must-not-migrate",
};
const user = normalizeAuthUser(source, new Date("2026-08-09T00:00:00Z"));
assert.equal(user.id, source.id.toLowerCase());
assert.equal(user.email, "person@example.com");
assert.equal(user.banned, true);
assert.deepEqual(user.ban_expires, source.banned_until);
assert.equal(user.two_factor_enabled, false);
assert.doesNotMatch(JSON.stringify(user), /must-not-migrate|password|refresh_token|mfa_secret/);
});
test("production identity preflight rejects duplicate canonical emails", () => {
const base = {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
raw_user_meta_data: {},
email_confirmed_at: null,
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-06-01T00:00:00Z"),
};
assert.throws(
() => normalizeAuthUsers([
base,
{ ...base, id: "018f4e6d-7a11-7000-8000-000000000002", email: " PERSON@example.com " },
]),
/invalid or duplicate auth identities/,
);
});
test("active administrators become usable identity admins", () => {
const ownerId = "018f4e6d-7a11-7000-8000-000000000001";
const adminId = "018f4e6d-7a11-7000-8000-000000000002";
const sourceUsers = [ownerId, adminId].map((id) => ({
id,
email: `${id}@example.com`,
raw_user_meta_data: {},
email_confirmed_at: null,
banned_until: null,
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-06-01T00:00:00Z"),
}));
const activeAdminUserIds = new Set([ownerId, adminId]);
const users = normalizeAuthUsers(
sourceUsers,
new Date("2026-08-09T00:00:00Z"),
activeAdminUserIds,
);
assert.deepEqual(users.map((user) => user.role), ["admin", "admin"]);
assert.doesNotThrow(() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId));
assert.throws(
() => assertActiveAdminUsers(users, new Set([adminId]), ownerId),
/Owner is not an active source administrator/,
);
assert.throws(
() => assertActiveAdminUsers([{ ...users[0], banned: true }, users[1]], activeAdminUserIds, ownerId),
/Owner is blocked/,
);
});
test("schema reader preserves PostgreSQL identity metadata", async () => {
const client = {
async query(text: string) {
if (text.includes("information_schema.columns")) {
return {
rows: [{
table_name: "redemption_attempts",
column_name: "id",
nullable: false,
column_default: null,
generated: false,
identity: true,
identity_generation: "ALWAYS",
}],
};
}
if (text.includes("PRIMARY KEY")) {
return { rows: [{ table_name: "redemption_attempts", columns: ["id"] }] };
}
return { rows: [] };
},
};
const schema = await readSchema(client, "public");
assert.deepEqual(schema.get("redemption_attempts")?.columns[0], {
name: "id",
nullable: false,
defaultValue: null,
generated: false,
identity: true,
identityGeneration: "ALWAYS",
});
});
test("transfer plan rejects source-only public columns", () => {
const source = new Map([
["profiles", table([{ name: "id" }, { name: "legacy_value" }])],
]);
const target = new Map([
["profiles", table([{ name: "id" }])],
]);
assert.throws(() => transferPlan(source, target), /target schema is missing a source column/);
});
test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", () => {
const source = new Map([
["parent", table([{ name: "id" }])],
["child", table([{ name: "id" }, { name: "parent_id" }])],
]);
const target = new Map([
["parent", table([{ name: "id" }])],
["child", table(
[{ name: "id" }, { name: "parent_id" }],
["id"],
[{ columns: ["parent_id"], refSchema: "public", refTable: "parent", refColumns: ["id"] }],
)],
]);
assert.deepEqual(transferPlan(source, target).ordered, ["parent", "child"]);
target.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false,
});
source.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false,
});
target.get("parent")!.foreignKeys.push({
columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"],
});
assert.throws(() => transferPlan(source, target), /foreign-key cycle/);
});
test("target preflight rejects existing business rows but permits migration seeds", async () => {
const counts = new Map([
["identity.users", 0],
["auth.users", 0],
["public.admin_roles", 6],
["public.profiles", 1],
]);
const client = {
async query(text: string) {
const match = text.match(/from\s+"(identity|auth|public)"\."([a-z_]+)"/i);
assert.ok(match, text);
return { rows: [{ count: String(counts.get(`${match[1]}.${match[2]}`) ?? 0) }] };
},
};
const targetTables = new Map([
["admin_roles", table([{ name: "id" }])],
["profiles", table([{ name: "id" }])],
]);
await assert.rejects(() => assertTargetEmpty(client, targetTables), /not empty/);
counts.set("public.profiles", 0);
await assert.doesNotReject(() => assertTargetEmpty(client, targetTables));
});
test("reconciliation hashes are stable and the script has no wildcard data reads", () => {
const rows = [
{ id: "b", payload: { z: 2, a: 1 } },
{ id: "a", payload: { a: 1, z: 2 } },
];
assert.equal(rowsSha256(rows, ["id", "payload"]), rowsSha256([...rows].reverse(), ["id", "payload"]));
const source = readFileSync(scriptPath, "utf8");
assert.doesNotMatch(source, /select\s+\*/i);
assert.match(source, /begin isolation level repeatable read read only/i);
assert.match(source, /pg_advisory_xact_lock/);
assert.match(source, /target business database is not empty/);
assert.match(source, /rollback/);
assert.match(source, /--preflight/);
assert.match(source, /--apply/);
assert.match(source, /--verify/);
assert.match(source, /overriding system value/i);
assert.match(source, /setval\(pg_get_serial_sequence/i);
assert.match(source, /target_primary_key_sha256/);
assert.match(source, /sourceKeyHash === targetKeyHash/);
assert.match(source, /users\.role = 'admin' and users\.banned = false/);
});
@@ -42,6 +42,10 @@ const giteaProductionWorkflow = new URL(
"../../.gitea/workflows/deploy-production.yml", "../../.gitea/workflows/deploy-production.yml",
import.meta.url, import.meta.url,
); );
const giteaProductionMigrationWorkflow = new URL(
"../../.gitea/workflows/migrate-production-database.yml",
import.meta.url,
);
const resetStagingAccountWorkflow = new URL( const resetStagingAccountWorkflow = new URL(
"../../.github/workflows/reset-staging-account.yml", "../../.github/workflows/reset-staging-account.yml",
import.meta.url, import.meta.url,
@@ -62,10 +66,22 @@ const productionDeployScript = new URL(
"../../deploy/run-production-deploy.sh", "../../deploy/run-production-deploy.sh",
import.meta.url, import.meta.url,
); );
const productionMigrationScript = new URL(
"../../deploy/run-production-migration.sh",
import.meta.url,
);
const productionSyncScript = new URL( const productionSyncScript = new URL(
"../../deploy/sync-production-tree.sh", "../../deploy/sync-production-tree.sh",
import.meta.url, import.meta.url,
); );
const productionEnvValidator = new URL(
"../../deploy/validate-production-env.sh",
import.meta.url,
);
const productionDatabaseEnvValidator = new URL(
"../../deploy/validate-production-database-env.sh",
import.meta.url,
);
function read(url: URL): string { function read(url: URL): string {
return readFileSync(url, "utf8"); return readFileSync(url, "utf8");
@@ -89,6 +105,7 @@ test("changed staging workflows are syntactically valid YAML", () => {
giteaDeployWorkflow, giteaDeployWorkflow,
giteaMigrationWorkflow, giteaMigrationWorkflow,
giteaProductionWorkflow, giteaProductionWorkflow,
giteaProductionMigrationWorkflow,
]) { ]) {
const result = spawnSync( const result = spawnSync(
"python", "python",
@@ -844,13 +861,16 @@ test("production deploy is manual-only and consumes the accepted staging artifac
}); });
test("staging scripts pass shell syntax validation", () => { test("deployment scripts pass shell syntax validation", () => {
for (const script of [ for (const script of [
deployScript, deployScript,
migrationScript, migrationScript,
syncScript, syncScript,
productionDeployScript, productionDeployScript,
productionMigrationScript,
productionSyncScript, productionSyncScript,
productionEnvValidator,
productionDatabaseEnvValidator,
]) { ]) {
const path = fileURLToPath(script); const path = fileURLToPath(script);
chmodSync(path, 0o755); chmodSync(path, 0o755);
@@ -858,3 +878,128 @@ test("staging scripts pass shell syntax validation", () => {
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
} }
}); });
test("Gitea production deploy consumes only gate-attested digests under manual control", () => {
const workflow = read(giteaProductionWorkflow);
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /deploy_sha:[\s\S]*allow_rollback:[\s\S]*verification_mode:/);
assert.match(workflow, /runs-on: manman-linux/);
assert.match(workflow, /group: production-mutation/);
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(workflow, /public staging has not accepted the requested SHA/);
assert.match(workflow, /main and staging must identify the same reviewed release/);
assert.match(workflow, /controller_sha256/);
assert.match(workflow, /sha256sum --check --status/);
assert.match(workflow, /steps\.images\.outputs\.api_image/);
assert.match(workflow, /steps\.images\.outputs\.web_image/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /\[\[ "\$DEPLOY_HOST" == "118\.194\.235\.34" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_USER" == "deploy" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_PATH" == "\/opt\/jyotisha-production" \]\]/);
assert.match(workflow, /bash '\$incoming\/deploy\/run-production-deploy\.sh'/);
assert.doesNotMatch(workflow, /docker compose[^\n]*build|db:migrate/);
});
test("production runner validates state and migrations before switching exact images", () => {
const runner = read(productionDeployScript);
const sync = read(productionSyncScript);
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /sync-production-tree\.sh/);
assertOrder(runner, [
"validate-production-env.sh",
"validate-production-database-env.sh",
"compose=(",
'"${compose[@]}" config --quiet',
"pull api web",
"up -d --no-build --pull never --wait postgres",
"--profile migration-check run --rm migration-checker",
]);
assert.match(runner, /-f deploy\/docker-compose\.production\.yml/);
assert.match(runner, /pending migrations: run Migrate Production Database/);
assert.doesNotMatch(runner, /--profile migration run --rm migrator/);
assert.match(runner, /if \[ "\$VERIFICATION_MODE" = "public" \]/);
assert.match(runner, /up -d --no-build api web/);
assert.match(runner, /up -d --no-build --force-recreate --no-deps caddy/);
assert.match(runner, /\["identity", "IDENTITY_DATABASE_URL"\]/);
assert.match(runner, /\["service", "SERVICE_DATABASE_URL"\]/);
assert.match(runner, /publicBody\.deployment\?\.gitCommit === process\.env\.EXPECTED_SHA/);
assert.match(runner, /production verification predicates did not converge/);
assert.match(runner, /mv -f "\$revision_file" "\$state_directory\/deployed-revision"/);
assert.match(sync, /--exclude='\/\.env\*'/);
assert.match(sync, /--exclude='\/backups\/'/);
assert.match(sync, /--exclude='\/\.state\/'/);
});
test("Gitea production schema migration is exact-SHA gated and isolated from ETL and deploy", () => {
const workflow = read(giteaProductionMigrationWorkflow);
const runner = read(productionMigrationScript);
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.*DEPLOY_SHA.*staging_head.*DEPLOY_SHA/s);
assert.match(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.*DEPLOY_SHA/s);
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(workflow, /group: production-mutation/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
assert.match(workflow, /\[\[ "\$DEPLOY_HOST" == "118\.194\.235\.34" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_USER" == "deploy" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_PATH" == "\/opt\/jyotisha-production" \]\]/);
assert.match(workflow, /sha256sum --check --status/);
assert.match(workflow, /recovery_reference:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /recovery_created_at:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /restore_verified:[\s\S]*required: true[\s\S]*default: false[\s\S]*type: boolean/);
assert.match(workflow, /\[\[ "\$RESTORE_VERIFIED" == "true" \]\]/);
assert.match(workflow, /%Y-%m-%dT%H:%M:%SZ/);
assert.match(workflow, /age > timedelta\(hours=24\)/);
assert.match(workflow, /migration files run sequentially and are not atomic as a whole/);
assert.match(workflow, /RECOVERY_REFERENCE='\$RECOVERY_REFERENCE'/);
assert.match(workflow, /RECOVERY_CREATED_AT='\$RECOVERY_CREATED_AT'/);
assert.match(workflow, /RESTORE_VERIFIED='\$RESTORE_VERIFIED'/);
assert.match(workflow, /run-production-migration\.sh/);
assert.doesNotMatch(workflow, /migrate-supabase-production|run-production-deploy|verification_mode|PRODUCTION_URL|CADDY/);
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /\[ "\$DEPLOY_PATH" = "\/opt\/jyotisha-production" \]/);
assert.match(runner, /sync-production-tree\.sh/);
assert.match(runner, /validate-production-env\.sh/);
assert.match(runner, /validate-production-database-env\.sh/);
assert.match(runner, /RECOVERY_REFERENCE RECOVERY_CREATED_AT RESTORE_VERIFIED/);
assert.match(runner, /date -u -d "\$RECOVERY_CREATED_AT" \+%s/);
assert.match(runner, /recovery_age_seconds <= 24 \* 60 \* 60/);
assert.match(runner, /production recovery point must have restore_verified=true/);
assert.match(runner, /migration files run sequentially and are not atomic as a whole/);
assert.match(runner, /migration_runner_database_url="postgresql:\/\/migration_runner:/);
assert.match(runner, /role%3Dschema_owner/);
assert.match(runner, /pg_has_role\('migration_runner', 'schema_owner', 'member'\)/);
assert.match(
runner,
/printf 'SCHEMA_DATABASE_URL=%s\\n' "\$migration_runner_database_url" >"\$migration_environment"/,
);
assert.doesNotMatch(runner, /(?:awk|cat)[^\n]*\.env\.production\.database[^\n]*migration_environment/);
assert.doesNotMatch(runner, />>"\$migration_environment"/);
assert.equal((runner.match(/--profile migration-check run --rm migration-checker/g) ?? []).length, 2);
assertOrder(runner, [
"--profile migration-check run --rm migration-checker",
"--profile migration run --rm migrator",
"postcheck_status",
]);
assert.match(runner, /production migration postcheck did not converge/);
assert.doesNotMatch(runner, /migrate-supabase-production|docker-compose\.server\.yml|docker-compose\.production\.yml/);
assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)|Caddyfile|PRODUCTION_URL|PRODUCTION_ADMIN_URL|mv -f[^\n]*deployed-revision/);
});