ops: prepare self-hosted production migration
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Codex
2026-08-08 21:04:12 -04:00
parent 61673d3a8b
commit 9b07e12b0c
13 changed files with 1342 additions and 404 deletions
+357 -35
View File
@@ -1,59 +1,381 @@
name: Deploy production (manual only)
name: Deploy production
on:
workflow_dispatch:
inputs:
deploy_sha:
description: Exact tested 40-character staging commit SHA
required: true
type: string
allow_rollback:
description: Explicitly permit a manual rollback to an older tested SHA
required: true
default: false
type: boolean
verification_mode:
description: Use internal before DNS cutover; public after DNS and TLS converge
required: true
default: internal
type: choice
options:
- internal
- public
permissions:
contents: read
actions: read
concurrency:
group: production
group: production-mutation
cancel-in-progress: false
env:
GITEA_SHA: ${{ gitea.sha }}
DEPLOY_HOST: 103.117.123.53
DEPLOY_PORT: '22000'
DEPLOY_USER: root
DEPLOY_PATH: /opt/jyotisha-app
queue: max
jobs:
deploy:
runs-on: xiaoxin
runs-on: manman-linux
timeout-minutes: 30
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 }}
PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
PRODUCTION_ADMIN_URL: ${{ vars.PRODUCTION_ADMIN_URL }}
STAGING_URL: ${{ vars.STAGING_URL }}
PRODUCTION_KNOWN_HOSTS: ${{ vars.PRODUCTION_KNOWN_HOSTS }}
steps:
- name: Checkout current Gitea revision
- name: Validate tested revision and gate run
id: revision
env:
REQUESTED_SHA: ${{ inputs.deploy_sha }}
REQUESTED_ROLLBACK: ${{ inputs.allow_rollback }}
VERIFICATION_MODE: ${{ inputs.verification_mode }}
run: |
set -euo pipefail
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA" main
git checkout --detach --force "$GITEA_SHA"
- name: Verify runner toolchain and current main
[[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
[[ "$VERIFICATION_MODE" == internal || "$VERIFICATION_MODE" == public ]] || { echo "invalid verification_mode" >&2; exit 1; }
[[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; }
allow_rollback=false
if [[ "$REQUESTED_ROLLBACK" == true ]]; then
allow_rollback=true
fi
runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
controller_sha="$(read_ref_sha main)"
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
echo "stale production revision refused; use explicit manual rollback only when intended" >&2
exit 1
fi
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
(.commits // []) as $commits |
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
def reaches($sha; $seen):
if $sha == $base then true
elif ($seen | index($sha)) != null then false
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
(.total_commits | type) == "number" and
.total_commits == ($commits | length) and ($commits | length) > 0 and
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
fi
controller_gate_run_id="$gate_run_id"
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")"
controller_run="$(jq -cer --arg sha "$controller_sha" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$controller_runs")"
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
fi
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$REQUESTED_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; }
if [[ "$allow_rollback" == false ]]; then
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" == "$REQUESTED_SHA" ]] || { echo "public staging has not accepted the requested SHA" >&2; exit 1; }
fi
{
echo "sha=$REQUESTED_SHA"
echo "gate_run_id=$gate_run_id"
echo "controller_sha=$controller_sha"
echo "controller_gate_run_id=$controller_gate_run_id"
echo "allow_rollback=$allow_rollback"
echo "verification_mode=$VERIFICATION_MODE"
} >>"$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
python3 --version
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
docker version
test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"
- name: Configure pinned production SSH
- name: Download target and controller gate artifacts
env:
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
TARGET_GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
CONTROLLER_GATE_RUN_ID: ${{ steps.revision.outputs.controller_gate_run_id }}
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
run: |
set -euo pipefail
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production
chmod 600 ~/.ssh/jyotisha-production
printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts
- name: Sync and rebuild reviewed revision
download_bundle() {
local run_id="$1" sha="$2" destination="$3" zip_path="$4"
local prefix artifacts selected name id attempt
prefix="staging-image-manifest-$sha-"
artifacts="$(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/$run_id/artifacts?limit=100")"
selected="$(jq -cer --arg prefix "$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")"
name="$(jq -er '.name' <<<"$selected")"
id="$(jq -er '.id' <<<"$selected")"
attempt="${name#"$prefix"}"
[[ "$name" == "$prefix"* && "$attempt" =~ ^[1-9][0-9]*$ && "$id" =~ ^[0-9]+$ ]]
install -d -m 700 "$destination"
curl --fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$id/zip" \
--output "$zip_path"
python3 - "$zip_path" "$destination" <<'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 not names or not set(names).issubset(allowed):
raise SystemExit("invalid production artifact bundle")
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
raise SystemExit("production 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 artifact path")
if mode and not stat.S_ISREG(mode):
raise SystemExit("unsafe production artifact type")
target = destination / entry.filename
with bundle.open(entry) as source, target.open("xb") as output:
output.write(source.read())
PY
[[ -f "$destination/manifest.env" ]]
}
rm -rf artifacts/staging-image artifacts/controller
download_bundle "$TARGET_GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-target.zip"
download_bundle "$CONTROLLER_GATE_RUN_ID" "$CONTROLLER_SHA" artifacts/controller "${RUNNER_TEMP}/production-controller.zip"
- name: Validate gate-attested controller and immutable image manifest
id: images
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
rsync -az --delete --exclude='.git/' --exclude='.env.production' --exclude='frontend/node_modules/' --exclude='frontend/.next/' -e "ssh $SSH_OPTIONS" ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && GITHUB_SHA='$GITEA_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans"
- name: Verify production
controller_manifest=artifacts/controller/manifest.env
controller_tar=artifacts/controller/controller.tar
[[ -f "$controller_tar" ]]
[[ "$(wc -l < "$controller_manifest" | tr -d ' ')" == 4 ]]
manifest_controller_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$controller_manifest")"
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$controller_manifest")"
[[ "$manifest_controller_sha" == "$CONTROLLER_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-deploy.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 controller bundle")
if sum(member.size for member in members) > 2 * 1024 * 1024:
raise SystemExit("production 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 controller bundle")
PY
install -d -m 700 artifacts/controller/extracted
tar -xf "$controller_tar" -C artifacts/controller/extracted
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
"$controller_manifest" "$CONTROLLER_SHA" "$IMAGE_REPOSITORY" >/dev/null
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
- name: Deploy exact image digests 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 }}
API_IMAGE: ${{ steps.images.outputs.api_image }}
WEB_IMAGE: ${{ steps.images.outputs.web_image }}
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
VERIFICATION_MODE: ${{ steps.revision.outputs.verification_mode }}
run: |
set -euo pipefail
curl -fsS --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null
test "$(curl -sS -o /dev/null -w '%{http_code}' https://jyotisha.chat/api/account)" = 401
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r=>{const b=await r.json();if(!r.ok||b.status!==\"ok\"||b.swisseph_available!==true)process.exit(1)})'"
[[ "$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" ]]
[[ "$PRODUCTION_URL" == "https://jyotisha.chat" ]]
[[ "$PRODUCTION_ADMIN_URL" == "https://admin.jyotisha.chat" ]]
[[ "$VERIFICATION_MODE" == internal || "$VERIFICATION_MODE" == public ]]
ssh_root="${RUNNER_TEMP}/production-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() {
[[ "$ALLOW_ROLLBACK" == true ]] && return
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 deployment; 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.XXXXXXXXXX")"
[[ "$incoming" == /tmp/jyotisha-production.* ]]
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/controller/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" && "$ALLOW_ROLLBACK" != true ]]; 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 "automatic production rollback or divergent deploy 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' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' PRODUCTION_URL='$PRODUCTION_URL' PRODUCTION_ADMIN_URL='$PRODUCTION_ADMIN_URL' VERIFICATION_MODE='$VERIFICATION_MODE' bash '$incoming/deploy/run-production-deploy.sh'"
require_current_release_heads
+5 -187
View File
@@ -1,4 +1,4 @@
name: Deploy production
name: Production deployment moved to Gitea
on:
workflow_dispatch:
@@ -6,193 +6,11 @@ on:
permissions:
contents: read
concurrency:
group: production
cancel-in-progress: false
env:
DEPLOY_HOST: 103.117.123.53
DEPLOY_PORT: "22000"
DEPLOY_USER: root
DEPLOY_PATH: /opt/jyotisha-app
jobs:
deploy:
retired:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout tested revision
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- name: Reject stale CI revision
id: revision
- name: Refuse deployment from the mirror
run: |
tested_sha="$(git rev-parse HEAD)"
main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
if [ "$tested_sha" = "$main_sha" ]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
echo "Deploying current main revision $tested_sha"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale CI revision $tested_sha; current main is $main_sha"
fi
- name: Configure SSH
if: steps.revision.outputs.deploy == 'true'
env:
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production
chmod 600 ~/.ssh/jyotisha-production
printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts
- name: Sync reviewed production environment keys
if: steps.revision.outputs.deploy == 'true'
env:
GEOAPIFY_API_KEY: ${{ secrets.GEOAPIFY_API_KEY }}
VEDASTRO_API_KEY: ${{ secrets.VEDASTRO_API_KEY }}
VEDASTRO_API_ENDPOINT: ${{ secrets.VEDASTRO_API_ENDPOINT }}
VEDASTRO_ENABLE_NETWORK: ${{ secrets.VEDASTRO_ENABLE_NETWORK }}
VEDASTRO_GATEWAY_MODE: ${{ secrets.VEDASTRO_GATEWAY_MODE }}
VEDASTRO_RANGE_SCAN_NETWORK_ENABLED: ${{ secrets.VEDASTRO_RANGE_SCAN_NETWORK_ENABLED }}
VEDASTRO_TIMEOUT_SECONDS: ${{ secrets.VEDASTRO_TIMEOUT_SECONDS }}
JYOTISH_DYNAMIC_RECTIFICATION_TOKEN: ${{ secrets.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN }}
run: |
set -euo pipefail
set +x
UPDATE_FILE="$RUNNER_TEMP/production-env-update.json"
REMOTE_UPDATE_FILE="$DEPLOY_PATH/.env.production.update.$GITHUB_RUN_ID"
export UPDATE_FILE
umask 077
python3 - <<'PY'
import json
import os
from pathlib import Path
keys = (
"GEOAPIFY_API_KEY",
"VEDASTRO_API_KEY",
"VEDASTRO_API_ENDPOINT",
"VEDASTRO_ENABLE_NETWORK",
"VEDASTRO_GATEWAY_MODE",
"VEDASTRO_RANGE_SCAN_NETWORK_ENABLED",
"VEDASTRO_TIMEOUT_SECONDS",
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN",
)
values = {key: os.environ.get(key, "") for key in keys}
missing = [key for key, value in values.items() if not value]
if missing:
raise SystemExit("required production environment secret is missing")
Path(os.environ["UPDATE_FILE"]).write_text(
json.dumps(values, ensure_ascii=False),
encoding="utf-8",
)
PY
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
SCP_OPTIONS="-i $HOME/.ssh/jyotisha-production -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
scp $SCP_OPTIONS "$UPDATE_FILE" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_UPDATE_FILE"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && python3 - '$REMOTE_UPDATE_FILE'" <<'PY'
import json
import os
import re
import sys
import tempfile
from pathlib import Path
env_path = Path(".env.production")
update_path = Path(sys.argv[1])
try:
if not env_path.is_file():
raise SystemExit("production environment file is missing")
updates = json.loads(update_path.read_text(encoding="utf-8"))
if not isinstance(updates, dict) or not updates:
raise SystemExit("production environment update is empty")
for key, value in updates.items():
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
raise SystemExit("production environment key is invalid")
if not isinstance(value, str) or not value or "\n" in value or "\0" in value:
raise SystemExit("production environment value is invalid")
original = env_path.read_text(encoding="utf-8").splitlines()
output = []
written = set()
assignment = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
def quote(value):
return "'" + value.replace("'", "'\"'\"'") + "'"
for line in original:
match = assignment.match(line)
key = match.group(1) if match else None
if key not in updates:
output.append(line)
continue
if key not in written:
output.append(f"{key}={quote(updates[key])}")
written.add(key)
for key, value in updates.items():
if key not in written:
output.append(f"{key}={quote(value)}")
fd, temporary_name = tempfile.mkstemp(
prefix=".env.production.",
dir=str(env_path.parent),
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write("\n".join(output) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, env_path)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
print(f"updated {len(updates)} production environment keys")
finally:
update_path.unlink(missing_ok=True)
PY
rm -f "$UPDATE_FILE"
- name: Sync and rebuild
if: steps.revision.outputs.deploy == 'true'
env:
DEPLOY_GIT_SHA: ${{ github.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
RSYNC_SSH="ssh $SSH_OPTIONS"
rsync -az --delete \
--exclude='.git/' \
--exclude='.env.production' \
--exclude='frontend/node_modules/' \
--exclude='frontend/.next/' \
-e "$RSYNC_SSH" \
./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans"
- name: Verify production
if: steps.revision.outputs.deploy == 'true'
env:
DEPLOY_GIT_SHA: ${{ github.sha }}
run: |
curl --fail --silent --show-error --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null
test "$(curl --silent --output /dev/null --write-out '%{http_code}' https://jyotisha.chat/api/account)" = "401"
deployed_sha=""
for attempt in $(seq 1 24); do
deployed_sha="$(curl --fail --silent --show-error https://jyotisha.chat/api/health | python3 -c 'import json, sys; print(json.load(sys.stdin).get("deployment", {}).get("gitCommit", ""))')" || deployed_sha=""
[ "$deployed_sha" = "$DEPLOY_GIT_SHA" ] && break
sleep 5
done
test "$deployed_sha" = "$DEPLOY_GIT_SHA" || { echo "Production revision did not converge: expected $DEPLOY_GIT_SHA, got ${deployed_sha:-empty}" >&2; exit 1; }
ssh -i ~/.ssh/jyotisha-production -p "$DEPLOY_PORT" \
-o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
"$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r => { const body = await r.json(); if (!r.ok || body.status !== \"ok\" || body.swisseph_available !== true) process.exit(1); console.log(JSON.stringify(body)); })'"
echo "Production deployment is controlled by .gitea/workflows/deploy-production.yml in git.copse.top." >&2
exit 1
+9 -8
View File
@@ -8,21 +8,22 @@
- Production domain: `https://jyotisha.chat`
- Primary source: `https://git.copse.top/root/Jyotisha.git`; GitHub upstream/mirror: `https://github.com/jesse-ux/Jyotisha.git`
- Server: Hong Kong Ubuntu 22.04 VPS, `103.117.123.53`, SSH port `22000`
- Runtime: `/opt/jyotisha-app`, Docker Compose file `deploy/docker-compose.server.yml`
- Secrets: `/opt/jyotisha-app/.env.production`; never print, copy into chat, or commit
- Migration target: Ubuntu VPS `118.194.235.34`; confirmed SSH port is a required deployment variable
- Target runtime: `/opt/jyotisha-production`, Compose project `jyotisha-production`
- Target secrets: `/opt/jyotisha-production/.env.production` and `.env.production.database`; never print, copy into chat, or commit
- Public edge: Caddy only; Next.js `3000` and Python API `5200` stay Docker-private
- Managed services: Spaceship DNS, Supabase project `vtvnfqmonbfuxmqkqdlc`, external model API
- Capacity boundary: 1 vCPU / 2 GB RAM / 40 GB disk / 5 Mbps; demo and low concurrency only
- Migration source: Spaceship DNS and Supabase project `vtvnfqmonbfuxmqkqdlc`; production target uses private PostgreSQL + Better Auth
- Capacity boundary: 2 vCPU / 4 GB RAM; use digest-pinned images, bounded DB pools, and no on-host application builds
- Cutover runbook: `docs/operations/production-server-migration-2026-08.md`; old VPS/Supabase remain rollback assets until final reconciliation
Deployment safety rules:
1. Run `git status --short --branch` before packaging; do not overwrite unrelated dirty files.
2. Verify `dig +short @launch1.spaceship.net A jyotisha.chat` returns `103.117.123.53` before troubleshooting Caddy certificate issuance.
3. Keep Supabase Auth Site URL and redirect URLs aligned with `https://jyotisha.chat`.
2. Before cutover, verify the authoritative DNS still matches the documented current phase; after cutover both user and admin hosts must resolve to `118.194.235.34`.
3. The target uses Better Auth with exact user/admin origins; migrated users must sign in again by OTP.
4. After deployment, verify `/login`, logged-out `/api/account` = `401`, internal `/api/health` = `200`, and `swisseph_available = true`.
5. Never expose port `5200`, `SUPABASE_SERVICE_ROLE_KEY`, model keys, user JWTs, passwords, or SSH private keys.
6. Production GitHub Actions validation, deployment, and migration workflows are manual-only. The explicitly authorized staging `Staging Backend Quality Gate` may run automatically for pull requests and pushes to `staging`, and a successful staging gate may automatically trigger `Deploy staging`; `Migrate Staging Database` remains manual-only. Run the required production validation workflows from the Actions page before manually starting production deployment; the production workflow and required secret are documented in `deploy/README.md`.
6. Production deployment is manual-only in Gitea. It consumes the exact staging-gate image digests, requires matching `main`/`staging`/public-staging SHA plus the manual release gate, and never changes DNS or imports production data. GitHub production deployment is retired.
## 1. High-Rigor Override
+19
View File
@@ -0,0 +1,19 @@
{$SITE_ADDRESS:https://jyotisha.chat} {
encode zstd gzip
@adminPaths path /admin /admin/* /api/admin/*
respond @adminPaths "Not found" 404
reverse_proxy web:3000
}
https://admin.jyotisha.chat {
encode zstd gzip
@root path /
redir @root /admin 308
reverse_proxy web:3000
}
https://www.jyotisha.chat {
redir https://jyotisha.chat{uri} 308
}
+61 -105
View File
@@ -1,77 +1,78 @@
# Production deployment and maintenance
This file is the operational source of truth for the current Jyotisha demo deployment.
This file is the operational source of truth for Jyotisha deployment. The production migration is governed by `docs/operations/production-server-migration-2026-08.md`; do not change DNS or retire the old environment outside that runbook.
## Current production
## Production migration state
| Item | Value |
| --- | --- |
| Public domain | `https://jyotisha.chat` |
| DNS | Spaceship nameservers (`launch1.spaceship.net`, `launch2.spaceship.net`) |
| Server | Hong Kong VPS, Ubuntu 22.04 x86_64 |
| Public host | `103.117.123.53` |
| SSH | port `22000`, public-key authentication only |
| Capacity | 1 vCPU / 2 GB RAM / 40 GB disk / 5 Mbps |
| App directory | `/opt/jyotisha-app` |
| Environment file | `/opt/jyotisha-app/.env.production` (`0600`) |
| Current public host | Old VPS; keep as a rollback asset until reconciliation completes |
| Target host | `118.194.235.34`, Ubuntu x86_64 |
| Target SSH | dedicated `deploy` user, confirmed variable port, public-key authentication only |
| Target capacity | 2 vCPU / 4 GB RAM; no application builds on host |
| Target app directory | `/opt/jyotisha-production` |
| Target environment files | `.env.production` and `.env.production.database` (`0600`) |
| Primary source repository | `https://git.copse.top/root/Jyotisha.git` |
| GitHub upstream/mirror | `https://github.com/jesse-ux/Jyotisha.git` |
| Supabase project | `vtvnfqmonbfuxmqkqdlc` |
| Migration source | Supabase project `vtvnfqmonbfuxmqkqdlc` + Supabase Auth |
| Migration target | private PostgreSQL 17 + Better Auth |
This machine is suitable for a client demo and low concurrency. Supabase and the model provider stay managed externally; do not self-host them on this VPS.
This migration changes both infrastructure and persistence. It is a controlled Supabase-to-private-PostgreSQL ETL, not a database-volume copy or full-dump restore. Keep the old VPS for 714 days and Supabase for 1430 days after cutover.
## Architecture
```text
Spaceship DNS
-> Caddy :80/:443
-> web:3000 (Next.js + Mastra, Docker-private)
-> api:5200 (Python Jyotish API, Docker-private)
-> Swiss Ephemeris / local engine
-> VedAstro gateway with local fallback
-> Supabase Cloud
-> external OpenAI-compatible model API
Spaceship DNS -> Caddy :80/:443 -> web:3000 -> api:5200
| -> local astrology engines
-> private PostgreSQL 17 + Better Auth
-> external model and mail providers
```
Only Caddy publishes host ports. Ports `3000` and `5200` must remain private.
## DNS and Supabase Auth
Spaceship resource records:
Final Spaceship resource records (apply only during the approved cutover window):
```text
A @ 103.117.123.53
A @ 118.194.235.34
A admin 118.194.235.34
CNAME www jyotisha.chat
```
Supabase Authentication URL Configuration:
```text
Site URL: https://jyotisha.chat
Redirect URLs: https://jyotisha.chat/**
https://www.jyotisha.chat/**
```
Before changing Caddy to the domain, verify the authoritative DNS result:
After cutover, verify both authoritative nameservers:
```bash
dig +short @launch1.spaceship.net A jyotisha.chat
dig +short @launch2.spaceship.net A admin.jyotisha.chat
```
It must return `103.117.123.53`. Caddy provisions and renews HTTPS automatically after DNS resolves.
Both user and admin hosts must return `118.194.235.34`. Caddy provisions and renews HTTPS automatically after DNS resolves.
## Production environment
`.env.production` combines the backend and frontend server variables. Required groups:
`.env.production` contains runtime-only application settings. Database bootstrap, migration, and backup credentials belong only in `.env.production.database`.
```dotenv
SITE_ADDRESS=https://jyotisha.chat
APP_ENV_FILE=../.env.production
CADDYFILE_PATH=./Caddyfile.production.selfhosted
JYOTISH_API_BASE=http://api:5200
GEOAPIFY_API_KEY=<server-side Geoapify geocoding key>
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
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:<encoded-secret>@postgres:5432/jyotisha
APP_DATABASE_URL=postgresql://app_runtime:<encoded-secret>@postgres:5432/jyotisha
SERVICE_DATABASE_URL=postgresql://service_runtime:<encoded-secret>@postgres:5432/jyotisha
ADMIN_DATABASE_URL=postgresql://admin_runtime:<encoded-secret>@postgres:5432/jyotisha
BETTER_AUTH_USER_SECRET=<production-only-secret>
RESEND_API_KEY=<production-only-secret>
RESEND_FROM_EMAIL=<verified-sender>
ADMIN_EMAILS=...
# Required to save/read database-backed 易支付 settings. Base64 decoding must
@@ -87,10 +88,7 @@ RECTIFICATION_PRICE_CREDITS=3
# Required to encrypt/decrypt model-provider API keys stored in the admin database.
# Base64 decoding must produce exactly 32 random bytes; do not reuse other keys.
# Staging migration/deploy creates this once when absent and removes legacy model API-key settings.
MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=<independent-base64-encoded-32-byte-key>
# OpenAI-compatible origins must be explicitly server-allowlisted.
# MODEL_PROVIDER_BASE_URL_ALLOWLIST=https://api.deepseek.com
# The admin database model catalog is the only runtime model/provider source.
# Provider API keys are entered in the admin UI and are never read from provider env vars.
@@ -105,47 +103,37 @@ VEDASTRO_TIMEOUT_SECONDS=20
VEDASTRO_API_KEY=<server-secret>
```
Never commit `.env.production`, `SUPABASE_SERVICE_ROLE_KEY`, model keys, user JWTs, SSH private keys or passwords. `NEXT_PUBLIC_SUPABASE_ANON_KEY` is intentionally public; authorization is enforced by Supabase RLS and server-side checks.
Never commit either production env file, model/payment keys, user tokens, SSH private keys, database URLs, or passwords. Do not retain Supabase runtime selectors in the target env; Supabase is migration source/rollback storage only.
After changing VedAstro variables, restart the API and verify the configuration without printing credentials:
```bash
docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build api
docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec api python3 scripts/diagnose_vedastro_mode.py
```
The report must show `mode: official_extended` and `network_enabled: true`. A missing raw response remains an upstream response boundary, not a successful external verification.
After changing runtime variables, use the exact-SHA Gitea workflow to recreate services; do not rebuild or rsync a local tree on the host.
## Connect and inspect
```bash
ssh -p 22000 root@103.117.123.53
cd /opt/jyotisha-app
COMPOSE='docker compose --env-file .env.production -f deploy/docker-compose.server.yml'
ssh -p <confirmed-port> deploy@118.194.235.34
cd /opt/jyotisha-production
COMPOSE='docker compose -p jyotisha-production --env-file .env.production -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.production.yml'
$COMPOSE ps
$COMPOSE logs --tail=100 api web caddy
$COMPOSE logs --tail=100 postgres api web caddy
free -h
docker stats --no-stream
```
The server has a persistent 2 GB `/swapfile`. UFW permits only SSH `22000/tcp`, HTTP `80/tcp`, HTTPS `443/tcp`, and the pre-existing WireGuard `51820/udp` rule.
UFW permits only the confirmed SSH port, HTTP, and HTTPS. PostgreSQL, Web, API, and the Docker API remain private.
## Manual deployment with GitHub Actions
## Manual production deployment with Gitea Actions
Production pushes and pull requests do not start GitHub Actions automatically. Run the required validation workflows from the Actions page, then manually start `.github/workflows/deploy-production.yml` for the tested branch. The deployment workflow syncs that revision with `rsync`, preserves `/opt/jyotisha-app/.env.production`, rebuilds both Docker services, and verifies the public login route, logged-out account response, and private Python health endpoint.
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.
For the reviewed conversational rectification and global birthplace schema set,
run `.github/workflows/apply-production-rectification-migrations.yml` with
`operation=check` first. If the ledger and checksums are clean, rerun the same
current-`main` revision with `operation=apply`. The workflow only accepts the
four allowlisted forward migrations, applies each migration and its ledger row
in one transaction, and refuses stale revisions or checksum drift.
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.
Required GitHub Actions secret:
Required Gitea Actions secret:
```text
PRODUCTION_SSH_PRIVATE_KEY = dedicated production deploy private key
PRODUCTION_SSH_PRIVATE_KEY = one-line base64 of the dedicated deploy private-key file
```
The workflow pins the VPS Ed25519 host key and serializes deployments with the `production` concurrency group.
The independently verified host key is stored in `PRODUCTION_KNOWN_HOSTS`. See the production migration runbook for all variables, data gates, and rollback boundaries.
## Staging deployment
@@ -178,7 +166,7 @@ SITE_ADDRESS=https://staging.jyotisha.chat
模型供应商的 `base_url` 不再依赖域名白名单,任意公网 HTTPS origin 均可由管理员配置;部署环境不需要 `MODEL_PROVIDER_BASE_URL_ALLOWLIST`。服务端仍强制 HTTPS、禁止凭据、localhost/内网/保留地址,并在 DNS 解析、请求地址 pinning 和重定向处理上执行 SSRF 防护。
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands.
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation.
After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA.
@@ -321,23 +309,7 @@ This disposable staging procedure does not authorize a production migration, pro
## Manual deployment fallback
If GitHub Actions is unavailable, deploy the tracked tree without copying local secrets:
```bash
cd /Users/jesse/Downloads/Copse/astrology/yinduzhanxing
git status --short --branch
rsync -az --delete \
--exclude='.git/' \
--exclude='.env.production' \
--exclude='frontend/node_modules/' \
--exclude='frontend/.next/' \
-e 'ssh -p 22000' \
./ root@103.117.123.53:/opt/jyotisha-app/
ssh -p 22000 root@103.117.123.53 \
'cd /opt/jyotisha-app && docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans'
```
The excluded `.env.production` remains only on the VPS.
There is no unreviewed rsync/build fallback for the new production. If Gitea Actions or the immutable artifact is unavailable, stop the release and restore the control plane; do not substitute a mutable image tag or copy a local working tree to production.
## Verification
@@ -349,29 +321,13 @@ curl -fsS -o /dev/null -w '%{http_code}\n' https://jyotisha.chat/api/account
The second command should return `401` while logged out. Verify the private Python API from inside the web container:
```bash
ssh -p 22000 root@103.117.123.53 \
'cd /opt/jyotisha-app && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e "fetch(\"http://api:5200/api/health\").then(async r=>{console.log(r.status); console.log(await r.text())})"'
ssh -p <confirmed-port> deploy@118.194.235.34 \
'cd /opt/jyotisha-production && docker compose -p jyotisha-production --env-file .env.production -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.production.yml exec -T web node -e "fetch(\"http://api:5200/api/health\").then(async r=>{console.log(r.status); console.log(await r.text())})"'
```
Expected: HTTP `200`, `"status": "ok"`, and `"swisseph_available": true`. Public access to `103.117.123.53:5200` must fail.
Expected: HTTP `200`, `"status": "ok"`, and `"swisseph_available": true`. Public access to `118.194.235.34:5200` and `:5432` must fail. The deployment workflow also executes `SELECT 1` through all four runtime database roles.
Before deploying application code that depends on any new Supabase migration (columns, tables, grants, policies, or RPCs), run `cd frontend && npx supabase db push --linked`; the GitHub deployment workflow does not apply database migrations. Multi-model chat specifically requires `20260717010000_chat_session_model.sql` before the new web image is deployed. Then manually verify: OTP login, onboarding/profile persistence, per-session `model_id` persistence, code redemption, admin code generation, authenticated `/api/models` returns only sanitized public metadata, invalid model IDs are rejected before charging, each configured model can answer, the 2.5-second free undo window, streaming response, one-credit charge, refund before the first output chunk, and charged stop with partial output preserved after streaming starts.
For the July 2026 new-user profile save fix, either run the manual GitHub Action
`Apply Supabase profile migrations` after adding `SUPABASE_DB_URL` or `DATABASE_URL`
to `/opt/jyotisha-app/.env.production`, or execute these five SQL migrations in
the Supabase SQL Editor with a project member account:
- `20260718010000_recover_missing_profile_rows.sql`
- `20260718020000_profiles_service_role_upsert_grants.sql`
- `20260718050000_profiles_service_role_upsert_grants.sql`
- `20260718070000_profiles_service_role_upsert_id.sql`
- `20260718080000_profiles_service_role_account_upsert_selects.sql`
Do not treat a green app deployment as proof this database step ran. If the SQL
Editor shows `You do not have access to this project`, use the correct Supabase
organization account or invite the current GitHub user to project
`vtvnfqmonbfuxmqkqdlc` before retrying.
Do not treat a green app deployment as proof of database migration or data reconciliation. The production deployment refuses pending target migrations but does not apply them. Follow the schema-first ETL and verification gates in the production migration runbook. Supabase remains the read-only rollback source until the retention and reconciliation window closes.
## Agentic birth-time rectification
@@ -392,16 +348,16 @@ evidence.
```bash
# Restart without rebuilding
docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d
docker compose -p jyotisha-production --env-file .env.production \
-f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml \
-f deploy/docker-compose.production.yml up -d
# Rebuild only the web container
docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build web caddy
# Rebuild only the Python API
docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build api
# Pull/deploy application images only through the exact-SHA Gitea workflow.
# Follow logs
docker compose --env-file .env.production -f deploy/docker-compose.server.yml logs -f --tail=100 api web caddy
docker compose -p jyotisha-production --env-file .env.production \
-f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml \
-f deploy/docker-compose.production.yml logs -f --tail=100 postgres api web caddy
```
## Optional Railway deployment
+8
View File
@@ -0,0 +1,8 @@
services:
web:
networks:
- default
- app
networks:
app:
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env bash
set -euo pipefail
set +x
required=(
INCOMING_PATH DEPLOY_PATH API_IMAGE WEB_IMAGE DEPLOY_SHA
EXPECTED_PREVIOUS_SHA ALLOW_ROLLBACK DOCKER_CONFIG PRODUCTION_URL
PRODUCTION_ADMIN_URL VERIFICATION_MODE
)
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 deployment input is missing: $key" >&2
exit 1
fi
done
sha_pattern='^[0-9a-f]{40}$'
digest_pattern='^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[1-9][0-9]{0,4})?(/[a-z0-9]+([._-][a-z0-9]+)*)+@sha256:[0-9a-f]{64}$'
image_id_pattern='^sha256:[0-9a-f]{64}$'
if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] ||
[[ ! "$API_IMAGE" =~ $digest_pattern ]] ||
[[ ! "$WEB_IMAGE" =~ $digest_pattern ]]; then
echo "unsafe production image identity" >&2
exit 1
fi
api_repository="${API_IMAGE%@sha256:*}"
web_repository="${WEB_IMAGE%@sha256:*}"
if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then
echo "invalid rollback authorization" >&2
exit 1
fi
if [ "$VERIFICATION_MODE" != "internal" ] && [ "$VERIFICATION_MODE" != "public" ]; then
echo "invalid production verification mode" >&2
exit 1
fi
case "$INCOMING_PATH" in
/tmp/jyotisha-production.*) ;;
*) echo "unsafe incoming production path" >&2; exit 1 ;;
esac
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" =~ $sha_pattern ]]; then
echo "invalid deployed production revision state" >&2
exit 1
fi
if [ "$current_sha" != "$EXPECTED_PREVIOUS_SHA" ]; then
echo "production revision changed while this deployment was waiting" >&2
exit 1
fi
if [ "$ALLOW_ROLLBACK" = "false" ] &&
[ "$current_sha" != "not-deployed" ] &&
[ "$current_sha" != "$DEPLOY_SHA" ] &&
[ "${FORWARD_REVISION_VERIFIED:-false}" != "true" ]; then
echo "forward production revision was not verified" >&2
exit 1
fi
container_id() {
"${docker_command[@]}" ps -aq \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter "label=com.docker.compose.service=$1" | head -n 1
}
repo_digest_for_container() {
local service="$1"
local repository="$2"
local id image_id
id="$(container_id "$service")"
[ -n "$id" ] || return 0
image_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$id")"
"${docker_command[@]}" image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image_id" |
awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }'
}
previous_api_image="$(repo_digest_for_container api "$api_repository")"
previous_web_image="$(repo_digest_for_container web "$web_repository")"
previous_api_id=""
previous_web_id=""
if [ -n "$(container_id api)" ]; then
previous_api_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$(container_id api)")"
fi
if [ -n "$(container_id web)" ]; then
previous_web_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$(container_id web)")"
fi
rollback_image() {
local digest_ref="$1"
local image_id="$2"
if [[ "$digest_ref" =~ $digest_pattern ]]; then
printf '%s' "$digest_ref"
elif [[ "$image_id" =~ $image_id_pattern ]]; then
printf '%s' "$image_id"
fi
}
previous_api_target="$(rollback_image "$previous_api_image" "$previous_api_id")"
previous_web_target="$(rollback_image "$previous_web_image" "$previous_web_id")"
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
compose=(
"${docker_command[@]}" compose -p jyotisha-production --env-file .env.production
-f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml
-f deploy/docker-compose.production.yml
)
export APP_ENV_FILE='../.env.production'
export DATABASE_ENV_FILE='../.env.production.database'
export CADDYFILE_PATH='./Caddyfile.production.selfhosted'
export SITE_ADDRESS='https://jyotisha.chat'
export GITHUB_SHA="$DEPLOY_SHA"
"${compose[@]}" config --quiet
"${compose[@]}" pull api web
"${compose[@]}" up -d --no-build --pull never --wait postgres
set +e
"${compose[@]}" --profile migration-check run --rm migration-checker
check_status=$?
set -e
if [ "$check_status" -eq 3 ]; then
echo "pending migrations: run Migrate Production Database for $DEPLOY_SHA" >&2
exit 3
fi
if [ "$check_status" -ne 0 ]; then
echo "production migration check failed safely" >&2
exit "$check_status"
fi
switched=false
rollback() {
local status=$?
if [ "$switched" = "true" ] &&
[ -n "$previous_api_target" ] &&
[ -n "$previous_web_target" ] &&
[[ "$current_sha" =~ $sha_pattern ]]; then
echo "production verification failed; restoring prior application images" >&2
rollback_services=(api web)
if [ "$VERIFICATION_MODE" = "public" ]; then rollback_services+=(caddy); fi
API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \
GITHUB_SHA="$current_sha" \
"${compose[@]}" up -d --no-build --remove-orphans \
"${rollback_services[@]}" || true
fi
exit "$status"
}
trap rollback ERR
switched=true
if [ "$VERIFICATION_MODE" = "public" ]; then
"${compose[@]}" up -d --no-build --remove-orphans
"${compose[@]}" up -d --no-build --force-recreate --no-deps caddy
else
# Before DNS cutover, do not trigger public certificate issuance for domains
# that still resolve to the old production host.
"${compose[@]}" up -d --no-build api web
fi
verify_container_image() {
local service="$1"
local expected_ref="$2"
local id expected_id running_id repo_digests
id="$(container_id "$service")"
[ -n "$id" ]
expected_id="$("${docker_command[@]}" image inspect --format '{{.Id}}' "$expected_ref")"
running_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$id")"
[ "$running_id" = "$expected_id" ]
repo_digests="$("${docker_command[@]}" image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$expected_id")"
grep -Fqx "$expected_ref" <<<"$repo_digests"
}
verify_container_image api "$API_IMAGE"
verify_container_image web "$WEB_IMAGE"
"${compose[@]}" exec -T \
-e EXPECTED_SHA="$DEPLOY_SHA" \
-e PRODUCTION_URL="$PRODUCTION_URL" \
-e PRODUCTION_ADMIN_URL="$PRODUCTION_ADMIN_URL" \
-e VERIFICATION_MODE="$VERIFICATION_MODE" \
web node --input-type=module <<'NODE'
import { Pool } from "pg";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (const [role, key] of [
["identity", "IDENTITY_DATABASE_URL"],
["app", "APP_DATABASE_URL"],
["service", "SERVICE_DATABASE_URL"],
["admin", "ADMIN_DATABASE_URL"],
]) {
const connectionString = process.env[key];
if (!connectionString) {
console.error(`database readiness missing for ${role}`);
process.exit(1);
}
const pool = new Pool({ connectionString, max: 1, connectionTimeoutMillis: 5_000 });
try {
const result = await pool.query("select 1 as ready");
if (result.rows[0]?.ready !== 1) throw new Error("unexpected readiness result");
} catch (error) {
console.error(`database readiness failed for ${role}`, error instanceof Error ? error.name : "query_error");
process.exit(1);
} finally {
await pool.end();
}
}
const internal = process.env.VERIFICATION_MODE === "internal";
const request = (origin, path, options = {}) => {
const target = internal ? `http://127.0.0.1:3000${path}` : `${origin}${path}`;
const headers = internal ? { ...options.headers, host: new URL(origin).host } : options.headers;
return fetch(target, { ...options, headers });
};
let observed = {};
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const login = await request(process.env.PRODUCTION_URL, "/login");
const userAdminPage = await request(process.env.PRODUCTION_URL, "/admin", { redirect: "manual" });
const userAdminApi = await request(process.env.PRODUCTION_URL, "/api/admin/session");
const adminPage = await request(process.env.PRODUCTION_ADMIN_URL, "/admin", { redirect: "manual" });
const adminApi = await request(process.env.PRODUCTION_ADMIN_URL, "/api/admin/session");
const account = await request(process.env.PRODUCTION_URL, "/api/account");
const publicHealth = await request(process.env.PRODUCTION_URL, "/api/health");
const publicBody = await publicHealth.json();
const privateHealth = await fetch("http://api:5200/api/health");
const privateBody = await privateHealth.json();
observed = {
attempt,
login: login.status,
userAdminPage: userAdminPage.status,
userAdminApi: userAdminApi.status,
adminPage: adminPage.status,
adminLocation: adminPage.headers.get("location"),
adminApi: adminApi.status,
account: account.status,
publicHealth: publicHealth.status,
publicSha: publicBody.deployment?.gitCommit ?? "missing",
privateHealth: privateHealth.status,
privateStatus: privateBody.status ?? "missing",
swissephAvailable: privateBody.swisseph_available === true,
verificationMode: process.env.VERIFICATION_MODE,
};
if (
login.ok
&& userAdminPage.status === 404
&& userAdminApi.status === 404
&& adminPage.status === 307
&& adminPage.headers.get("location") === "/login"
&& adminApi.status === 401
&& account.status === 401
&& publicHealth.ok
&& publicBody.deployment?.gitCommit === process.env.EXPECTED_SHA
&& privateHealth.ok
&& privateBody.status === "ok"
&& privateBody.swisseph_available === true
) {
process.exit(0);
}
} catch (error) {
observed = {
attempt,
error: error instanceof Error ? error.name : "verification_error",
};
}
if (attempt < 12) await delay(5_000);
}
console.error("production verification predicates did not converge", JSON.stringify(observed));
process.exit(1);
NODE
revision_file="$state_directory/deployed-revision.tmp.$$"
printf '%s\n' "$DEPLOY_SHA" >"$revision_file"
chmod 600 "$revision_file"
mv -f "$revision_file" "$state_directory/deployed-revision"
trap - ERR
printf 'previous_sha=%s\nprevious_api_image=%s\nprevious_api_id=%s\n' \
"$current_sha" "${previous_api_image:-not-deployed}" "${previous_api_id:-not-deployed}"
printf 'previous_web_image=%s\nprevious_web_id=%s\nverified_sha=%s\n' \
"${previous_web_image:-not-deployed}" "${previous_web_id:-not-deployed}" "$DEPLOY_SHA"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then
echo "usage: sync-production-tree.sh SOURCE_DIRECTORY DESTINATION_DIRECTORY" >&2
exit 1
fi
destination_deploy="$2/deploy"
if [ -d "$destination_deploy" ]; then
docker run --rm --pull never --network none --read-only --user 0:0 \
--cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \
-v "$destination_deploy:/destination" postgres:17-alpine \
chown -R "$(id -u):$(id -g)" /destination
chmod -R u+rwX "$destination_deploy"
fi
rsync -az --delete --no-owner --no-group \
--exclude='/.git/' \
--exclude='/.env*' \
--exclude='/.docker/' \
--exclude='/backups/' \
--exclude='/.state/' \
--exclude='/.incoming/' \
--exclude='/frontend/node_modules/' \
--exclude='/frontend/.next/' \
"$1/" "$2/"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
set +x
ENV_FILE="${1:-.env.production.database}"
if [ ! -e "$ENV_FILE" ]; then
echo "production database environment file is missing" >&2
exit 1
fi
if [ -L "$ENV_FILE" ]; then
echo "production database environment file must not be a symlink" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "production database environment path must be a regular file" >&2
exit 1
fi
if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then
:
else
MODE="$(stat -f '%Lp' "$ENV_FILE")"
fi
if [ "$MODE" != "600" ]; then
echo "production database environment file must have mode 0600" >&2
exit 1
fi
if OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null)"; then
:
else
OWNER="$(stat -f '%u' "$ENV_FILE")"
fi
EXPECTED_OWNER_UID="${EXPECTED_PRODUCTION_ENV_OWNER_UID:-$(id -u)}"
if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then
echo "production database environment file has an invalid owner" >&2
exit 1
fi
definition_count() {
local key="$1"
grep -Ec "^[[:space:]]*(export[[:space:]]+)?${key}([[:space:]]*=|[[:space:]]*$)" "$ENV_FILE" || true
}
environment_value() {
local key="$1"
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" "$ENV_FILE"
}
is_safe_literal() {
local value="$1"
local inner
# Required values are literal single-line values: use an unquoted token or
# matching non-empty quotes. Dotenv interpolation, comments, and malformed
# quoting are rejected rather than evaluated, so generate secrets without $.
if [ -z "$value" ] || [[ "$value" == *'$'* ]]; then
return 1
fi
case "$value" in
\"*\")
inner="${value:1}"
inner="${inner%?}"
[ -n "$inner" ] && [[ "$inner" != *'"'* ]]
;;
\'*\')
inner="${value:1}"
inner="${inner%?}"
[ -n "$inner" ] && [[ "$inner" != *"'"* ]]
;;
*\"*|*\'*)
return 1
;;
*[[:space:]]*|*\#*)
return 1
;;
esac
}
require_once_non_empty() {
local key="$1"
local count
local value
count="$(definition_count "$key")"
value="$(environment_value "$key")"
if [ "$count" -ne 1 ] || ! is_safe_literal "$value"; then
echo "required production database literal is missing, duplicated, or ambiguous: $key" >&2
exit 1
fi
}
required=(
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD
SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD
SERVICE_RUNTIME_PASSWORD ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD
BACKUP_READER_PASSWORD
PRODUCTION_BACKUP_ENCRYPTION_KEY SCHEMA_DATABASE_URL
)
for key in "${required[@]}"; do
require_once_non_empty "$key"
done
if [ "$(environment_value POSTGRES_DB)" != "jyotisha" ]; then
echo "invalid production database selector: POSTGRES_DB" >&2
exit 1
fi
if [ "$(environment_value POSTGRES_USER)" != "postgres" ]; then
echo "invalid production database selector: POSTGRES_USER" >&2
exit 1
fi
if ! [[ "$(environment_value SCHEMA_DATABASE_URL)" =~ ^postgresql://schema_owner:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then
echo "invalid production database selector: SCHEMA_DATABASE_URL" >&2
exit 1
fi
echo "production database environment validated"
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env.production}"
if [ ! -f "$ENV_FILE" ]; then
echo "production environment file is missing: $ENV_FILE" >&2
exit 1
fi
if [ -L "$ENV_FILE" ]; then
echo "production environment file must not be a symlink" >&2
exit 1
fi
if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then
:
else
MODE="$(stat -f '%Lp' "$ENV_FILE")"
fi
if [ "$MODE" != "600" ]; then
echo "production environment file must have mode 0600" >&2
exit 1
fi
if OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null)"; then
:
else
OWNER="$(stat -f '%u' "$ENV_FILE")"
fi
EXPECTED_OWNER_UID="${EXPECTED_PRODUCTION_ENV_OWNER_UID:-$(id -u)}"
if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then
echo "production environment file has an invalid owner" >&2
exit 1
fi
require_selector() {
local key="$1"
local expected="$2"
local count
local definition_pattern
definition_pattern="^[[:space:]]*(export[[:space:]]+)?${key}([[:space:]]*=|[[:space:]]*$)"
count="$(grep -Ec "$definition_pattern" "$ENV_FILE" || true)"
if [ "$count" -ne 1 ] || ! grep -Fqx "${key}=${expected}" "$ENV_FILE"; then
echo "invalid production selector: $key" >&2
exit 1
fi
}
require_selector APP_ENV_FILE ../.env.production
require_selector CADDYFILE_PATH ./Caddyfile.production.selfhosted
require_selector SITE_ADDRESS https://jyotisha.chat
require_selector AUTH_PROVIDER self-hosted
require_selector SELF_HOSTED_IDENTITY_ENABLED true
require_selector AUTH_USER_ORIGIN https://jyotisha.chat
require_selector ADMIN_USER_ORIGIN https://admin.jyotisha.chat
require_literal() {
local key="$1"
local minimum_length="$2"
local count value
count="$(grep -Ec "^${key}=" "$ENV_FILE" || true)"
if [ "$count" -ne 1 ]; then
echo "invalid production identity setting: $key" >&2
exit 1
fi
value="$(grep -E "^${key}=" "$ENV_FILE")"
value="${value#*=}"
if [ "${#value}" -lt "$minimum_length" ] ||
[[ "$value" == *'$'* || "$value" == *'"'* || "$value" == *"'"* ]]; then
echo "invalid production identity setting: $key" >&2
exit 1
fi
LITERAL_VALUE="$value"
}
require_literal IDENTITY_DATABASE_URL 50
identity_database_url="$LITERAL_VALUE"
if ! [[ "$identity_database_url" =~ ^postgresql://identity_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then
echo "invalid production identity setting: IDENTITY_DATABASE_URL" >&2
exit 1
fi
require_literal APP_DATABASE_URL 45
app_database_url="$LITERAL_VALUE"
if ! [[ "$app_database_url" =~ ^postgresql://app_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then
echo "invalid production database setting: APP_DATABASE_URL" >&2
exit 1
fi
require_literal SERVICE_DATABASE_URL 49
service_database_url="$LITERAL_VALUE"
if ! [[ "$service_database_url" =~ ^postgresql://service_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then
echo "invalid production database setting: SERVICE_DATABASE_URL" >&2
exit 1
fi
require_literal ADMIN_DATABASE_URL 45
admin_database_url="$LITERAL_VALUE"
if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then
echo "invalid production database setting: ADMIN_DATABASE_URL" >&2
exit 1
fi
require_literal BETTER_AUTH_USER_SECRET 32
require_literal RESEND_API_KEY 10
require_literal RESEND_FROM_EMAIL 5
if [[ "$LITERAL_VALUE" != *@* ]]; then
echo "invalid production identity setting: RESEND_FROM_EMAIL" >&2
exit 1
fi
require_literal ADMIN_EMAILS 3
if [[ "$LITERAL_VALUE" != *@* ]]; then
echo "invalid production identity setting: ADMIN_EMAILS" >&2
exit 1
fi
require_literal EPAY_CONFIG_ENCRYPTION_KEY 44
if [ "${#LITERAL_VALUE}" -ne 44 ] ||
[[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
echo "invalid production identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2
exit 1
fi
require_literal MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY 44
if [ "${#LITERAL_VALUE}" -ne 44 ] ||
[[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
echo "invalid production model provider setting: MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY" >&2
exit 1
fi
legacy_model_setting_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)='
if grep -Eq "$legacy_model_setting_pattern" "$ENV_FILE"; then
echo "legacy model environment settings are forbidden" >&2
exit 1
fi
require_selector EPAY_CHAT_ENABLED false
require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32
personal_report_enabled_count="$(grep -Ec '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)"
personal_report_enabled="$(grep -E '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)"
personal_report_enabled="${personal_report_enabled#*=}"
if [ "$personal_report_enabled_count" -ne 1 ] ||
[[ "$personal_report_enabled" != "true" && "$personal_report_enabled" != "false" ]]; then
echo "invalid production personal report setting: PERSONAL_REPORT_ENABLED" >&2
exit 1
fi
personal_report_daily_limit_count="$(grep -Ec '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)"
personal_report_daily_limit="$(grep -E '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)"
personal_report_daily_limit="${personal_report_daily_limit#*=}"
if [ "$personal_report_daily_limit_count" -ne 1 ] ||
[[ ! "$personal_report_daily_limit" =~ ^[1-9][0-9]*$ ]]; then
echo "invalid production personal report setting: PERSONAL_REPORT_DAILY_LIMIT" >&2
exit 1
fi
echo "production environment selectors: valid"
@@ -0,0 +1,195 @@
# Production migration to 118.194.235.34
Status: **planned; do not change DNS or retire the old production yet**.
This runbook moves production to the current reviewed `staging` release while also changing the persistence and identity layers:
- old runtime: VPS + Supabase PostgreSQL + Supabase Auth;
- target runtime: `118.194.235.34` + private PostgreSQL 17 + Better Auth;
- user site: `https://jyotisha.chat`;
- admin site: `https://admin.jyotisha.chat`.
This is not a volume copy. A full Supabase dump must not be restored over the target database.
## 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:
1. current `main`;
2. current `staging`;
3. a successful push-triggered `Staging Backend Quality Gate`;
4. a successful manually triggered `Jyotish Release Quality Gate`;
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.
## Required Gitea configuration
Repository variables:
| Name | Required value |
| --- | --- |
| `PRODUCTION_HOST` | `118.194.235.34` |
| `PRODUCTION_PORT` | Confirmed SSH port; do not assume `22` |
| `PRODUCTION_USER` | `deploy` |
| `PRODUCTION_PATH` | `/opt/jyotisha-production` |
| `PRODUCTION_URL` | `https://jyotisha.chat` |
| `PRODUCTION_ADMIN_URL` | `https://admin.jyotisha.chat` |
| `PRODUCTION_KNOWN_HOSTS` | Independently verified pinned host-key line |
| `STAGING_URL` | `https://staging.jyotisha.chat` |
Repository secrets:
- `PRODUCTION_SSH_PRIVATE_KEY`: the dedicated deploy private-key file encoded as one unwrapped base64 line;
- `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`.
Do not put the Ubuntu password, database URLs, Resend key, payment key, model-provider key, or encryption master keys in Gitea. The supplied bootstrap password must be rotated after an SSH key has been verified; it must never be committed or printed in a workflow.
## New-server bootstrap
Perform this interactively before any workflow dispatch:
1. Patch Ubuntu and install Docker Engine, Compose v2, `rsync`, `curl`, `jq`, `flock`, and UFW.
2. Create a non-root `deploy` user, install a dedicated Ed25519 public key, and grant only the reviewed passwordless commands needed for Docker and deployment-tree ownership.
3. Verify a second key-only session, then disable root login and password authentication and rotate the bootstrap password.
4. Permit only the confirmed SSH port plus `80/tcp`, `443/tcp`, and `443/udp`. Do not publish `3000`, `5200`, `5432`, or the Docker API.
5. Create a 24 GB swap file and enable Docker log rotation. Keep at least 15 GB free before the first image pull and database import.
6. Create `/opt/jyotisha-production`, owned by `deploy`, and preload the reviewed `postgres:17-alpine` and Caddy images. PostgreSQL image upgrades are separate maintenance operations.
Create these host-only files with owner `deploy` and mode `0600`:
```text
/opt/jyotisha-production/.env.production
/opt/jyotisha-production/.env.production.database
```
The application selectors must be exact:
```dotenv
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
EPAY_CHAT_ENABLED=false
```
Use distinct production credentials for PostgreSQL roles, Better Auth, Resend, backup encryption, and dynamic rectification. Preserve the existing `EPAY_CONFIG_ENCRYPTION_KEY` and `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` only through an approved secret-to-secret transfer. If either key cannot be transferred safely, exclude its ciphertext rows from import and re-enter those settings in the new admin UI.
## 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.
The tool must:
- connect to the source using `REPEATABLE READ READ ONLY`;
- refuse a non-empty target business database;
- use explicit columns and dependency order, never `SELECT *`;
- preserve all user UUIDs and transactional primary keys;
- import Supabase `auth.users` into Better Auth without passwords, sessions, JWTs, provider tokens, or MFA secrets;
- map `banned_until`, or emit an explicit blocked-user reconciliation manifest;
- merge seed/configuration records by natural key rather than copying target-generated IDs;
- map active administrators to canonical target role codes and require at least one explicit Owner;
- run post-import reconciliation for legacy billing and retired birth-time rectification rows;
- 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.
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.
## Rehearsal
Complete at least one isolated full-data rehearsal before scheduling the final window:
1. Apply all target schema migrations to an empty rehearsal database.
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.
4. Verify one Owner exists, every active admin has a target role, and database roles remain isolated.
5. Test OTP login, historical balance and history reads, admin login/RBAC, report generation/export, payment callback rejection/idempotency, and model provider access.
6. Create an encrypted backup, restore it into a separate database, and repeat smoke checks.
7. Record the observed export/import/verification duration and use it to set the maintenance window.
For the 2-core/4-GB host, keep database pools bounded (recommended starting maxima: identity 5, app 5, admin 3) rather than allowing three pools of 10 to consume all 30 PostgreSQL connections.
## DNS preparation
At least one current TTL period before cutover, reduce relevant Spaceship TTLs to `300`. Do not change record targets yet. Check both authoritative nameservers and remove any legacy `AAAA` record that points elsewhere.
Final records are:
| Type | Host | Value |
| --- | --- | --- |
| `A` | `@` | `118.194.235.34` |
| `A` | `admin` | `118.194.235.34` |
| `CNAME` | `www` | `jyotisha.chat` |
Both `jyotisha.chat` and `admin.jyotisha.chat` are required. The application rejects unknown identity hosts, and the user domain intentionally hides `/admin` and `/api/admin/*`.
## Cutover sequence
### T-24 hours
- Confirm the exact release SHA is deployed and accepted on staging.
- Run the manual release quality gate for that SHA.
- Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts.
- 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.
### Maintenance freeze
1. Set payment/package creation off and keep `EPAY_CHAT_ENABLED=false`.
2. Put old production in maintenance mode and stop Web/Agent/background writes.
3. Disable new Supabase registrations for the window.
4. Confirm source row counts stop changing.
5. Take the final encrypted source backup and one consistent source snapshot.
6. Run the production ETL once, then post-import reconciliation and verification.
7. Verify role-specific `SELECT 1` connectivity for identity, app, service, and admin roles; verify permission isolation separately.
8. Keep the new site in maintenance mode while running user/admin/report/payment smoke checks.
Do not attempt an ad-hoc full-plus-incremental migration. Several tables lack a common `updated_at` or soft-delete contract, so an improvised delta can lose deletes, refunds, or accounting changes.
### DNS and public verification
1. Change the three Spaceship records only after all final data assertions pass.
2. Verify both authoritative nameservers, then public recursive resolvers.
3. Wait for Caddy certificates for both user and admin hosts.
4. Dispatch the same exact SHA with `verification_mode=public`.
5. Verify OTP login, logged-out account `401`, user-host admin paths `404`, admin-host unauthenticated behavior, health SHA, report generation/export, and one controlled payment callback test.
6. Re-enable public writes. Re-enable payment only after DNS convergence and callback verification.
## Go/no-go assertions
Cutover is **no-go** if any of these conditions is true:
- `main`, `staging`, staging health, gate artifact, or requested SHA differs;
- any migration or manifest checksum is unresolved;
- source/target identity, balance, order, subscription, or report reconciliation differs;
- there is no active Owner or an active admin has no role;
- migrated banned users are not accounted for;
- a production encryption key/ciphertext decision is unresolved;
- backup restore has not been demonstrated;
- role-specific database readiness or isolation fails;
- either user/admin TLS host is unavailable;
- pending payment writes or callbacks can still reach the old writable database.
## Rollback boundary
Before the new database accepts real writes, rollback is: restore old DNS targets, keep the old site/Supabase authoritative, and investigate the isolated target.
After the new database accepts real writes, a DNS-only rollback is unsafe. First stop new writes, reconcile the new PostgreSQL delta back to the chosen authority, and obtain an explicit operator decision. Otherwise post-cutover users, orders, credits, and reports can be lost.
Keep the old VPS in maintenance/read-only mode for at least 714 days and retain Supabase for 1430 days. Do not destroy either immediately after DNS cutover.
## Workflow dispatch
Normal release:
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.
3. Open Gitea Actions → `Deploy production`.
4. Enter the exact 40-character 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.
+30 -60
View File
@@ -25,33 +25,6 @@ function webHealthcheckBlock(web: string) {
return match[1];
}
function workflowStepBlock(workflow: string, stepName: string) {
const match = workflow.match(new RegExp(`^ - name: ${stepName}\\n([\\s\\S]*?)(?=^ - name:|(?![\\s\\S]))`, "m"));
assert.ok(match, `expected ${stepName} workflow step`);
return match[1];
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const testedShaExpression = "${{ github.sha }}";
function assertWorkflowUsesTestedSha(workflow: string) {
const checkout = workflowStepBlock(workflow, "Checkout tested revision");
const sync = workflowStepBlock(workflow, "Sync and rebuild");
const verification = workflowStepBlock(workflow, "Verify production");
const shaExpression = escapeRegExp(testedShaExpression);
assert.match(checkout, new RegExp(`ref: ${shaExpression}`), "Checkout tested revision must check out the tested SHA");
assert.match(sync, new RegExp(`DEPLOY_GIT_SHA: ${shaExpression}`), "Sync and rebuild must use the tested SHA");
assert.match(sync, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/, "Sync and rebuild must inject its SHA into the web runtime");
assert.match(verification, new RegExp(`DEPLOY_GIT_SHA: ${shaExpression}`), "Verify production must use the tested SHA");
assert.match(verification, /curl --fail --silent --show-error https:\/\/jyotisha\.chat\/api\/health/);
assert.match(verification, /get\("deployment", \{\}\)\.get\("gitCommit"/);
assert.match(verification, /Production revision did not converge/);
}
test("health endpoint exposes deployment identity for production verification", () => {
const source = readFileSync(
new URL("../src/app/api/health/route.ts", import.meta.url),
@@ -73,26 +46,15 @@ test("health endpoint exposes deployment identity for production verification",
assert.doesNotMatch(source, /anyEnvCheck\(\["LLM_MODELS_JSON"|OPENAI_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY/);
});
test("manual production deployment passes the selected revision into the web runtime", () => {
const compose = readFileSync(
new URL("../../deploy/docker-compose.server.yml", import.meta.url),
"utf8",
);
test("GitHub mirror cannot deploy production", () => {
const workflow = readFileSync(
new URL("../../.github/workflows/deploy-production.yml", import.meta.url),
"utf8",
);
assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/);
assert.match(workflow, /workflow_dispatch:/);
assert.doesNotMatch(workflow, /workflow_run:/);
assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.sha \}\}/);
assert.match(workflow, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/);
assert.match(workflow, /get\("deployment", \{\}\)\.get\("gitCommit"/);
assert.match(workflow, /DEPLOY_GIT_SHA/);
assert.match(workflow, /Production revision did not converge/);
assert.match(workflow, /git ls-remote origin refs\/heads\/main/);
assert.match(workflow, /steps\.revision\.outputs\.deploy == 'true'/);
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.match(workflow, /Refuse deployment from the mirror/);
assert.match(workflow, /exit 1/);
assert.doesNotMatch(workflow, /ssh|rsync|docker compose/);
});
test("server compose accepts staging paths while preserving production defaults", () => {
@@ -178,6 +140,21 @@ test("staging Caddy serves the same app on two exact hosts", () => {
assert.doesNotMatch(caddy, /www\.jyotisha\.chat/);
});
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.match(caddy, /^https:\/\/www\.jyotisha\.chat \{$/m);
assert.match(caddy, /redir https:\/\/jyotisha\.chat\{uri\} 308/);
assert.equal((caddy.match(/reverse_proxy web:3000/g) ?? []).length, 2);
});
test("staging deploy consumes only the isolated staging environment and tested revision", () => {
const qualityGate = readFileSync(
new URL("../../.github/workflows/backend-quality-gate.yml", import.meta.url),
@@ -442,24 +419,17 @@ test("production traffic waits for a healthy web container and retries short rep
assert.match(caddyfile, /reverse_proxy web:3000 \{\n\s+lb_try_duration 10s\n\s+lb_try_interval 250ms\n\s+\}/);
});
test("production workflow consistently uses its tested revision from checkout through verification", () => {
const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
test("Gitea production verification binds the exact requested SHA", () => {
const workflow = readFileSync(new URL("../../.gitea/workflows/deploy-production.yml", import.meta.url), "utf8");
const runner = readFileSync(new URL("../../deploy/run-production-deploy.sh", import.meta.url), "utf8");
assertWorkflowUsesTestedSha(workflow);
});
test("production workflow rejects a SHA mismatch in verification", () => {
const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
const verification = workflowStepBlock(workflow, "Verify production");
const mismatchedVerification = verification.replace(
testedShaExpression,
"${{ github.event.workflow_run.head_sha || github.sha }}",
);
assert.throws(
() => assertWorkflowUsesTestedSha(workflow.replace(verification, mismatchedVerification)),
/Verify production must use the tested SHA/,
);
assert.match(workflow, /REQUESTED_SHA: \$\{\{ inputs\.deploy_sha \}\}/);
assert.match(workflow, /main and staging must identify the same reviewed release/);
assert.match(workflow, /public staging has not accepted the requested SHA/);
assert.match(workflow, /DEPLOY_SHA='\$DEPLOY_SHA'/);
assert.match(runner, /publicBody\.deployment\?\.gitCommit === process\.env\.EXPECTED_SHA/);
assert.match(runner, /VERIFICATION_MODE/);
assert.match(runner, /select 1 as ready/);
});
test("production API probes health rapidly while a replacement container starts", () => {
@@ -58,6 +58,14 @@ const syncScript = new URL(
"../../deploy/sync-staging-tree.sh",
import.meta.url,
);
const productionDeployScript = new URL(
"../../deploy/run-production-deploy.sh",
import.meta.url,
);
const productionSyncScript = new URL(
"../../deploy/sync-production-tree.sh",
import.meta.url,
);
function read(url: URL): string {
return readFileSync(url, "utf8");
@@ -80,6 +88,7 @@ test("changed staging workflows are syntactically valid YAML", () => {
giteaQualityWorkflow,
giteaDeployWorkflow,
giteaMigrationWorkflow,
giteaProductionWorkflow,
]) {
const result = spawnSync(
"python",
@@ -791,20 +800,38 @@ test("run-local registry state and incoming trees are always cleaned up", () =>
}
});
test("production remains manual-only and separate from staging database automation", () => {
for (const production of [
readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8"),
read(giteaProductionWorkflow),
]) {
assert.match(production, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(production, /workflow_run:|\n\s+push:/);
assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/);
test("production deploy is manual-only and consumes the accepted staging artifact", () => {
const mirror = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
const production = read(giteaProductionWorkflow);
for (const workflow of [mirror, production]) {
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
}
assert.match(mirror, /Production deployment is controlled by \.gitea\/workflows\/deploy-production\.yml/);
assert.match(production, /branch=staging&event=push&status=success/);
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
assert.match(production, /public staging has not accepted the requested SHA/);
assert.match(production, /current_main.*DEPLOY_SHA.*current_staging.*DEPLOY_SHA/s);
assert.match(production, /verification_mode:/);
assert.match(production, /PRODUCTION_HOST/);
assert.match(production, /118\.194\.235\.34/);
assert.match(production, /PRODUCTION_SSH_PRIVATE_KEY/);
assert.match(production, /staging-image-manifest-/);
assert.match(production, /run-production-deploy\.sh/);
assert.doesNotMatch(production, /--build|root@|103\.117\.123\.53/);
});
test("staging scripts pass shell syntax validation", () => {
for (const script of [deployScript, migrationScript, syncScript]) {
for (const script of [
deployScript,
migrationScript,
syncScript,
productionDeployScript,
productionSyncScript,
]) {
const path = fileURLToPath(script);
chmodSync(path, 0o755);
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });